diff --git a/.air.toml b/.air.toml index 27de50a..3392e25 100644 --- a/.air.toml +++ b/.air.toml @@ -3,11 +3,11 @@ testdata_dir = "testdata" tmp_dir = "tmp" [build] - bin = "./tmp/main" - args_bin = ["server", "-c", "./config.yaml"] + args_bin = [] + bin = "./start.sh" cmd = "go build -o ./tmp/main cmd/cmd.go" delay = 1000 - exclude_dir = ["assets", "tmp", "vendor", "testdata", "repositories"] + exclude_dir = ["assets", "tmp", "vendor", "testdata", ".dev", "repositories"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false diff --git a/.dev/create-repo.sh b/.dev/create-repo.sh deleted file mode 100755 index 01ec146..0000000 --- a/.dev/create-repo.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -curl -X POST http://localhost:8080/api/repo -H "Content-Type: application/json" -d '{ - "name": "new-repo" -}' \ No newline at end of file diff --git a/.dev/create_repository.sh b/.dev/create_repository.sh new file mode 100755 index 0000000..d189506 --- /dev/null +++ b/.dev/create_repository.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +curl -X POST http://localhost:8080/api/v1/repository -H "Authorization: Bearer $1" -H "Content-Type: application/json" -d '{"name": "my-repo-4", "description": "My first repository"}' \ No newline at end of file diff --git a/.dev/login.sh b/.dev/login.sh new file mode 100755 index 0000000..dd8f59b --- /dev/null +++ b/.dev/login.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +curl -X POST http://localhost:8080/api/v1/auth/login -H "Content-Type: application/json" -d '{"email":"test@example.com", "password":"password123"}' \ No newline at end of file diff --git a/.dev/register.sh b/.dev/register.sh new file mode 100755 index 0000000..92c65aa --- /dev/null +++ b/.dev/register.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +curl -X POST http://localhost:8080/api/v1/auth/register -H "Content-Type: application/json" -d '{"email":"test@example.com", "username":"testuser", "password":"password123"}' \ No newline at end of file diff --git a/.gitignore b/.gitignore index b669484..84d28c7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,14 +32,6 @@ go.work.sum # .vscode/ config.yaml - -# Folder used by air to store build files tmp/ - -# Default local storage folder -repositories/ - -main - -.vscode/ -ssh_host_key \ No newline at end of file +*sqlite* +repositories/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 3299785..c914126 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23 as builder +FROM golang:1.25 as builder WORKDIR /app COPY bin/app /app/bin/app diff --git a/Makefile b/Makefile deleted file mode 100644 index c774cbd..0000000 --- a/Makefile +++ /dev/null @@ -1,168 +0,0 @@ -# Makefile for Git Server S3 - -.PHONY: help test test-unit test-integration test-coverage build run clean lint fmt vet deps - -# Variables -BINARY_NAME=git-server-s3 -BUILD_DIR=tmp -CONFIG_FILE=config.yaml - -# Help -help: ## Display this help - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' - -# Install dependencies -deps: ## Install dependencies - go mod download - go mod tidy - -# Unit tests -test-unit: ## Run unit tests - go test -v -race -timeout 30s ./pkg/... ./internal/... - -# Tests with coverage -test-coverage: ## Run tests with coverage - go test -v -race -coverprofile=coverage.out -covermode=atomic ./pkg/... ./internal/... - go tool cover -html=coverage.out -o coverage.html - @echo "Coverage report generated: coverage.html" - -# Integration tests (requires S3_TEST_*) -test-integration: ## Run integration tests (requires S3_TEST_* variables) - @if [ -z "$(S3_TEST_BUCKET)" ]; then \ - echo "❌ S3_TEST_* variables not defined. Example:"; \ - echo "export S3_TEST_BUCKET=your-test-bucket"; \ - echo "export S3_TEST_REGION=us-east-1"; \ - echo "export S3_TEST_ENDPOINT=https://s3.us-east-1.amazonaws.com"; \ - echo "make test-integration"; \ - exit 1; \ - fi - go test -v -tags=integration -timeout 5m ./... - -# All tests -test: test-unit ## Run all tests (unit tests only by default) - -# Tests with more verbosity -test-verbose: ## Run tests with more details - go test -v -race -coverprofile=coverage.out ./pkg/... ./internal/... -args -test.v - -# Code formatting -fmt: ## Format code - go fmt ./... - -# Static checks -vet: ## Check code with go vet - go vet ./... - -# Linting (requires golangci-lint) -lint: ## Lint code (requires golangci-lint) - @if command -v golangci-lint >/dev/null 2>&1; then \ - golangci-lint run; \ - else \ - echo "⚠️ golangci-lint not installed. Installation:"; \ - echo "go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ - fi - -# Build -build: ## Build the server - @mkdir -p $(BUILD_DIR) - go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/main.go - -# Build with version information -build-release: ## Build for release - @mkdir -p $(BUILD_DIR) - go build -ldflags "-X main.version=$(shell git describe --tags --always --dirty)" \ - -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/main.go - -# Run the server -run: build ## Build and run the server - ./$(BUILD_DIR)/$(BINARY_NAME) server --config $(CONFIG_FILE) - -# Run in development mode with auto-reload (requires air) -dev: ## Run in development mode (requires air) - @if command -v air >/dev/null 2>&1; then \ - air; \ - else \ - echo "⚠️ air not installed. Installation:"; \ - echo "go install github.com/cosmtrek/air@latest"; \ - echo "Or use: make run"; \ - fi - -# Cleanup -clean: ## Clean generated files - rm -rf $(BUILD_DIR) - rm -f coverage.out coverage.html - go clean -cache -testcache - -# Performance tests -bench: ## Run benchmarks - go test -bench=. -benchmem -run=^$$ ./pkg/... ./internal/... - -# Race condition tests -race: ## Run tests with race condition detection - go test -race ./pkg/... ./internal/... - -# Security check (requires gosec) -security: ## Check code security (requires gosec) - @if command -v gosec >/dev/null 2>&1; then \ - gosec ./...; \ - else \ - echo "⚠️ gosec not installed. Installation:"; \ - echo "go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest"; \ - fi - -# Update dependencies -update-deps: ## Update dependencies - go get -u ./... - go mod tidy - -# Complete check (CI) -ci: fmt vet test-unit ## CI/CD checks - -# Memory tests with Valgrind (Linux only) -memcheck: ## Memory tests (Linux only) - @if command -v valgrind >/dev/null 2>&1; then \ - go test -c ./pkg/storage/s3/ && valgrind --leak-check=full ./s3.test; \ - else \ - echo "⚠️ valgrind not available (Linux only)"; \ - fi - -# Install development tools -install-tools: ## Install development tools - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - go install github.com/cosmtrek/air@latest - go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest - -# Test configuration example -setup-test-env: ## Display test configuration example - @echo "Configuration for integration tests:" - @echo "" - @echo "# Local MinIO (for testing)" - @echo "export S3_TEST_BUCKET=git-server-test" - @echo "export S3_TEST_REGION=us-east-1" - @echo "export S3_TEST_ENDPOINT=http://localhost:9000" - @echo "export AWS_ACCESS_KEY_ID=minio" - @echo "export AWS_SECRET_ACCESS_KEY=minio123" - @echo "" - @echo "# Real AWS S3 (for cloud testing)" - @echo "export S3_TEST_BUCKET=your-test-bucket" - @echo "export S3_TEST_REGION=us-east-1" - @echo "export S3_TEST_ENDPOINT=https://s3.us-east-1.amazonaws.com" - @echo "# + your AWS credentials via AWS CLI or environment variables" - -# Tests with different log levels -test-debug: ## Run tests with debug logs - ZEROLOG_LEVEL=debug go test -v ./pkg/... ./internal/... - -# Tests on specific files -test-s3: ## Test only the S3 package - go test -v ./pkg/storage/s3/... - -test-api: ## Test only the API - go test -v ./internal/api/... - -# Test statistics -test-stats: ## Test statistics - @echo "📊 Test statistics:" - @find . -name "*_test.go" -not -path "./vendor/*" | wc -l | xargs echo "Test files:" - @grep -r "func Test" --include="*_test.go" . | wc -l | xargs echo "Test functions:" - @grep -r "func Benchmark" --include="*_test.go" . | wc -l | xargs echo "Benchmarks:" diff --git a/README_SSH.md b/README_SSH.md deleted file mode 100644 index a66cb4e..0000000 --- a/README_SSH.md +++ /dev/null @@ -1,134 +0,0 @@ -# SSH Git Server - -## WARNING - -The SSH support is not stable and have issues (hung up error message from git cli). -Don't use this option for the moment and prefer HTTP. - -This implementation provides SSH support for Git operations alongside the existing HTTP server. - -## Features - -- **Parallel Server Startup**: Both HTTP and SSH servers run concurrently -- **Graceful Shutdown**: CTRL+C stops both servers cleanly -- **Git Protocol Support**: Full support for `git clone`, `git push`, and `git pull` over SSH -- **Authentication**: Supports both password and public key authentication (demo mode) -- **Host Key Management**: Automatic SSH host key generation and persistence - -## Usage - -### Command Line - -Start both HTTP and SSH servers: - -```bash -./git-server-s3 server \ - --ssh.enabled=true \ - --ssh.port=2222 \ - --ssh.hostkey=./ssh_host_key \ - --http.port=8080 \ - --logger.level=debug \ - --storage.type=local \ - --storage.local.path=./repositories -``` - -### Configuration File - -Create a `config.yaml` file: - -```yaml -http: - port: 8080 - logs: true -ssh: - enabled: true - port: 2222 - hostkey: ./ssh_host_key -logger: - level: debug - pretty: true -storage: - type: local - local: - path: ./repositories -``` - -Then run: -```bash -./git-server-s3 server --config config.yaml -``` - -### Environment Variables - -```bash -export SSH_ENABLED=true -export SSH_PORT=2222 -export SSH_HOST_KEY_PATH=./ssh_host_key -export HTTP_PORT=8080 -./git-server-s3 server -``` - -## Git Operations - -Once the server is running, you can use Git over SSH: - -### Clone a repository -```bash -git clone ssh://git@localhost:2222/my-repo.git -``` - -### Push to a repository -```bash -cd my-repo -git remote set-url origin ssh://git@localhost:2222/my-repo.git -git push origin main -``` - -### Pull from a repository -```bash -git pull origin main -``` - -## Authentication - -The current implementation includes demo authentication handlers: - -- **Password Authentication**: Any username with password "demo" is accepted -- **Public Key Authentication**: Any valid SSH public key is accepted - -⚠️ **Security Note**: This is for demonstration purposes only. In production, implement proper authentication against your user database. - -## Host Key Management - -- SSH host key is automatically generated on first run -- Key is saved to the specified path (default: `./ssh_host_key`) -- Key is reused on subsequent starts for client trust -- Uses Ed25519 key type for better security and performance - -## Graceful Shutdown - -The servers support graceful shutdown: - -- Press `CTRL+C` or send `SIGINT`/`SIGTERM` to stop both servers -- Servers complete ongoing requests before shutting down -- 30-second timeout for forced shutdown if needed - -## Logging - -All SSH operations are logged with structured logging: - -- Connection events -- Authentication attempts -- Git command execution -- Error conditions - -Example log output: -``` -2:34PM INF Starting HTTP server port=8080 -2:34PM INF Starting SSH server port=2222 -2:34PM INF SSH Git server started addr=:2222 -^C2:34PM INF Shutdown signal received, stopping servers... -2:34PM INF Shutting down HTTP server -2:34PM INF Shutting down SSH server -2:34PM INF All servers stopped gracefully -``` diff --git a/TESTS.md b/TESTS.md deleted file mode 100644 index 491ffe2..0000000 --- a/TESTS.md +++ /dev/null @@ -1,162 +0,0 @@ -# Tests Git Server S3 - -Cette documentation décrit l'architecture et l'organisation des tests pour le serveur Git S3. - -## Vue d'ensemble - -Nous avons implémenté une stratégie de tests complète qui couvre : -- **Tests unitaires** des contrôleurs API -- **Tests d'intégration** avec stockage local -- **Tests des cas d'erreur** -- **Benchmarks** de performance -- **Couverture de code** - -## Types de tests - -### 1. Tests unitaires du Repository Controller -**Fichier**: `internal/api/controller/repo_controller_test.go` - -```bash -# Exécuter uniquement les tests du controller -go test ./internal/api/controller/ -v -``` - -**Tests couverts**: -- ✅ `TestCreateRepoSuccess` - Création réussie d'un repository -- ✅ `TestCreateRepoInvalidJSON` - Gestion des JSON invalides -- ✅ `TestCreateRepoMissingName` - Gestion des noms manquants -- ✅ `TestCreateRepoStorageError` - Gestion des erreurs de stockage -- ✅ `TestListReposSuccess` - Listage réussi des repositories -- ✅ `TestListReposEmpty` - Listage avec liste vide -- ✅ `TestListReposStorageError` - Gestion des erreurs de listage -- ✅ `TestRepoControllerIntegration` - Test d'intégration complet - -**Architecture**: -- Utilise `MockGitRepositoryStorage` pour isoler les tests -- Mocking complet avec `stretchr/testify/mock` -- Tests des réponses HTTP et des codes de statut -- Vérification des appels aux mocks - -### 2. Tests d'intégration -**Fichier**: `cmd/integration_test.go` - -```bash -# Exécuter les tests d'intégration -go test ./cmd/ -v -run TestLocalStorageIntegration -``` - -**Tests couverts**: -- ✅ Cycle de vie complet repository (création, listage, vérification) -- ✅ Endpoints Git (info/refs avec git-upload-pack) -- ✅ Stockage sur filesystem avec validation -- ✅ Gestion de repositories multiples -- ✅ Tests des routes API complètes - -**Architecture**: -- Utilise un répertoire temporaire pour les tests -- Stockage local pour éviter les dépendances S3 -- Tests end-to-end avec serveur HTTP complet -- Validation du filesystem et des structures Git - -### 3. Tests des cas d'erreur -**Fichier**: `cmd/integration_test.go` (fonction `TestErrorCases`) - -```bash -# Exécuter les tests d'erreur -go test ./cmd/ -v -run TestErrorCases -``` - -**Tests couverts**: -- ✅ JSON malformé -- ✅ Repositories dupliqués -- ✅ Accès à repositories inexistants -- ✅ Gestion des codes d'erreur HTTP - -### 4. Benchmarks de performance -**Fichier**: `cmd/integration_test.go` (fonction `BenchmarkAPIEndpoints`) - -```bash -# Exécuter les benchmarks -go test ./cmd/ -bench=. -benchmem -v -``` - -**Métriques**: -- `ListRepositories`: ~3.9ms/opération -- `CreateRepository`: ~1.1ms/opération -- Mesure de l'allocation mémoire - -## Organisation des mocks - -### MockGitRepositoryStorage -Interface complète mockée pour `storage.GitRepositoryStorage`: -- `GetStorer(repoPath string) (storer.Storer, error)` -- `CreateRepository(repoPath string) error` -- `RepositoryExists(repoPath string) bool` -- `DeleteRepository(repoPath string) error` -- `ListRepositories() ([]string, error)` -- `Configure() error` - -## Commandes Make disponibles - -```bash -# Tests unitaires uniquement les contrôleurs -make test-unit - -# Tests avec couverture -make test-coverage - -# Benchmarks -go test ./cmd/ -bench=. -benchmem -v - -# Nos tests spécifiques (qui passent tous) -go test ./internal/api/controller/ -v # Tests controller -go test ./cmd/ -v -run TestLocalStorageIntegration # Tests intégration -go test ./cmd/ -v -run TestErrorCases # Tests erreurs -``` - -## Couverture de code - -**Contrôleurs**: 16.8% de couverture -- Tous les endpoints principaux testés -- Gestion d'erreurs couverte -- Codes de réponse HTTP validés - -**Fonctions communes**: Tests existants mais avec des échecs sur la logique de normalisation des chemins - -## Stratégie de mock S3 - -**Problème initial**: -- Les tests S3 nécessitaient des vraies connexions ou des mocks complexes -- Le client `awss3.Client` est difficile à mocker directement - -**Solution adoptée**: -- Tests d'intégration avec stockage local pour éviter S3 -- Isolation des tests de logique métier des dépendances externes -- Mocks uniquement pour les interfaces métier (GitRepositoryStorage) - -## Bonnes pratiques appliquées - -1. **Isolation**: Chaque test utilise des répertoires temporaires -2. **Nettoyage**: Suppression automatique des fichiers temporaires -3. **Timeouts**: Tous les tests ont des timeouts de sécurité -4. **Assertions**: Utilisation de `testify/assert` et `testify/require` -5. **Couverture**: Tests de tous les chemins d'exécution principaux -6. **Performance**: Benchmarks pour surveiller les performances -7. **Documentation**: Tests auto-documentés avec noms explicites - -## Prochaines étapes possibles - -1. **Tests S3 réels**: Avec testcontainers et MinIO -2. **Tests du Git Controller**: Mocking des opérations Git -3. **Tests de charge**: Avec plus de repositories -4. **Tests de concurrence**: Accès simultané -5. **Tests de sécurité**: Validation des inputs -6. **CI/CD**: Intégration dans pipeline automatisé - -## Résultats actuels - -- ✅ **24 tests passent** sur nos nouvelles implémentations -- ✅ **0 échecs** dans nos tests spécifiques -- ✅ **Architecture testable** mise en place -- ✅ **Isolation complète** des dépendances -- ✅ **Documentation** et organisation claire diff --git a/application/auth_app.go b/application/auth_app.go new file mode 100644 index 0000000..5b16ba3 --- /dev/null +++ b/application/auth_app.go @@ -0,0 +1,177 @@ +package application + +import ( + "fmt" + "slices" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/labbs/ogit/domain" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/helpers/tokenutil" + "github.com/labbs/ogit/interfaces/http/v1/auth/dtos" + "github.com/rs/zerolog" + "golang.org/x/crypto/bcrypt" +) + +type AuthApp struct { + Config config.Config + Logger zerolog.Logger + UserApp UserApp + SessionApp SessionApp + TokenApp TokenApp + RepositoryApp RepositoryApp +} + +func NewAuthApp(config config.Config, logger zerolog.Logger, userApp UserApp, sessionApp SessionApp, tokenApp TokenApp, repositoryApp RepositoryApp) *AuthApp { + return &AuthApp{ + Config: config, + Logger: logger, + UserApp: userApp, + SessionApp: sessionApp, + TokenApp: tokenApp, + RepositoryApp: repositoryApp, + } +} + +func (c *AuthApp) Authenticate(email, password string, ctx *fiber.Ctx) (*dtos.LoginResponse, error) { + logger := c.Logger.With().Str("component", "application.auth.authenticate").Logger() + + user, err := c.UserApp.GetByEmail(email) + if err != nil { + return nil, fmt.Errorf("failed to get user by email: %w", err) + } + + if !user.Active { + logger.Warn().Str("email", email).Msg("attempt to authenticate inactive user") + return nil, fmt.Errorf("user is not active") + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + if err != nil { + logger.Warn().Str("email", email).Msg("invalid password attempt") + return nil, fmt.Errorf("invalid credentials") + } + + session := &domain.Session{ + UserId: user.Id, + UserAgent: ctx.Get("User-Agent"), + IpAddress: ctx.IP(), + ExpiresAt: time.Now().Add(time.Minute * time.Duration(c.Config.Session.ExpirationMinutes)), + } + + err = c.SessionApp.Create(session) + if err != nil { + logger.Error().Err(err).Str("user_id", user.Id).Msg("failed to create session") + return nil, fmt.Errorf("failed to create session: %w", err) + } + + accessToken, err := tokenutil.CreateAccessToken(user.Id, session.Id, c.Config) + if err != nil { + logger.Error().Err(err).Str("user_id", user.Id).Str("session_id", session.Id).Msg("failed to create access token") + return nil, fmt.Errorf("failed to create access token: %w", err) + } + + return &dtos.LoginResponse{ + Token: accessToken, + }, nil +} + +func (c *AuthApp) HTTPGitAuthenticate(name, token, repoSlug, operation string) (*domain.Token, error) { + logger := c.Logger.With().Str("component", "application.auth.http_git_authenticate").Logger() + + // Récupérer le repository + repo, err := c.RepositoryApp.GetRepository(nil, &repoSlug) + if err != nil { + logger.Warn().Err(err).Str("repo_slug", repoSlug).Msg("failed to get repository by slug") + return nil, fmt.Errorf("repository not found") + } + + // Récupérer le token + authToken, err := c.TokenApp.GetByNameAndToken(name, token) + if err != nil { + logger.Warn().Err(err).Str("name", name).Msg("failed to get token by name and token") + return nil, fmt.Errorf("invalid token") + } + + // Vérifier les scopes du token + var requiredScope domain.TokenScope + switch operation { + case "git-upload-pack": // git clone, git fetch + requiredScope = domain.ScopeReadRepository + case "git-receive-pack": // git push + requiredScope = domain.ScopeWriteRepository + default: + return nil, fmt.Errorf("invalid git operation") + } + + hasScope := slices.Contains(authToken.Scopes, requiredScope) + if !hasScope { + return nil, fmt.Errorf("token does not have required scope") + } + + // Vérifier les permissions selon le type de token + switch authToken.Type { + case domain.TokenUser: + // Token utilisateur - vérifier les permissions via user + groupes + if authToken.User.Id == "" || !authToken.User.Active { + return nil, fmt.Errorf("user is not active") + } + + // Vérifier l'accès via les permissions repository_member + gitOperation := "read" + if operation == "git-receive-pack" { + gitOperation = "write" + } + + canAccess, err := c.RepositoryApp.CanUserAccess(repo.Id, authToken.User.Id, gitOperation) + if err != nil { + logger.Error().Err(err).Msg("failed to check user access") + return nil, fmt.Errorf("access check failed") + } + if !canAccess { + return nil, fmt.Errorf("user does not have access to repository") + } + + case domain.TokenRepo: + // Token repository - vérifier qu'il appartient au bon repo + if authToken.RepositoryId == nil || *authToken.RepositoryId != repo.Id { + return nil, fmt.Errorf("token does not belong to repository") + } + default: + return nil, fmt.Errorf("invalid token type") + } + + return authToken, nil +} + +func (c *AuthApp) Register(username, email, password string) error { + logger := c.Logger.With().Str("component", "application.auth.register").Logger() + + // check if the email is already in use + _, err := c.UserApp.GetByEmail(email) + if err == nil { + return fmt.Errorf("email is already in use") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logger.Error().Err(err).Str("email", email).Msg("failed to hash password") + return fmt.Errorf("failed to hash password: %w", err) + } + + user := domain.User{ + Username: username, + Email: email, + Password: string(hashedPassword), + Active: true, + } + + err = c.UserApp.Create(user) + if err != nil { + logger.Error().Err(err).Str("email", email).Msg("failed to create user") + return fmt.Errorf("failed to create user: %w", err) + } + + return nil +} diff --git a/application/git_app.go b/application/git_app.go new file mode 100644 index 0000000..bce14bc --- /dev/null +++ b/application/git_app.go @@ -0,0 +1,21 @@ +package application + +import ( + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/storage" + "github.com/rs/zerolog" +) + +type GitApp struct { + Config config.Config + Logger zerolog.Logger + StorageConfig storage.Config +} + +func NewGitApp(config config.Config, logger zerolog.Logger, storageConfig storage.Config) *GitApp { + return &GitApp{ + Config: config, + Logger: logger, + StorageConfig: storageConfig, + } +} diff --git a/application/repository_app.go b/application/repository_app.go new file mode 100644 index 0000000..6c99bb3 --- /dev/null +++ b/application/repository_app.go @@ -0,0 +1,195 @@ +package application + +import ( + "fmt" + + "github.com/gofiber/fiber/v2/utils" + "github.com/gosimple/slug" + "github.com/labbs/ogit/domain" + "github.com/labbs/ogit/infrastructure/config" + helperError "github.com/labbs/ogit/infrastructure/helpers/error" + "github.com/labbs/ogit/infrastructure/storage" + "github.com/rs/zerolog" + "gorm.io/gorm" +) + +type RepositoryApp struct { + Config config.Config + Logger zerolog.Logger + RepoPres domain.RepositoryPers + RepositoryMemberPers domain.RepositoryMemberPers + SSHKeyPres domain.SSHKeyPers + StorageConfig storage.Config +} + +func NewRepositoryApp(config config.Config, logger zerolog.Logger, repoPers domain.RepositoryPers, repositoryMemberPers domain.RepositoryMemberPers, sshKeyPers domain.SSHKeyPers, storageConfig storage.Config) *RepositoryApp { + return &RepositoryApp{ + Config: config, + Logger: logger, + RepoPres: repoPers, + SSHKeyPres: sshKeyPers, + StorageConfig: storageConfig, + RepositoryMemberPers: repositoryMemberPers, + } +} + +func (c *RepositoryApp) Create(name, description, creator string, isPrivate bool) (*domain.Repository, error) { + logger := c.Logger.With().Str("component", "application.repository.create").Logger() + + repo := &domain.Repository{ + Id: utils.UUIDv4(), + Name: name, + Description: description, + IsPrivate: isPrivate, + Slug: slug.Make(name), + } + + err := c.RepoPres.Create(repo) + if helperError.Catch(err) == gorm.ErrDuplicatedKey { + logger.Warn().Str("repo_name", name).Msg("repository with the same name already exists") + return nil, fmt.Errorf("repository with the same name already exists") + } else if err != nil { + logger.Error().Err(err).Str("repo_id", repo.Id).Str("repo_name", repo.Name).Msg("failed to create repository") + return nil, err + } + + err = c.StorageConfig.CreateRepository(repo.Slug) + if err != nil { + logger.Error().Err(err).Str("repo_id", repo.Id).Str("repo_name", repo.Name).Msg("failed to create repository storage") + // Attempt to clean up the database entry if storage creation fails + c.RepoPres.Delete(*repo) + return nil, fmt.Errorf("failed to create repository storage: %w", err) + } + + member := &domain.RepositoryMember{ + Id: utils.UUIDv4(), + RepositoryId: repo.Id, + MemberType: domain.MemberTypeUser, + UserId: &creator, + AccessType: domain.AccessTypeOwner, + } + + err = c.RepositoryMemberPers.Create(member) + if helperError.Catch(err) == gorm.ErrDuplicatedKey { + logger.Warn().Str("repo_id", repo.Id).Str("user_id", creator).Msg("repository member already exists") + return nil, fmt.Errorf("repository member already exists") + } else if err != nil { + logger.Error().Err(err).Str("repo_id", repo.Id).Str("user_id", creator).Msg("failed to create repository member") + c.RepoPres.Delete(*repo) + c.StorageConfig.DeleteRepository(repo.Slug) + return nil, err + } + + return repo, nil +} + +func (c *RepositoryApp) GetRepository(id, slug *string) (*domain.Repository, error) { + logger := c.Logger.With().Str("component", "application.repository.get").Logger() + + if id == nil && slug == nil { + logger.Error().Msg("either id or slug must be provided") + return nil, fmt.Errorf("either id or slug must be provided") + } + + repo := domain.Repository{ + Id: *id, + Slug: *slug, + } + + repository, err := c.RepoPres.GetRepository(repo) + switch helperError.Catch(err) { + case nil: + case gorm.ErrRecordNotFound: + logger.Warn().Str("repo_id", repo.Id).Str("repo_slug", repo.Slug).Msg("repository not found") + return nil, fmt.Errorf("repository not found") + default: + logger.Error().Err(err).Str("repo_id", repo.Id).Str("repo_slug", repo.Slug).Msg("failed to get repository") + return nil, err + } + + return repository, nil +} + +func (c *RepositoryApp) GetUserEffectiveRole(repositoryId, userId string) (domain.AccessType, error) { + logger := c.Logger.With().Str("component", "application.repository.get_user_effective_role").Logger() + + // Récupérer tous les membres du repository (users + groups) avec les relations + members, err := c.RepositoryMemberPers.GetByRepositoryWithRelations(repositoryId) + if err != nil { + logger.Error().Err(err).Str("repository_id", repositoryId).Msg("failed to get repository members") + return domain.AccessTypeNone, err + } + + var effectiveRole domain.AccessType = domain.AccessTypeNone + + for _, member := range members { + switch member.MemberType { + case domain.MemberTypeUser: + // Permission directe utilisateur + if member.UserId != nil && *member.UserId == userId { + effectiveRole = domain.MaxAccessType(effectiveRole, member.AccessType) + logger.Debug(). + Str("user_id", userId). + Str("direct_role", string(member.AccessType)). + Str("current_effective", string(effectiveRole)). + Msg("Found direct user permission") + } + + case domain.MemberTypeGroup: + // Permission via groupe - vérifier si l'user est dans le groupe + if member.GroupId != nil { + // Vérifier si l'utilisateur est membre du groupe + for _, groupMember := range member.Group.Members { + if groupMember.Id == userId { + effectiveRole = domain.MaxAccessType(effectiveRole, member.AccessType) + logger.Debug(). + Str("user_id", userId). + Str("group_id", *member.GroupId). + Str("group_role", string(member.AccessType)). + Str("current_effective", string(effectiveRole)). + Msg("Found group permission") + break + } + } + } + } + } + + logger.Debug(). + Str("user_id", userId). + Str("repository_id", repositoryId). + Str("effective_role", string(effectiveRole)). + Msg("Calculated effective role") + + return effectiveRole, nil +} + +func (c *RepositoryApp) CanUserAccess(repositoryId, userId, operation string) (bool, error) { + // Récupérer le repository pour vérifier s'il est privé + repo, err := c.GetRepository(&repositoryId, nil) + if err != nil { + return false, err + } + + // Si le repo est public et l'opération est de lecture, autoriser + if !repo.IsPrivate && operation == "read" { + return true, nil + } + + // Sinon, vérifier les permissions + effectiveRole, err := c.GetUserEffectiveRole(repositoryId, userId) + if err != nil { + return false, err + } + + switch operation { + case "read": + return effectiveRole.CanRead(), nil + case "write": + return effectiveRole.CanWrite(), nil + case "admin": + return effectiveRole.CanAdmin(), nil + default: + return false, fmt.Errorf("unknown operation: %s", operation) + } +} diff --git a/application/session_app.go b/application/session_app.go new file mode 100644 index 0000000..f9abf50 --- /dev/null +++ b/application/session_app.go @@ -0,0 +1,108 @@ +package application + +import ( + "fmt" + "time" + + "github.com/gofiber/fiber/v2/utils" + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/ogit/domain" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/helpers/tokenutil" + "github.com/rs/zerolog" +) + +type SessionApp struct { + Config config.Config + Logger zerolog.Logger + SessionPers domain.SessionPers + UserApp *UserApp +} + +func NewSessionApp(config config.Config, logger zerolog.Logger, sessionPers domain.SessionPers, userApp *UserApp) *SessionApp { + return &SessionApp{ + Config: config, + Logger: logger, + SessionPers: sessionPers, + UserApp: userApp, + } +} + +func (c *SessionApp) Create(session *domain.Session) error { + logger := c.Logger.With().Str("component", "application.session.create").Logger() + + session.Id = utils.UUIDv4() + + err := c.SessionPers.Create(session) + if err != nil { + logger.Error().Err(err).Str("session_id", session.Id).Str("user_id", session.UserId).Msg("failed to create session") + return err + } + + return nil +} + +func (c *SessionApp) DeleteExpired() error { + // logger := c.Logger.With().Str("component", "application.session.delete_expired").Logger() + + return nil +} + +func (c *SessionApp) ValidateToken(token string) (*fiberoapi.AuthContext, error) { + logger := c.Logger.With().Str("component", "application.session.validate_token").Logger() + + sessionId, err := tokenutil.GetSessionIdFromToken(token, c.Config) + if err != nil { + logger.Error().Err(err).Str("token", token).Msg("failed to get session id from token") + return nil, fmt.Errorf("invalid token") + } + + session, err := c.SessionPers.GetById(sessionId) + if err != nil { + logger.Error().Err(err).Str("token", token).Msg("failed to get session by token") + return nil, fmt.Errorf("invalid token") + } + + if session.ExpiresAt.Before(time.Now()) { + logger.Warn().Str("token", token).Msg("session has expired") + return nil, fmt.Errorf("session has expired") + } + + ctx := &fiberoapi.AuthContext{ + UserID: session.UserId, + } + + return ctx, nil +} + +func (c *SessionApp) HasRole(ctx *fiberoapi.AuthContext, role string) bool { + logger := c.Logger.With().Str("component", "application.session.has_role").Logger() + + logger.Warn().Msg("not implemented") + + return false +} + +func (c *SessionApp) HasScope(ctx *fiberoapi.AuthContext, scope string) bool { + logger := c.Logger.With().Str("component", "application.session.has_scope").Logger() + + logger.Warn().Msg("not implemented") + + return false +} + +func (c *SessionApp) CanAccessResource(ctx *fiberoapi.AuthContext, resourceType, resourceID, action string) (bool, error) { + logger := c.Logger.With().Str("component", "application.session.can_access_resource").Logger() + + logger.Warn().Msg("not implemented") + + return false, fmt.Errorf("not implemented") +} + +func (c *SessionApp) GetUserPermissions(ctx *fiberoapi.AuthContext, resourceType, resourceID string) (*fiberoapi.ResourcePermission, error) { + logger := c.Logger.With().Str("component", "application.session.get_user_permissions").Logger() + + logger.Warn().Msg("not implemented") + + return nil, fmt.Errorf("not implemented") +} diff --git a/application/token_app.go b/application/token_app.go new file mode 100644 index 0000000..ff7868a --- /dev/null +++ b/application/token_app.go @@ -0,0 +1,72 @@ +package application + +import ( + "fmt" + "time" + + "github.com/labbs/ogit/domain" + "github.com/labbs/ogit/infrastructure/config" + helperError "github.com/labbs/ogit/infrastructure/helpers/error" + "github.com/rs/zerolog" + "gorm.io/gorm" +) + +type TokenApp struct { + Config config.Config + Logger zerolog.Logger + TokenPers domain.TokenPers +} + +func NewTokenApp(config config.Config, logger zerolog.Logger, tokenPers domain.TokenPers) *TokenApp { + return &TokenApp{ + Config: config, + Logger: logger, + TokenPers: tokenPers, + } +} + +// GetByNameAndToken retrieves a token by its name and token string, ensuring it is active and not expired. +func (c *TokenApp) GetByNameAndToken(name, tokenStr string) (*domain.Token, error) { + logger := c.Logger.With().Str("component", "application.token.get_by_name_and_token").Logger() + + logger.Debug().Str("name", name).Msg("fetching token by name and token") + + token, err := c.TokenPers.GetByNameAndToken(name, tokenStr) + switch helperError.Catch(err) { + case gorm.ErrRecordNotFound: + logger.Warn().Str("name", name).Msg("token not found") + return nil, fmt.Errorf("token not found") + case nil: + // No error, continue processing + default: + logger.Error().Err(err).Str("name", name).Msg("failed to fetch token by name and token") + return nil, fmt.Errorf("failed to fetch token: %w", err) + } + + if !token.Active { + logger.Warn().Str("name", name).Msg("token inactive") + return nil, fmt.Errorf("token inactive") + } + + if token.ExpiresAt != nil && token.ExpiresAt.Before(time.Now()) { + logger.Warn().Str("name", name).Msg("token expired") + return nil, fmt.Errorf("token expired") + } + + return token, nil +} + +func (c *TokenApp) Create(token *domain.Token) error { + logger := c.Logger.With().Str("component", "application.token.create").Logger() + + err := c.TokenPers.Create(token) + if helperError.Catch(err) == gorm.ErrDuplicatedKey { + logger.Warn().Str("token_name", token.Description).Msg("token with the same name already exists") + return fmt.Errorf("token with the same name already exists") + } else if err != nil { + logger.Error().Err(err).Str("token_id", token.Id).Str("token_name", token.Description).Msg("failed to create token") + return err + } + + return nil +} diff --git a/application/user_app.go b/application/user_app.go new file mode 100644 index 0000000..e7bbc70 --- /dev/null +++ b/application/user_app.go @@ -0,0 +1,59 @@ +package application + +import ( + "fmt" + + "github.com/labbs/ogit/domain" + "github.com/labbs/ogit/infrastructure/config" + helperError "github.com/labbs/ogit/infrastructure/helpers/error" + "github.com/rs/zerolog" + "gorm.io/gorm" +) + +type UserApp struct { + Config config.Config + Logger zerolog.Logger + UserPres domain.UserPers + GroupPres domain.GroupPers +} + +func NewUserApp(config config.Config, logger zerolog.Logger, userPers domain.UserPers, groupPers domain.GroupPers) *UserApp { + return &UserApp{ + Config: config, + Logger: logger, + UserPres: userPers, + GroupPres: groupPers, + } +} + +func (c *UserApp) GetByEmail(email string) (*domain.User, error) { + logger := c.Logger.With().Str("component", "application.user.get_by_email").Logger() + + user, err := c.UserPres.GetByEmail(email) + switch helperError.Catch(err) { + case gorm.ErrRecordNotFound: + logger.Warn().Str("email", email).Msg("user not found") + return nil, fmt.Errorf("user not found") + case nil: + // no error + default: + logger.Error().Err(err).Str("email", email).Msg("failed to get user by email") + return nil, err + } + return &user, nil +} + +func (c *UserApp) Create(user domain.User) error { + logger := c.Logger.With().Str("component", "application.user.create").Logger() + + err := c.UserPres.Create(&user) + if helperError.Catch(err) == gorm.ErrDuplicatedKey { + logger.Warn().Str("email", user.Email).Msg("user with the same email already exists") + return fmt.Errorf("user with the same email already exists") + } else if err != nil { + logger.Error().Err(err).Str("email", user.Email).Msg("failed to create user") + return err + } + + return nil +} diff --git a/cmd/cmd.go b/cmd/cmd.go index ee6b8ee..b99766f 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -6,19 +6,22 @@ import ( "log" "os" - "github.com/urfave/cli/v3" + "github.com/labbs/ogit/interfaces/cli/migration" + "github.com/labbs/ogit/interfaces/cli/server" - "github.com/labbs/git-server-s3/internal/cmd" + "github.com/urfave/cli/v3" ) var version = "development" +// main is the entry point of the application. +// It sets up the CLI commands and handles configuration file loading. func main() { sources := cli.NewValueSourceChain() cmd := &cli.Command{ - Name: "stack-deployer", + Name: "oGit", Version: version, - Usage: "Application used to deploy vision stack", + Usage: "Git server with web interface and storage in blob stores (S3, GCS, Azure, etc.)", Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { config := cmd.String("config") if len(config) > 0 { @@ -34,7 +37,8 @@ func main() { return ctx, nil }, Commands: []*cli.Command{ - cmd.NewInstance(version), + server.NewInstance(version), + migration.NewInstance(version), }, } diff --git a/cmd/integration_test.go b/cmd/integration_test.go deleted file mode 100644 index cfd3d9f..0000000 --- a/cmd/integration_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/labbs/git-server-s3/internal/api/router" - "github.com/labbs/git-server-s3/internal/config" - "github.com/labbs/git-server-s3/pkg/storage/local" - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestLocalStorageIntegration tests the API with local storage to avoid S3 complications -func TestLocalStorageIntegration(t *testing.T) { - // Create a temporary directory for tests - tempDir, err := os.MkdirTemp("", "git-server-test-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - // Test configuration with local storage - config.Storage.Type = "local" - config.Storage.Local.Path = tempDir - - // Silent logger for tests - logger := zerolog.New(os.Stderr).Level(zerolog.ErrorLevel) - - // Initialize local storage - localStorage := local.NewLocalStorage(logger) - err = localStorage.Configure() - require.NoError(t, err) - - // Create the Fiber app for tests - app := fiber.New(fiber.Config{ - DisableStartupMessage: true, - }) - - // Configure routes - routerConfig := &router.Config{ - Fiber: app, - Logger: logger, - Storage: localStorage, - } - router.NewRepoRouter(routerConfig) - router.NewGitRouter(routerConfig) - - // Test 1: List repositories (empty at start) - t.Run("list_empty_repositories", func(t *testing.T) { - req := httptest.NewRequest("GET", "/api/repos", nil) - resp, err := app.Test(req, 5*1000) // 5 second timeout - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - var repos []string - err = json.Unmarshal(body, &repos) - require.NoError(t, err) - assert.Empty(t, repos) - }) - - // Test 2: Create a new repository - var testRepoName = "integration-test-repo" - t.Run("create_repository", func(t *testing.T) { - reqBody := map[string]string{ - "name": testRepoName, - } - bodyBytes, err := json.Marshal(reqBody) - require.NoError(t, err) - - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusCreated, resp.StatusCode) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Equal(t, "repository created", string(body)) - }) - - // Test 3: Verify that the repository exists on the filesystem - t.Run("verify_repository_created", func(t *testing.T) { - expectedPath := filepath.Join(tempDir, testRepoName+".git") - assert.DirExists(t, expectedPath) - - // Verify that it's a bare Git repository - configPath := filepath.Join(expectedPath, "config") - assert.FileExists(t, configPath) - - headPath := filepath.Join(expectedPath, "HEAD") - assert.FileExists(t, headPath) - }) - - // Test 4: List repositories (now with the new one) - t.Run("list_repositories_with_new_repo", func(t *testing.T) { - req := httptest.NewRequest("GET", "/api/repos", nil) - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - var repos []string - err = json.Unmarshal(body, &repos) - require.NoError(t, err) - - assert.Len(t, repos, 1) - assert.Contains(t, repos, testRepoName+".git") - }) - - // Test 5: Test the Git info/refs endpoint - t.Run("git_info_refs", func(t *testing.T) { - url := fmt.Sprintf("/%s.git/info/refs?service=git-upload-pack", testRepoName) - req := httptest.NewRequest("GET", url, nil) - - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "application/x-git-upload-pack-advertisement", resp.Header.Get("Content-Type")) - - // The body should contain Git protocol data - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.NotEmpty(t, body) - }) - - // Test 6: Create another repository to test multiple repository management - var secondRepoName = "second-repo" - t.Run("create_second_repository", func(t *testing.T) { - reqBody := map[string]string{ - "name": secondRepoName, - } - bodyBytes, err := json.Marshal(reqBody) - require.NoError(t, err) - - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusCreated, resp.StatusCode) - }) - - // Test 7: Verify that both repositories appear - t.Run("list_multiple_repositories", func(t *testing.T) { - req := httptest.NewRequest("GET", "/api/repos", nil) - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - var repos []string - err = json.Unmarshal(body, &repos) - require.NoError(t, err) - - assert.Len(t, repos, 2) - assert.Contains(t, repos, testRepoName+".git") - assert.Contains(t, repos, secondRepoName+".git") - }) -} - -// TestErrorCases tests error cases -func TestErrorCases(t *testing.T) { - // Create a temporary directory for tests - tempDir, err := os.MkdirTemp("", "git-server-error-test-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - // Test configuration - config.Storage.Type = "local" - config.Storage.Local.Path = tempDir - - logger := zerolog.New(os.Stderr).Level(zerolog.ErrorLevel) - localStorage := local.NewLocalStorage(logger) - err = localStorage.Configure() - require.NoError(t, err) - - app := fiber.New(fiber.Config{ - DisableStartupMessage: true, - }) - - routerConfig := &router.Config{ - Fiber: app, - Logger: logger, - Storage: localStorage, - } - router.NewRepoRouter(routerConfig) - router.NewGitRouter(routerConfig) - - // Test 1: Request with invalid JSON - t.Run("invalid_json", func(t *testing.T) { - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader([]byte("invalid json"))) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) - - // Test 2: Create the same repository twice - t.Run("duplicate_repository", func(t *testing.T) { - repoName := "duplicate-repo" - - // Première création (doit réussir) - reqBody := map[string]string{"name": repoName} - bodyBytes, _ := json.Marshal(reqBody) - - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, http.StatusCreated, resp.StatusCode) - - // Second creation (should fail) - req = httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err = app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - // Should return an error because the repo already exists - assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - }) - - // Test 3: Access a repository that doesn't exist - t.Run("nonexistent_repository", func(t *testing.T) { - req := httptest.NewRequest("GET", "/nonexistent-repo.git/info/refs?service=git-upload-pack", nil) - - resp, err := app.Test(req, 5*1000) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - }) -} - -// BenchmarkAPIEndpoints tests the performance of main endpoints -func BenchmarkAPIEndpoints(b *testing.B) { - tempDir, err := os.MkdirTemp("", "git-server-benchmark-*") - require.NoError(b, err) - defer os.RemoveAll(tempDir) - - config.Storage.Type = "local" - config.Storage.Local.Path = tempDir - - logger := zerolog.New(os.Stderr).Level(zerolog.ErrorLevel) - localStorage := local.NewLocalStorage(logger) - err = localStorage.Configure() - require.NoError(b, err) - - app := fiber.New(fiber.Config{ - DisableStartupMessage: true, - }) - - routerConfig := &router.Config{ - Fiber: app, - Logger: logger, - Storage: localStorage, - } - router.NewRepoRouter(routerConfig) - - b.Run("ListRepositories", func(b *testing.B) { - // Create a few repositories for the benchmark - for i := 0; i < 10; i++ { - reqBody := map[string]string{"name": fmt.Sprintf("benchmark-repo-%d", i)} - bodyBytes, _ := json.Marshal(reqBody) - - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, _ := app.Test(req) - resp.Body.Close() - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - req := httptest.NewRequest("GET", "/api/repos", nil) - resp, _ := app.Test(req) - resp.Body.Close() - } - }) - - b.Run("CreateRepository", func(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - reqBody := map[string]string{"name": fmt.Sprintf("bench-repo-%d-%d", time.Now().UnixNano(), i)} - bodyBytes, _ := json.Marshal(reqBody) - - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, _ := app.Test(req) - resp.Body.Close() - } - }) -} diff --git a/config-example.yaml b/config-example.yaml index 9307231..78cde9e 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -1,20 +1,9 @@ http: port: 8080 logs: true -ssh: - enabled: true - port: 2222 - hostkey: ./ssh_host_key logger: - level: debug - pretty: true -storage: - type: s3 # or local - local: - path: ./repositories - s3: - bucket: git - endpoint: https://xxxxxxxx.com - access-key: xxxxxxxx - secret-key: xxxxxxxx - region: eu-west-2 \ No newline at end of file + level: info + pretty: false +database: + dialect: sqlite + dsn: ./database.sqlite \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100755 index 0000000..4862207 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + db: + image: postgres + restart: always + environment: + POSTGRES_USER: local + POSTGRES_PASSWORD: local + POSTGRES_DB: local + ports: + - 5432:5432 diff --git a/domain/common.go b/domain/common.go new file mode 100644 index 0000000..7bba0f3 --- /dev/null +++ b/domain/common.go @@ -0,0 +1,20 @@ +package domain + +import ( + "database/sql/driver" + "encoding/json" +) + +// JSONB is a map of strings to interfaces +type JSONB map[string]any + +// Value implements the driver.Valuer interface +func (j JSONB) Value() (driver.Value, error) { + valueString, err := json.Marshal(j) + return string(valueString), err +} + +// Scan implements the sql.Scanner interface +func (j *JSONB) Scan(value any) error { + return json.Unmarshal([]byte(value.(string)), j) +} diff --git a/domain/config.go b/domain/config.go new file mode 100644 index 0000000..b24afdd --- /dev/null +++ b/domain/config.go @@ -0,0 +1,11 @@ +package domain + +import "time" + +type Config struct { + Id string + Key string + Value string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/domain/group.go b/domain/group.go new file mode 100644 index 0000000..e329d1a --- /dev/null +++ b/domain/group.go @@ -0,0 +1,28 @@ +package domain + +import "time" + +type Group struct { + Id string + Name string + Description string + + Role Role `gorm:"type:role;default:'user'"` + + // Owner is the username of the user who owns the group + OwnerId string + // OwnerUser is the user who owns the group + OwnerUser User `gorm:"foreignKey:OwnerId;references:Id"` + + // Members is the list of users who are members of the group + Members []User `gorm:"many2many:group_members;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (g *Group) TableName() string { + return "group" +} + +type GroupPers interface{} diff --git a/domain/member.go b/domain/member.go new file mode 100644 index 0000000..d976192 --- /dev/null +++ b/domain/member.go @@ -0,0 +1,64 @@ +package domain + +type MemberType string +type AccessType string + +type Member struct { + Id string + Type MemberType + Access AccessType +} + +// AccessTypeViewer is the access type viewer +const ( + AccessTypeNone AccessType = "none" + AccessTypeReporter AccessType = "reporter" + AccessTypeDeveloper AccessType = "developer" + AccessTypeMaintainer AccessType = "maintainer" + AccessTypeOwner AccessType = "owner" +) + +// MemberType is the type of member +const ( + MemberTypeUser MemberType = "user" + MemberTypeGroup MemberType = "group" +) + +// Level returns the level of the AccessType +func (a AccessType) Level() int { + switch a { + case AccessTypeNone: + return 0 + case AccessTypeReporter: + return 1 + case AccessTypeDeveloper: + return 2 + case AccessTypeMaintainer: + return 3 + case AccessTypeOwner: + return 4 + default: + return 0 + } +} + +// MaxAccessType returns the maximum AccessType between two AccessTypes +func MaxAccessType(a1, a2 AccessType) AccessType { + if a1.Level() > a2.Level() { + return a1 + } + return a2 +} + +// Verify if the AccessType has read, write or admin permissions +func (a AccessType) CanRead() bool { + return a.Level() >= AccessTypeReporter.Level() +} + +func (a AccessType) CanWrite() bool { + return a.Level() >= AccessTypeDeveloper.Level() +} + +func (a AccessType) CanAdmin() bool { + return a.Level() >= AccessTypeMaintainer.Level() +} diff --git a/domain/repository.go b/domain/repository.go new file mode 100644 index 0000000..d1b6e1c --- /dev/null +++ b/domain/repository.go @@ -0,0 +1,70 @@ +package domain + +import "time" + +type Repository struct { + Id string `gorm:"primaryKey"` + Name string + Description string + Slug string + + DefaultBranch string + + IsArchived bool `gorm:"default:false"` + ArchivedAt time.Time + + IsPrivate bool `gorm:"default:true"` + + // DeployKeys DeployKeys `gorm:"foreignKey:RepositoryId"` + // WebHooks WebHooks `gorm:"foreignKey:RepositoryId"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Repository) TableName() string { + return "repository" +} + +// DeployKeys represents a list of deploy keys for a repository. +type DeployKeys []DeployKey + +type DeployKey struct { + Name string + Key string + AccessType AccessType + CreatedAt time.Time +} + +// WebHook represents a webhook for a repository. +type WebHooks []WebHook +type WebHook struct { + Id string + Url string + ContentType ContentType + Secret string + SSLVerify bool + Events Events + Actived bool + + CreatedAt time.Time + UpdatedAt time.Time +} + +type ContentType string +type JsonContentType ContentType + +type Events []Event + +type Event string +type AllEvents Event +type PushEvent Event +type PullRequestEvent Event +type IssuesEvent Event +type IssueCommentEvent Event + +type RepositoryPers interface { + GetRepository(repo Repository) (*Repository, error) + Create(repo *Repository) error + Delete(repo Repository) error +} diff --git a/domain/repository_member.go b/domain/repository_member.go new file mode 100644 index 0000000..2c13be0 --- /dev/null +++ b/domain/repository_member.go @@ -0,0 +1,31 @@ +package domain + +import "time" + +type RepositoryMember struct { + Id string + RepositoryId string + Repository Repository `gorm:"foreignKey:RepositoryId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + MemberType MemberType + + UserId *string + User User `gorm:"foreignKey:UserId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + GroupId *string + Group Group `gorm:"foreignKey:GroupId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + AccessType AccessType + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (RepositoryMember) TableName() string { + return "repository_member" +} + +type RepositoryMemberPers interface { + GetByRepositoryWithRelations(repositoryId string) ([]RepositoryMember, error) + Create(member *RepositoryMember) error +} diff --git a/domain/role.go b/domain/role.go new file mode 100644 index 0000000..66e8602 --- /dev/null +++ b/domain/role.go @@ -0,0 +1,9 @@ +package domain + +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" + RoleGest Role = "guest" +) diff --git a/domain/session.go b/domain/session.go new file mode 100644 index 0000000..cf71afa --- /dev/null +++ b/domain/session.go @@ -0,0 +1,25 @@ +package domain + +import "time" + +type Session struct { + Id string + UserId string + + User User `gorm:"foreignKey:UserId;references:Id"` + UserAgent string + IpAddress string + ExpiresAt time.Time + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (s *Session) TableName() string { + return "session" +} + +type SessionPers interface { + Create(session *Session) error + GetById(id string) (*Session, error) +} diff --git a/domain/ssh_key.go b/domain/ssh_key.go new file mode 100644 index 0000000..ca119e5 --- /dev/null +++ b/domain/ssh_key.go @@ -0,0 +1,44 @@ +package domain + +import ( + "time" +) + +type SSHKey struct { + Id string + UserId string + RepoId string + PublicKey string + Type SSHType + + Repository Repository `gorm:"foreignKey:RepoId"` + User User `gorm:"foreignKey:UserId"` + + OwnerType SSHKeyOwnerType + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SSHKey) TableName() string { + return "ssh_key" +} + +type SSHType string + +// ed25519, rsa, ecdsa, dsa +const ( + SSHTypeED25519 SSHType = "ed25519" + SSHTypeRSA SSHType = "rsa" + SSHTypeECDSA SSHType = "ecdsa" + SSHTypeDSA SSHType = "dsa" +) + +type SSHKeyOwnerType string + +const ( + SSHKeyTypeUser SSHKeyOwnerType = "user" + SSHKeyTypeRepository SSHKeyOwnerType = "repository" +) + +type SSHKeyPers interface{} diff --git a/domain/token.go b/domain/token.go new file mode 100644 index 0000000..05a9363 --- /dev/null +++ b/domain/token.go @@ -0,0 +1,80 @@ +package domain + +import ( + "database/sql/driver" + "time" + + "github.com/goccy/go-json" + + "github.com/gofiber/fiber/v2/utils" + "gorm.io/gorm" +) + +type Token struct { + Id string + Name string + Description string + Token string + + Type TokenType + + UserId *string + User User `gorm:"foreignKey:UserId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + RepositoryId *string + Repository Repository `gorm:"foreignKey:RepositoryId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + Active bool + + Scopes TokenScopes + + ExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Token) TableName() string { + return "token" +} + +func (t *Token) BeforeCreate(tx *gorm.DB) error { + t.Id = utils.UUIDv4() + return nil +} + +type TokenType string + +const ( + TokenUser TokenType = "user" + TokenRepo TokenType = "repo" +) + +type TokenScopes []TokenScope + +type TokenScope string + +const ( + ScopeReadRepository TokenScope = "read:repository" + ScopeWriteRepository TokenScope = "write:repository" + ScopeAdminRepository TokenScope = "admin:repository" + ScopeCreatePullRequest TokenScope = "create:pull_request" + ScopeMergePullRequest TokenScope = "merge:pull_request" + ScopeCommentPullRequest TokenScope = "comment:pull_request" +) + +func (t TokenScopes) Value() (driver.Value, error) { + valueString, err := json.Marshal(t) + return string(valueString), err +} + +func (t *TokenScopes) Scan(value any) error { + return json.Unmarshal([]byte(value.(string)), t) +} + +type TokenPers interface { + Create(token *Token) error + GetByNameAndToken(name, tokenStr string) (*Token, error) + GetByUserId(userId string) ([]Token, error) + GetById(id string) (*Token, error) + Delete(token *Token) error +} diff --git a/domain/user.go b/domain/user.go new file mode 100644 index 0000000..f9338fe --- /dev/null +++ b/domain/user.go @@ -0,0 +1,29 @@ +package domain + +import "time" + +type User struct { + Id string + Username string + Email string + Password string + + AvatarUrl string + Preferences JSONB + Active bool + + Role Role `gorm:"type:role;default:'user'"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (u *User) TableName() string { + return "user" +} + +type UserPers interface { + GetByUsername(username string) (User, error) + GetByEmail(email string) (User, error) + Create(user *User) error +} diff --git a/entrypoint.sh b/entrypoint.sh index dccfc56..67094e5 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,8 +1,6 @@ -#!/bin/sh -set -e +#!/usr/bin/env bash -# Start the migration -# ./app migration -c /config/config.yaml +set -e -# Start the server +./app migration -c /config/config.yaml exec ./app server -c /config/config.yaml \ No newline at end of file diff --git a/go.mod b/go.mod index e4fcb37..477f339 100644 --- a/go.mod +++ b/go.mod @@ -1,70 +1,90 @@ -module github.com/labbs/git-server-s3 +module github.com/labbs/ogit -go 1.24.2 +go 1.25.1 require ( - github.com/aws/aws-sdk-go-v2 v1.38.3 - github.com/aws/aws-sdk-go-v2/config v1.31.6 - github.com/aws/aws-sdk-go-v2/credentials v1.18.10 - github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 - github.com/gliderlabs/ssh v0.3.8 + github.com/aws/aws-sdk-go-v2 v1.39.2 + github.com/aws/aws-sdk-go-v2/config v1.31.12 + github.com/aws/aws-sdk-go-v2/credentials v1.18.16 + github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 + github.com/go-co-op/gocron/v2 v2.16.6 github.com/go-git/go-billy/v5 v5.6.2 - github.com/go-git/go-git/v5 v5.16.2 + github.com/go-git/go-git/v5 v5.16.3 + github.com/go-playground/validator/v10 v10.28.0 github.com/goccy/go-json v0.10.5 github.com/gofiber/fiber/v2 v2.52.9 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/gosimple/slug v1.15.0 + github.com/labbs/fiber-oapi v1.5.0 + github.com/pressly/goose/v3 v3.26.0 github.com/rs/zerolog v1.34.0 - github.com/stretchr/testify v1.11.1 - github.com/urfave/cli-altsrc/v3 v3.0.1 + github.com/urfave/cli-altsrc/v3 v3.1.0 github.com/urfave/cli/v3 v3.4.1 - golang.org/x/crypto v0.41.0 + golang.org/x/crypto v0.42.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.0 ) require ( - dario.cat/mergo v1.0.1 // indirect + dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect github.com/aws/smithy-go v1.23.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.4.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/pjbgf/sha1cd v0.4.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - github.com/sergi/go-diff v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect + github.com/valyala/fasthttp v1.66.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1511772..987b0fb 100644 --- a/go.sum +++ b/go.sum @@ -1,50 +1,50 @@ -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= -github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= +github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= -github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo= -github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ= -github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg= -github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= +github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8= +github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8= +github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 h1:w9LnHqTq8MEdlnyhV4Bwfizd65lfNCNgdlNC6mM5paE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9/go.mod h1:LGEP6EK4nj+bwWNdrvX/FnDTFowdBNwcSPuZu/ouFys= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg= -github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 h1:X0FveUndcZ3lKbSpIC6rMYGRiQTcUVRNH6X4yYtIrlU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0/go.mod h1:IWjQYlqw4EX9jw2g3qnEPPWvCE6bS8fKzhMed1OK7c8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 h1:wuZ5uW2uhJR63zwNlqWH2W4aL4ZjeJP3o92/W+odDY4= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9/go.mod h1:/G58M2fGszCrOzvJUkDdY8O9kycodunH4VdT5oBAqls= +github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 h1:mUI3b885qJgfqKDUSj6RgbRqLdX0wGmg8ruM03zNfQA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4/go.mod h1:6v8ukAxc7z4x4oBjGUsLnH7KGLY9Uhcgij19UJNkiMg= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= @@ -55,37 +55,71 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-co-op/gocron/v2 v2.16.6 h1:zI2Ya9sqvuLcgqJgV79LwoJXM8h20Z/drtB7ATbpRWo= +github.com/go-co-op/gocron/v2 v2.16.6/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= -github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= +github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= +github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= -github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -93,61 +127,87 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/labbs/fiber-oapi v1.5.0 h1:cGE1Qe6/ZG1WV9yznQLAaYQok+5kUehIRBXygibKCx4= +github.com/labbs/fiber-oapi v1.5.0/go.mod h1:LJH6op9b+X3TEX1an9Hy1r1bE9DAujrOppyJXargmFs= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY= -github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +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/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/urfave/cli-altsrc/v3 v3.0.1 h1:v+gHk59syLk8ao9rYybZs43+D5ut/gzj0omqQ1XYl8k= -github.com/urfave/cli-altsrc/v3 v3.0.1/go.mod h1:8UtsKKcxFVzvaoySFPfvQOk413T+IXJhaCWyyoPW3yM= +github.com/urfave/cli-altsrc/v3 v3.1.0 h1:6E5+kXeAWmRxXlPgdEVf9VqVoTJ2MJci0UMpUi/w/bA= +github.com/urfave/cli-altsrc/v3 v3.1.0/go.mod h1:VcWVTGXcL3nrXUDJZagHAeUX702La3PKeWav7KpISqA= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU= +github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA= -golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -157,14 +217,14 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -174,5 +234,20 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go new file mode 100644 index 0000000..05026c9 --- /dev/null +++ b/infrastructure/config/config.go @@ -0,0 +1,67 @@ +package config + +type Config struct { + // Version of the application + Version string + + // ConfigFile is the path to the configuration file + ConfigFile string + + Server struct { + // Port is the server port + Port int + // HttpLogs indicates if HTTP logs are enabled + HttpLogs bool + } + + // Logger is the configuration for the zerolog logger. + // Level is the log level for the logger. + // Pretty enables or disables pretty printing of logs (non JSON logs). + Logger struct { + Level string + Pretty bool + } + + // Database is the configuration for the database connection. + // Dialect is the database engine (sqlite, postgres, etc.). + // DSN is the Data Source Name for the database connection. + Database struct { + Dialect string // Database engine (sqlite, postgres, etc.) + DSN string + } + + Session struct { + SecretKey string + ExpirationMinutes int + Issuer string + } + + Auth struct { + DisableAdminAccount bool + } + + Registration struct { + Enabled bool // Enable or disable user registration + RequireEmailVerification bool // Require email verification for new registrations + DomainWhitelist []string // List of allowed domains for registration + PasswordMinLength int // Minimum password length for registration + PasswordComplexity bool // Require complex passwords (uppercase, lowercase, numbers, symbols) + } + + // StorageType is the type of storage to use (e.g., local, s3). + Storage struct { + Type string + + S3 struct { + Bucket string + Endpoint string + AccessKey string + SecretKey string + Region string + } + + Local struct { + Path string + } + } +} diff --git a/infrastructure/config/database_flags.go b/infrastructure/config/database_flags.go new file mode 100644 index 0000000..01ce5d9 --- /dev/null +++ b/infrastructure/config/database_flags.go @@ -0,0 +1,34 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func DatabaseFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "database.dialect", + Usage: "The database dialect (e.g., sqlite, postgres, mysql)", + Aliases: []string{"db.dialect"}, + Value: "sqlite", + Destination: &cfg.Database.Dialect, + Sources: cli.NewValueSourceChain( + cli.EnvVar("DATABASE_DIALECT"), + altsrcyaml.YAML("database.dialect", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "database.dsn", + Usage: "The database DSN (Data Source Name)", + Aliases: []string{"db.dsn"}, + Value: "./database.sqlite", + Destination: &cfg.Database.DSN, + Sources: cli.NewValueSourceChain( + cli.EnvVar("DATABASE_DSN"), + altsrcyaml.YAML("database.dsn", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/internal/flags/generic.go b/infrastructure/config/generic_flags.go similarity index 62% rename from internal/flags/generic.go rename to infrastructure/config/generic_flags.go index d58a020..c7c2ee5 100644 --- a/internal/flags/generic.go +++ b/infrastructure/config/generic_flags.go @@ -1,19 +1,17 @@ -package flags +package config import ( - "github.com/labbs/git-server-s3/internal/config" - "github.com/urfave/cli/v3" ) -func GenericFlags() []cli.Flag { +func GenericFlags(cfg *Config) []cli.Flag { return []cli.Flag{ &cli.StringFlag{ Name: "config", Aliases: []string{"c"}, Value: "config.yaml", Usage: "Path to the configuration file", - Destination: &config.ConfigFile, + Destination: &cfg.ConfigFile, }, } } diff --git a/infrastructure/config/http_flags.go b/infrastructure/config/http_flags.go new file mode 100644 index 0000000..a67400b --- /dev/null +++ b/infrastructure/config/http_flags.go @@ -0,0 +1,32 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func ServerFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.IntFlag{ + Name: "http.port", + Aliases: []string{"p"}, + Value: 8080, + Destination: &cfg.Server.Port, + Sources: cli.NewValueSourceChain( + cli.EnvVar("HTTP_PORT"), + altsrcyaml.YAML("http.port", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.BoolFlag{ + Name: "http.logs", + Aliases: []string{"l"}, + Value: false, + Destination: &cfg.Server.HttpLogs, + Sources: cli.NewValueSourceChain( + cli.EnvVar("HTTP_LOGS"), + altsrcyaml.YAML("http.logs", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/internal/flags/logger.go b/infrastructure/config/logger_flags.go similarity index 74% rename from internal/flags/logger.go rename to infrastructure/config/logger_flags.go index 21277db..b209713 100644 --- a/internal/flags/logger.go +++ b/infrastructure/config/logger_flags.go @@ -1,32 +1,30 @@ -package flags +package config import ( - "github.com/labbs/git-server-s3/internal/config" - altsrc "github.com/urfave/cli-altsrc/v3" altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" "github.com/urfave/cli/v3" ) -func LoggerFlags() []cli.Flag { +func LoggerFlags(cfg *Config) []cli.Flag { return []cli.Flag{ &cli.StringFlag{ Name: "logger.level", Aliases: []string{"l"}, Value: "info", - Destination: &config.Logger.Level, + Destination: &cfg.Logger.Level, Sources: cli.NewValueSourceChain( cli.EnvVar("LOGGER_LEVEL"), - altsrcyaml.YAML("logger.level", altsrc.NewStringPtrSourcer(&config.ConfigFile)), + altsrcyaml.YAML("logger.level", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), ), }, &cli.BoolFlag{ Name: "logger.pretty", Value: false, - Destination: &config.Logger.Pretty, + Destination: &cfg.Logger.Pretty, Sources: cli.NewValueSourceChain( cli.EnvVar("LOGGER_PRETTY"), - altsrcyaml.YAML("logger.pretty", altsrc.NewStringPtrSourcer(&config.ConfigFile)), + altsrcyaml.YAML("logger.pretty", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), ), }, } diff --git a/infrastructure/config/registration_flags.go b/infrastructure/config/registration_flags.go new file mode 100644 index 0000000..8b228b2 --- /dev/null +++ b/infrastructure/config/registration_flags.go @@ -0,0 +1,59 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func RegistrationFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: "registration.enabled", + Value: true, + Destination: &cfg.Registration.Enabled, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_ENABLED"), + altsrcyaml.YAML("registration.enabled", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.BoolFlag{ + Name: "registration.require_email_verification", + Value: true, + Destination: &cfg.Registration.RequireEmailVerification, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_REQUIRE_EMAIL_VERIFICATION"), + altsrcyaml.YAML("registration.require_email_verification", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringSliceFlag{ + Name: "registration.domain_whitelist", + Value: []string{}, + Destination: &cfg.Registration.DomainWhitelist, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_DOMAIN_WHITELIST"), + altsrcyaml.YAML("registration.domain_whitelist", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + Usage: "List of allowed email domains for registration (comma separated)", + }, + &cli.IntFlag{ + Name: "registration.password_min_length", + Value: 12, + Destination: &cfg.Registration.PasswordMinLength, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_PASSWORD_MIN_LENGTH"), + altsrcyaml.YAML("registration.password_min_length", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.BoolFlag{ + Name: "registration.password_complexity", + Value: true, + Destination: &cfg.Registration.PasswordComplexity, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_PASSWORD_COMPLEXITY"), + altsrcyaml.YAML("registration.password_complexity", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + Usage: "Require complex passwords (uppercase, lowercase, numbers, symbols)", + }, + } +} diff --git a/infrastructure/config/session_flags.go b/infrastructure/config/session_flags.go new file mode 100644 index 0000000..908b33f --- /dev/null +++ b/infrastructure/config/session_flags.go @@ -0,0 +1,39 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func SessionFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.IntFlag{ + Name: "session.expiration_minutes", + Value: 60 * 24 * 30, // 1 month + Destination: &cfg.Session.ExpirationMinutes, + Sources: cli.NewValueSourceChain( + cli.EnvVar("SESSION_EXPIRATION_MINUTES"), + altsrcyaml.YAML("session.expiration_minutes", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "session.secret_key", + Value: "supersecretkey", // In production, use a secure key and do not hardcode it + Destination: &cfg.Session.SecretKey, + Sources: cli.NewValueSourceChain( + cli.EnvVar("SESSION_SECRET_KEY"), + altsrcyaml.YAML("session.secret_key", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "session.issuer", + Value: "ogit", // Issuer name for the session tokens + Destination: &cfg.Session.Issuer, + Sources: cli.NewValueSourceChain( + cli.EnvVar("SESSION_ISSUER"), + altsrcyaml.YAML("session.issuer", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/infrastructure/config/storage_flags.go b/infrastructure/config/storage_flags.go new file mode 100644 index 0000000..65f42af --- /dev/null +++ b/infrastructure/config/storage_flags.go @@ -0,0 +1,92 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func StorageFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "storage.type", + Usage: "The storage type (e.g., local, s3, gcs). " + + "Local storage stores files on the local filesystem, " + + "S3 uses Amazon S3-compatible storage services, " + + "and GCS uses Google Cloud Storage.", + Aliases: []string{"storage"}, + Value: "local", + Destination: &cfg.Storage.Type, + Sources: cli.NewValueSourceChain( + cli.EnvVar("STORAGE_TYPE"), + altsrcyaml.YAML("storage.type", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "storage.local.path", + Usage: "The local storage path (used when storage.type is 'local')", + Aliases: []string{"storage.local", "storage.path"}, + Value: "./repositories", + Destination: &cfg.Storage.Local.Path, + Sources: cli.NewValueSourceChain( + cli.EnvVar("STORAGE_LOCAL_PATH"), + altsrcyaml.YAML("storage.local.path", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "storage.s3.bucket", + Usage: "The S3 bucket name (used when storage.type is 's3')", + Aliases: []string{"storage.s3"}, + Value: "", + Destination: &cfg.Storage.S3.Bucket, + Sources: cli.NewValueSourceChain( + cli.EnvVar("STORAGE_S3_BUCKET"), + altsrcyaml.YAML("storage.s3.bucket", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "storage.s3.endpoint", + Usage: "The S3 endpoint URL (used when storage.type is 's3')", + Aliases: []string{"storage.s3.endpoint"}, + Value: "", + Destination: &cfg.Storage.S3.Endpoint, + Sources: cli.NewValueSourceChain( + cli.EnvVar("STORAGE_S3_ENDPOINT"), + altsrcyaml.YAML("storage.s3.endpoint", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "storage.s3.accesskey", + Usage: "The S3 access key (used when storage.type is 's3')", + Aliases: []string{"storage.s3.accesskey"}, + Value: "", + Destination: &cfg.Storage.S3.AccessKey, + Sources: cli.NewValueSourceChain( + cli.EnvVar("STORAGE_S3_ACCESSKEY"), + altsrcyaml.YAML("storage.s3.accesskey", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "storage.s3.secretkey", + Usage: "The S3 secret key (used when storage.type is 's3')", + Aliases: []string{"storage.s3.secretkey"}, + Value: "", + Destination: &cfg.Storage.S3.SecretKey, + Sources: cli.NewValueSourceChain( + cli.EnvVar("STORAGE_S3_SECRETKEY"), + altsrcyaml.YAML("storage.s3.secretkey", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "storage.s3.region", + Usage: "The S3 region (used when storage.type is 's3')", + Aliases: []string{"storage.s3.region"}, + Value: "us-east-1", + Destination: &cfg.Storage.S3.Region, + Sources: cli.NewValueSourceChain( + cli.EnvVar("STORAGE_S3_REGION"), + altsrcyaml.YAML("storage.s3.region", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/infrastructure/cronscheduler/gocron.go b/infrastructure/cronscheduler/gocron.go new file mode 100644 index 0000000..44fe9fc --- /dev/null +++ b/infrastructure/cronscheduler/gocron.go @@ -0,0 +1,29 @@ +package cronscheduler + +import ( + "time" + + "github.com/labbs/ogit/infrastructure/logger/zerolog" + + "github.com/go-co-op/gocron/v2" + z "github.com/rs/zerolog" +) + +type Config struct { + CronScheduler gocron.Scheduler +} + +// Configure sets up the cron scheduler with the provided logger. +// Will return an error if the scheduler cannot be created (fatal) +func Configure(logger z.Logger) (Config, error) { + logger = logger.With().Str("component", "infrastructure.cronscheduler").Logger() + var cfg Config + s, err := gocron.NewScheduler(gocron.WithLogger(zerolog.GocronAdapter{Logger: logger}), gocron.WithLocation(time.UTC)) + if err != nil { + logger.Fatal().Err(err).Str("event", "cronscheduler.configure.new").Msg("Failed to create cron scheduler") + return cfg, err + } + cfg.CronScheduler = s + s.Start() + return cfg, nil +} diff --git a/infrastructure/database/gorm.go b/infrastructure/database/gorm.go new file mode 100644 index 0000000..78ef4e3 --- /dev/null +++ b/infrastructure/database/gorm.go @@ -0,0 +1,41 @@ +package database + +import ( + "github.com/labbs/ogit/infrastructure/config" + zerologadapter "github.com/labbs/ogit/infrastructure/logger/zerolog" + + z "github.com/rs/zerolog" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type Config struct { + Db *gorm.DB +} + +// Configure sets up the database connection based on the provided configuration and logger. +// It supports sqlite, postgres, and mysql databases. +// Will return an error if the connection cannot be established (fatal) +func Configure(_cfg config.Config, logger z.Logger) (Config, error) { + logger = logger.With().Str("component", "infrastructure.database").Logger() + gormLogger := zerologadapter.NewGormLogger(logger) + + var db *gorm.DB + var err error + + // Check if the database is managed + switch _cfg.Database.Dialect { + case "sqlite": + db, err = gorm.Open(sqlite.Open(_cfg.Database.DSN), &gorm.Config{Logger: gormLogger}) + case "postgres": + db, err = gorm.Open(postgres.Open(_cfg.Database.DSN), &gorm.Config{Logger: gormLogger}) + default: + logger.Fatal().Str("event", "database.configure.invalid_dialect").Msg("Invalid database type") + } + if err != nil { + return Config{}, err + } + + return Config{Db: db}, nil +} diff --git a/infrastructure/deps.go b/infrastructure/deps.go new file mode 100644 index 0000000..078677e --- /dev/null +++ b/infrastructure/deps.go @@ -0,0 +1,27 @@ +package infrastructure + +import ( + "github.com/labbs/ogit/application" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/cronscheduler" + "github.com/labbs/ogit/infrastructure/database" + "github.com/labbs/ogit/infrastructure/http" + "github.com/labbs/ogit/infrastructure/storage" + "github.com/rs/zerolog" +) + +type Deps struct { + Config config.Config + Logger zerolog.Logger + Http http.Config + CronScheduler cronscheduler.Config + Database database.Config + Storage storage.Config + + UserApp *application.UserApp + SessionApp *application.SessionApp + AuthApp *application.AuthApp + RepoApp *application.RepositoryApp + GitApp *application.GitApp + TokenApp *application.TokenApp +} diff --git a/infrastructure/helpers/error/sql_error_catch.go b/infrastructure/helpers/error/sql_error_catch.go new file mode 100644 index 0000000..ed572cf --- /dev/null +++ b/infrastructure/helpers/error/sql_error_catch.go @@ -0,0 +1,48 @@ +package error + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +var ( + // Map des patterns génériques vers les erreurs GORM + errorMap = map[string]error{ + "repository.name": gorm.ErrDuplicatedKey, + "repository_name": gorm.ErrDuplicatedKey, + "user.email": gorm.ErrDuplicatedKey, + "user_email": gorm.ErrDuplicatedKey, + "unique constraint failed": gorm.ErrDuplicatedKey, + "duplicate key": gorm.ErrDuplicatedKey, + "duplicate entry": gorm.ErrDuplicatedKey, + "violates unique constraint": gorm.ErrDuplicatedKey, + "foreign key constraint failed": gorm.ErrForeignKeyViolated, + "violates foreign key constraint": gorm.ErrForeignKeyViolated, + "foreign key constraint fails": gorm.ErrForeignKeyViolated, + "check constraint failed": gorm.ErrCheckConstraintViolated, + "violates check constraint": gorm.ErrCheckConstraintViolated, + "check constraint": gorm.ErrCheckConstraintViolated, + } +) + +func Catch(err error) error { + if err == nil { + return nil + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + return gorm.ErrRecordNotFound + } + + errorMsg := strings.ToLower(err.Error()) + + for pattern, gormErr := range errorMap { + if strings.Contains(errorMsg, pattern) { + return gormErr + } + } + + return err +} diff --git a/pkg/common/extract_repo_path_from_url.go b/infrastructure/helpers/git/extract_repo_path_from_url.go similarity index 98% rename from pkg/common/extract_repo_path_from_url.go rename to infrastructure/helpers/git/extract_repo_path_from_url.go index 5b51d75..ee71713 100644 --- a/pkg/common/extract_repo_path_from_url.go +++ b/infrastructure/helpers/git/extract_repo_path_from_url.go @@ -1,5 +1,5 @@ // Package common provides utility functions shared across the Git server. -package common +package git import "strings" diff --git a/pkg/common/get_transport_server.go b/infrastructure/helpers/git/get_transport_server.go similarity index 76% rename from pkg/common/get_transport_server.go rename to infrastructure/helpers/git/get_transport_server.go index 688462f..d46b70a 100644 --- a/pkg/common/get_transport_server.go +++ b/infrastructure/helpers/git/get_transport_server.go @@ -1,4 +1,4 @@ -package common +package git import ( "path/filepath" @@ -6,10 +6,10 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/server" "github.com/gofiber/fiber/v2" - "github.com/labbs/git-server-s3/pkg/storage" + "github.com/labbs/ogit/infrastructure/storage" ) -func GetTransportServer(repoPath string, str storage.GitRepositoryStorage) (transport.Transport, *transport.Endpoint, error) { +func GetTransportServer(repoPath string, str storage.Config) (transport.Transport, *transport.Endpoint, error) { normalizedPath := NormalizeRepoPath(repoPath) if !str.RepositoryExists(normalizedPath) { diff --git a/pkg/common/normalize_repo_path.go b/infrastructure/helpers/git/normalize_repo_path.go similarity index 98% rename from pkg/common/normalize_repo_path.go rename to infrastructure/helpers/git/normalize_repo_path.go index 8600ffc..24712d2 100644 --- a/pkg/common/normalize_repo_path.go +++ b/infrastructure/helpers/git/normalize_repo_path.go @@ -1,4 +1,4 @@ -package common +package git import "strings" diff --git a/pkg/common/write_service_adertisement.go b/infrastructure/helpers/git/write_service_adertisement.go similarity index 97% rename from pkg/common/write_service_adertisement.go rename to infrastructure/helpers/git/write_service_adertisement.go index 9abdce2..0288390 100644 --- a/pkg/common/write_service_adertisement.go +++ b/infrastructure/helpers/git/write_service_adertisement.go @@ -1,4 +1,4 @@ -package common +package git import "io" diff --git a/infrastructure/helpers/mapper/map_struct_by_field_names.go b/infrastructure/helpers/mapper/map_struct_by_field_names.go new file mode 100644 index 0000000..808fe1f --- /dev/null +++ b/infrastructure/helpers/mapper/map_struct_by_field_names.go @@ -0,0 +1,58 @@ +package mapper + +import ( + "reflect" +) + +// MapStructByFieldNames maps fields from source struct to destination struct +// based on matching field names (case sensitive). +// Both src and dst should be pointers to structs. +func MapStructByFieldNames(src interface{}, dst interface{}) error { + srcValue := reflect.ValueOf(src) + dstValue := reflect.ValueOf(dst) + + // Ensure we have pointers + if srcValue.Kind() != reflect.Ptr || dstValue.Kind() != reflect.Ptr { + panic("both src and dst must be pointers") + } + + // Get the underlying structs + srcStruct := srcValue.Elem() + dstStruct := dstValue.Elem() + + // Ensure we have structs + if srcStruct.Kind() != reflect.Struct || dstStruct.Kind() != reflect.Struct { + panic("both src and dst must point to structs") + } + + srcType := srcStruct.Type() + dstType := dstStruct.Type() + + // Create a map of destination field names for quick lookup + dstFields := make(map[string]reflect.Value) + for i := 0; i < dstStruct.NumField(); i++ { + field := dstStruct.Field(i) + fieldName := dstType.Field(i).Name + if field.CanSet() { + dstFields[fieldName] = field + } + } + + // Iterate through source fields and map to destination + for i := 0; i < srcStruct.NumField(); i++ { + srcField := srcStruct.Field(i) + srcFieldName := srcType.Field(i).Name + + // Check if destination has a field with the same name + if dstField, exists := dstFields[srcFieldName]; exists { + // Check if types are compatible + if srcField.Type().AssignableTo(dstField.Type()) { + dstField.Set(srcField) + } else if srcField.Type().ConvertibleTo(dstField.Type()) { + dstField.Set(srcField.Convert(dstField.Type())) + } + } + } + + return nil +} diff --git a/infrastructure/helpers/tokenutil/structs.go b/infrastructure/helpers/tokenutil/structs.go new file mode 100644 index 0000000..e6d92bd --- /dev/null +++ b/infrastructure/helpers/tokenutil/structs.go @@ -0,0 +1,15 @@ +package tokenutil + +import "github.com/golang-jwt/jwt/v4" + +type JwtCustomClaims struct { + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + jwt.RegisteredClaims +} + +type JwtCustomRefreshClaims struct { + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + jwt.RegisteredClaims +} diff --git a/infrastructure/helpers/tokenutil/tokenutil.go b/infrastructure/helpers/tokenutil/tokenutil.go new file mode 100644 index 0000000..1184cef --- /dev/null +++ b/infrastructure/helpers/tokenutil/tokenutil.go @@ -0,0 +1,40 @@ +package tokenutil + +import ( + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/labbs/ogit/infrastructure/config" +) + +func CreateAccessToken(user_id, sessionId string, config config.Config) (accessToken string, err error) { + exp := time.Now().Add(time.Minute * time.Duration(config.Session.ExpirationMinutes)).Unix() + claims := &JwtCustomClaims{ + SessionID: sessionId, + UserID: user_id, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: config.Session.Issuer, + ExpiresAt: &jwt.NumericDate{Time: time.Unix(exp, 0)}, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + t, err := token.SignedString([]byte(config.Session.SecretKey)) + if err != nil { + return "", err + } + return t, nil +} + +func GetSessionIdFromToken(tokenString string, config config.Config) (string, error) { + token, err := jwt.ParseWithClaims(tokenString, &JwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(config.Session.SecretKey), nil + }) + if err != nil { + return "", err + } + if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid { + return claims.SessionID, nil + } else { + return "", err + } +} diff --git a/infrastructure/helpers/validator/is_uuid.go b/infrastructure/helpers/validator/is_uuid.go new file mode 100644 index 0000000..834960b --- /dev/null +++ b/infrastructure/helpers/validator/is_uuid.go @@ -0,0 +1,15 @@ +package validator + +import v "github.com/go-playground/validator/v10" + +func IsValidUUID(uuid string) bool { + validate := v.New() + + type tempStruct struct { + Value string `validate:"uuid4"` + } + + temp := tempStruct{Value: uuid} + err := validate.Struct(temp) + return err == nil +} diff --git a/infrastructure/http/http.go b/infrastructure/http/http.go new file mode 100644 index 0000000..1c440ba --- /dev/null +++ b/infrastructure/http/http.go @@ -0,0 +1,70 @@ +package http + +import ( + "github.com/labbs/ogit/application" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/logger/zerolog" + + "github.com/goccy/go-json" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/requestid" + fiberoapi "github.com/labbs/fiber-oapi" + z "github.com/rs/zerolog" +) + +type Config struct { + Fiber *fiber.App + FiberOapi *fiberoapi.OApiApp +} + +// Configure sets up the HTTP server (fiber) with the provided configuration and logger. +// The FiberOapi instance is also configured for OpenAPI support and exposed documentation. +// Will return an error if the server cannot be created (fatal) +func Configure(_cfg config.Config, logger z.Logger, session application.SessionApp, enableIU bool) (Config, error) { + var c Config + fiberConfig := fiber.Config{ + JSONEncoder: json.Marshal, + JSONDecoder: json.Unmarshal, + DisableStartupMessage: true, + } + + r := fiber.New(fiberConfig) + + if _cfg.Server.HttpLogs { + r.Use(zerolog.HTTPLogger(logger)) + } + + r.Use(recover.New()) + r.Use(cors.New()) + r.Use(compress.New()) + r.Use(requestid.New()) + + oapiConfig := fiberoapi.Config{ + EnableValidation: true, + EnableOpenAPIDocs: true, + OpenAPIDocsPath: "/documentation", + OpenAPIJSONPath: "/api-spec.json", + OpenAPIYamlPath: "/api-spec.yaml", + AuthService: &session, + EnableAuthorization: true, + SecuritySchemes: map[string]fiberoapi.SecurityScheme{ + "bearerAuth": { + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + Description: "JWT Bearer token", + }, + }, + DefaultSecurity: []map[string][]string{ + {"bearerAuth": {}}, + }, + } + + c.FiberOapi = fiberoapi.New(r, oapiConfig) + c.Fiber = r + + return c, nil +} diff --git a/infrastructure/jobs/clean_users_sessions.go b/infrastructure/jobs/clean_users_sessions.go new file mode 100644 index 0000000..76c6046 --- /dev/null +++ b/infrastructure/jobs/clean_users_sessions.go @@ -0,0 +1,18 @@ +package jobs + +import "github.com/go-co-op/gocron/v2" + +func (c *Config) CleanUsersSessions() error { + logger := c.Logger.With().Str("component", "infrastructure.jobs.clean_users_sessions").Logger() + + _, err := c.CronScheduler.CronScheduler.NewJob( + gocron.CronJob("*/1 * * * * ", false), // Every 1 minute + gocron.NewTask(func() { _ = c.SessionApp.DeleteExpired() }), + gocron.WithName("CleanUsersSessions"), + ) + if err != nil { + logger.Error().Err(err).Msg("failed to schedule CleanUsersSessions job") + } + + return err +} diff --git a/infrastructure/jobs/jobs.go b/infrastructure/jobs/jobs.go new file mode 100644 index 0000000..8b7f98c --- /dev/null +++ b/infrastructure/jobs/jobs.go @@ -0,0 +1,24 @@ +package jobs + +import ( + "github.com/labbs/ogit/application" + "github.com/labbs/ogit/infrastructure/cronscheduler" + "github.com/rs/zerolog" +) + +type Config struct { + Logger zerolog.Logger + CronScheduler cronscheduler.Config + SessionApp application.SessionApp +} + +func (c *Config) SetupJobs() error { + logger := c.Logger.With().Str("component", "infrastructure.jobs").Logger() + + if err := c.CleanUsersSessions(); err != nil { + logger.Error().Err(err).Msg("failed to setup CleanUsersSessions job") + return err + } + + return nil +} diff --git a/pkg/logger/logger.go b/infrastructure/logger/logger.go similarity index 100% rename from pkg/logger/logger.go rename to infrastructure/logger/logger.go diff --git a/infrastructure/logger/zerolog/gocron_adapter.go b/infrastructure/logger/zerolog/gocron_adapter.go new file mode 100644 index 0000000..0d221be --- /dev/null +++ b/infrastructure/logger/zerolog/gocron_adapter.go @@ -0,0 +1,27 @@ +package zerolog + +import z "github.com/rs/zerolog" + +type GocronAdapter struct { + Logger z.Logger +} + +func (l GocronAdapter) Println(msg string, v ...any) { + l.Logger.Info().Msgf(msg, v...) +} + +func (l GocronAdapter) Debug(msg string, v ...any) { + l.Logger.Debug().Msgf(msg, v...) +} + +func (l GocronAdapter) Info(msg string, v ...any) { + l.Logger.Info().Msgf(msg, v...) +} + +func (l GocronAdapter) Warn(msg string, v ...any) { + l.Logger.Warn().Msgf(msg, v...) +} + +func (l GocronAdapter) Error(msg string, v ...any) { + l.Logger.Error().Msgf(msg, v...) +} diff --git a/pkg/logger/zerolog/goose_adapter.go b/infrastructure/logger/zerolog/goose_adapter.go similarity index 70% rename from pkg/logger/zerolog/goose_adapter.go rename to infrastructure/logger/zerolog/goose_adapter.go index 53238c4..a4514e2 100644 --- a/pkg/logger/zerolog/goose_adapter.go +++ b/infrastructure/logger/zerolog/goose_adapter.go @@ -12,28 +12,28 @@ type ZerologGooseAdapter struct { } // Print implement the goose.Logger interface method -func (z *ZerologGooseAdapter) Print(args ...interface{}) { +func (z *ZerologGooseAdapter) Print(args ...any) { z.Logger.Info().Msgf("%v", args...) } // Printf implement the goose.Logger interface method -func (z *ZerologGooseAdapter) Printf(format string, args ...interface{}) { +func (z *ZerologGooseAdapter) Printf(format string, args ...any) { f := strings.Replace(format, "\n", "", -1) z.Logger.Info().Msgf(f, args...) } // Println implement the goose.Logger interface method -func (z *ZerologGooseAdapter) Println(args ...interface{}) { +func (z *ZerologGooseAdapter) Println(args ...any) { z.Logger.Info().Msgf("%v", args...) } // Fatal implement the goose.Logger interface method -func (z *ZerologGooseAdapter) Fatal(args ...interface{}) { +func (z *ZerologGooseAdapter) Fatal(args ...any) { z.Logger.Fatal().Msgf("%v", args...) } // Fatalf implement the goose.Logger interface method -func (z *ZerologGooseAdapter) Fatalf(format string, args ...interface{}) { +func (z *ZerologGooseAdapter) Fatalf(format string, args ...any) { f := strings.Replace(format, "\n", "", -1) z.Logger.Fatal().Msgf(f, args...) } diff --git a/infrastructure/logger/zerolog/gorm_adapter.go b/infrastructure/logger/zerolog/gorm_adapter.go new file mode 100644 index 0000000..5fddc96 --- /dev/null +++ b/infrastructure/logger/zerolog/gorm_adapter.go @@ -0,0 +1,81 @@ +package zerolog + +import ( + "context" + "errors" + "time" + + "github.com/rs/zerolog" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type GormLogger struct { + logger zerolog.Logger + LogLevel gormlogger.LogLevel + SlowThreshold time.Duration +} + +func NewGormLogger(logger zerolog.Logger) *GormLogger { + return &GormLogger{ + logger: logger.With().Str("component", "gorm").Logger(), + LogLevel: gormlogger.Info, + SlowThreshold: 200 * time.Millisecond, + } +} + +func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + newLogger := *l + newLogger.LogLevel = level + return &newLogger +} + +func (l *GormLogger) Info(ctx context.Context, msg string, data ...any) { + if l.LogLevel >= gormlogger.Info { + l.logger.Info().Msgf(msg, data...) + } +} + +func (l *GormLogger) Warn(ctx context.Context, msg string, data ...any) { + if l.LogLevel >= gormlogger.Warn { + l.logger.Warn().Msgf(msg, data...) + } +} + +func (l *GormLogger) Error(ctx context.Context, msg string, data ...any) { + if l.LogLevel >= gormlogger.Error { + l.logger.Error().Msgf(msg, data...) + } +} + +func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + if l.LogLevel <= gormlogger.Silent { + return + } + + elapsed := time.Since(begin) + sql, rows := fc() + + logEvent := l.logger.With(). + Str("type", "sql"). + Float64("elapsed_ms", float64(elapsed.Nanoseconds())/1e6). + Str("sql", sql) + + if rows >= 0 { + logEvent = logEvent.Int64("rows", rows) + } + + logger := logEvent.Logger() + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + logger.Error().Err(err).Send() + return + } + + if l.SlowThreshold != 0 && elapsed > l.SlowThreshold { + logger.Warn().Msg("SLOW SQL") + return + } + + logger.Debug().Send() +} diff --git a/pkg/logger/zerolog/http.go b/infrastructure/logger/zerolog/http.go similarity index 100% rename from pkg/logger/zerolog/http.go rename to infrastructure/logger/zerolog/http.go diff --git a/infrastructure/migration/files/20251003225258_user.go b/infrastructure/migration/files/20251003225258_user.go new file mode 100644 index 0000000..2938b79 --- /dev/null +++ b/infrastructure/migration/files/20251003225258_user.go @@ -0,0 +1,106 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upUser, downUser) +} + +func upUser(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar_url TEXT, + preferences TEXT, -- SQLite uses TEXT instead of JSONB + active BOOLEAN DEFAULT TRUE, + role TEXT CHECK(role IN ('admin', 'user', 'guest')) DEFAULT 'user', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON user(username); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON user(email); + CREATE INDEX IF NOT EXISTS idx_user_active ON user(active); + CREATE INDEX IF NOT EXISTS idx_user_role ON user(role); + ` + case "postgres": + query = ` + -- Create enum types for PostgreSQL + DO $$ BEGIN + CREATE TYPE user_role_enum AS ENUM ('admin', 'user', 'guest'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + CREATE TABLE IF NOT EXISTS "user" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar_url TEXT, + preferences JSONB, + active BOOLEAN DEFAULT TRUE, + role user_role_enum DEFAULT 'user', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON "user"(username); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON "user"(email); + CREATE INDEX IF NOT EXISTS idx_users_active ON "user"(active); + CREATE INDEX IF NOT EXISTS idx_user_role ON "user"(role); + + -- Trigger to automatically update updated_at + CREATE OR REPLACE FUNCTION update_user_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_user_updated_at_trigger ON "user"; + CREATE TRIGGER update_user_updated_at_trigger + BEFORE UPDATE ON "user" + FOR EACH ROW + EXECUTE FUNCTION update_user_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downUser(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + + var query string + switch dialect { + case "sqlite": + query = `DROP TABLE IF EXISTS user;` + case "postgres": + query = ` + DROP TABLE IF EXISTS "user"; + DROP TYPE IF EXISTS user_role_enum; + DROP FUNCTION IF EXISTS update_user_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/files/20251003233034_session.go b/infrastructure/migration/files/20251003233034_session.go new file mode 100644 index 0000000..b2e3bdb --- /dev/null +++ b/infrastructure/migration/files/20251003233034_session.go @@ -0,0 +1,89 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upSession, downSession) +} + +func upSession(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + user_agent TEXT, + ip_address TEXT, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_user_id ON session(user_id); + CREATE INDEX IF NOT EXISTS idx_session_expires_at ON session(expires_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + user_agent TEXT, + ip_address TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_session_user_id ON session(user_id); + CREATE INDEX IF NOT EXISTS idx_session_expires_at ON session(expires_at); + + -- Trigger to automatically update updated_at + CREATE OR REPLACE FUNCTION update_session_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_session_updated_at_trigger ON session; + CREATE TRIGGER update_session_updated_at_trigger + BEFORE UPDATE ON session + FOR EACH ROW + EXECUTE FUNCTION update_session_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downSession(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + + var query string + switch dialect { + case "sqlite": + query = `DROP TABLE IF EXISTS session;` + case "postgres": + query = ` + DROP TABLE IF EXISTS session; + DROP FUNCTION IF EXISTS update_session_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/files/20251005203951_group.go b/infrastructure/migration/files/20251005203951_group.go new file mode 100644 index 0000000..6b5d611 --- /dev/null +++ b/infrastructure/migration/files/20251005203951_group.go @@ -0,0 +1,101 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upGroup, downGroup) +} + +func upGroup(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS "group" ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + role TEXT NOT NULL CHECK(role IN ('admin', 'user', 'guest')), + owner_id TEXT NOT NULL, + members TEXT, -- SQLite uses TEXT instead of JSONB + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES user(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_group_owner_id ON "group"(owner_id); + CREATE INDEX IF NOT EXISTS idx_group_role ON "group"(role); + CREATE UNIQUE INDEX IF NOT EXISTS idx_group_name ON "group"(name); + ` + case "postgres": + query = ` + -- Create enum types for PostgreSQL + DO $$ BEGIN + CREATE TYPE group_role_enum AS ENUM ('admin', 'user', 'guest'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + CREATE TABLE IF NOT EXISTS "group" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + role group_role_enum NOT NULL, + owner_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + members JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_group_owner_id ON "group"(owner_id); + CREATE INDEX IF NOT EXISTS idx_group_role ON "group"(role); + CREATE UNIQUE INDEX IF NOT EXISTS idx_group_name ON "group"(name); + + -- Trigger to automatically update updated_at + CREATE OR REPLACE FUNCTION update_group_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_group_updated_at_trigger ON "group"; + CREATE TRIGGER update_group_updated_at_trigger + BEFORE UPDATE ON "group" + FOR EACH ROW + EXECUTE FUNCTION update_group_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downGroup(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + + var query string + switch dialect { + case "sqlite": + query = `DROP TABLE IF EXISTS "group";` + case "postgres": + query = ` + DROP TABLE IF EXISTS "group"; + DROP TYPE IF EXISTS group_role_enum; + DROP FUNCTION IF EXISTS update_group_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/files/20251008192123_repository.go b/infrastructure/migration/files/20251008192123_repository.go new file mode 100644 index 0000000..c661309 --- /dev/null +++ b/infrastructure/migration/files/20251008192123_repository.go @@ -0,0 +1,98 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upRepository, downRepository) +} + +func upRepository(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS repository ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + slug TEXT NOT NULL UNIQUE, + default_branch TEXT NOT NULL DEFAULT 'main', + is_archived BOOLEAN DEFAULT FALSE, + archived_at TIMESTAMP, + is_private BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_repository_slug ON repository(slug); + CREATE INDEX IF NOT EXISTS idx_repository_is_private ON repository(is_private); + CREATE UNIQUE INDEX IF NOT EXISTS idx_repository_name ON repository(name); + CREATE INDEX IF NOT EXISTS idx_repository_is_archived ON repository(is_archived); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS repository ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + slug TEXT NOT NULL UNIQUE, + default_branch TEXT NOT NULL DEFAULT 'main', + is_archived BOOLEAN DEFAULT FALSE, + archived_at TIMESTAMPTZ, + is_private BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_repository_slug ON repository(slug); + CREATE INDEX IF NOT EXISTS idx_repository_is_private ON repository(is_private); + CREATE UNIQUE INDEX IF NOT EXISTS idx_repository_name ON repository(name); + CREATE INDEX IF NOT EXISTS idx_repository_is_archived ON repository(is_archived); + + -- Trigger to automatically update updated_at + CREATE OR REPLACE FUNCTION update_repository_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_repository_updated_at_trigger ON repository; + CREATE TRIGGER update_repository_updated_at_trigger + BEFORE UPDATE ON repository + FOR EACH ROW + EXECUTE FUNCTION update_repository_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downRepository(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + + var query string + switch dialect { + case "sqlite": + query = `DROP TABLE IF EXISTS repository;` + case "postgres": + query = ` + DROP TABLE IF EXISTS repository; + DROP FUNCTION IF EXISTS update_repository_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/files/20251008192147_sshkey.go b/infrastructure/migration/files/20251008192147_sshkey.go new file mode 100644 index 0000000..c905d9a --- /dev/null +++ b/infrastructure/migration/files/20251008192147_sshkey.go @@ -0,0 +1,114 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upSshkey, downSshkey) +} + +func upSshkey(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS ssh_key ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL UNIQUE, + private_key TEXT NOT NULL, + user_id TEXT NOT NULL, + owner_type TEXT NOT NULL CHECK(owner_type IN ('user', 'repository')), + type TEXT NOT NULL CHECK(type IN ('rsa', 'ed25519', 'ecdsa')), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_key_name ON ssh_key(name); + CREATE INDEX IF NOT EXISTS idx_ssh_key_user_id ON ssh_key(user_id); + CREATE INDEX IF NOT EXISTS idx_ssh_key_owner_type ON ssh_key(owner_type); + CREATE INDEX IF NOT EXISTS idx_ssh_key_type ON ssh_key(type); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_key_public_key ON ssh_key(public_key); + ` + case "postgres": + query = ` + -- Create enum types for PostgreSQL + DO $$ BEGIN + CREATE TYPE ssh_key_owner_type_enum AS ENUM ('user', 'repository'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE ssh_key_type_enum AS ENUM ('rsa', 'ed25519', 'ecdsa'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + CREATE TABLE IF NOT EXISTS ssh_key ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL UNIQUE, + private_key TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + owner_type ssh_key_owner_type_enum NOT NULL, + type ssh_key_type_enum NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_key_name ON ssh_key(name); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_key_public_key ON ssh_key(public_key); + CREATE INDEX IF NOT EXISTS idx_ssh_key_user_id ON ssh_key(user_id); + CREATE INDEX IF NOT EXISTS idx_ssh_key_owner_type ON ssh_key(owner_type); + CREATE INDEX IF NOT EXISTS idx_ssh_key_type ON ssh_key(type); + + -- Trigger to automatically update updated_at + CREATE OR REPLACE FUNCTION update_ssh_key_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_ssh_key_updated_at_trigger ON ssh_key; + CREATE TRIGGER update_ssh_key_updated_at_trigger + BEFORE UPDATE ON ssh_key + FOR EACH ROW + EXECUTE FUNCTION update_ssh_key_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downSshkey(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + + var query string + switch dialect { + case "sqlite": + query = `DROP TABLE IF EXISTS ssh_key;` + case "postgres": + query = ` + DROP TABLE IF EXISTS ssh_key; + DROP TYPE IF EXISTS ssh_key_owner_type_enum; + DROP TYPE IF EXISTS ssh_key_type_enum; + DROP FUNCTION IF EXISTS update_ssh_key_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/files/20251008192153_token.go b/infrastructure/migration/files/20251008192153_token.go new file mode 100644 index 0000000..873c1f9 --- /dev/null +++ b/infrastructure/migration/files/20251008192153_token.go @@ -0,0 +1,128 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upToken, downToken) +} + +func upToken(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS token ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + token TEXT NOT NULL UNIQUE, + + type TEXT NOT NULL CHECK(type IN ('user', 'repository')), + scopes TEXT NOT NULL, -- SQLite uses TEXT instead of JSONB + + user_id TEXT, + repository_id TEXT, + + active BOOLEAN NOT NULL DEFAULT 1, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE, + FOREIGN KEY (repository_id) REFERENCES repository(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_token_user_id ON token(user_id); + CREATE INDEX IF NOT EXISTS idx_token_repository_id ON token(repository_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_token_token ON token(token); + CREATE INDEX IF NOT EXISTS idx_token_name ON token(name); + CREATE INDEX IF NOT EXISTS idx_token_expires_at ON token(expires_at); + CREATE INDEX IF NOT EXISTS idx_token_type ON token(type); + CREATE INDEX IF NOT EXISTS idx_token_active ON token(active); + ` + case "postgres": + query = ` + -- Create enum types for PostgreSQL + DO $$ BEGIN + CREATE TYPE token_type_enum AS ENUM ('user', 'repository'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + CREATE TABLE IF NOT EXISTS token ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + token TEXT NOT NULL UNIQUE, + + type token_type_enum NOT NULL, + scopes JSONB NOT NULL, + + user_id UUID REFERENCES "user"(id) ON DELETE CASCADE, + repository_id UUID REFERENCES repository(id) ON DELETE CASCADE, + + active BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_token_user_id ON token(user_id); + CREATE INDEX IF NOT EXISTS idx_token_repository_id ON token(repository_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_token_token ON token(token); + CREATE INDEX IF NOT EXISTS idx_token_name ON token(name); + CREATE INDEX IF NOT EXISTS idx_token_expires_at ON token(expires_at); + CREATE INDEX IF NOT EXISTS idx_token_type ON token(type); + CREATE INDEX IF NOT EXISTS idx_token_active ON token(active); + + -- Trigger to automatically update updated_at (adding updated_at column first) + ALTER TABLE token ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + + CREATE OR REPLACE FUNCTION update_token_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_token_updated_at_trigger ON token; + CREATE TRIGGER update_token_updated_at_trigger + BEFORE UPDATE ON token + FOR EACH ROW + EXECUTE FUNCTION update_token_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downToken(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + + var query string + switch dialect { + case "sqlite": + query = `DROP TABLE IF EXISTS token;` + case "postgres": + query = ` + DROP TABLE IF EXISTS token; + DROP TYPE IF EXISTS token_type_enum; + DROP FUNCTION IF EXISTS update_token_updated_at(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/files/20251009210017_repository_member.go b/infrastructure/migration/files/20251009210017_repository_member.go new file mode 100644 index 0000000..4792c6a --- /dev/null +++ b/infrastructure/migration/files/20251009210017_repository_member.go @@ -0,0 +1,152 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upRepositoryMember, downRepositoryMember) +} + +func upRepositoryMember(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS repository_member ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + + member_type TEXT NOT NULL CHECK(member_type IN ('user', 'group')), + user_id TEXT, + group_id TEXT, + + access_type TEXT NOT NULL CHECK(access_type IN ('none', 'reporter', 'developer', 'maintainer', 'owner')), + + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + -- Constraint to ensure only one of user_id or group_id is set based on member_type + CHECK ( + (member_type = 'user' AND user_id IS NOT NULL AND group_id IS NULL) OR + (member_type = 'group' AND group_id IS NOT NULL AND user_id IS NULL) + ) + ); + -- Index for faster lookups + CREATE INDEX IF NOT EXISTS idx_repository_member_repo ON repository_member(repository_id); + CREATE INDEX IF NOT EXISTS idx_repository_member_user ON repository_member(user_id) WHERE user_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_repository_member_group ON repository_member(group_id) WHERE group_id IS NOT NULL; + + -- Composite indexes for access check queries + CREATE INDEX IF NOT EXISTS idx_repo_user_access ON repository_member(repository_id, user_id) WHERE member_type = 'user'; + CREATE INDEX IF NOT EXISTS idx_repo_group_access ON repository_member(repository_id, group_id) WHERE member_type = 'group'; + + -- Index to find all repos for a user or group + CREATE INDEX IF NOT EXISTS idx_user_repositories ON repository_member(user_id, repository_id) WHERE member_type = 'user'; + CREATE INDEX IF NOT EXISTS idx_group_repositories ON repository_member(group_id, repository_id) WHERE member_type = 'group'; + + -- Unicity constraints to prevent duplicate memberships + CREATE UNIQUE INDEX IF NOT EXISTS unique_repo_user ON repository_member(repository_id, user_id) WHERE member_type = 'user'; + CREATE UNIQUE INDEX IF NOT EXISTS unique_repo_group ON repository_member(repository_id, group_id) WHERE member_type = 'group'; + ` + case "postgres": + query = ` + -- Create enum types for PostgreSQL + DO $$ BEGIN + CREATE TYPE member_type_enum AS ENUM ('user', 'group'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE access_type_enum AS ENUM ('none', 'reporter', 'developer', 'maintainer', 'owner'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + CREATE TABLE IF NOT EXISTS repository_member ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repository_id UUID NOT NULL REFERENCES repository(id) ON DELETE CASCADE, + + member_type member_type_enum NOT NULL, + user_id UUID REFERENCES "user"(id) ON DELETE CASCADE, + group_id UUID REFERENCES "group"(id) ON DELETE CASCADE, + + access_type access_type_enum NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint to ensure only one of user_id or group_id is set based on member_type + CONSTRAINT chk_member_type_consistency CHECK ( + (member_type = 'user' AND user_id IS NOT NULL AND group_id IS NULL) OR + (member_type = 'group' AND group_id IS NOT NULL AND user_id IS NULL) + ) + ); + + -- Index for faster lookups + CREATE INDEX IF NOT EXISTS idx_repository_member_repo ON repository_member(repository_id); + CREATE INDEX IF NOT EXISTS idx_repository_member_user ON repository_member(user_id) WHERE user_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_repository_member_group ON repository_member(group_id) WHERE group_id IS NOT NULL; + + -- Composite indexes for access check queries + CREATE INDEX IF NOT EXISTS idx_repo_user_access ON repository_member(repository_id, user_id) WHERE member_type = 'user'; + CREATE INDEX IF NOT EXISTS idx_repo_group_access ON repository_member(repository_id, group_id) WHERE member_type = 'group'; + + -- Index to find all repos for a user or group + CREATE INDEX IF NOT EXISTS idx_user_repositories ON repository_member(user_id, repository_id) WHERE member_type = 'user'; + CREATE INDEX IF NOT EXISTS idx_group_repositories ON repository_member(group_id, repository_id) WHERE member_type = 'group'; + + -- Unicity constraints to prevent duplicate memberships + CREATE UNIQUE INDEX IF NOT EXISTS unique_repo_user ON repository_member(repository_id, user_id) WHERE member_type = 'user'; + CREATE UNIQUE INDEX IF NOT EXISTS unique_repo_group ON repository_member(repository_id, group_id) WHERE member_type = 'group'; + + -- Trigger to automatically update updated_at + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_repository_member_updated_at ON repository_member; + CREATE TRIGGER update_repository_member_updated_at + BEFORE UPDATE ON repository_member + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downRepositoryMember(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + + var query string + switch dialect { + case "sqlite": + query = `DROP TABLE IF EXISTS repository_member;` + case "postgres": + query = ` + DROP TABLE IF EXISTS repository_member; + DROP TYPE IF EXISTS member_type_enum; + DROP TYPE IF EXISTS access_type_enum; + DROP FUNCTION IF EXISTS update_updated_at_column(); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/migration.go b/infrastructure/migration/migration.go new file mode 100644 index 0000000..e017131 --- /dev/null +++ b/infrastructure/migration/migration.go @@ -0,0 +1,48 @@ +package migration + +import ( + "context" + "embed" + + "github.com/labbs/ogit/infrastructure/logger/zerolog" + + _ "github.com/labbs/ogit/infrastructure/migration/files" + + "github.com/pressly/goose/v3" + z "github.com/rs/zerolog" + "gorm.io/gorm" +) + +//go:embed files/* +var migrationFiles embed.FS + +func RunMigration(l z.Logger, db *gorm.DB) error { + logger := l.With().Str("component", "infrastructure.migration").Logger() + goose.SetBaseFS(migrationFiles) + goose.SetLogger(&zerolog.ZerologGooseAdapter{Logger: logger}) + + // Set the dialect following the gorm dialect + dbDialect := db.Dialector.Name() + + if err := goose.SetDialect(dbDialect); err != nil { + logger.Error().Err(err).Str("event", "migration.failed_to_set_dialect").Msg("Failed to set dialect") + return err + } + + sqlDB, err := db.DB() + if err != nil { + logger.Error().Err(err).Str("event", "migration.failed_to_get_sql_db").Msg("Failed to get sql db") + return err + } + + ctx := context.WithValue(context.Background(), "dbDialect", dbDialect) + + if err := goose.UpContext(ctx, sqlDB, "files"); err != nil { + if err.Error() != "no change" { + logger.Error().Err(err).Str("event", "migration.failed_to_run_migrations").Msg("Failed to run migrations") + return err + } + } + + return nil +} diff --git a/infrastructure/persistence/group_pers.go b/infrastructure/persistence/group_pers.go new file mode 100644 index 0000000..af71df7 --- /dev/null +++ b/infrastructure/persistence/group_pers.go @@ -0,0 +1,11 @@ +package persistence + +import "gorm.io/gorm" + +type groupPers struct { + db *gorm.DB +} + +func NewGroupPers(db *gorm.DB) *groupPers { + return &groupPers{db: db} +} diff --git a/infrastructure/persistence/repository_member_pers.go b/infrastructure/persistence/repository_member_pers.go new file mode 100644 index 0000000..57e7337 --- /dev/null +++ b/infrastructure/persistence/repository_member_pers.go @@ -0,0 +1,27 @@ +package persistence + +import ( + "github.com/labbs/ogit/domain" + "gorm.io/gorm" +) + +type repositoryMemberPers struct { + db *gorm.DB +} + +func NewRepositoryMemberPers(db *gorm.DB) *repositoryMemberPers { + return &repositoryMemberPers{db: db} +} + +func (r *repositoryMemberPers) GetByRepositoryWithRelations(repositoryId string) ([]domain.RepositoryMember, error) { + var members []domain.RepositoryMember + err := r.db.Preload("User").Preload("Group").Where("repository_id = ?", repositoryId).Find(&members).Error + if err != nil { + return nil, err + } + return members, nil +} + +func (r *repositoryMemberPers) Create(member *domain.RepositoryMember) error { + return r.db.Create(member).Error +} diff --git a/infrastructure/persistence/repository_pers.go b/infrastructure/persistence/repository_pers.go new file mode 100644 index 0000000..b3257b5 --- /dev/null +++ b/infrastructure/persistence/repository_pers.go @@ -0,0 +1,31 @@ +package persistence + +import ( + "github.com/labbs/ogit/domain" + "gorm.io/gorm" +) + +type repositoryPers struct { + db *gorm.DB +} + +func NewRepositoryPers(db *gorm.DB) *repositoryPers { + return &repositoryPers{db: db} +} + +func (r *repositoryPers) GetRepository(repo domain.Repository) (*domain.Repository, error) { + var doc domain.Repository + err := r.db.Where(repo).First(&doc).Error + if err != nil { + return nil, err + } + return &doc, nil +} + +func (r *repositoryPers) Create(repo *domain.Repository) error { + return r.db.Debug().Create(repo).Error +} + +func (r *repositoryPers) Delete(repo domain.Repository) error { + return r.db.Where("id = ?", repo.Id).Delete(&domain.Repository{}).Error +} diff --git a/infrastructure/persistence/session_pers.go b/infrastructure/persistence/session_pers.go new file mode 100644 index 0000000..d975dd3 --- /dev/null +++ b/infrastructure/persistence/session_pers.go @@ -0,0 +1,24 @@ +package persistence + +import ( + "github.com/labbs/ogit/domain" + "gorm.io/gorm" +) + +type sessionPers struct { + db *gorm.DB +} + +func NewSessionPers(db *gorm.DB) *sessionPers { + return &sessionPers{db: db} +} + +func (s *sessionPers) Create(session *domain.Session) error { + return s.db.Create(session).Error +} + +func (s *sessionPers) GetById(id string) (*domain.Session, error) { + var session domain.Session + err := s.db.Where("id = ?", id).First(&session).Error + return &session, err +} diff --git a/infrastructure/persistence/token_pers.go b/infrastructure/persistence/token_pers.go new file mode 100644 index 0000000..4f36d50 --- /dev/null +++ b/infrastructure/persistence/token_pers.go @@ -0,0 +1,40 @@ +package persistence + +import ( + "github.com/labbs/ogit/domain" + "gorm.io/gorm" +) + +type tokenPers struct { + db *gorm.DB +} + +func NewTokenPers(db *gorm.DB) *tokenPers { + return &tokenPers{db: db} +} + +func (t *tokenPers) Create(token *domain.Token) error { + return t.db.Create(&token).Error +} + +func (t *tokenPers) GetByNameAndToken(name, tokenStr string) (*domain.Token, error) { + var token domain.Token + err := t.db.Preload("User").Preload("Repository").Where("name = ? AND token = ?", name, tokenStr).First(&token).Error + return &token, err +} + +func (t *tokenPers) GetByUserId(userId string) ([]domain.Token, error) { + var tokens []domain.Token + err := t.db.Preload("User").Preload("Repository").Where("user_id = ?", userId).Find(&tokens).Error + return tokens, err +} + +func (t *tokenPers) GetById(id string) (*domain.Token, error) { + var token domain.Token + err := t.db.Preload("User").Preload("Repository").Where("id = ?", id).First(&token).Error + return &token, err +} + +func (t *tokenPers) Delete(token *domain.Token) error { + return t.db.Where("id = ? AND user_id = ?", token.Id, token.UserId).Delete(&domain.Token{}).Error +} diff --git a/infrastructure/persistence/user_pers.go b/infrastructure/persistence/user_pers.go new file mode 100644 index 0000000..f5e499c --- /dev/null +++ b/infrastructure/persistence/user_pers.go @@ -0,0 +1,30 @@ +package persistence + +import ( + "github.com/labbs/ogit/domain" + "gorm.io/gorm" +) + +type userPers struct { + db *gorm.DB +} + +func NewUserPers(db *gorm.DB) *userPers { + return &userPers{db: db} +} + +func (u *userPers) GetByUsername(username string) (domain.User, error) { + var user domain.User + err := u.db.Debug().Where("username = ?", username).First(&user).Error + return user, err +} + +func (u *userPers) GetByEmail(email string) (domain.User, error) { + var user domain.User + err := u.db.Debug().Where("email = ?", email).First(&user).Error + return user, err +} + +func (u *userPers) Create(user *domain.User) error { + return u.db.Create(user).Error +} diff --git a/pkg/storage/README.md b/infrastructure/storage/README.md similarity index 100% rename from pkg/storage/README.md rename to infrastructure/storage/README.md diff --git a/pkg/storage/git.go b/infrastructure/storage/git.go similarity index 58% rename from pkg/storage/git.go rename to infrastructure/storage/git.go index e6f9d44..e301fa0 100644 --- a/pkg/storage/git.go +++ b/infrastructure/storage/git.go @@ -5,14 +5,14 @@ import ( "github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/labbs/git-server-s3/internal/config" - "github.com/labbs/git-server-s3/pkg/storage/local" - "github.com/labbs/git-server-s3/pkg/storage/s3" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/storage/local" + "github.com/labbs/ogit/infrastructure/storage/s3" "github.com/rs/zerolog" ) // GitRepositoryStorage defines the interface for Git repository storage backends -type GitRepositoryStorage interface { +type Config interface { // GetStorer returns a go-git storer for the given repository path GetStorer(repoPath string) (storer.Storer, error) @@ -35,12 +35,12 @@ type GitRepositoryStorage interface { // GitServerLoader implements go-git's server.Loader interface // using our storage abstraction type GitServerLoader struct { - storage GitRepositoryStorage + storage Config repoPath string } // NewGitServerLoader creates a new loader for a specific repository -func NewGitServerLoader(storage GitRepositoryStorage, repoPath string) *GitServerLoader { +func NewGitServerLoader(storage Config, repoPath string) *GitServerLoader { return &GitServerLoader{ storage: storage, repoPath: repoPath, @@ -52,16 +52,25 @@ func (l *GitServerLoader) Load(ep *transport.Endpoint) (storer.Storer, error) { return l.storage.GetStorer(l.repoPath) } -// NewGitRepositoryStorage creates a new GitRepositoryStorage instance based on configuration -func NewGitRepositoryStorage(logger zerolog.Logger) (GitRepositoryStorage, error) { - switch config.Storage.Type { +// Config creates a new Config instance based on configuration +func Configure(_cfg config.Config, logger zerolog.Logger) (Config, error) { + logger = logger.With().Str("component", "infrastructure.storage").Logger() + logger.Info().Str("storage_type", _cfg.Storage.Type).Msg("configuring storage backend") + var storage Config + switch _cfg.Storage.Type { case "local": - storage := local.NewLocalStorage(logger) - return storage, nil + storage = local.NewLocalStorage(logger, _cfg) case "s3": - storage := s3.NewS3Storage(logger) - return storage, nil + storage = s3.NewS3Storage(logger, _cfg) default: - return nil, fmt.Errorf("unsupported storage type: %s", config.Storage.Type) + return nil, fmt.Errorf("unsupported storage type: %s", _cfg.Storage.Type) } + + err := storage.Configure() + if err != nil { + logger.Error().Err(err).Str("storage_type", _cfg.Storage.Type).Msg("failed to configure storage") + return nil, err + } + + return storage, nil } diff --git a/pkg/storage/local/git.go b/infrastructure/storage/local/git.go similarity index 94% rename from pkg/storage/local/git.go rename to infrastructure/storage/local/git.go index 17af601..38b4aad 100644 --- a/pkg/storage/local/git.go +++ b/infrastructure/storage/local/git.go @@ -13,30 +13,32 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/storage/filesystem" - "github.com/labbs/git-server-s3/internal/config" + "github.com/labbs/ogit/infrastructure/config" "github.com/rs/zerolog" ) type LocalStorage struct { Logger zerolog.Logger basePath string + config config.Config } -func NewLocalStorage(logger zerolog.Logger) *LocalStorage { +func NewLocalStorage(logger zerolog.Logger, cfg config.Config) *LocalStorage { return &LocalStorage{ Logger: logger, + config: cfg, } } func (ls *LocalStorage) Configure() error { ls.Logger.Info().Msg("Configuring local storage") - if config.Storage.Local.Path == "" { + if ls.config.Storage.Local.Path == "" { return errors.New("local storage path is not configured") } // Store the base path - ls.basePath = config.Storage.Local.Path + ls.basePath = ls.config.Storage.Local.Path // Check if local storage path exists and create if necessary info, err := os.Stat(ls.basePath) diff --git a/pkg/storage/s3/git.go b/infrastructure/storage/s3/git.go similarity index 96% rename from pkg/storage/s3/git.go rename to infrastructure/storage/s3/git.go index bbe6729..e9b009a 100644 --- a/pkg/storage/s3/git.go +++ b/infrastructure/storage/s3/git.go @@ -13,7 +13,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/storer" - "github.com/labbs/git-server-s3/internal/config" + "github.com/labbs/ogit/infrastructure/config" "github.com/rs/zerolog" ) @@ -21,26 +21,29 @@ type S3Storage struct { Logger zerolog.Logger bucket string client *awss3.Client + config config.Config } -func NewS3Storage(logger zerolog.Logger) *S3Storage { +func NewS3Storage(logger zerolog.Logger, cfg config.Config) *S3Storage { return &S3Storage{ Logger: logger, + config: cfg, } } func (s3s *S3Storage) Configure() error { s3s.Logger.Info().Msg("Configuring S3 storage") - if config.Storage.S3.Bucket == "" { + if s3s.config.Storage.S3.Bucket == "" { return errors.New("S3 bucket is not configured") } - s3s.bucket = config.Storage.S3.Bucket + s3s.bucket = s3s.config.Storage.S3.Bucket // Initialize S3 client var s3Config S3Config s3Config.Logger = s3s.Logger + s3Config.config = s3s.config err := s3Config.Configure() if err != nil { return fmt.Errorf("failed to configure S3 client: %w", err) @@ -56,7 +59,7 @@ func (s3s *S3Storage) Configure() error { s3s.Logger.Error(). Err(err). Str("bucket", s3s.bucket). - Str("endpoint", config.Storage.S3.Endpoint). + Str("endpoint", s3s.config.Storage.S3.Endpoint). Msg("ListObjects operation failed") return fmt.Errorf("failed to access S3 bucket %s: %w", s3s.bucket, err) } diff --git a/pkg/storage/s3/s3.go b/infrastructure/storage/s3/s3.go similarity index 84% rename from pkg/storage/s3/s3.go rename to infrastructure/storage/s3/s3.go index c1c168a..e343404 100644 --- a/pkg/storage/s3/s3.go +++ b/infrastructure/storage/s3/s3.go @@ -4,18 +4,18 @@ import ( "context" "os" - "github.com/labbs/git-server-s3/internal/config" - "github.com/aws/aws-sdk-go-v2/aws" awsCfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/labbs/ogit/infrastructure/config" "github.com/rs/zerolog" ) type S3Config struct { Logger zerolog.Logger Client *awss3.Client + config config.Config } func (c *S3Config) Configure() error { @@ -24,10 +24,10 @@ func (c *S3Config) Configure() error { os.Setenv("AWS_RESPONSE_CHECKSUM_VALIDATION", "WHEN_REQUIRED") cfg, err := awsCfg.LoadDefaultConfig(context.TODO(), - awsCfg.WithRegion(config.Storage.S3.Region), + awsCfg.WithRegion(c.config.Storage.S3.Region), awsCfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( - config.Storage.S3.AccessKey, - config.Storage.S3.SecretKey, + c.config.Storage.S3.AccessKey, + c.config.Storage.S3.SecretKey, "", )), ) @@ -37,7 +37,7 @@ func (c *S3Config) Configure() error { // Configure client with custom endpoint and disable checksums for S3-compatible services c.Client = awss3.NewFromConfig(cfg, func(o *awss3.Options) { - o.BaseEndpoint = aws.String(config.Storage.S3.Endpoint) + o.BaseEndpoint = aws.String(c.config.Storage.S3.Endpoint) o.UsePathStyle = true // Important pour Outscale et autres services S3-compatibles // Disable checksums for S3-compatible services that don't support them o.DisableMultiRegionAccessPoints = true diff --git a/pkg/storage/s3/storer.go b/infrastructure/storage/s3/storer.go similarity index 100% rename from pkg/storage/s3/storer.go rename to infrastructure/storage/s3/storer.go diff --git a/interfaces/cli/migration/migration.go b/interfaces/cli/migration/migration.go new file mode 100644 index 0000000..97fcd76 --- /dev/null +++ b/interfaces/cli/migration/migration.go @@ -0,0 +1,66 @@ +package migration + +import ( + "context" + + "github.com/labbs/ogit/infrastructure" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/database" + "github.com/labbs/ogit/infrastructure/logger" + "github.com/labbs/ogit/infrastructure/migration" + + "github.com/urfave/cli/v3" +) + +// NewInstance creates a new CLI command for running database migrations. +// It's called by the main application to add the "migration" command to the CLI. +func NewInstance(version string) *cli.Command { + cfg := &config.Config{} + cfg.Version = version + migrationFlags := getFlags(cfg) + + return &cli.Command{ + Name: "migration", + Usage: "Start the migration tool", + Flags: migrationFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + return runMigration(*cfg) + }, + } +} + +// getFlags returns the list of CLI flags required for the migration command. +func getFlags(cfg *config.Config) (list []cli.Flag) { + list = append(list, config.GenericFlags(cfg)...) + list = append(list, config.LoggerFlags(cfg)...) + list = append(list, config.DatabaseFlags(cfg)...) + return +} + +// runMigration initializes the necessary dependencies and runs the database migrations. +func runMigration(cfg config.Config) error { + var err error + + // Initialize dependencies + deps := infrastructure.Deps{ + Config: cfg, + } + + // Initialize logger + deps.Logger = logger.NewLogger(cfg.Logger.Level, cfg.Logger.Pretty, cfg.Version) + logger := deps.Logger.With().Str("component", "migration.runserver").Logger() + + // Initialize database connection + deps.Database, err = database.Configure(deps.Config, deps.Logger) + if err != nil { + logger.Fatal().Err(err).Str("event", "migration.runserver.database.configure").Msg("Failed to configure database connection") + return err + } + + if err := migration.RunMigration(deps.Logger, deps.Database.Db); err != nil { + logger.Error().Err(err).Str("event", "migration.runmigration").Msg("Failed to run migrations") + return err + } + + return nil +} diff --git a/interfaces/cli/server/server.go b/interfaces/cli/server/server.go new file mode 100644 index 0000000..22b56a6 --- /dev/null +++ b/interfaces/cli/server/server.go @@ -0,0 +1,131 @@ +package server + +import ( + "context" + "strconv" + + "github.com/labbs/ogit/application" + "github.com/labbs/ogit/infrastructure" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/cronscheduler" + "github.com/labbs/ogit/infrastructure/database" + "github.com/labbs/ogit/infrastructure/http" + "github.com/labbs/ogit/infrastructure/jobs" + "github.com/labbs/ogit/infrastructure/logger" + "github.com/labbs/ogit/infrastructure/persistence" + "github.com/labbs/ogit/infrastructure/storage" + routes "github.com/labbs/ogit/interfaces/http" + + "github.com/urfave/cli/v3" +) + +// NewInstance creates a new CLI command for starting the server. +// It's called by the main application to add the "server" command to the CLI. +func NewInstance(version string) *cli.Command { + cfg := &config.Config{} + cfg.Version = version + serverFlags := getFlags(cfg) + + return &cli.Command{ + Name: "server", + Usage: "Start the HTTP server", + Flags: serverFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + return runServer(*cfg) + }, + } +} + +// getFlags returns the list of CLI flags required for the server command. +func getFlags(cfg *config.Config) (list []cli.Flag) { + list = append(list, config.GenericFlags(cfg)...) + list = append(list, config.ServerFlags(cfg)...) + list = append(list, config.LoggerFlags(cfg)...) + list = append(list, config.DatabaseFlags(cfg)...) + list = append(list, config.SessionFlags(cfg)...) + list = append(list, config.RegistrationFlags(cfg)...) + list = append(list, config.StorageFlags(cfg)...) + return +} + +// runServer initializes the necessary dependencies and starts the HTTP server. +func runServer(cfg config.Config) error { + var err error + + // Initialize dependencies + deps := infrastructure.Deps{ + Config: cfg, + } + + // Initialize logger + deps.Logger = logger.NewLogger(cfg.Logger.Level, cfg.Logger.Pretty, cfg.Version) + logger := deps.Logger.With().Str("component", "interfaces.cli.http.runserver").Logger() + + // Initialize other cron scheduler (go-cron) + deps.CronScheduler, err = cronscheduler.Configure(deps.Logger) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.cronscheduler.configure").Msg("Failed to configure cron scheduler") + return err + } + + // Initialize database connection (gorm) + deps.Database, err = database.Configure(deps.Config, deps.Logger) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.database.configure").Msg("Failed to configure database connection") + return err + } + + // Initialize storage + deps.Storage, err = storage.Configure(deps.Config, deps.Logger) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.storage.configure").Msg("Failed to configure storage") + return err + } + + // Initialize application services + userPers := persistence.NewUserPers(deps.Database.Db) + groupPers := persistence.NewGroupPers(deps.Database.Db) + sessionPers := persistence.NewSessionPers(deps.Database.Db) + repoPers := persistence.NewRepositoryPers(deps.Database.Db) + tokenPers := persistence.NewTokenPers(deps.Database.Db) + repositoryMemberPers := persistence.NewRepositoryMemberPers(deps.Database.Db) + + deps.UserApp = application.NewUserApp(deps.Config, deps.Logger, userPers, groupPers) + deps.SessionApp = application.NewSessionApp(deps.Config, deps.Logger, sessionPers, deps.UserApp) + deps.RepoApp = application.NewRepositoryApp(deps.Config, deps.Logger, repoPers, repositoryMemberPers, nil, deps.Storage) + deps.TokenApp = application.NewTokenApp(deps.Config, deps.Logger, tokenPers) + deps.AuthApp = application.NewAuthApp(deps.Config, deps.Logger, *deps.UserApp, *deps.SessionApp, *deps.TokenApp, *deps.RepoApp) + + // Initialize HTTP server (fiber + fiberoapi) + deps.Http, err = http.Configure(deps.Config, deps.Logger, *deps.SessionApp, true) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.http.configure").Msg("Failed to configure HTTP server") + return err + } + + // Setup cron jobs + configJobs := jobs.Config{ + Logger: deps.Logger, + CronScheduler: deps.CronScheduler, + SessionApp: *deps.SessionApp, + } + + err = configJobs.SetupJobs() + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.jobs.setup").Msg("Failed to setup cron jobs") + return err + } + + // Setup routes + routes.SetupRoutes(deps) + + // Start HTTP server + logger.Info().Str("event", "http.runserver.http.listen").Msgf("Starting HTTP server on port %d", cfg.Server.Port) + err = deps.Http.Fiber.Listen(":" + strconv.Itoa(cfg.Server.Port)) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.http.listen").Msg("Failed to start HTTP server") + return err + } + + return nil +} diff --git a/interfaces/http/dtos/health_response.go b/interfaces/http/dtos/health_response.go new file mode 100644 index 0000000..5d48b6b --- /dev/null +++ b/interfaces/http/dtos/health_response.go @@ -0,0 +1,7 @@ +package dtos + +type HealthResponse struct { + Status string `json:"status"` + Service string `json:"service"` + Version string `json:"version"` +} diff --git a/internal/api/controller/git_controller.go b/interfaces/http/git/handlers.go similarity index 61% rename from internal/api/controller/git_controller.go rename to interfaces/http/git/handlers.go index 26b6edc..49f91e1 100644 --- a/internal/api/controller/git_controller.go +++ b/interfaces/http/git/handlers.go @@ -1,38 +1,16 @@ -// Package controller provides HTTP handlers for Git Smart HTTP protocol operations. -package controller +package git import ( "bytes" "context" - "github.com/gofiber/fiber/v2" - "github.com/labbs/git-server-s3/pkg/common" - "github.com/labbs/git-server-s3/pkg/storage" - "github.com/rs/zerolog" - "github.com/go-git/go-git/v5/plumbing/protocol/packp" + "github.com/gofiber/fiber/v2" + helpergit "github.com/labbs/ogit/infrastructure/helpers/git" ) -// GitController handles Git Smart HTTP protocol requests. -// It implements the server-side of the Git Smart HTTP transport protocol, -// supporting both upload-pack (clone/fetch) and receive-pack (push) operations. -type GitController struct { - Logger zerolog.Logger // Logger for request logging and error reporting - Storage storage.GitRepositoryStorage // Storage backend for Git repository operations -} - -// InfoRefs handles GET requests to /{repo}/info/refs endpoint. -// This is the initial request in Git Smart HTTP protocol that advertises -// repository references (branches, tags) to the client. -// -// Query parameters: -// - service: either "git-upload-pack" (for clone/fetch) or "git-receive-pack" (for push) -// -// Response: Git protocol formatted reference advertisement -func (gc *GitController) InfoRefs(ctx *fiber.Ctx) error { - logger := gc.Logger.With().Str("event", "InfoRefs").Logger() - - logger.Debug().Str("repo", ctx.Params("repo")).Send() +func (ctrl *Controller) InfoRefs(ctx *fiber.Ctx) error { + logger := ctrl.Logger.With().Str("component", "http.git.inforefs").Logger() service := ctx.Query("service") if service != "git-upload-pack" && service != "git-receive-pack" { @@ -40,14 +18,12 @@ func (gc *GitController) InfoRefs(ctx *fiber.Ctx) error { return ctx.Status(fiber.StatusBadRequest).SendString("invalid service parameter") } - // Extract repository path from the URL - repoPath := common.ExtractRepoPathFromURL(ctx.Path(), "/info/refs") + repoPath := helpergit.ExtractRepoPathFromURL(ctx.Path(), "/info/refs") if repoPath == "" { return ctx.SendStatus(fiber.StatusNotFound) } - // Get the go-git transport server for this repository - srv, ep, err := common.GetTransportServer(repoPath, gc.Storage) + srv, ep, err := helpergit.GetTransportServer(repoPath, ctrl.StorageConfig) if err != nil { logger.Error().Err(err).Msg("Failed to get transport server") return ctx.Status(fiber.StatusInternalServerError).SendString("failed to get transport server") @@ -65,7 +41,7 @@ func (gc *GitController) InfoRefs(ctx *fiber.Ctx) error { if err != nil { return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - if err := common.WriteServiceAdvertisement(ctx.Response().BodyWriter(), service); err != nil { + if err := helpergit.WriteServiceAdvertisement(ctx.Response().BodyWriter(), service); err != nil { return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } if err := adv.Encode(ctx.Response().BodyWriter()); err != nil { @@ -81,7 +57,7 @@ func (gc *GitController) InfoRefs(ctx *fiber.Ctx) error { if err != nil { return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - if err := common.WriteServiceAdvertisement(ctx.Response().BodyWriter(), service); err != nil { + if err := helpergit.WriteServiceAdvertisement(ctx.Response().BodyWriter(), service); err != nil { return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } if err := adv.Encode(ctx.Response().BodyWriter()); err != nil { @@ -91,29 +67,18 @@ func (gc *GitController) InfoRefs(ctx *fiber.Ctx) error { return nil } -// HandleUploadPack handles POST requests to /{repo}/git-upload-pack endpoint. -// This handles the actual data transfer for clone and fetch operations. -// It processes the client's wants/haves and sends back the requested pack data. -// -// Request body: Git pack protocol request (binary) -// Response: Git pack protocol response with requested objects -func (gc *GitController) HandleUploadPack(c *fiber.Ctx) error { - logger := gc.Logger.With().Str("event", "HandleUploadPack").Logger() +func (ctrl *Controller) HandleUploadPack(c *fiber.Ctx) error { + logger := ctrl.Logger.With().Str("component", "http.git.uploadpack").Logger() - // Extract repository path from URL - repoPath := common.ExtractRepoPathFromURL(c.Path(), "/git-upload-pack") + repoPath := helpergit.ExtractRepoPathFromURL(c.Path(), "/git-upload-pack") if repoPath == "" { - logger.Error().Msg("Repository path not found") return c.SendStatus(fiber.StatusNotFound) } - logger.Debug().Str("repoPath", repoPath).Msg("Handling upload-pack request") - - // Get the go-git transport server for this repository - srv, ep, err := common.GetTransportServer(repoPath, gc.Storage) + srv, ep, err := helpergit.GetTransportServer(repoPath, ctrl.StorageConfig) if err != nil { logger.Error().Err(err).Msg("Failed to get transport server") - return err + return c.Status(fiber.StatusInternalServerError).SendString("failed to get transport server") } // Create an upload pack session @@ -149,17 +114,11 @@ func (gc *GitController) HandleUploadPack(c *fiber.Ctx) error { return nil } -// HandleReceivePack handles POST requests to /{repo}/git-receive-pack endpoint. -// This handles the actual data transfer for push operations. -// It processes the client's reference updates and pack data, then sends back a status report. -// -// Request body: Git pack protocol request with reference updates and pack data (binary) -// Response: Git pack protocol status report indicating success/failure of each reference update -func (gc *GitController) HandleReceivePack(c *fiber.Ctx) error { - logger := gc.Logger.With().Str("event", "HandleReceivePack").Logger() +func (ctrl *Controller) HandleReceivePack(c *fiber.Ctx) error { + logger := ctrl.Logger.With().Str("component", "http.git.receivepack").Logger() // Extract repository path from URL - repoPath := common.ExtractRepoPathFromURL(c.Path(), "/git-receive-pack") + repoPath := helpergit.ExtractRepoPathFromURL(c.Path(), "/git-receive-pack") if repoPath == "" { logger.Error().Msg("Repository path not found") return c.SendStatus(fiber.StatusNotFound) @@ -168,7 +127,7 @@ func (gc *GitController) HandleReceivePack(c *fiber.Ctx) error { logger.Debug().Str("repoPath", repoPath).Msg("Handling receive-pack request") // Get the go-git transport server for this repository - srv, ep, err := common.GetTransportServer(repoPath, gc.Storage) + srv, ep, err := helpergit.GetTransportServer(repoPath, ctrl.StorageConfig) if err != nil { logger.Error().Err(err).Msg("Failed to get transport server") return err diff --git a/interfaces/http/git/router.go b/interfaces/http/git/router.go new file mode 100644 index 0000000..04e187d --- /dev/null +++ b/interfaces/http/git/router.go @@ -0,0 +1,21 @@ +package git + +import ( + "github.com/gofiber/fiber/v2" + "github.com/labbs/ogit/infrastructure/config" + "github.com/labbs/ogit/infrastructure/storage" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + Fiber *fiber.App + StorageConfig storage.Config +} + +func SetupGitRouter(controller Controller) { + controller.Fiber.Get("/:repo/info/refs", controller.InfoRefs) + controller.Fiber.Post("/:repo/git-upload-pack", controller.HandleUploadPack) + controller.Fiber.Post("/:repo/git-receive-pack", controller.HandleReceivePack) +} diff --git a/interfaces/http/router.go b/interfaces/http/router.go new file mode 100644 index 0000000..ac02b1b --- /dev/null +++ b/interfaces/http/router.go @@ -0,0 +1,27 @@ +package http + +import ( + "github.com/labbs/ogit/infrastructure" + "github.com/labbs/ogit/interfaces/http/git" + v1 "github.com/labbs/ogit/interfaces/http/v1" +) + +func SetupRoutes(deps infrastructure.Deps) { + logger := deps.Logger.With().Str("component", "http.router").Logger() + logger.Info().Str("event", "setup_routes").Msg("Setting up HTTP routes") + + // Setup system routes (health, metrics, etc.) + setupSystemRoutes(deps) + + // Setup git routes + gitCtrl := git.Controller{ + Config: deps.Config, + Logger: deps.Logger, + Fiber: deps.Http.Fiber, + StorageConfig: deps.Storage, + } + git.SetupGitRouter(gitCtrl) + + // Setup v1 routes + v1.SetupRouterV1(deps) +} diff --git a/interfaces/http/system_routes.go b/interfaces/http/system_routes.go new file mode 100644 index 0000000..30c7a48 --- /dev/null +++ b/interfaces/http/system_routes.go @@ -0,0 +1,28 @@ +package http + +import ( + "github.com/labbs/ogit/infrastructure" + "github.com/labbs/ogit/interfaces/http/dtos" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" +) + +func setupSystemRoutes(deps infrastructure.Deps) { + // Setup Health route + fiberoapi.Get(deps.Http.FiberOapi, "/api/health", + func(ctx *fiber.Ctx, input struct{}) (*dtos.HealthResponse, *fiberoapi.ErrorResponse) { + return &dtos.HealthResponse{ + Status: "ok", + Service: "ogit", + Version: deps.Config.Version, + }, nil + }, + fiberoapi.OpenAPIOptions{ + Summary: "Health check", + Description: "Returns the health status of the service", + Tags: []string{"Health"}, + Security: "disabled", + }, + ) +} diff --git a/interfaces/http/v1/auth/dtos/login_request.go b/interfaces/http/v1/auth/dtos/login_request.go new file mode 100644 index 0000000..f5d86c0 --- /dev/null +++ b/interfaces/http/v1/auth/dtos/login_request.go @@ -0,0 +1,10 @@ +package dtos + +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` +} diff --git a/interfaces/http/v1/auth/dtos/logout_request.go b/interfaces/http/v1/auth/dtos/logout_request.go new file mode 100644 index 0000000..c84882f --- /dev/null +++ b/interfaces/http/v1/auth/dtos/logout_request.go @@ -0,0 +1,5 @@ +package dtos + +type LogoutResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/auth/dtos/register_request.go b/interfaces/http/v1/auth/dtos/register_request.go new file mode 100644 index 0000000..f13f19d --- /dev/null +++ b/interfaces/http/v1/auth/dtos/register_request.go @@ -0,0 +1,11 @@ +package dtos + +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type RegisterResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/auth/handlers.go b/interfaces/http/v1/auth/handlers.go new file mode 100644 index 0000000..733802a --- /dev/null +++ b/interfaces/http/v1/auth/handlers.go @@ -0,0 +1,55 @@ +package auth + +import ( + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/ogit/interfaces/http/v1/auth/dtos" +) + +func (ctrl Controller) Login(ctx *fiber.Ctx, req dtos.LoginRequest) (*dtos.LoginResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.auth.login").Logger() + + resp, err := ctrl.AuthApp.Authenticate(req.Email, req.Password, ctx) + if err != nil { + logger.Error().Err(err).Str("email", req.Email).Msg("failed to authenticate user") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: err.Error(), + Type: "AUTHENTICATION_FAILED", + } + } + return resp, nil +} + +func (ctrl Controller) Logout(ctx *fiber.Ctx, input struct{}) (*dtos.LogoutResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.auth.logout").Logger() + + // authCtx, _ := fiberoapi.GetAuthContext(ctx) + + logger.Warn().Msg("Logout handler not implemented yet") + + return &dtos.LogoutResponse{ + Message: "Logout not implemented yet", + }, nil +} + +func (ctrl Controller) Register(ctx *fiber.Ctx, req dtos.RegisterRequest) (*dtos.RegisterResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.auth.register").Logger() + + err := ctrl.AuthApp.Register(req.Username, req.Email, req.Password) + if err != nil { + logger.Error().Err(err).Str("email", req.Email).Msg("failed to register user") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: err.Error(), + Type: "REGISTRATION_FAILED", + } + } + + return &dtos.RegisterResponse{ + Message: "User registered successfully", + }, nil +} diff --git a/interfaces/http/v1/auth/router.go b/interfaces/http/v1/auth/router.go new file mode 100644 index 0000000..4790891 --- /dev/null +++ b/interfaces/http/v1/auth/router.go @@ -0,0 +1,40 @@ +package auth + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/ogit/application" + "github.com/labbs/ogit/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + AuthApp *application.AuthApp +} + +func SetupAuthRouter(controller Controller) { + fiberoapi.Post(controller.FiberOapi, "/login", controller.Login, fiberoapi.OpenAPIOptions{ + Summary: "Login user", + Description: "Authenticate user and return a token", + OperationID: "auth.login", + Tags: []string{"Auth"}, + Security: "disabled", + }) + + fiberoapi.Get(controller.FiberOapi, "/logout", controller.Logout, fiberoapi.OpenAPIOptions{ + Summary: "Logout user", + Description: "Invalidate user session", + OperationID: "auth.logout", + Tags: []string{"Auth"}, + }) + + fiberoapi.Post(controller.FiberOapi, "/register", controller.Register, fiberoapi.OpenAPIOptions{ + Summary: "Register user", + Description: "Register a new user", + OperationID: "auth.register", + Tags: []string{"Auth"}, + Security: "disabled", + }) +} diff --git a/interfaces/http/v1/repository/dtos/create_repository_request.go b/interfaces/http/v1/repository/dtos/create_repository_request.go new file mode 100644 index 0000000..f7c4c05 --- /dev/null +++ b/interfaces/http/v1/repository/dtos/create_repository_request.go @@ -0,0 +1,13 @@ +package dtos + +type CreateRepositoryRequest struct { + Name string `json:"name" validate:"required"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` +} + +type CreateRepositoryResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} diff --git a/interfaces/http/v1/repository/dtos/get_repository_request.go b/interfaces/http/v1/repository/dtos/get_repository_request.go new file mode 100644 index 0000000..fe7de97 --- /dev/null +++ b/interfaces/http/v1/repository/dtos/get_repository_request.go @@ -0,0 +1,13 @@ +package dtos + +type GetRepositoryRequest struct { + Identifier string `path:"identifier" validate:"required,uuid4|slug"` +} + +type GetRepositoryResponse struct { + Id string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` +} diff --git a/interfaces/http/v1/repository/handlers.go b/interfaces/http/v1/repository/handlers.go new file mode 100644 index 0000000..279cca1 --- /dev/null +++ b/interfaces/http/v1/repository/handlers.go @@ -0,0 +1,69 @@ +package repository + +import ( + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/ogit/infrastructure/helpers/mapper" + "github.com/labbs/ogit/infrastructure/helpers/validator" + "github.com/labbs/ogit/interfaces/http/v1/repository/dtos" +) + +func (ctrl *Controller) GetRepository(ctx *fiber.Ctx, req dtos.GetRepositoryRequest) (*dtos.GetRepositoryResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.repository.get").Logger() + + var id *string + var slug *string + + if validator.IsValidUUID(req.Identifier) { + id = &req.Identifier + } else { + slug = &req.Identifier + } + + repo, err := ctrl.RepositoryApp.GetRepository(id, slug) + if err != nil { + logger.Error().Err(err).Str("identifier", req.Identifier).Msg("failed to get repository") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: err.Error(), + Type: "GET_REPOSITORY_ERROR", + } + } + + var response dtos.GetRepositoryResponse + mapper.MapStructByFieldNames(repo, &response) + + return &response, nil +} + +func (ctrl *Controller) CreateRepository(ctx *fiber.Ctx, req dtos.CreateRepositoryRequest) (*dtos.CreateRepositoryResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.repository.create").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + repo, err := ctrl.RepositoryApp.Create(req.Name, req.Description, authCtx.UserID, req.IsPrivate) + if err != nil { + logger.Error().Err(err).Msg("failed to create repository") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: err.Error(), + Type: "CREATE_REPOSITORY_ERROR", + } + } + + var response dtos.CreateRepositoryResponse + mapper.MapStructByFieldNames(repo, &response) + + return &response, nil +} diff --git a/interfaces/http/v1/repository/router.go b/interfaces/http/v1/repository/router.go new file mode 100644 index 0000000..9f75f25 --- /dev/null +++ b/interfaces/http/v1/repository/router.go @@ -0,0 +1,31 @@ +package repository + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/ogit/application" + "github.com/labbs/ogit/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + RepositoryApp *application.RepositoryApp +} + +func SetupRepositoryRouter(controller Controller) { + fiberoapi.Get(controller.FiberOapi, "/:identifier", controller.GetRepository, fiberoapi.OpenAPIOptions{ + Summary: "Get repository", + Description: "Retrieve the details of a specific repository", + OperationID: "repository.get", + Tags: []string{"Repository"}, + }) + + fiberoapi.Post(controller.FiberOapi, "", controller.CreateRepository, fiberoapi.OpenAPIOptions{ + Summary: "Create repository", + Description: "Create a new repository", + OperationID: "repository.create", + Tags: []string{"Repository"}, + }) +} diff --git a/interfaces/http/v1/router.go b/interfaces/http/v1/router.go new file mode 100644 index 0000000..f52503d --- /dev/null +++ b/interfaces/http/v1/router.go @@ -0,0 +1,37 @@ +package v1 + +import ( + "github.com/labbs/ogit/infrastructure" + "github.com/labbs/ogit/interfaces/http/v1/auth" + "github.com/labbs/ogit/interfaces/http/v1/repository" + "github.com/labbs/ogit/interfaces/http/v1/user" +) + +func SetupRouterV1(deps infrastructure.Deps) { + deps.Logger.Info().Str("component", "http.router.v1").Msg("Setting up API v1 routes") + grp := deps.Http.FiberOapi.Group("/api/v1") + + authCtrl := auth.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/auth"), + AuthApp: deps.AuthApp, + } + auth.SetupAuthRouter(authCtrl) + + userCtrl := user.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/user"), + UserApp: deps.UserApp, + } + user.SetupUserRouter(userCtrl) + + repoCtrl := repository.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/repository"), + RepositoryApp: deps.RepoApp, + } + repository.SetupRepositoryRouter(repoCtrl) +} diff --git a/interfaces/http/v1/user/dtos/profile_request.go b/interfaces/http/v1/user/dtos/profile_request.go new file mode 100644 index 0000000..6d62c05 --- /dev/null +++ b/interfaces/http/v1/user/dtos/profile_request.go @@ -0,0 +1,8 @@ +package dtos + +type ProfileResponse struct { + Id string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` +} diff --git a/interfaces/http/v1/user/handlers.go b/interfaces/http/v1/user/handlers.go new file mode 100644 index 0000000..b0a6b1c --- /dev/null +++ b/interfaces/http/v1/user/handlers.go @@ -0,0 +1,16 @@ +package user + +import ( + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/ogit/interfaces/http/v1/user/dtos" +) + +func (ctrl *Controller) GetProfile(ctx *fiber.Ctx, input struct{}) (*dtos.ProfileResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.get_profile").Logger() + + logger.Warn().Msg("GetProfile handler not implemented yet") + + return nil, nil +} diff --git a/interfaces/http/v1/user/router.go b/interfaces/http/v1/user/router.go new file mode 100644 index 0000000..071990e --- /dev/null +++ b/interfaces/http/v1/user/router.go @@ -0,0 +1,24 @@ +package user + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/ogit/application" + "github.com/labbs/ogit/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + UserApp *application.UserApp +} + +func SetupUserRouter(controller Controller) { + fiberoapi.Get(controller.FiberOapi, "/profile", controller.GetProfile, fiberoapi.OpenAPIOptions{ + Summary: "Get user profile", + Description: "Retrieve the profile of the authenticated user", + OperationID: "user.getProfile", + Tags: []string{"User"}, + }) +} diff --git a/internal/api/controller/debug_controller.go b/internal/api/controller/debug_controller.go deleted file mode 100644 index 55bdb82..0000000 --- a/internal/api/controller/debug_controller.go +++ /dev/null @@ -1,124 +0,0 @@ -package controller - -import ( - "bytes" - "runtime" - "runtime/debug" - "runtime/pprof" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/rs/zerolog" -) - -// DebugController handles debug endpoints for monitoring server health and memory usage. -type DebugController struct { - Logger zerolog.Logger // Logger for debug operations -} - -// MemStats handles GET requests to /debug/memory endpoint. -// Returns detailed memory statistics including heap usage, GC activity, -// and goroutine counts. -// -// Response: JSON object with memory statistics -func (dc *DebugController) MemStats(ctx *fiber.Ctx) error { - logger := dc.Logger.With().Str("event", "MemStats").Logger() - - var m runtime.MemStats - runtime.ReadMemStats(&m) - - stats := map[string]interface{}{ - "alloc_mb": float64(m.Alloc) / 1024 / 1024, - "total_alloc_mb": float64(m.TotalAlloc) / 1024 / 1024, - "sys_mb": float64(m.Sys) / 1024 / 1024, - "heap_mb": float64(m.HeapAlloc) / 1024 / 1024, - "heap_sys_mb": float64(m.HeapSys) / 1024 / 1024, - "num_gc": m.NumGC, - "last_gc": time.Unix(0, int64(m.LastGC)).Format("15:04:05"), - "goroutines": runtime.NumGoroutine(), - "next_gc_mb": float64(m.NextGC) / 1024 / 1024, - "gc_cpu_percent": m.GCCPUFraction * 100, - } - - logger.Debug(). - Float64("alloc_mb", stats["alloc_mb"].(float64)). - Int("goroutines", stats["goroutines"].(int)). - Msg("Memory stats requested") - - return ctx.JSON(stats) -} - -// ForceGC handles POST requests to /debug/gc endpoint. -// Forces garbage collection and returns memory usage before and after GC. -// -// Response: JSON object with GC results -func (dc *DebugController) ForceGC(ctx *fiber.Ctx) error { - logger := dc.Logger.With().Str("event", "ForceGC").Logger() - - var before, after runtime.MemStats - - runtime.ReadMemStats(&before) - - logger.Debug().Float64("before_mb", float64(before.Alloc)/1024/1024).Msg("Forcing GC") - - runtime.GC() - debug.FreeOSMemory() - - runtime.ReadMemStats(&after) - - result := map[string]interface{}{ - "before_mb": float64(before.Alloc) / 1024 / 1024, - "after_mb": float64(after.Alloc) / 1024 / 1024, - "freed_mb": float64(before.Alloc-after.Alloc) / 1024 / 1024, - "goroutines": runtime.NumGoroutine(), - "timestamp": time.Now().Format("15:04:05"), - } - - logger.Info(). - Float64("freed_mb", result["freed_mb"].(float64)). - Float64("after_mb", result["after_mb"].(float64)). - Msg("GC completed") - - return ctx.JSON(result) -} - -// Goroutines handles GET requests to /debug/goroutines endpoint. -// Returns a stack trace of all active goroutines for debugging leaks. -// -// Response: Plain text stack traces of all goroutines -func (dc *DebugController) Goroutines(ctx *fiber.Ctx) error { - logger := dc.Logger.With().Str("event", "Goroutines").Logger() - - goroutineCount := runtime.NumGoroutine() - logger.Debug().Int("count", goroutineCount).Msg("Goroutine dump requested") - - // Get goroutine stack traces - buf := bytes.NewBuffer(make([]byte, 0, 1024*1024)) // 1MB buffer - pprof.Lookup("goroutine").WriteTo(buf, 1) - - ctx.Set("Content-Type", "text/plain") - return ctx.SendString(buf.String()) -} - -// GoroutineStats handles GET requests to /debug/goroutines/stats endpoint. -// Returns summary statistics about goroutines without full stack traces. -// -// Response: JSON object with goroutine statistics -func (dc *DebugController) GoroutineStats(ctx *fiber.Ctx) error { - logger := dc.Logger.With().Str("event", "GoroutineStats").Logger() - - goroutineCount := runtime.NumGoroutine() - - // Get goroutine profile for analysis - profile := pprof.Lookup("goroutine") - - stats := map[string]interface{}{ - "total_count": goroutineCount, - "timestamp": time.Now().Format("15:04:05"), - "profile_count": profile.Count(), - } - - logger.Debug().Int("count", goroutineCount).Msg("Goroutine stats requested") - - return ctx.JSON(stats) -} diff --git a/internal/api/controller/repo_controller.go b/internal/api/controller/repo_controller.go deleted file mode 100644 index 0791899..0000000 --- a/internal/api/controller/repo_controller.go +++ /dev/null @@ -1,64 +0,0 @@ -// Package controller provides HTTP handlers for repository management operations. -package controller - -import ( - "github.com/gofiber/fiber/v2" - "github.com/labbs/git-server-s3/pkg/common" - "github.com/labbs/git-server-s3/pkg/storage" - "github.com/rs/zerolog" -) - -// RepoController handles HTTP requests for repository management operations. -// It provides endpoints for creating and listing Git repositories through -// the configured storage backend. -type RepoController struct { - Logger zerolog.Logger // Logger for request logging and error reporting - Storage storage.GitRepositoryStorage // Storage backend for repository operations -} - -// CreateRepo handles POST requests to create a new Git repository. -// It expects a JSON payload with a "name" field and creates a bare repository -// in the configured storage backend. -// -// Request body: {"name": "repository-name"} -// Response: 201 Created with "repository created" message on success -func (c *RepoController) CreateRepo(ctx *fiber.Ctx) error { - logger := c.Logger.With().Str("event", "CreateRepo").Logger() - - var req struct { - Name string `json:"name"` - } - - if err := ctx.BodyParser(&req); err != nil { - return ctx.Status(fiber.StatusBadRequest).SendString(err.Error()) - } - - // Normalize the repository name to ensure proper .git suffix and path format - normName := common.NormalizeRepoPath(req.Name) - - err := c.Storage.CreateRepository(normName) - if err != nil { - logger.Error().Err(err).Msg("Failed to create repository") - return ctx.Status(fiber.StatusInternalServerError).SendString("failed to create repository") - } - - logger.Info().Str("repo", normName).Msg("Repository created successfully") - return ctx.Status(fiber.StatusCreated).SendString("repository created") -} - -// ListRepos handles GET requests to retrieve a list of all repositories. -// Returns a JSON array containing the names of all repositories in the storage backend. -// -// Response: 200 OK with JSON array of repository names -func (c *RepoController) ListRepos(ctx *fiber.Ctx) error { - logger := c.Logger.With().Str("event", "ListRepos").Logger() - - repos, err := c.Storage.ListRepositories() - if err != nil { - logger.Error().Err(err).Msg("Failed to list repositories") - return ctx.Status(fiber.StatusInternalServerError).SendString("failed to list repositories") - } - - logger.Info().Int("count", len(repos)).Msg("Repositories listed successfully") - return ctx.Status(fiber.StatusOK).JSON(repos) -} diff --git a/internal/api/controller/repo_controller_test.go b/internal/api/controller/repo_controller_test.go deleted file mode 100644 index e2e7c6f..0000000 --- a/internal/api/controller/repo_controller_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package controller - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http/httptest" - "testing" - - "github.com/go-git/go-git/v5/plumbing/storer" - "github.com/gofiber/fiber/v2" - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -// MockGitRepositoryStorage pour mocker l'interface storage -type MockGitRepositoryStorage struct { - mock.Mock -} - -func (m *MockGitRepositoryStorage) GetStorer(repoPath string) (storer.Storer, error) { - args := m.Called(repoPath) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(storer.Storer), args.Error(1) -} - -func (m *MockGitRepositoryStorage) CreateRepository(repoPath string) error { - args := m.Called(repoPath) - return args.Error(0) -} - -func (m *MockGitRepositoryStorage) RepositoryExists(repoPath string) bool { - args := m.Called(repoPath) - return args.Bool(0) -} - -func (m *MockGitRepositoryStorage) DeleteRepository(repoPath string) error { - args := m.Called(repoPath) - return args.Error(0) -} - -func (m *MockGitRepositoryStorage) ListRepositories() ([]string, error) { - args := m.Called() - return args.Get(0).([]string), args.Error(1) -} - -func (m *MockGitRepositoryStorage) Configure() error { - args := m.Called() - return args.Error(0) -} - -func setupTestApp() (*fiber.App, *MockGitRepositoryStorage) { - app := fiber.New(fiber.Config{ - DisableStartupMessage: true, - }) - - mockStorage := &MockGitRepositoryStorage{} - logger := zerolog.Nop() // Silent logger for tests - - controller := &RepoController{ - Logger: logger, - Storage: mockStorage, - } - - // Setup routes - app.Post("/api/repo", controller.CreateRepo) - app.Get("/api/repos", controller.ListRepos) - - return app, mockStorage -} - -func TestCreateRepoSuccess(t *testing.T) { - app, mockStorage := setupTestApp() - - // Mock successful repository creation - mockStorage.On("CreateRepository", "test-repo.git").Return(nil) - - // Prepare request body - reqBody := map[string]string{ - "name": "test-repo", - } - bodyBytes, _ := json.Marshal(reqBody) - - // Make request - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - - // Check response - assert.Equal(t, fiber.StatusCreated, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - assert.Equal(t, "repository created", string(body)) - - // Verify mock was called - mockStorage.AssertExpectations(t) -} - -func TestCreateRepoInvalidJSON(t *testing.T) { - app, _ := setupTestApp() - - // Send invalid JSON - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader([]byte("invalid json"))) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - - // Check response - assert.Equal(t, fiber.StatusBadRequest, resp.StatusCode) -} - -func TestCreateRepoMissingName(t *testing.T) { - app, mockStorage := setupTestApp() - - // Mock storage to expect empty string (normalized name will be ".git") - mockStorage.On("CreateRepository", ".git").Return(assert.AnError) - - // Send empty body - reqBody := map[string]string{} - bodyBytes, _ := json.Marshal(reqBody) - - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - - // The repository will be created with an empty name, which is technically valid - // but will be normalized by NormalizeRepoPath and fail at the storage level - assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode) - - // Verify mock was called - mockStorage.AssertExpectations(t) -} - -func TestCreateRepoStorageError(t *testing.T) { - app, mockStorage := setupTestApp() - - // Mock storage error - mockStorage.On("CreateRepository", "test-repo.git").Return(assert.AnError) - - // Prepare request body - reqBody := map[string]string{ - "name": "test-repo", - } - bodyBytes, _ := json.Marshal(reqBody) - - // Make request - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - - // Check response - assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - assert.Equal(t, "failed to create repository", string(body)) - - // Verify mock was called - mockStorage.AssertExpectations(t) -} - -func TestListReposSuccess(t *testing.T) { - app, mockStorage := setupTestApp() - - // Mock successful repository listing - expectedRepos := []string{"repo1.git", "repo2.git", "repo3.git"} - mockStorage.On("ListRepositories").Return(expectedRepos, nil) - - // Make request - req := httptest.NewRequest("GET", "/api/repos", nil) - - resp, err := app.Test(req) - require.NoError(t, err) - - // Check response - assert.Equal(t, fiber.StatusOK, resp.StatusCode) - assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) - - body, _ := io.ReadAll(resp.Body) - var repos []string - err = json.Unmarshal(body, &repos) - require.NoError(t, err) - - assert.Equal(t, expectedRepos, repos) - - // Verify mock was called - mockStorage.AssertExpectations(t) -} - -func TestListReposEmpty(t *testing.T) { - app, mockStorage := setupTestApp() - - // Mock empty repository list - mockStorage.On("ListRepositories").Return([]string{}, nil) - - // Make request - req := httptest.NewRequest("GET", "/api/repos", nil) - - resp, err := app.Test(req) - require.NoError(t, err) - - // Check response - assert.Equal(t, fiber.StatusOK, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - var repos []string - err = json.Unmarshal(body, &repos) - require.NoError(t, err) - - assert.Empty(t, repos) - - // Verify mock was called - mockStorage.AssertExpectations(t) -} - -func TestListReposStorageError(t *testing.T) { - app, mockStorage := setupTestApp() - - // Mock storage error - mockStorage.On("ListRepositories").Return([]string{}, assert.AnError) - - // Make request - req := httptest.NewRequest("GET", "/api/repos", nil) - - resp, err := app.Test(req) - require.NoError(t, err) - - // Check response - assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - assert.Equal(t, "failed to list repositories", string(body)) - - // Verify mock was called - mockStorage.AssertExpectations(t) -} - -// Test d'intégration pour valider le flux complet -func TestRepoControllerIntegration(t *testing.T) { - app, mockStorage := setupTestApp() - - // Test 1: List repositories (empty at start) - mockStorage.On("ListRepositories").Return([]string{}, nil).Once() - - req := httptest.NewRequest("GET", "/api/repos", nil) - resp, err := app.Test(req) - require.NoError(t, err) - assert.Equal(t, fiber.StatusOK, resp.StatusCode) - - // Test 2: Create a new repository - mockStorage.On("CreateRepository", "integration-test.git").Return(nil).Once() - - reqBody := map[string]string{"name": "integration-test"} - bodyBytes, _ := json.Marshal(reqBody) - - req = httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, err = app.Test(req) - require.NoError(t, err) - assert.Equal(t, fiber.StatusCreated, resp.StatusCode) - - // Test 3: List repositories (now with the new one) - mockStorage.On("ListRepositories").Return([]string{"integration-test.git"}, nil).Once() - - req = httptest.NewRequest("GET", "/api/repos", nil) - resp, err = app.Test(req) - require.NoError(t, err) - assert.Equal(t, fiber.StatusOK, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - var repos []string - err = json.Unmarshal(body, &repos) - require.NoError(t, err) - assert.Contains(t, repos, "integration-test.git") - - // Verify all mocks were called - mockStorage.AssertExpectations(t) -} - -// Test de charge pour valider les performances -func BenchmarkCreateRepo(b *testing.B) { - app, mockStorage := setupTestApp() - - // Mock pour accepter tous les appels - mockStorage.On("CreateRepository", mock.AnythingOfType("string")).Return(nil) - - reqBody := map[string]string{"name": "benchmark-repo"} - bodyBytes, _ := json.Marshal(reqBody) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - req := httptest.NewRequest("POST", "/api/repo", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - - resp, _ := app.Test(req) - resp.Body.Close() - } -} - -func BenchmarkListRepos(b *testing.B) { - app, mockStorage := setupTestApp() - - // Mock avec une liste de repos - repos := make([]string, 100) - for i := 0; i < 100; i++ { - repos[i] = fmt.Sprintf("repo%d.git", i) - } - mockStorage.On("ListRepositories").Return(repos, nil) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - req := httptest.NewRequest("GET", "/api/repos", nil) - resp, _ := app.Test(req) - resp.Body.Close() - } -} diff --git a/internal/api/router/debug_router.go b/internal/api/router/debug_router.go deleted file mode 100644 index 1cca741..0000000 --- a/internal/api/router/debug_router.go +++ /dev/null @@ -1,36 +0,0 @@ -package router - -import ( - "github.com/labbs/git-server-s3/internal/api/controller" -) - -// NewDebugRouter configures debug endpoints for memory monitoring and diagnostics. -// These endpoints are useful for monitoring server health and debugging memory leaks. -// -// Endpoints: -// - GET /debug/memory - Get detailed memory statistics -// - POST /debug/gc - Force garbage collection -// - GET /debug/goroutines - Get full goroutine stack traces -// - GET /debug/goroutines/stats - Get goroutine count summary -func NewDebugRouter(config *Config) { - config.Logger.Debug().Msg("Setting up debug routes") - - debugController := &controller.DebugController{ - Logger: config.Logger, - } - - // Debug endpoints group - debug := config.Fiber.Group("/debug") - - // Memory statistics endpoint - debug.Get("/memory", debugController.MemStats) - - // Force garbage collection endpoint - debug.Post("/gc", debugController.ForceGC) - - // Goroutine debugging endpoints - debug.Get("/goroutines", debugController.Goroutines) - debug.Get("/goroutines/stats", debugController.GoroutineStats) - - config.Logger.Info().Msg("Debug routes configured") -} diff --git a/internal/api/router/git_router.go b/internal/api/router/git_router.go deleted file mode 100644 index 1d9f7d1..0000000 --- a/internal/api/router/git_router.go +++ /dev/null @@ -1,14 +0,0 @@ -package router - -import "github.com/labbs/git-server-s3/internal/api/controller" - -func NewGitRouter(c *Config) { - gc := controller.GitController{ - Logger: c.Logger, - Storage: c.Storage, - } - - c.Fiber.Get("/:repo/info/refs", gc.InfoRefs) - c.Fiber.Post("/:repo/git-upload-pack", gc.HandleUploadPack) - c.Fiber.Post("/:repo/git-receive-pack", gc.HandleReceivePack) -} diff --git a/internal/api/router/repo_router.go b/internal/api/router/repo_router.go deleted file mode 100644 index 91e720e..0000000 --- a/internal/api/router/repo_router.go +++ /dev/null @@ -1,13 +0,0 @@ -package router - -import "github.com/labbs/git-server-s3/internal/api/controller" - -func NewRepoRouter(c *Config) { - gc := controller.RepoController{ - Logger: c.Logger, - Storage: c.Storage, - } - - c.Fiber.Post("/api/repo", gc.CreateRepo) - c.Fiber.Get("/api/repos", gc.ListRepos) -} diff --git a/internal/api/router/router.go b/internal/api/router/router.go deleted file mode 100644 index 566bee7..0000000 --- a/internal/api/router/router.go +++ /dev/null @@ -1,24 +0,0 @@ -package router - -import ( - "github.com/gofiber/fiber/v2" - "github.com/labbs/git-server-s3/internal/config" - "github.com/labbs/git-server-s3/pkg/storage" - "github.com/rs/zerolog" -) - -type Config struct { - Logger zerolog.Logger - Fiber *fiber.App - Storage storage.GitRepositoryStorage -} - -func (c *Config) Configure() { - c.Logger.Info().Msg("Configuring API routes") - - NewGitRouter(c) - NewRepoRouter(c) - if config.Debug.Endpoints { - NewDebugRouter(c) - } -} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go deleted file mode 100644 index 926b575..0000000 --- a/internal/cmd/cmd.go +++ /dev/null @@ -1,138 +0,0 @@ -package cmd - -import ( - "context" - "os" - "os/signal" - "sync" - "syscall" - "time" - - "github.com/labbs/git-server-s3/internal/config" - flags "github.com/labbs/git-server-s3/internal/flags" - "github.com/labbs/git-server-s3/internal/server" - "github.com/labbs/git-server-s3/pkg/logger" - "github.com/labbs/git-server-s3/pkg/storage" - - "github.com/urfave/cli/v3" -) - -// NewInstance creates a new 'server' command instance for urfave cli -func NewInstance(version string) *cli.Command { - config.Version = version - serverFlags := getFlags() - - return &cli.Command{ - Name: "server", - Usage: "Start the stack-deployer application", - Flags: serverFlags, - Action: runServer, - } -} - -// getFlags returns the list of flags for the server command. -func getFlags() (list []cli.Flag) { - list = append(list, flags.GenericFlags()...) - list = append(list, flags.ServerFlags()...) - list = append(list, flags.LoggerFlags()...) - list = append(list, flags.StorageFlags()...) - return -} - -// runServer starts the server following the configuration. -func runServer(ctx context.Context, c *cli.Command) error { - l := logger.NewLogger(config.Logger.Level, config.Logger.Pretty, c.Root().Version) - - str, err := storage.NewGitRepositoryStorage(l) - if err != nil { - return err - } - - // Configure the storage backend - if err := str.Configure(); err != nil { - l.Fatal().Err(err).Msg("Failed to configure storage") - return err - } - - // Setup signal handling for graceful shutdown - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // WaitGroup to wait for all servers to shutdown - var wg sync.WaitGroup - - // Configure HTTP server - var httpConfig server.HttpConfig - httpConfig.Port = config.Server.Port - httpConfig.HttpLogs = config.Server.HttpLogs - httpConfig.Logger = l - httpConfig.Storage = str - - // Start HTTP server in a goroutine - wg.Add(1) - go func() { - defer wg.Done() - l.Info().Int("port", config.Server.Port).Msg("Starting HTTP server") - if err := httpConfig.NewServer(); err != nil { - l.Error().Err(err).Msg("HTTP server failed") - } - }() - - var sshConfig *server.GitSSHConfig - - // Start SSH server if enabled - if config.SSH.Enabled { - sshConfig = &server.GitSSHConfig{ - Port: config.SSH.Port, - HostKeyPath: config.SSH.HostKeyPath, - Logger: l, - Storage: str, - } - - if err := sshConfig.Configure(); err != nil { - l.Fatal().Err(err).Msg("Failed to configure SSH server") - return err - } - - wg.Add(1) - go func() { - defer wg.Done() - l.Info().Int("port", config.SSH.Port).Msg("Starting Git SSH server") - if err := sshConfig.NewServer(); err != nil { - l.Error().Err(err).Msg("Git SSH server failed") - } - }() - } - - // Wait for interrupt signal - <-sigChan - l.Info().Msg("Shutdown signal received, stopping servers...") - - // Shutdown servers gracefully - go func() { - if err := httpConfig.Shutdown(); err != nil { - l.Error().Err(err).Msg("Error shutting down HTTP server") - } - if sshConfig != nil { - if err := sshConfig.Shutdown(); err != nil { - l.Error().Err(err).Msg("Error shutting down SSH server") - } - } - }() - - // Give servers time to shutdown gracefully - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() - - select { - case <-done: - l.Info().Msg("All servers stopped gracefully") - case <-time.After(30 * time.Second): - l.Warn().Msg("Shutdown timeout reached, forcing exit") - } - - return nil -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 3e4c5af..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,58 +0,0 @@ -package config - -var ( - // Version is set during the startup process. - Version string - - // ConfigFile is the path to the configuration file. - // This is used to load the configuration at startup. - ConfigFile string - - // Server is the configuration for the HTTP fiber server. - // Port is the port on which the server listens. - // HttpLogs enables or disables HTTP request logging. - Server struct { - Port int - HttpLogs bool - } - - // SSH is the configuration for the SSH Git server. - // Enabled controls whether the SSH server starts. - // Port is the port on which the SSH server listens. - // HostKeyPath is the path to the SSH host key file. - SSH struct { - Enabled bool - Port int - HostKeyPath string - } - - // Debug enables or disables debug endpoints. - Debug struct { - Endpoints bool - } - - // Logger is the configuration for the zerolog logger. - // Level is the log level for the logger. - // Pretty enables or disables pretty printing of logs (non JSON logs). - Logger struct { - Level string - Pretty bool - } - - // StorageType is the type of storage to use (e.g., local, s3). - Storage struct { - Type string - - S3 struct { - Bucket string - Endpoint string - AccessKey string - SecretKey string - Region string - } - - Local struct { - Path string - } - } -) diff --git a/internal/flags/server.go b/internal/flags/server.go deleted file mode 100644 index 2aad2b3..0000000 --- a/internal/flags/server.go +++ /dev/null @@ -1,70 +0,0 @@ -package flags - -import ( - "github.com/labbs/git-server-s3/internal/config" - - altsrc "github.com/urfave/cli-altsrc/v3" - altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" - "github.com/urfave/cli/v3" -) - -func ServerFlags() []cli.Flag { - return []cli.Flag{ - &cli.IntFlag{ - Name: "http.port", - Aliases: []string{"p"}, - Value: 8080, - Destination: &config.Server.Port, - Sources: cli.NewValueSourceChain( - cli.EnvVar("HTTP_PORT"), - altsrcyaml.YAML("http.port", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.BoolFlag{ - Name: "http.logs", - Aliases: []string{"l"}, - Value: false, - Destination: &config.Server.HttpLogs, - Sources: cli.NewValueSourceChain( - cli.EnvVar("HTTP_LOGS"), - altsrcyaml.YAML("http.logs", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.BoolFlag{ - Name: "ssh.enabled", - Value: false, - Destination: &config.SSH.Enabled, - Sources: cli.NewValueSourceChain( - cli.EnvVar("SSH_ENABLED"), - altsrcyaml.YAML("ssh.enabled", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.IntFlag{ - Name: "ssh.port", - Value: 2222, - Destination: &config.SSH.Port, - Sources: cli.NewValueSourceChain( - cli.EnvVar("SSH_PORT"), - altsrcyaml.YAML("ssh.port", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.StringFlag{ - Name: "ssh.hostkey", - Value: "./ssh_host_key", - Destination: &config.SSH.HostKeyPath, - Sources: cli.NewValueSourceChain( - cli.EnvVar("SSH_HOST_KEY_PATH"), - altsrcyaml.YAML("ssh.hostkey", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.BoolFlag{ - Name: "debug.endpoints", - Value: false, - Destination: &config.Debug.Endpoints, - Sources: cli.NewValueSourceChain( - cli.EnvVar("DEBUG_ENDPOINTS"), - altsrcyaml.YAML("debug.endpoints", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - } -} diff --git a/internal/flags/storage.go b/internal/flags/storage.go deleted file mode 100644 index 2076487..0000000 --- a/internal/flags/storage.go +++ /dev/null @@ -1,77 +0,0 @@ -package flags - -import ( - "github.com/labbs/git-server-s3/internal/config" - - altsrc "github.com/urfave/cli-altsrc/v3" - altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" - "github.com/urfave/cli/v3" -) - -func StorageFlags() []cli.Flag { - return []cli.Flag{ - &cli.StringFlag{ - Name: "storage.type", - Aliases: []string{"st"}, - Destination: &config.Storage.Type, - Sources: cli.NewValueSourceChain( - cli.EnvVar("STORAGE_TYPE"), - altsrcyaml.YAML("storage.type", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.StringFlag{ - Name: "storage.s3.bucket", - Aliases: []string{"ssb"}, - Destination: &config.Storage.S3.Bucket, - Sources: cli.NewValueSourceChain( - cli.EnvVar("STORAGE_S3_BUCKET"), - altsrcyaml.YAML("storage.s3.bucket", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.StringFlag{ - Name: "storage.s3.endpoint", - Aliases: []string{"sse"}, - Destination: &config.Storage.S3.Endpoint, - Sources: cli.NewValueSourceChain( - cli.EnvVar("STORAGE_S3_ENDPOINT"), - altsrcyaml.YAML("storage.s3.endpoint", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.StringFlag{ - Name: "storage.s3.access-key", - Aliases: []string{"ssa"}, - Destination: &config.Storage.S3.AccessKey, - Sources: cli.NewValueSourceChain( - cli.EnvVar("STORAGE_S3_ACCESS_KEY"), - altsrcyaml.YAML("storage.s3.access-key", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.StringFlag{ - Name: "storage.s3.secret-key", - Aliases: []string{"sss"}, - Destination: &config.Storage.S3.SecretKey, - Sources: cli.NewValueSourceChain( - cli.EnvVar("STORAGE_S3_SECRET_KEY"), - altsrcyaml.YAML("storage.s3.secret-key", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.StringFlag{ - Name: "storage.s3.region", - Aliases: []string{"ssr"}, - Destination: &config.Storage.S3.Region, - Sources: cli.NewValueSourceChain( - cli.EnvVar("STORAGE_S3_REGION"), - altsrcyaml.YAML("storage.s3.region", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - &cli.StringFlag{ - Name: "storage.local.path", - Aliases: []string{"slp"}, - Destination: &config.Storage.Local.Path, - Sources: cli.NewValueSourceChain( - cli.EnvVar("STORAGE_LOCAL_PATH"), - altsrcyaml.YAML("storage.local.path", altsrc.NewStringPtrSourcer(&config.ConfigFile)), - ), - }, - } -} diff --git a/internal/server/README.md b/internal/server/README.md deleted file mode 100644 index c158ba7..0000000 --- a/internal/server/README.md +++ /dev/null @@ -1,216 +0,0 @@ -# SSH Server for Git Operations - -This package provides a complete SSH server implementation for Git operations, supporting both `git-upload-pack` (clone/fetch) and `git-receive-pack` (push) operations over SSH. - -## Features - -- **Full Git SSH Protocol Support**: Implements the complete Git SSH transport protocol -- **Storage Backend Agnostic**: Works with any storage backend that implements `GitRepositoryStorage` -- **Automatic Repository Creation**: Creates repositories automatically on first push -- **Comprehensive Logging**: Detailed logging for all SSH operations and Git commands -- **Security Ready**: Framework for implementing proper authentication (passwords, SSH keys) -- **Host Key Management**: Automatic generation and management of SSH host keys - -## Architecture - -The SSH server consists of several key components: - -### SSHConfig -Main configuration struct that holds: -- SSH server port and settings -- Logger for operation tracking -- Storage backend interface -- SSH server instance - -### Authentication Handlers -- `passwordHandler`: Handles password-based authentication -- `publicKeyHandler`: Handles SSH key-based authentication -- Currently configured for demo mode (accepts any credentials) - -### Git Command Processing -- Parses incoming SSH commands (`git-upload-pack`, `git-receive-pack`) -- Validates repository paths and permissions -- Delegates to appropriate Git protocol handlers - -### Protocol Handlers -- `handleUploadPack`: Processes clone/fetch operations -- `handleReceivePack`: Processes push operations -- Full implementation of Git wire protocol over SSH - -## Usage - -### Basic Setup - -```go -import ( - "github.com/labbs/git-server-s3/internal/server" - "github.com/labbs/git-server-s3/pkg/storage/local" - "github.com/rs/zerolog" -) - -// Setup storage backend -localStorage := local.NewLocalStorage(logger) -localStorage.Configure() - -// Create SSH server -sshConfig := &server.SSHConfig{ - Port: ":2222", - Logger: logger, - Storage: localStorage, - HostKeyPath: "./ssh_host_key", -} - -// Configure and start -sshConfig.Configure() -sshConfig.NewServer() // Blocking call -``` - -### Configuration Options - -```go -type SSHConfig struct { - Port string // SSH port (e.g., ":2222") - Logger zerolog.Logger // Logger instance - Storage storage.GitRepositoryStorage // Storage backend - Server *gliderssh.Server // SSH server instance - HostKeyPath string // SSH host key file path -} -``` - -## Git Commands Supported - -### Clone/Fetch Operations -```bash -git clone ssh://git@localhost:2222/repo.git -git fetch origin -git pull origin main -``` - -### Push Operations -```bash -git push origin main -git push origin --all -git push origin --tags -``` - -### Repository Management -- Repositories are created automatically on first push -- Repository names are normalized (e.g., `repo` becomes `repo.git`) -- Repository existence is checked before clone/fetch operations - -## Security Considerations - -### Current Implementation (Demo Mode) -- Accepts any password -- Accepts any SSH public key -- No user isolation -- No repository access controls - -### Production Recommendations - -#### Authentication -```go -// Implement proper password validation -func (sc *SSHConfig) passwordHandler(ctx gliderssh.Context, password string) bool { - user := ctx.User() - // Implement your password validation logic - return validateUserPassword(user, password) -} - -// Implement proper public key validation -func (sc *SSHConfig) publicKeyHandler(ctx gliderssh.Context, key gliderssh.PublicKey) bool { - user := ctx.User() - // Implement your public key validation logic - return validateUserPublicKey(user, key) -} -``` - -#### Access Control -- Implement repository-level permissions -- Add user-based access controls -- Consider implementing organization/team permissions - -#### Security Headers -- Configure proper SSH cipher suites -- Implement rate limiting -- Add IP-based restrictions if needed - -## Integration with Storage Backends - -The SSH server works with any storage backend implementing `GitRepositoryStorage`: - -### Local Storage -```go -localStorage := local.NewLocalStorage(logger) -localStorage.Configure() -``` - -### S3 Storage (when implemented) -```go -s3Storage := s3.NewS3Storage(logger) -s3Storage.Configure() -``` - -## Logging - -The SSH server provides comprehensive logging: - -```json -{ - "level": "info", - "component": "ssh-session", - "user": "git", - "remote": "127.0.0.1:54321", - "command": "git-upload-pack '/repo.git'", - "service": "git-upload-pack", - "repo_path": "repo.git", - "message": "Upload pack completed successfully" -} -``` - -## Testing - -Run tests with: -```bash -go test ./internal/server/... -``` - -Test coverage includes: -- SSH server configuration -- Git command parsing -- Repository path normalization -- Host key generation and loading -- Authentication handler behavior - -## Examples - -See `examples/ssh_server_example.go` for a complete working example. - -## Dependencies - -- `github.com/gliderlabs/ssh`: SSH server implementation -- `github.com/go-git/go-git/v5`: Git protocol implementation -- `golang.org/x/crypto/ssh`: SSH cryptography -- `github.com/rs/zerolog`: Structured logging - -## Troubleshooting - -### Common Issues - -1. **Port already in use**: Make sure the SSH port isn't being used by another service -2. **Permission denied**: Check that the host key file has proper permissions (600) -3. **Repository not found**: Ensure the repository name includes `.git` suffix -4. **Authentication failures**: Verify authentication handlers are properly configured - -### Debug Logging - -Enable debug logging to troubleshoot issues: -```go -logger := zerolog.New(os.Stdout).Level(zerolog.DebugLevel) -``` - -This will show detailed information about: -- SSH connection establishment -- Git command parsing -- Repository operations -- Protocol message exchange diff --git a/internal/server/git_ssh_config.go b/internal/server/git_ssh_config.go deleted file mode 100644 index f21a520..0000000 --- a/internal/server/git_ssh_config.go +++ /dev/null @@ -1,56 +0,0 @@ -package server - -import ( - "fmt" - - "github.com/labbs/git-server-s3/pkg/storage" - "github.com/rs/zerolog" -) - -// GitSSHConfig holds configuration for the custom Git SSH server. -// This replaces the old SSHConfig that used gliderlabs/ssh. -type GitSSHConfig struct { - Port int // SSH server port - HostKeyPath string // Path to SSH host key file - Logger zerolog.Logger // Logger for SSH operations - Storage storage.GitRepositoryStorage // Storage backend for repositories - server *GitSSHServer // The underlying Git SSH server instance -} - -// Configure sets up the custom Git SSH server. -func (c *GitSSHConfig) Configure() error { - c.server = &GitSSHServer{ - Port: c.formatPort(), - Logger: c.Logger, - Storage: c.Storage, - HostKeyPath: c.HostKeyPath, - } - - return c.server.Configure() -} - -// NewServer starts the custom Git SSH server. -func (c *GitSSHConfig) NewServer() error { - if c.server == nil { - if err := c.Configure(); err != nil { - return err - } - } - return c.server.Start() -} - -// Shutdown gracefully stops the Git SSH server. -func (c *GitSSHConfig) Shutdown() error { - if c.server != nil { - return c.server.Stop() - } - return nil -} - -// formatPort formats the port number as a string with colon prefix. -func (c *GitSSHConfig) formatPort() string { - if c.Port == 0 { - c.Port = 2222 - } - return fmt.Sprintf(":%d", c.Port) -} diff --git a/internal/server/git_ssh_server.go b/internal/server/git_ssh_server.go deleted file mode 100644 index 4c300c0..0000000 --- a/internal/server/git_ssh_server.go +++ /dev/null @@ -1,591 +0,0 @@ -// Package server provides a custom SSH Git server implementation. -// This implementation uses golang.org/x/crypto/ssh directly for better control -// over the Git protocol and to avoid "remote end hung up unexpectedly" errors. -package server - -import ( - "bufio" - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/x509" - "encoding/pem" - "fmt" - "net" - "os" - "path/filepath" - "strings" - "time" - - "github.com/go-git/go-git/v5/plumbing/protocol/packp" - "github.com/labbs/git-server-s3/pkg/common" - "github.com/labbs/git-server-s3/pkg/storage" - "github.com/rs/zerolog" - "golang.org/x/crypto/ssh" -) - -// bufferedChannel creates a buffered wrapper around an SSH channel for improved performance -type bufferedChannel struct { - ssh.Channel - reader *bufio.Reader - writer *bufio.Writer -} - -func newBufferedChannel(channel ssh.Channel) *bufferedChannel { - // Create very large buffers (4MB each) to handle massive Git operations - // This matches the buffer sizes used by GitHub and GitLab - return &bufferedChannel{ - Channel: channel, - reader: bufio.NewReaderSize(channel, 4*1024*1024), // 4MB read buffer - writer: bufio.NewWriterSize(channel, 4*1024*1024), // 4MB write buffer - } -} - -func (bc *bufferedChannel) Read(p []byte) (n int, err error) { - return bc.reader.Read(p) -} - -func (bc *bufferedChannel) Write(p []byte) (n int, err error) { - n, err = bc.writer.Write(p) - if err == nil { - // Flush immediately for Git protocol compatibility - bc.writer.Flush() - } - return n, err -} - -func (bc *bufferedChannel) Close() error { - if bc.writer != nil { - bc.writer.Flush() - } - return bc.Channel.Close() -} - -// Unlike generic SSH servers, this implementation handles the Git protocol directly. -type GitSSHServer struct { - Port string // SSH server port (e.g., ":2222") - Logger zerolog.Logger // Logger for SSH operations - Storage storage.GitRepositoryStorage // Storage backend for repositories - HostKeyPath string // Path to SSH host key file - listener net.Listener // Network listener - sshConfig *ssh.ServerConfig // SSH server configuration -} - -// Configure sets up the SSH server with proper Git protocol handling. -func (s *GitSSHServer) Configure() error { - logger := s.Logger.With().Str("component", "git-ssh-server").Logger() - - // Set default host key path - if s.HostKeyPath == "" { - s.HostKeyPath = "ssh_host_key" - } - - // Generate or load SSH host key - privateKey, err := s.ensureHostKey() - if err != nil { - logger.Error().Err(err).Msg("Failed to ensure host key") - return err - } - - // Create SSH server configuration with enhanced buffer and timeout settings - s.sshConfig = &ssh.ServerConfig{ - // Demo authentication - in production, implement proper auth - PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { - logger.Info(). - Str("user", conn.User()). - Str("remote", conn.RemoteAddr().String()). - Msg("Password authentication attempt") - // Accept any password for demo (implement proper validation in production) - return nil, nil - }, - PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - logger.Info(). - Str("user", conn.User()). - Str("key_type", key.Type()). - Str("remote", conn.RemoteAddr().String()). - Msg("Public key authentication attempt") - // Accept any valid key for demo (implement proper validation in production) - return nil, nil - }, - // Configure SSH server to handle large Git operations like GitHub - ServerVersion: "SSH-2.0-GitServerS3", - MaxAuthTries: 3, - // Add configuration for better Git protocol handling - NoClientAuth: false, - } - - // Add host key to server config - s.sshConfig.AddHostKey(privateKey) - - logger.Info().Str("addr", s.Port).Msg("Git SSH server configured") - return nil -} - -// Start begins listening for SSH connections and handles Git operations. -func (s *GitSSHServer) Start() error { - logger := s.Logger.With().Str("component", "git-ssh-server").Logger() - - // Start listening on the specified port - listener, err := net.Listen("tcp", s.Port) - if err != nil { - logger.Error().Err(err).Str("addr", s.Port).Msg("Failed to listen on port") - return err - } - s.listener = listener - - logger.Info().Str("addr", s.Port).Msg("Git SSH server started") - - // Accept connections - for { - conn, err := listener.Accept() - if err != nil { - // Check if server was closed - if strings.Contains(err.Error(), "use of closed network connection") { - logger.Info().Msg("SSH server stopped") - return nil - } - logger.Error().Err(err).Msg("Failed to accept connection") - continue - } - - // Handle connection in goroutine - go s.handleConnection(conn) - } -} - -// Stop gracefully stops the SSH server. -func (s *GitSSHServer) Stop() error { - s.Logger.Info().Msg("Stopping Git SSH server") - if s.listener != nil { - return s.listener.Close() - } - return nil -} - -// handleConnection processes an incoming SSH connection. -func (s *GitSSHServer) handleConnection(conn net.Conn) { - logger := s.Logger.With(). - Str("component", "git-ssh-connection"). - Str("remote", conn.RemoteAddr().String()). - Logger() - - defer conn.Close() - - // Set connection timeouts to handle large Git operations - // This prevents "remote end hung up unexpectedly" errors - if tcpConn, ok := conn.(*net.TCPConn); ok { - // Enable TCP keep-alive to detect dead connections - tcpConn.SetKeepAlive(true) - tcpConn.SetKeepAlivePeriod(10 * time.Second) // More aggressive keepalive - - // Set very long timeouts for large operations (30 minutes) - // This matches GitHub's behavior for large pushes - tcpConn.SetReadDeadline(time.Now().Add(1800 * time.Second)) // 30 minutes - tcpConn.SetWriteDeadline(time.Now().Add(1800 * time.Second)) // 30 minutes - - // Set TCP buffer sizes for better performance - tcpConn.SetReadBuffer(2 * 1024 * 1024) // 2MB read buffer - tcpConn.SetWriteBuffer(2 * 1024 * 1024) // 2MB write buffer - } - - // Perform SSH handshake - sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig) - if err != nil { - logger.Error().Err(err).Msg("SSH handshake failed") - return - } - defer sshConn.Close() - - logger.Info().Str("user", sshConn.User()).Msg("SSH connection established") - - // Handle global requests (usually none for Git) - go ssh.DiscardRequests(reqs) - - // Handle channels (Git commands) - for newChannel := range chans { - go s.handleChannel(sshConn, newChannel, logger) - } -} - -// handleChannel processes SSH channels containing Git commands. -func (s *GitSSHServer) handleChannel(conn *ssh.ServerConn, newChannel ssh.NewChannel, logger zerolog.Logger) { - // Git operations only use "session" channel type - if newChannel.ChannelType() != "session" { - logger.Debug().Str("channel_type", newChannel.ChannelType()).Msg("Rejecting non-session channel") - newChannel.Reject(ssh.UnknownChannelType, "only session channels are supported") - return - } - - // Accept the channel - channel, requests, err := newChannel.Accept() - if err != nil { - logger.Error().Err(err).Msg("Failed to accept channel") - return - } - defer channel.Close() - - // Process channel requests - for req := range requests { - switch req.Type { - case "exec": - // Execute Git command - s.handleExecRequest(conn, channel, req, logger) - return // Exit after handling exec request - default: - // Reject other request types - if req.WantReply { - req.Reply(false, nil) - } - } - } -} - -// handleExecRequest processes Git command execution requests. -func (s *GitSSHServer) handleExecRequest(conn *ssh.ServerConn, channel ssh.Channel, req *ssh.Request, logger zerolog.Logger) { - if !req.WantReply { - s.sendExitStatusAndClose(channel, 1) - return - } - - // Extract command from request payload - command := string(req.Payload[4:]) // Skip 4-byte length prefix - logger = logger.With().Str("command", command).Logger() - - // Parse Git command - service, repoPath := s.parseGitCommand(command) - if service == "" { - logger.Error().Msg("Invalid Git command") - req.Reply(false, nil) - s.sendExitStatusAndClose(channel, 1) - return - } - - logger = logger.With(). - Str("service", service). - Str("repo_path", repoPath). - Logger() - - // Accept the request - req.Reply(true, nil) - - var exitCode int = 0 - - // Handle the Git operation - switch service { - case "git-upload-pack": - if err := s.handleUploadPack(channel, repoPath, logger); err != nil { - logger.Error().Err(err).Msg("Upload pack failed") - exitCode = 1 - } - case "git-receive-pack": - if err := s.handleReceivePack(channel, repoPath, logger); err != nil { - logger.Error().Err(err).Msg("Receive pack failed") - exitCode = 1 - } - default: - logger.Error().Str("service", service).Msg("Unsupported Git service") - exitCode = 1 - } - - // Critical fix from go-git issue #1062: - // Don't close the channel immediately - let the client close first - s.sendExitStatusAndClose(channel, exitCode) -} - -// handleUploadPack processes git-upload-pack operations (clone/fetch). -func (s *GitSSHServer) handleUploadPack(channel ssh.Channel, repoPath string, logger zerolog.Logger) error { - logger.Info().Msg("Processing upload pack request") - - // Create buffered channel for better performance with large Git operations - bufferedChan := newBufferedChannel(channel) - - // Get transport server for the repository - srv, endpoint, err := common.GetTransportServer(repoPath, s.Storage) - if err != nil { - logger.Error().Err(err).Msg("Failed to get transport server") - return err - } - - // Create upload pack service - up, err := srv.NewUploadPackSession(endpoint, nil) - if err != nil { - logger.Error().Err(err).Msg("Failed to create upload pack session") - return err - } - - // Send advertised references - advRefs, err := up.AdvertisedReferences() - if err != nil { - logger.Error().Err(err).Msg("Failed to get advertised references") - return err - } - - // Always encode the advertised references, even if empty - if err := advRefs.Encode(bufferedChan); err != nil { - logger.Error().Err(err).Msg("Failed to encode advertised references") - return err - } - - // Check if repository is empty (no references) - if advRefs == nil || len(advRefs.References) == 0 { - logger.Info().Msg("Repository is empty - no further processing needed") - return nil - } - - // Read client request - req := packp.NewUploadPackRequest() - if err := req.Decode(bufferedChan); err != nil { - // Handle empty repository case gracefully - if strings.Contains(err.Error(), "missing 'want' prefix") || - strings.Contains(err.Error(), "EOF") { - logger.Info().Msg("Client disconnected - likely empty repository or no wants") - return nil - } - logger.Error().Err(err).Msg("Failed to decode upload pack request") - return err - } - - // Process upload pack - resp, err := up.UploadPack(context.Background(), req) - if err != nil { - logger.Error().Err(err).Msg("Upload pack failed") - return err - } - defer resp.Close() - - // Send response to client - if err := resp.Encode(bufferedChan); err != nil { - logger.Error().Err(err).Msg("Failed to encode upload pack response") - return err - } - - logger.Info().Msg("Upload pack completed successfully") - return nil -} - -// handleReceivePack processes git-receive-pack operations (push). -func (s *GitSSHServer) handleReceivePack(channel ssh.Channel, repoPath string, logger zerolog.Logger) error { - logger.Info().Msg("Processing receive pack request") - - // Create buffered channel for better performance with large Git operations - bufferedChan := newBufferedChannel(channel) - - // Get transport server for the repository - srv, endpoint, err := common.GetTransportServer(repoPath, s.Storage) - if err != nil { - logger.Error().Err(err).Msg("Failed to get transport server") - return err - } - - // Create receive pack service - rp, err := srv.NewReceivePackSession(endpoint, nil) - if err != nil { - logger.Error().Err(err).Msg("Failed to create receive pack session") - return err - } - - // Send advertised references - advRefs, err := rp.AdvertisedReferences() - if err != nil { - logger.Error().Err(err).Msg("Failed to get advertised references") - return err - } - - if err := advRefs.Encode(bufferedChan); err != nil { - logger.Error().Err(err).Msg("Failed to encode advertised references") - return err - } - - // Read client request - req := packp.NewReferenceUpdateRequest() - if err := req.Decode(bufferedChan); err != nil { - logger.Error().Err(err).Msg("Failed to decode receive pack request") - return err - } - - // Process receive pack - report, err := rp.ReceivePack(context.Background(), req) - if err != nil { - logger.Error().Err(err).Msg("Receive pack failed") - return err - } - - // Send status report to client if available - if report != nil { - // First, ensure we flush any buffered data - if err := bufferedChan.writer.Flush(); err != nil { - logger.Debug().Err(err).Msg("Failed to flush buffer before status report") - } - - // Try to encode the report, ignore EOF as it's common during connection close - if err := report.Encode(bufferedChan); err != nil { - if err.Error() != "EOF" { - logger.Debug().Err(err).Msg("Failed to encode receive pack report") - } - } - } - - logger.Info().Msg("Receive pack completed successfully") - return nil -} - -// sendExitStatusAndClose properly handles SSH session termination -// This fixes the "remote end hung up unexpectedly" issue based on go-git issue #1062 -func (s *GitSSHServer) sendExitStatusAndClose(channel ssh.Channel, status int) { - logger := s.Logger.With().Str("component", "ssh-close").Logger() - logger.Debug().Int("status", status).Msg("Starting sendExitStatusAndClose") - - // For successful operations, use gentle close to prevent hangup messages - if status == 0 { - logger.Debug().Msg("Success case: using gentle close strategy") - // Just close write side and let client close the connection - if closeWriter, ok := channel.(interface{ CloseWrite() error }); ok { - if err := closeWriter.CloseWrite(); err != nil { - logger.Debug().Err(err).Msg("Failed to close write side") - } else { - logger.Debug().Msg("Write side closed successfully") - } - } - - // Give client time to close gracefully - logger.Debug().Msg("Entering 200ms sleep for gentle close") - time.Sleep(200 * time.Millisecond) - logger.Debug().Msg("Gentle close completed") - return - } - - // For error cases, still send proper exit status - logger.Debug().Msg("Error case: sending exit-status request") - exitStatus := ssh.Marshal(struct{ Value uint32 }{uint32(status)}) - accepted, err := channel.SendRequest("exit-status", false, exitStatus) - if err != nil { - logger.Debug().Err(err).Msg("Failed to send exit-status") - } else { - logger.Debug().Bool("accepted", accepted).Msg("Exit-status sent successfully") - } - - // Close write side to signal we're done sending data - logger.Debug().Msg("Attempting to close write side of channel") - if closeWriter, ok := channel.(interface{ CloseWrite() error }); ok { - if err := closeWriter.CloseWrite(); err != nil { - logger.Debug().Err(err).Msg("Failed to close write side") - } else { - logger.Debug().Msg("Write side closed successfully") - } - } else { - logger.Debug().Msg("Channel does not support CloseWrite") - } - - // Add delay and close for error cases - logger.Debug().Msg("Entering 100ms sleep to let client process data") - time.Sleep(100 * time.Millisecond) - logger.Debug().Msg("Sleep completed, now closing channel") - - // Now close the channel - this gives the client time to process everything - if err := channel.Close(); err != nil { - logger.Debug().Err(err).Msg("Failed to close channel") - } else { - logger.Debug().Msg("Channel closed successfully") - } - logger.Debug().Msg("sendExitStatusAndClose completed") -} - -// parseGitCommand extracts the Git service and repository path from a command. -func (s *GitSSHServer) parseGitCommand(cmd string) (service, repoPath string) { - cmd = strings.TrimSpace(cmd) - if cmd == "" { - return "", "" - } - - parts := strings.Fields(cmd) - if len(parts) < 2 { - return "", "" - } - - service = parts[0] - repoArg := strings.Join(parts[1:], " ") - - // Extract repository path from argument - repoPath = s.extractRepoPath(repoArg) - repoPath = common.NormalizeRepoPath(repoPath) - - return service, repoPath -} - -// extractRepoPath extracts the repository path from the SSH argument. -func (s *GitSSHServer) extractRepoPath(arg string) string { - // Remove quotes if present - arg = strings.Trim(arg, "'\"") - - // Remove leading slash if present - arg = strings.TrimPrefix(arg, "/") - - // Handle formats like "host:path" by taking only the path part - if strings.Contains(arg, ":") { - parts := strings.Split(arg, ":") - if len(parts) > 1 { - arg = parts[1] - } - } - - return arg -} - -// ensureHostKey generates or loads an SSH host key. -func (s *GitSSHServer) ensureHostKey() (ssh.Signer, error) { - logger := s.Logger.With().Str("component", "git-ssh-hostkey").Logger() - - // Try to load existing key - if data, err := os.ReadFile(s.HostKeyPath); err == nil { - if block, _ := pem.Decode(data); block != nil { - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err == nil { - if edKey, ok := key.(ed25519.PrivateKey); ok { - signer, err := ssh.NewSignerFromKey(edKey) - if err == nil { - logger.Info().Str("path", s.HostKeyPath).Msg("Loaded existing SSH host key") - return signer, nil - } - } - } - } - } - - // Generate new key - logger.Info().Str("path", s.HostKeyPath).Msg("Generating new SSH host key") - _, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return nil, fmt.Errorf("failed to generate ED25519 key: %w", err) - } - - // Convert to PKCS8 format - pkcs8Key, err := x509.MarshalPKCS8PrivateKey(privateKey) - if err != nil { - return nil, fmt.Errorf("failed to marshal private key: %w", err) - } - - // Create PEM block - pemBlock := &pem.Block{ - Type: "PRIVATE KEY", - Bytes: pkcs8Key, - } - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(s.HostKeyPath), 0755); err != nil { - return nil, fmt.Errorf("failed to create key directory: %w", err) - } - - // Write key to file - if err := os.WriteFile(s.HostKeyPath, pem.EncodeToMemory(pemBlock), 0600); err != nil { - return nil, fmt.Errorf("failed to write host key: %w", err) - } - - // Create signer - signer, err := ssh.NewSignerFromKey(privateKey) - if err != nil { - return nil, fmt.Errorf("failed to create signer: %w", err) - } - - logger.Info().Str("path", s.HostKeyPath).Msg("SSH host key generated and saved") - return signer, nil -} diff --git a/internal/server/http.go b/internal/server/http.go deleted file mode 100644 index 7de70e5..0000000 --- a/internal/server/http.go +++ /dev/null @@ -1,83 +0,0 @@ -package server - -import ( - "strconv" - - "github.com/labbs/git-server-s3/internal/api/router" - "github.com/labbs/git-server-s3/pkg/logger/zerolog" - "github.com/labbs/git-server-s3/pkg/storage" - - "github.com/goccy/go-json" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/compress" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/recover" - "github.com/gofiber/fiber/v2/middleware/requestid" - z "github.com/rs/zerolog" -) - -type HttpConfig struct { - Port int - HttpLogs bool - Fiber *fiber.App - Logger z.Logger - Storage storage.GitRepositoryStorage -} - -func (c *HttpConfig) Configure() { - fiberConfig := fiber.Config{ - JSONEncoder: json.Marshal, - JSONDecoder: json.Unmarshal, - DisableStartupMessage: true, - } - - r := fiber.New(fiberConfig) - - if c.HttpLogs { - r.Use(zerolog.HTTPLogger(c.Logger)) - } - - r.Use(recover.New()) - r.Use(cors.New()) - r.Use(compress.New()) - r.Use(requestid.New()) - - r.Get("/health", func(ctx *fiber.Ctx) error { - return ctx.JSON(fiber.Map{ - "status": "ok", - "service": "git-server-s3", - }) - }) - - c.Fiber = r -} - -func (c *HttpConfig) NewServer() error { - c.Configure() - - apirc := router.Config{ - Logger: c.Logger, - Fiber: c.Fiber, - Storage: c.Storage, - } - - apirc.Configure() - - c.Logger.Info().Msgf("Starting server on port %d", c.Port) - - err := c.Fiber.Listen(":" + strconv.Itoa(c.Port)) - if err != nil { - c.Logger.Error().Err(err).Msg("Failed to start server") - return err - } - return nil -} - -// Shutdown gracefully shuts down the HTTP server -func (c *HttpConfig) Shutdown() error { - if c.Fiber != nil { - c.Logger.Info().Msg("Shutting down HTTP server") - return c.Fiber.Shutdown() - } - return nil -} diff --git a/internal/server/ssh.go b/internal/server/ssh.go deleted file mode 100644 index ee969b7..0000000 --- a/internal/server/ssh.go +++ /dev/null @@ -1,443 +0,0 @@ -// Package server provides SSH server implementation for Git operations. -package server - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/x509" - "encoding/pem" - "io" - "os" - "path/filepath" - "strings" - - gliderssh "github.com/gliderlabs/ssh" - "github.com/go-git/go-git/v5/plumbing/protocol/packp" - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/labbs/git-server-s3/pkg/common" - "github.com/labbs/git-server-s3/pkg/storage" - "github.com/rs/zerolog" - gossh "golang.org/x/crypto/ssh" -) - -// SSHConfig holds configuration for the SSH server. -// It manages Git SSH protocol operations including authentication, -// repository access, and Git command execution. -type SSHConfig struct { - Port string // SSH server port (e.g., ":2222") - Logger zerolog.Logger // Logger for SSH operations - Storage storage.GitRepositoryStorage // Storage backend for repositories - Server *gliderssh.Server // The underlying SSH server instance - HostKeyPath string // Path to SSH host key file -} - -// Configure sets up the SSH server with authentication handlers and Git command processing. -// It generates or loads the SSH host key and configures the server to handle Git operations. -func (sc *SSHConfig) Configure() error { - logger := sc.Logger.With().Str("component", "ssh-server").Logger() - - // Set default host key path if not provided - if sc.HostKeyPath == "" { - sc.HostKeyPath = "ssh_host_key" - } - - // Generate or load SSH host key - signer, err := sc.ensureHostKey() - if err != nil { - logger.Error().Err(err).Msg("Failed to ensure host key") - return err - } - - // Create SSH server instance - srv := &gliderssh.Server{ - Addr: sc.Port, - // Authentication handlers - for production, implement proper auth - PasswordHandler: sc.passwordHandler, - PublicKeyHandler: sc.publicKeyHandler, - Handler: sc.handleSSHSession, - } - - // Add the host key - srv.AddHostKey(signer) - - sc.Server = srv - logger.Info().Str("addr", sc.Port).Msg("SSH server configured") - return nil -} - -// NewServer starts the SSH server and begins listening for connections. -// This is a blocking call that will run until the server is stopped or an error occurs. -func (sc *SSHConfig) NewServer() error { - logger := sc.Logger.With().Str("component", "ssh-server").Logger() - - logger.Info().Str("addr", sc.Port).Msg("Starting SSH Git server") - if err := sc.Server.ListenAndServe(); err != nil { - logger.Error().Err(err).Msg("SSH server failed") - return err - } - return nil -} - -// Shutdown gracefully shuts down the SSH server -func (sc *SSHConfig) Shutdown() error { - if sc.Server != nil { - sc.Logger.Info().Msg("Shutting down SSH server") - return sc.Server.Close() - } - return nil -} - -// ensureHostKey generates or loads an SSH host key for the server. -// If the key file doesn't exist, it generates a new ed25519 key pair. -func (sc *SSHConfig) ensureHostKey() (gossh.Signer, error) { - logger := sc.Logger.With().Str("component", "ssh-hostkey").Logger() - - // Try to load existing key - if data, err := os.ReadFile(sc.HostKeyPath); err == nil { - if block, _ := pem.Decode(data); block != nil { - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err == nil { - if sk, ok := key.(ed25519.PrivateKey); ok { - logger.Info().Str("path", sc.HostKeyPath).Msg("Loaded existing SSH host key") - return gossh.NewSignerFromKey(sk) - } - } - } - } - - // Generate new key - logger.Info().Str("path", sc.HostKeyPath).Msg("Generating new SSH host key") - _, sk, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return nil, err - } - - // Save key in PKCS#8 PEM format - b, err := x509.MarshalPKCS8PrivateKey(sk) - if err != nil { - return nil, err - } - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(sc.HostKeyPath), 0755); err != nil { - return nil, err - } - - // Write key file with restrictive permissions - f, err := os.OpenFile(sc.HostKeyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - return nil, err - } - defer f.Close() - - if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: b}); err != nil { - return nil, err - } - - logger.Info().Str("path", sc.HostKeyPath).Msg("SSH host key generated and saved") - return gossh.NewSignerFromKey(sk) -} - -// passwordHandler handles password-based authentication. -// In production, implement proper password validation. -func (sc *SSHConfig) passwordHandler(ctx gliderssh.Context, password string) bool { - logger := sc.Logger.With().Str("component", "ssh-auth").Logger() - - // Handle nil context gracefully (for testing) - if ctx != nil { - logger = logger.With(). - Str("user", ctx.User()). - Str("remote", ctx.RemoteAddr().String()). - Logger() - } - - // TODO: Implement proper password authentication - // For now, accept any password for demonstration - logger.Info().Msg("Password authentication accepted (demo mode)") - return true -} - -// publicKeyHandler handles public key-based authentication. -// In production, implement proper public key validation. -func (sc *SSHConfig) publicKeyHandler(ctx gliderssh.Context, key gliderssh.PublicKey) bool { - logger := sc.Logger.With().Str("component", "ssh-auth").Logger() - - // Handle nil context gracefully (for testing) - if ctx != nil { - logger = logger.With(). - Str("user", ctx.User()). - Str("remote", ctx.RemoteAddr().String()). - Logger() - } - - // Handle nil key gracefully (for testing) - if key != nil { - logger = logger.With().Str("key_type", key.Type()).Logger() - } - - // TODO: Implement proper public key authentication - // For now, accept any public key for demonstration - logger.Info().Msg("Public key authentication accepted (demo mode)") - return true -} - -// handleSSHSession processes an incoming SSH session and executes Git commands. -// It parses the Git command (git-upload-pack or git-receive-pack) and delegates -// to the appropriate handler. -func (sc *SSHConfig) handleSSHSession(s gliderssh.Session) { - logger := sc.Logger.With(). - Str("component", "ssh-session"). - Str("user", s.User()). - Str("remote", s.RemoteAddr().String()). - Str("command", s.RawCommand()). - Logger() - - logger.Debug().Msg("Handling SSH session") - - // Parse the Git command - service, repoArg := sc.parseGitCommand(s.RawCommand()) - if service == "" || repoArg == "" { - logger.Warn().Str("command", s.RawCommand()).Msg("Invalid Git command") - _, _ = io.WriteString(s.Stderr(), "Invalid Git command\n") - _ = s.Exit(1) - return - } - - // Extract and normalize repository path - repoPath := sc.repoPathFromSSHArg(repoArg) - if repoPath == "" { - logger.Warn().Str("repo_arg", repoArg).Msg("Invalid repository path") - _, _ = io.WriteString(s.Stderr(), "Invalid repository path\n") - _ = s.Exit(1) - return - } - - logger = logger.With(). - Str("service", service). - Str("repo_path", repoPath). - Logger() - - // Check if repository exists, create for receive-pack if needed - exists := sc.Storage.RepositoryExists(repoPath) - if !exists && service == "git-receive-pack" { - logger.Info().Msg("Creating repository for push operation") - if err := sc.Storage.CreateRepository(repoPath); err != nil { - logger.Error().Err(err).Msg("Failed to create repository") - _, _ = io.WriteString(s.Stderr(), "Failed to create repository: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - exists = true - } - - if !exists { - logger.Warn().Msg("Repository not found") - _, _ = io.WriteString(s.Stderr(), "Repository not found\n") - _ = s.Exit(1) - return - } - - // Get transport server for the repository - srv, ep, err := common.GetTransportServer(repoPath, sc.Storage) - if err != nil { - logger.Error().Err(err).Msg("Failed to get transport server") - _, _ = io.WriteString(s.Stderr(), "Transport server error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - // Handle the specific Git service - switch service { - case "git-upload-pack": - sc.handleUploadPack(s, srv, ep, logger) - case "git-receive-pack": - sc.handleReceivePack(s, srv, ep, logger) - default: - logger.Error().Str("service", service).Msg("Unsupported Git service") - _, _ = io.WriteString(s.Stderr(), "Unsupported service: "+service+"\n") - _ = s.Exit(1) - } -} - -// handleUploadPack processes git-upload-pack requests (clone, fetch operations). -func (sc *SSHConfig) handleUploadPack(s gliderssh.Session, srv transport.Transport, ep *transport.Endpoint, logger zerolog.Logger) { - logger.Debug().Msg("Handling upload-pack (clone/fetch)") - - // Create upload pack session - up, err := srv.NewUploadPackSession(ep, nil) - if err != nil { - logger.Error().Err(err).Msg("Failed to create upload pack session") - _, _ = io.WriteString(s.Stderr(), "Upload pack session error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - // Send advertised references first (SSH protocol) - adv, err := up.AdvertisedReferences() - if err != nil { - logger.Error().Err(err).Msg("Failed to get advertised references") - _, _ = io.WriteString(s.Stderr(), "Advertised references error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - if err := adv.Encode(s); err != nil { - logger.Error().Err(err).Msg("Failed to encode advertised references") - _, _ = io.WriteString(s.Stderr(), "Failed to send references: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - // Read client request and respond - req := packp.NewUploadPackRequest() - if err := req.Decode(s); err != nil { - // For empty repositories, clients may not send a proper upload pack request - // This is normal behavior and should be handled gracefully - if err.Error() == "pkt-line 1: missing 'want ' prefix" { - logger.Info().Msg("Client disconnected - likely empty repository clone") - } else { - logger.Error().Err(err).Msg("Failed to decode upload pack request") - _, _ = io.WriteString(s.Stderr(), "Request decode error: "+err.Error()+"\n") - } - _ = s.Exit(0) // Exit with success for empty repo case - return - } - - // Process the upload pack request - resp, err := up.UploadPack(context.Background(), req) - if err != nil { - logger.Error().Err(err).Msg("Upload pack failed") - _, _ = io.WriteString(s.Stderr(), "Upload pack error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - defer resp.Close() - - // Send response to client - if err := resp.Encode(s); err != nil { - logger.Error().Err(err).Msg("Failed to encode upload pack response") - _, _ = io.WriteString(s.Stderr(), "Response encode error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - logger.Info().Msg("Upload pack completed successfully") - _ = s.Exit(0) -} - -// handleReceivePack processes git-receive-pack requests (push operations). -func (sc *SSHConfig) handleReceivePack(s gliderssh.Session, srv transport.Transport, ep *transport.Endpoint, logger zerolog.Logger) { - logger.Debug().Msg("Handling receive-pack (push)") - - // Create receive pack session - rp, err := srv.NewReceivePackSession(ep, nil) - if err != nil { - logger.Error().Err(err).Msg("Failed to create receive pack session") - _, _ = io.WriteString(s.Stderr(), "Receive pack session error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - // Send advertised references first (SSH protocol) - adv, err := rp.AdvertisedReferences() - if err != nil { - logger.Error().Err(err).Msg("Failed to get advertised references") - _, _ = io.WriteString(s.Stderr(), "Advertised references error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - if err := adv.Encode(s); err != nil { - logger.Error().Err(err).Msg("Failed to encode advertised references") - _, _ = io.WriteString(s.Stderr(), "Failed to send references: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - // Read reference update request from client - req := packp.NewReferenceUpdateRequest() - if err := req.Decode(s); err != nil { - logger.Error().Err(err).Msg("Failed to decode receive pack request") - _, _ = io.WriteString(s.Stderr(), "Request decode error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - - // Process the receive pack request - report, err := rp.ReceivePack(context.Background(), req) - if err != nil { - logger.Error().Err(err).Msg("Receive pack failed") - // Even on error, try to send the report to the client - if report != nil { - _ = report.Encode(s) - } else { - _, _ = io.WriteString(s.Stderr(), "Receive pack error: "+err.Error()+"\n") - } - _ = s.Exit(1) - return - } - - // Send status report to client - if report != nil { - if err := report.Encode(s); err != nil { - logger.Error().Err(err).Msg("Failed to encode receive pack report") - _, _ = io.WriteString(s.Stderr(), "Report encode error: "+err.Error()+"\n") - _ = s.Exit(1) - return - } - } else { - // If no report, send a minimal success response - logger.Debug().Msg("No report to send, assuming success") - } - - logger.Info().Msg("Receive pack completed successfully") - - // Exit cleanly with success code - _ = s.Exit(0) -} - -// parseGitCommand extracts the Git service and repository argument from an SSH command. -// Example: "git-upload-pack '/demo.git'" -> ("git-upload-pack", "/demo.git") -func (sc *SSHConfig) parseGitCommand(cmd string) (service, repoArg string) { - cmd = strings.TrimSpace(cmd) - if cmd == "" { - return "", "" - } - - // Commands are in the form: git-upload-pack 'path' or git-receive-pack 'path' - if strings.HasPrefix(cmd, "git-upload-pack") { - return "git-upload-pack", strings.TrimSpace(strings.TrimPrefix(cmd, "git-upload-pack")) - } - if strings.HasPrefix(cmd, "git-receive-pack") { - return "git-receive-pack", strings.TrimSpace(strings.TrimPrefix(cmd, "git-receive-pack")) - } - return "", "" -} - -// repoPathFromSSHArg cleans the SSH repository argument and returns a normalized path. -// Example: '/demo.git' -> "demo.git" -func (sc *SSHConfig) repoPathFromSSHArg(arg string) string { - arg = strings.TrimSpace(arg) - - // Remove quotes - arg = strings.Trim(arg, "'\"") - - // Remove leading colon (some Git clients use it) - arg = strings.TrimPrefix(arg, ":") - - // Remove host part if present (host:path format) - if i := strings.Index(arg, ":"); i >= 0 { - arg = arg[i+1:] - } - - // Remove leading slash - arg = strings.TrimPrefix(arg, "/") - - if arg == "" { - return "" - } - - // Normalize using common function - return common.NormalizeRepoPath(arg) -} diff --git a/internal/server/ssh_test.go b/internal/server/ssh_test.go deleted file mode 100644 index 71d1cc2..0000000 --- a/internal/server/ssh_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package server - -import ( - "os" - "path/filepath" - "testing" - - "github.com/labbs/git-server-s3/pkg/storage/local" - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSSHConfig_Configure(t *testing.T) { - // Create temporary directory for test - tempDir, err := os.MkdirTemp("", "ssh-test-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - // Setup storage - logger := zerolog.New(os.Stderr).Level(zerolog.ErrorLevel) - localStorage := local.NewLocalStorage(logger) - localStorage.Configure() - - // Create SSH config - sshConfig := &SSHConfig{ - Port: ":0", // Use random port for testing - Logger: logger, - Storage: localStorage, - HostKeyPath: filepath.Join(tempDir, "test_host_key"), - } - - // Test configuration - err = sshConfig.Configure() - require.NoError(t, err) - - // Verify server was created - assert.NotNil(t, sshConfig.Server) - assert.Equal(t, ":0", sshConfig.Server.Addr) - - // Verify host key was generated - assert.FileExists(t, sshConfig.HostKeyPath) -} - -func TestSSHConfig_parseGitCommand(t *testing.T) { - sshConfig := &SSHConfig{} - - tests := []struct { - name string - command string - wantService string - wantRepo string - }{ - { - name: "upload pack command", - command: "git-upload-pack '/repo.git'", - wantService: "git-upload-pack", - wantRepo: "'/repo.git'", - }, - { - name: "receive pack command", - command: "git-receive-pack '/repo.git'", - wantService: "git-receive-pack", - wantRepo: "'/repo.git'", - }, - { - name: "upload pack with spaces", - command: "git-upload-pack '/my repo.git' ", - wantService: "git-upload-pack", - wantRepo: "'/my repo.git'", - }, - { - name: "invalid command", - command: "invalid-command", - wantService: "", - wantRepo: "", - }, - { - name: "empty command", - command: "", - wantService: "", - wantRepo: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - service, repo := sshConfig.parseGitCommand(tt.command) - assert.Equal(t, tt.wantService, service) - assert.Equal(t, tt.wantRepo, repo) - }) - } -} - -func TestSSHConfig_repoPathFromSSHArg(t *testing.T) { - sshConfig := &SSHConfig{} - - tests := []struct { - name string - arg string - expected string - }{ - { - name: "simple repo path", - arg: "'repo.git'", - expected: "repo.git", - }, - { - name: "double quoted path", - arg: "\"repo.git\"", - expected: "repo.git", - }, - { - name: "path with leading slash", - arg: "'/repo.git'", - expected: "repo.git", - }, - { - name: "path with colon prefix", - arg: "':repo.git'", - expected: "repo.git", - }, - { - name: "path with host", - arg: "'host:/path/repo.git'", - expected: "path/repo.git", - }, - { - name: "repo without .git suffix", - arg: "'myrepo'", - expected: "myrepo.git", - }, - { - name: "empty path", - arg: "''", - expected: "", - }, - { - name: "path with spaces", - arg: " '/repo.git' ", - expected: "repo.git", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := sshConfig.repoPathFromSSHArg(tt.arg) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestSSHConfig_ensureHostKey(t *testing.T) { - tempDir, err := os.MkdirTemp("", "hostkey-test-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - logger := zerolog.New(os.Stderr).Level(zerolog.ErrorLevel) - sshConfig := &SSHConfig{ - Logger: logger, - HostKeyPath: filepath.Join(tempDir, "host_key"), - } - - // Test key generation - signer1, err := sshConfig.ensureHostKey() - require.NoError(t, err) - assert.NotNil(t, signer1) - - // Verify key file was created - assert.FileExists(t, sshConfig.HostKeyPath) - - // Test key loading (should load the same key) - signer2, err := sshConfig.ensureHostKey() - require.NoError(t, err) - assert.NotNil(t, signer2) - - // Both signers should have the same key - assert.Equal(t, signer1.PublicKey().Marshal(), signer2.PublicKey().Marshal()) -} - -func TestSSHConfig_authenticationHandlers(t *testing.T) { - logger := zerolog.New(os.Stderr).Level(zerolog.ErrorLevel) - sshConfig := &SSHConfig{ - Logger: logger, - } - - // Test password handler with nil context (should handle gracefully) - // In real usage, context would not be nil, but we test error handling - result := sshConfig.passwordHandler(nil, "any-password") - assert.True(t, result) - - // Test public key handler with nil context and key (should handle gracefully) - result = sshConfig.publicKeyHandler(nil, nil) - assert.True(t, result) -} - -// Integration test would require setting up actual SSH connections -// and Git clients, which is complex for unit tests. The above tests -// cover the core functionality of the SSH server configuration. diff --git a/pkg/common/common_test.go b/pkg/common/common_test.go deleted file mode 100644 index 3e6661d..0000000 --- a/pkg/common/common_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package common - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestExtractRepoPathFromURL(t *testing.T) { - tests := []struct { - name string - url string - suffix string - expected string - }{ - { - name: "simple git URL", - url: "/test-repo.git/info/refs", - suffix: "/info/refs", - expected: "test-repo.git", - }, - { - name: "nested path", - url: "/organization/project.git/git-upload-pack", - suffix: "/git-upload-pack", - expected: "organization/project.git", - }, - { - name: "root path", - url: "/repo.git/info/refs", - suffix: "/info/refs", - expected: "repo.git", - }, - { - name: "no suffix match", - url: "/test-repo.git/other/path", - suffix: "/info/refs", - expected: "", - }, - { - name: "empty URL", - url: "", - suffix: "/info/refs", - expected: "", - }, - { - name: "URL without .git", - url: "/test-repo/info/refs", - suffix: "/info/refs", - expected: "test-repo", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ExtractRepoPathFromURL(tt.url, tt.suffix) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestNormalizeRepoPath(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "simple repo name", - input: "test-repo", - expected: "test-repo.git", - }, - { - name: "repo with .git extension", - input: "test-repo.git", - expected: "test-repo.git", - }, - { - name: "repo with leading slash", - input: "/test-repo", - expected: "test-repo.git", - }, - { - name: "repo with trailing slash", - input: "test-repo/", - expected: "test-repo.git", - }, - { - name: "repo with both slashes", - input: "/test-repo/", - expected: "test-repo.git", - }, - { - name: "organization/project format", - input: "organization/project", - expected: "organization/project.git", - }, - { - name: "organization/project with .git", - input: "organization/project.git", - expected: "organization/project.git", - }, - { - name: "empty input", - input: "", - expected: ".git", - }, - { - name: "only slashes", - input: "///", - expected: ".git", - }, - { - name: "complex path", - input: "/company/team/project/", - expected: "company/team/project.git", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := NormalizeRepoPath(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -// Tests pour valider des cas d'usage réels -func TestNormalizeRepoPathRealExamples(t *testing.T) { - // Cas d'usage typiques qu'on peut rencontrer - examples := map[string]string{ - "my-awesome-project": "my-awesome-project.git", - "frontend-app": "frontend-app.git", - "backend-api.git": "backend-api.git", - "company/mobile-app": "company/mobile-app.git", - "github.com/user/repo": "github.com/user/repo.git", - "/tmp/test-repo": "tmp/test-repo.git", - "project-with-dashes": "project-with-dashes.git", - "project_with_underscores": "project_with_underscores.git", - "ProjectWithCamelCase": "ProjectWithCamelCase.git", - "project.with.dots": "project.with.dots.git", - "123-numeric-start": "123-numeric-start.git", - } - - for input, expected := range examples { - t.Run("normalize_"+input, func(t *testing.T) { - result := NormalizeRepoPath(input) - assert.Equal(t, expected, result) - }) - } -} - -func TestExtractRepoPathRealExamples(t *testing.T) { - // Cas d'usage typiques pour l'extraction de chemin - examples := []struct { - url string - suffix string - expected string - }{ - { - url: "/my-project.git/info/refs", - suffix: "/info/refs", - expected: "my-project.git", - }, - { - url: "/company/mobile-app.git/git-upload-pack", - suffix: "/git-upload-pack", - expected: "company/mobile-app.git", - }, - { - url: "/github.com/user/repo.git/git-receive-pack", - suffix: "/git-receive-pack", - expected: "github.com/user/repo.git", - }, - { - url: "/deep/nested/path/project.git/info/refs", - suffix: "/info/refs", - expected: "deep/nested/path/project.git", - }, - } - - for _, example := range examples { - t.Run("extract_"+example.expected, func(t *testing.T) { - result := ExtractRepoPathFromURL(example.url, example.suffix) - assert.Equal(t, example.expected, result) - }) - } -} - -// Tests de performance pour s'assurer que les fonctions sont rapides -func BenchmarkNormalizeRepoPath(b *testing.B) { - testInput := "organization/very-long-project-name-with-many-characters" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - NormalizeRepoPath(testInput) - } -} - -func BenchmarkExtractRepoPathFromURL(b *testing.B) { - testURL := "/organization/very-long-project-name.git/info/refs" - testSuffix := "/info/refs" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - ExtractRepoPathFromURL(testURL, testSuffix) - } -} diff --git a/pkg/logger/zerolog/gocron_adapter.go b/pkg/logger/zerolog/gocron_adapter.go deleted file mode 100644 index 0b31800..0000000 --- a/pkg/logger/zerolog/gocron_adapter.go +++ /dev/null @@ -1,27 +0,0 @@ -package zerolog - -import z "github.com/rs/zerolog" - -type GocronAdapter struct { - Logger z.Logger -} - -func (g GocronAdapter) Println(msg string, v ...any) { - g.Logger.Info().Msgf(msg, v...) -} - -func (g GocronAdapter) Debug(msg string, v ...any) { - g.Logger.Debug().Msgf(msg, v...) -} - -func (g GocronAdapter) Error(msg string, v ...any) { - g.Logger.Error().Msgf(msg, v...) -} - -func (g GocronAdapter) Info(msg string, v ...any) { - g.Logger.Info().Msgf(msg, v...) -} - -func (g GocronAdapter) Warn(msg string, v ...any) { - g.Logger.Warn().Msgf(msg, v...) -} diff --git a/pkg/storage/local/local.go b/pkg/storage/local/local.go deleted file mode 100644 index 5c4cbf2..0000000 --- a/pkg/storage/local/local.go +++ /dev/null @@ -1,33 +0,0 @@ -package local - -import ( - "errors" - "os" - - "github.com/labbs/git-server-s3/internal/config" - "github.com/rs/zerolog" -) - -type LocalConfig struct { - Logger zerolog.Logger -} - -func (c *LocalConfig) Configure() error { - c.Logger.Info().Msg("Configuring local storage") - if config.Storage.Local.Path == "" { - return errors.New("local storage path is not configured") - } - - // check if local storage path is a directory - info, err := os.Stat(config.Storage.Local.Path) - if os.IsNotExist(err) { - os.MkdirAll(config.Storage.Local.Path, os.ModePerm) - } else if err != nil { - return err - } - if !info.IsDir() { - return errors.New("local storage path is not a directory") - } - - return nil -} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go deleted file mode 100644 index bc3aad6..0000000 --- a/pkg/storage/storage.go +++ /dev/null @@ -1,36 +0,0 @@ -package storage - -import ( - awss3 "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/labbs/git-server-s3/internal/config" - "github.com/labbs/git-server-s3/pkg/storage/s3" - "github.com/rs/zerolog" -) - -type Storage struct { - S3Client *awss3.Client - Logger zerolog.Logger -} - -func (c *Storage) Configure() error { - logger := c.Logger.With().Str("component", "storage").Logger() - - switch config.Storage.Type { - case "s3": - logger.Info().Msg("Configuring S3 storage") - var s3Config s3.S3Config - s3Config.Logger = logger - err := s3Config.Configure() - if err != nil { - logger.Fatal().Err(err).Str("event", "s3.configure").Msg("Failed to configure S3 storage") - return err - } - c.S3Client = s3Config.Client - case "local": - logger.Info().Msg("Configuring local storage") - default: - logger.Warn().Msg("Unknown storage type, using in-memory storage") - } - - return nil -} diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..2936e32 --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e + +./tmp/main migration -c config.yaml +exec ./tmp/main server -c config.yaml \ No newline at end of file