mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-04 04:22:57 +00:00
1309 lines
39 KiB
Go
1309 lines
39 KiB
Go
package api
|
|
|
|
import (
|
|
"containr/internal/database"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// GitProvider represents a Git provider configuration per user.
|
|
type GitProvider struct {
|
|
ID string `json:"id" db:"id"`
|
|
Name string `json:"name" db:"name"` // github, gitlab, bitbucket, gitea, github_app
|
|
DisplayName string `json:"display_name" db:"display_name"`
|
|
APIUrl string `json:"api_url" db:"api_url"`
|
|
WebhookUrl string `json:"webhook_url" db:"webhook_url"`
|
|
UserID string `json:"user_id" db:"user_id"`
|
|
AccessToken string `json:"-" db:"access_token"` // Hidden in JSON responses
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// GitRepository represents a connected Git repository
|
|
type GitRepository struct {
|
|
ID string `json:"id" db:"id"`
|
|
ProviderID string `json:"provider_id" db:"provider_id"`
|
|
Name string `json:"name" db:"name"`
|
|
FullName string `json:"full_name" db:"full_name"`
|
|
Description string `json:"description" db:"description"`
|
|
CloneURL string `json:"clone_url" db:"clone_url"`
|
|
WebhookURL string `json:"webhook_url" db:"webhook_url"`
|
|
DefaultBranch string `json:"default_branch" db:"default_branch"`
|
|
IsPrivate bool `json:"is_private" db:"is_private"`
|
|
UserID string `json:"user_id" db:"user_id"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// GitWebhook represents a webhook configuration
|
|
type GitWebhook struct {
|
|
ID string `json:"id" db:"id"`
|
|
RepoID string `json:"repo_id" db:"repo_id"`
|
|
ProviderID string `json:"provider_id" db:"provider_id"`
|
|
Events string `json:"events" db:"events"` // JSON array of events
|
|
Secret string `json:"-" db:"webhook_secret"` // Hidden in JSON responses
|
|
Active bool `json:"active" db:"active"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// CreateGitProviderRequest represents a request to create a Git provider
|
|
type CreateGitProviderRequest struct {
|
|
Name string `json:"name" binding:"required,oneof=github gitlab bitbucket gitea github_app"`
|
|
DisplayName string `json:"display_name" binding:"required"`
|
|
AccessToken string `json:"access_token" binding:"required"`
|
|
APIUrl string `json:"api_url,omitempty"`
|
|
}
|
|
|
|
type ConnectGitHubAppRequest struct {
|
|
InstallationID int64 `json:"installation_id" binding:"required,gt=0"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
}
|
|
|
|
// CreateGitRepoRequest represents a request to connect a Git repository
|
|
type CreateGitRepoRequest struct {
|
|
ProviderID string `json:"provider_id" binding:"required"`
|
|
RepoFullName string `json:"repo_full_name" binding:"required"`
|
|
}
|
|
|
|
// CreateWebhookRequest represents a request to create a webhook
|
|
type CreateWebhookRequest struct {
|
|
RepoID string `json:"repo_id" binding:"required"`
|
|
Events []string `json:"events" binding:"required"`
|
|
Branch string `json:"branch"`
|
|
}
|
|
|
|
func handleGetGitProviders(c *gin.Context) {
|
|
ctx, ok := requireGitRequestContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rows, err := ctx.db.Query(`
|
|
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
|
|
FROM git_providers
|
|
WHERE user_id = $1
|
|
ORDER BY created_at DESC
|
|
`, ctx.userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
providers := make([]GitProvider, 0)
|
|
for rows.Next() {
|
|
var provider GitProvider
|
|
if err := rows.Scan(
|
|
&provider.ID,
|
|
&provider.Name,
|
|
&provider.DisplayName,
|
|
&provider.APIUrl,
|
|
&provider.WebhookUrl,
|
|
&provider.UserID,
|
|
&provider.CreatedAt,
|
|
&provider.UpdatedAt,
|
|
); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
providers = append(providers, provider)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"providers": providers})
|
|
}
|
|
|
|
func handleGetGitHubAppInstallURL(c *gin.Context) {
|
|
slug := strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG"))
|
|
if slug == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"install_url": fmt.Sprintf("https://github.com/apps/%s/installations/new", slug),
|
|
})
|
|
}
|
|
|
|
func handleConnectGitHubApp(c *gin.Context) {
|
|
ctx, ok := requireGitRequestContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req ConnectGitHubAppRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
displayName := strings.TrimSpace(req.DisplayName)
|
|
if displayName == "" {
|
|
displayName = fmt.Sprintf("GitHub App #%d", req.InstallationID)
|
|
}
|
|
|
|
provider := GitProvider{
|
|
ID: uuid.NewString(),
|
|
Name: "github_app",
|
|
DisplayName: displayName,
|
|
APIUrl: strings.TrimSpace(getenvOrDefault("GITHUB_APP_BASE_URL", "https://api.github.com")),
|
|
WebhookUrl: "https://github.com",
|
|
AccessToken: strconv.FormatInt(req.InstallationID, 10),
|
|
UserID: ctx.userID,
|
|
}
|
|
|
|
if err := ctx.db.QueryRow(`
|
|
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
|
ON CONFLICT (name, user_id)
|
|
DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
api_url = EXCLUDED.api_url,
|
|
webhook_url = EXCLUDED.webhook_url,
|
|
access_token = EXCLUDED.access_token,
|
|
updated_at = NOW()
|
|
RETURNING id, created_at, updated_at
|
|
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID).Scan(&provider.ID, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect GitHub App installation"})
|
|
return
|
|
}
|
|
|
|
provider.AccessToken = ""
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "GitHub App connected",
|
|
"provider": provider,
|
|
})
|
|
}
|
|
|
|
func handleCreateGitProvider(c *gin.Context) {
|
|
ctx, ok := requireGitRequestContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateGitProviderRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
req.Name = strings.ToLower(strings.TrimSpace(req.Name))
|
|
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
|
req.AccessToken = strings.TrimSpace(req.AccessToken)
|
|
req.APIUrl = strings.TrimSpace(req.APIUrl)
|
|
|
|
if !validateGitToken(req.Name, req.AccessToken) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid access token for " + req.Name})
|
|
return
|
|
}
|
|
|
|
provider := GitProvider{
|
|
ID: uuid.NewString(),
|
|
Name: req.Name,
|
|
DisplayName: req.DisplayName,
|
|
AccessToken: req.AccessToken,
|
|
UserID: ctx.userID,
|
|
}
|
|
|
|
switch req.Name {
|
|
case "github":
|
|
provider.APIUrl = "https://api.github.com"
|
|
provider.WebhookUrl = "https://api.github.com"
|
|
case "gitlab":
|
|
provider.APIUrl = req.APIUrl
|
|
if provider.APIUrl == "" {
|
|
provider.APIUrl = strings.TrimSpace(getenvOrDefault("GITLAB_API_URL", "https://gitlab.com/api/v4"))
|
|
}
|
|
provider.APIUrl = strings.TrimSuffix(provider.APIUrl, "/")
|
|
provider.WebhookUrl = strings.TrimSpace(getenvOrDefault("GITLAB_BASE_URL", "https://gitlab.com"))
|
|
case "bitbucket":
|
|
provider.APIUrl = req.APIUrl
|
|
if provider.APIUrl == "" {
|
|
provider.APIUrl = strings.TrimSpace(getenvOrDefault("BITBUCKET_API_URL", "https://api.bitbucket.org/2.0"))
|
|
}
|
|
provider.APIUrl = strings.TrimSuffix(provider.APIUrl, "/")
|
|
provider.WebhookUrl = strings.TrimSpace(getenvOrDefault("BITBUCKET_BASE_URL", "https://bitbucket.org"))
|
|
case "gitea":
|
|
provider.APIUrl = req.APIUrl
|
|
if provider.APIUrl == "" {
|
|
provider.APIUrl = strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "https://gitea.example.com"))
|
|
}
|
|
provider.APIUrl = strings.TrimSuffix(provider.APIUrl, "/")
|
|
provider.WebhookUrl = provider.APIUrl
|
|
case "github_app":
|
|
provider.APIUrl = strings.TrimSpace(getenvOrDefault("GITHUB_APP_BASE_URL", "https://api.github.com"))
|
|
provider.WebhookUrl = "https://github.com"
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported provider"})
|
|
return
|
|
}
|
|
|
|
if _, err := ctx.db.Exec(`
|
|
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
|
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Provider already connected for this account"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Git provider"})
|
|
return
|
|
}
|
|
|
|
if err := ctx.db.QueryRow(`
|
|
SELECT created_at, updated_at
|
|
FROM git_providers
|
|
WHERE id = $1
|
|
`, provider.ID).Scan(&provider.CreatedAt, &provider.UpdatedAt); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read created provider"})
|
|
return
|
|
}
|
|
|
|
provider.AccessToken = ""
|
|
c.JSON(http.StatusCreated, provider)
|
|
}
|
|
|
|
func handleGetGitRepositories(c *gin.Context) {
|
|
ctx, ok := requireGitRequestContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
providerID := strings.TrimSpace(c.Param("providerId"))
|
|
if _, err := uuid.Parse(providerID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
|
return
|
|
}
|
|
|
|
var providerName, providerDisplayName, providerAccessToken, providerAPIURL string
|
|
err := ctx.db.QueryRow(`
|
|
SELECT name, display_name, access_token, api_url
|
|
FROM git_providers
|
|
WHERE id = $1 AND user_id = $2
|
|
`, providerID, ctx.userID).Scan(&providerName, &providerDisplayName, &providerAccessToken, &providerAPIURL)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
if c.DefaultQuery("sync", "1") != "0" {
|
|
if syncErr := syncProviderRepositories(ctx.db, providerID, ctx.userID, providerName, providerAPIURL, providerAccessToken); syncErr != nil {
|
|
log.Printf("Git repository sync failed for provider %s (%s): %v", providerName, providerID, syncErr)
|
|
}
|
|
}
|
|
|
|
page := positiveIntOrDefault(c.DefaultQuery("page", "1"), 1)
|
|
limit := positiveIntOrDefault(c.DefaultQuery("limit", "20"), 20)
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
offset := (page - 1) * limit
|
|
search := strings.TrimSpace(c.Query("search"))
|
|
searchNullable := sql.NullString{String: search, Valid: search != ""}
|
|
|
|
rows, err := ctx.db.Query(`
|
|
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
|
|
default_branch, is_private, user_id, created_at, updated_at
|
|
FROM git_repositories
|
|
WHERE provider_id = $1 AND user_id = $2
|
|
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
|
|
ORDER BY updated_at DESC
|
|
LIMIT $4 OFFSET $5
|
|
`, providerID, ctx.userID, searchNullable, limit, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
repositories := make([]GitRepository, 0)
|
|
for rows.Next() {
|
|
var repo GitRepository
|
|
if err := rows.Scan(
|
|
&repo.ID,
|
|
&repo.ProviderID,
|
|
&repo.Name,
|
|
&repo.FullName,
|
|
&repo.Description,
|
|
&repo.CloneURL,
|
|
&repo.WebhookURL,
|
|
&repo.DefaultBranch,
|
|
&repo.IsPrivate,
|
|
&repo.UserID,
|
|
&repo.CreatedAt,
|
|
&repo.UpdatedAt,
|
|
); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
repositories = append(repositories, repo)
|
|
}
|
|
|
|
var total int
|
|
if err := ctx.db.QueryRow(`
|
|
SELECT COUNT(*)
|
|
FROM git_repositories
|
|
WHERE provider_id = $1 AND user_id = $2
|
|
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
|
|
`, providerID, ctx.userID, searchNullable).Scan(&total); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"provider": gin.H{
|
|
"id": providerID,
|
|
"name": providerName,
|
|
"display_name": providerDisplayName,
|
|
},
|
|
"repositories": repositories,
|
|
"pagination": gin.H{
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
},
|
|
})
|
|
}
|
|
|
|
func handleConnectGitRepository(c *gin.Context) {
|
|
ctx, ok := requireGitRequestContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateGitRepoRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
providerID := strings.TrimSpace(req.ProviderID)
|
|
if _, err := uuid.Parse(providerID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
|
return
|
|
}
|
|
|
|
var providerName string
|
|
err := ctx.db.QueryRow(`
|
|
SELECT name
|
|
FROM git_providers
|
|
WHERE id = $1 AND user_id = $2
|
|
`, providerID, ctx.userID).Scan(&providerName)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
repoName, fullName, err := normalizeRepositoryFullName(req.RepoFullName)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var existing GitRepository
|
|
err = ctx.db.QueryRow(`
|
|
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
|
|
default_branch, is_private, user_id, created_at, updated_at
|
|
FROM git_repositories
|
|
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
|
|
`, providerID, fullName, ctx.userID).Scan(
|
|
&existing.ID,
|
|
&existing.ProviderID,
|
|
&existing.Name,
|
|
&existing.FullName,
|
|
&existing.Description,
|
|
&existing.CloneURL,
|
|
&existing.WebhookURL,
|
|
&existing.DefaultBranch,
|
|
&existing.IsPrivate,
|
|
&existing.UserID,
|
|
&existing.CreatedAt,
|
|
&existing.UpdatedAt,
|
|
)
|
|
switch {
|
|
case err == nil:
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Repository already connected",
|
|
"repository": existing,
|
|
})
|
|
return
|
|
case !errors.Is(err, sql.ErrNoRows):
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
repo := GitRepository{
|
|
ID: uuid.NewString(),
|
|
ProviderID: providerID,
|
|
Name: repoName,
|
|
FullName: fullName,
|
|
Description: "",
|
|
CloneURL: deriveCloneURL(providerName, fullName),
|
|
DefaultBranch: "main",
|
|
IsPrivate: false,
|
|
UserID: ctx.userID,
|
|
}
|
|
|
|
if err := ctx.db.QueryRow(`
|
|
INSERT INTO git_repositories (
|
|
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
|
RETURNING created_at, updated_at
|
|
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description, repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID).Scan(&repo.CreatedAt, &repo.UpdatedAt); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Repository is already connected"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Repository connected successfully",
|
|
"repository": repo,
|
|
})
|
|
}
|
|
|
|
func handleCreateWebhook(c *gin.Context) {
|
|
ctx, ok := requireGitRequestContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateWebhookRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if len(req.Events) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "events cannot be empty"})
|
|
return
|
|
}
|
|
|
|
repoID := strings.TrimSpace(req.RepoID)
|
|
if _, err := uuid.Parse(repoID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
|
|
return
|
|
}
|
|
|
|
var providerID string
|
|
err := ctx.db.QueryRow(`
|
|
SELECT provider_id
|
|
FROM git_repositories
|
|
WHERE id = $1 AND user_id = $2
|
|
`, repoID, ctx.userID).Scan(&providerID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
eventsJSON, err := json.Marshal(req.Events)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid events payload"})
|
|
return
|
|
}
|
|
|
|
secret, err := generateWebhookSecret()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate webhook secret"})
|
|
return
|
|
}
|
|
|
|
webhook := GitWebhook{
|
|
ID: uuid.NewString(),
|
|
RepoID: repoID,
|
|
ProviderID: providerID,
|
|
Events: string(eventsJSON),
|
|
Secret: secret,
|
|
Active: true,
|
|
}
|
|
|
|
if err := ctx.db.QueryRow(`
|
|
INSERT INTO git_webhooks (
|
|
id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
|
|
ON CONFLICT (repo_id, provider_id)
|
|
DO UPDATE SET
|
|
events = EXCLUDED.events,
|
|
webhook_secret = EXCLUDED.webhook_secret,
|
|
active = TRUE,
|
|
branch_filter = EXCLUDED.branch_filter,
|
|
updated_at = NOW()
|
|
RETURNING id, active, created_at, updated_at
|
|
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, strings.TrimSpace(req.Branch)).Scan(&webhook.ID, &webhook.Active, &webhook.CreatedAt, &webhook.UpdatedAt); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure webhook"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Webhook configured successfully",
|
|
"webhook": gin.H{
|
|
"id": webhook.ID,
|
|
"repo_id": webhook.RepoID,
|
|
"provider_id": webhook.ProviderID,
|
|
"events": req.Events,
|
|
"branch": strings.TrimSpace(req.Branch),
|
|
"active": webhook.Active,
|
|
"created_at": webhook.CreatedAt,
|
|
"updated_at": webhook.UpdatedAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
func handleGetConnectedRepositories(c *gin.Context) {
|
|
ctx, ok := requireGitRequestContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
page := positiveIntOrDefault(c.DefaultQuery("page", "1"), 1)
|
|
limit := positiveIntOrDefault(c.DefaultQuery("limit", "10"), 10)
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
offset := (page - 1) * limit
|
|
|
|
rows, err := ctx.db.Query(`
|
|
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, ''), r.clone_url,
|
|
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
|
|
p.name as provider_name, p.display_name
|
|
FROM git_repositories r
|
|
JOIN git_providers p ON r.provider_id = p.id
|
|
WHERE r.user_id = $1
|
|
ORDER BY r.updated_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
`, ctx.userID, limit, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
repositories := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var repo GitRepository
|
|
var providerName, providerDisplayName string
|
|
if err := rows.Scan(
|
|
&repo.ID,
|
|
&repo.ProviderID,
|
|
&repo.Name,
|
|
&repo.FullName,
|
|
&repo.Description,
|
|
&repo.CloneURL,
|
|
&repo.DefaultBranch,
|
|
&repo.IsPrivate,
|
|
&repo.UserID,
|
|
&repo.CreatedAt,
|
|
&repo.UpdatedAt,
|
|
&providerName,
|
|
&providerDisplayName,
|
|
); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
repositories = append(repositories, map[string]interface{}{
|
|
"id": repo.ID,
|
|
"provider_id": repo.ProviderID,
|
|
"name": repo.Name,
|
|
"full_name": repo.FullName,
|
|
"description": repo.Description,
|
|
"clone_url": repo.CloneURL,
|
|
"default_branch": repo.DefaultBranch,
|
|
"is_private": repo.IsPrivate,
|
|
"created_at": repo.CreatedAt,
|
|
"updated_at": repo.UpdatedAt,
|
|
"provider": map[string]string{
|
|
"name": providerName,
|
|
"display_name": providerDisplayName,
|
|
},
|
|
})
|
|
}
|
|
|
|
var total int
|
|
if err := ctx.db.QueryRow(`
|
|
SELECT COUNT(*)
|
|
FROM git_repositories
|
|
WHERE user_id = $1
|
|
`, ctx.userID).Scan(&total); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"repositories": repositories,
|
|
"pagination": gin.H{
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
},
|
|
})
|
|
}
|
|
|
|
type remoteGitRepository struct {
|
|
Name string
|
|
FullName string
|
|
Description string
|
|
CloneURL string
|
|
DefaultBranch string
|
|
IsPrivate bool
|
|
}
|
|
|
|
func syncProviderRepositories(db *database.DB, providerID, userID, providerName, providerAPIURL, accessToken string) error {
|
|
repositories, err := fetchProviderRepositories(providerName, providerAPIURL, accessToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, repository := range repositories {
|
|
if strings.TrimSpace(repository.FullName) == "" || strings.TrimSpace(repository.Name) == "" {
|
|
continue
|
|
}
|
|
|
|
cloneURL := strings.TrimSpace(repository.CloneURL)
|
|
if cloneURL == "" {
|
|
cloneURL = deriveCloneURL(providerName, repository.FullName)
|
|
}
|
|
|
|
defaultBranch := strings.TrimSpace(repository.DefaultBranch)
|
|
if defaultBranch == "" {
|
|
defaultBranch = "main"
|
|
}
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO git_repositories (
|
|
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
|
ON CONFLICT (provider_id, full_name)
|
|
DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
description = EXCLUDED.description,
|
|
clone_url = EXCLUDED.clone_url,
|
|
default_branch = EXCLUDED.default_branch,
|
|
is_private = EXCLUDED.is_private,
|
|
updated_at = NOW()
|
|
`, uuid.NewString(), providerID, repository.Name, repository.FullName, repository.Description, cloneURL, defaultBranch, repository.IsPrivate, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func fetchProviderRepositories(providerName, providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
|
|
switch strings.ToLower(strings.TrimSpace(providerName)) {
|
|
case "github":
|
|
return fetchGitHubRepositories(providerAPIURL, accessToken)
|
|
case "github_app":
|
|
return fetchGitHubAppRepositories(providerAPIURL, accessToken)
|
|
case "gitlab":
|
|
return fetchGitLabRepositories(providerAPIURL, accessToken)
|
|
case "bitbucket":
|
|
return fetchBitbucketRepositories(providerAPIURL, accessToken)
|
|
case "gitea":
|
|
return fetchGiteaRepositories(providerAPIURL, accessToken)
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func fetchGitHubRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
|
|
if strings.TrimSpace(accessToken) == "" {
|
|
return nil, fmt.Errorf("github access token is missing")
|
|
}
|
|
|
|
baseURL := strings.TrimSpace(providerAPIURL)
|
|
if baseURL == "" {
|
|
baseURL = "https://api.github.com"
|
|
}
|
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
|
|
request, err := http.NewRequest(http.MethodGet, baseURL+"/user/repos?per_page=100&sort=updated", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/vnd.github+json")
|
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
|
return nil, fmt.Errorf("github repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
|
}
|
|
|
|
var rows []map[string]interface{}
|
|
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]remoteGitRepository, 0, len(rows))
|
|
for _, row := range rows {
|
|
fullName := asGitString(row["full_name"])
|
|
name := asGitString(row["name"])
|
|
if fullName == "" || name == "" {
|
|
continue
|
|
}
|
|
result = append(result, remoteGitRepository{
|
|
Name: name,
|
|
FullName: fullName,
|
|
Description: asGitString(row["description"]),
|
|
CloneURL: asGitString(row["clone_url"]),
|
|
DefaultBranch: asGitString(row["default_branch"]),
|
|
IsPrivate: asBoolValue(row["private"]),
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func fetchGitHubAppRepositories(providerAPIURL, installationIDRaw string) ([]remoteGitRepository, error) {
|
|
installationID, err := strconv.ParseInt(strings.TrimSpace(installationIDRaw), 10, 64)
|
|
if err != nil || installationID <= 0 {
|
|
return nil, fmt.Errorf("github_app installation id is invalid")
|
|
}
|
|
|
|
baseURL := strings.TrimSpace(providerAPIURL)
|
|
if baseURL == "" {
|
|
baseURL = strings.TrimSpace(getenvOrDefault("GITHUB_APP_BASE_URL", "https://api.github.com"))
|
|
}
|
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
|
|
token, err := getGitHubAppInstallationToken(baseURL, installationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/installation/repositories?per_page=100", baseURL), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header.Set("Accept", "application/vnd.github+json")
|
|
request.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
|
return nil, fmt.Errorf("github app repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
|
}
|
|
|
|
var wrapper struct {
|
|
Repositories []map[string]interface{} `json:"repositories"`
|
|
}
|
|
if err := json.NewDecoder(response.Body).Decode(&wrapper); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]remoteGitRepository, 0, len(wrapper.Repositories))
|
|
for _, row := range wrapper.Repositories {
|
|
fullName := asGitString(row["full_name"])
|
|
name := asGitString(row["name"])
|
|
if fullName == "" || name == "" {
|
|
continue
|
|
}
|
|
result = append(result, remoteGitRepository{
|
|
Name: name,
|
|
FullName: fullName,
|
|
Description: asGitString(row["description"]),
|
|
CloneURL: asGitString(row["clone_url"]),
|
|
DefaultBranch: asGitString(row["default_branch"]),
|
|
IsPrivate: asBoolValue(row["private"]),
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func fetchGiteaRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
|
|
if strings.TrimSpace(accessToken) == "" {
|
|
return nil, fmt.Errorf("gitea access token is missing")
|
|
}
|
|
|
|
baseURL := strings.TrimSpace(providerAPIURL)
|
|
if baseURL == "" {
|
|
baseURL = strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "https://gitea.example.com"))
|
|
}
|
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
|
|
request, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/user/repos?limit=100", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Authorization", "token "+strings.TrimSpace(accessToken))
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
|
return nil, fmt.Errorf("gitea repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
|
}
|
|
|
|
var rows []map[string]interface{}
|
|
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]remoteGitRepository, 0, len(rows))
|
|
for _, row := range rows {
|
|
fullName := asGitString(row["full_name"])
|
|
name := asGitString(row["name"])
|
|
if fullName == "" || name == "" {
|
|
continue
|
|
}
|
|
result = append(result, remoteGitRepository{
|
|
Name: name,
|
|
FullName: fullName,
|
|
Description: asGitString(row["description"]),
|
|
CloneURL: asGitString(row["clone_url"]),
|
|
DefaultBranch: asGitString(row["default_branch"]),
|
|
IsPrivate: asBoolValue(row["private"]),
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func fetchGitLabRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
|
|
if strings.TrimSpace(accessToken) == "" {
|
|
return nil, fmt.Errorf("gitlab access token is missing")
|
|
}
|
|
|
|
baseURL := strings.TrimSpace(providerAPIURL)
|
|
if baseURL == "" {
|
|
baseURL = strings.TrimSpace(getenvOrDefault("GITLAB_API_URL", "https://gitlab.com/api/v4"))
|
|
}
|
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
|
|
request, err := http.NewRequest(http.MethodGet, baseURL+"/projects?membership=true&simple=true&per_page=100&order_by=last_activity_at&sort=desc", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("PRIVATE-TOKEN", strings.TrimSpace(accessToken))
|
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
|
return nil, fmt.Errorf("gitlab repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
|
}
|
|
|
|
var rows []map[string]interface{}
|
|
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]remoteGitRepository, 0, len(rows))
|
|
for _, row := range rows {
|
|
fullName := asGitString(row["path_with_namespace"])
|
|
name := asGitString(row["name"])
|
|
if fullName == "" || name == "" {
|
|
continue
|
|
}
|
|
|
|
visibility := strings.ToLower(asGitString(row["visibility"]))
|
|
isPrivate := visibility == "private"
|
|
if visibility == "" {
|
|
isPrivate = asBoolValue(row["private"])
|
|
}
|
|
|
|
result = append(result, remoteGitRepository{
|
|
Name: name,
|
|
FullName: fullName,
|
|
Description: asGitString(row["description"]),
|
|
CloneURL: asGitString(row["http_url_to_repo"]),
|
|
DefaultBranch: asGitString(row["default_branch"]),
|
|
IsPrivate: isPrivate,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func fetchBitbucketRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
|
|
if strings.TrimSpace(accessToken) == "" {
|
|
return nil, fmt.Errorf("bitbucket access token is missing")
|
|
}
|
|
|
|
baseURL := strings.TrimSpace(providerAPIURL)
|
|
if baseURL == "" {
|
|
baseURL = strings.TrimSpace(getenvOrDefault("BITBUCKET_API_URL", "https://api.bitbucket.org/2.0"))
|
|
}
|
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
|
|
client := &http.Client{Timeout: 12 * time.Second}
|
|
nextURL := baseURL + "/repositories?role=member&pagelen=100"
|
|
maxPages := 5
|
|
result := make([]remoteGitRepository, 0, 128)
|
|
|
|
for page := 0; page < maxPages && nextURL != ""; page++ {
|
|
request, err := http.NewRequest(http.MethodGet, nextURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
|
|
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
|
response.Body.Close()
|
|
return nil, fmt.Errorf("bitbucket repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
|
}
|
|
|
|
var payload struct {
|
|
Values []map[string]interface{} `json:"values"`
|
|
Next string `json:"next"`
|
|
}
|
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
|
response.Body.Close()
|
|
return nil, err
|
|
}
|
|
response.Body.Close()
|
|
|
|
for _, row := range payload.Values {
|
|
fullName := asGitString(row["full_name"])
|
|
name := asGitString(row["name"])
|
|
if fullName == "" || name == "" {
|
|
continue
|
|
}
|
|
|
|
defaultBranch := "main"
|
|
if branch, ok := row["mainbranch"].(map[string]interface{}); ok {
|
|
if parsed := asGitString(branch["name"]); parsed != "" {
|
|
defaultBranch = parsed
|
|
}
|
|
}
|
|
|
|
cloneURL := ""
|
|
if links, ok := row["links"].(map[string]interface{}); ok {
|
|
if cloneLinks, ok := links["clone"].([]interface{}); ok {
|
|
for _, raw := range cloneLinks {
|
|
link, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if strings.EqualFold(asGitString(link["name"]), "https") {
|
|
cloneURL = asGitString(link["href"])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result = append(result, remoteGitRepository{
|
|
Name: name,
|
|
FullName: fullName,
|
|
Description: asGitString(row["description"]),
|
|
CloneURL: cloneURL,
|
|
DefaultBranch: defaultBranch,
|
|
IsPrivate: asBoolValue(row["is_private"]),
|
|
})
|
|
}
|
|
|
|
nextURL = strings.TrimSpace(payload.Next)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) {
|
|
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
|
|
privateKeyPEM := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
|
|
if appID == "" || privateKeyPEM == "" {
|
|
return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured")
|
|
}
|
|
|
|
jwtToken, err := createGitHubAppJWT(appID, privateKeyPEM)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/app/installations/%d/access_tokens", strings.TrimSuffix(baseURL, "/"), installationID), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/vnd.github+json")
|
|
request.Header.Set("Authorization", "Bearer "+jwtToken)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusCreated {
|
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
|
return "", fmt.Errorf("github app token request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
|
}
|
|
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(body.Token) == "" {
|
|
return "", fmt.Errorf("github app token response missing token")
|
|
}
|
|
return body.Token, nil
|
|
}
|
|
|
|
func createGitHubAppJWT(appID, privateKeyPEM string) (string, error) {
|
|
privateKey, err := parseGitHubAppPrivateKey(privateKeyPEM)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
claims := jwt.RegisteredClaims{
|
|
Issuer: strings.TrimSpace(appID),
|
|
IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
return token.SignedString(privateKey)
|
|
}
|
|
|
|
func parseGitHubAppPrivateKey(raw string) (*rsa.PrivateKey, error) {
|
|
normalized := strings.ReplaceAll(strings.TrimSpace(raw), "\\n", "\n")
|
|
block, _ := pem.Decode([]byte(normalized))
|
|
if block == nil {
|
|
return nil, fmt.Errorf("invalid github app private key format")
|
|
}
|
|
|
|
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
|
return key, nil
|
|
}
|
|
|
|
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
privateKey, ok := parsed.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("github app private key is not RSA")
|
|
}
|
|
return privateKey, nil
|
|
}
|
|
|
|
func asGitString(value interface{}) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return strings.TrimSpace(typed)
|
|
default:
|
|
return strings.TrimSpace(fmt.Sprint(typed))
|
|
}
|
|
}
|
|
|
|
func asBoolValue(value interface{}) bool {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case string:
|
|
parsed, _ := strconv.ParseBool(strings.TrimSpace(typed))
|
|
return parsed
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func getenvOrDefault(key, fallback string) string {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func validateGitToken(provider, token string) bool {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return false
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(provider), "github_app") {
|
|
installationID, err := strconv.ParseInt(token, 10, 64)
|
|
return err == nil && installationID > 0
|
|
}
|
|
return true
|
|
}
|
|
|
|
type gitRequestContext struct {
|
|
userID string
|
|
db *database.DB
|
|
}
|
|
|
|
func requireGitRequestContext(c *gin.Context) (*gitRequestContext, bool) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
dbValue, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database context not found"})
|
|
return nil, false
|
|
}
|
|
|
|
db, ok := dbValue.(*database.DB)
|
|
if !ok || db == nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid database context"})
|
|
return nil, false
|
|
}
|
|
|
|
return &gitRequestContext{
|
|
userID: userID,
|
|
db: db,
|
|
}, true
|
|
}
|
|
|
|
func positiveIntOrDefault(raw string, fallback int) int {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value <= 0 {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func isUniqueConstraintError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(err.Error())
|
|
return strings.Contains(lower, "duplicate key") || strings.Contains(lower, "unique constraint")
|
|
}
|
|
|
|
func normalizeRepositoryFullName(input string) (name string, fullName string, err error) {
|
|
value := strings.TrimSpace(input)
|
|
if value == "" {
|
|
return "", "", fmt.Errorf("repo_full_name is required")
|
|
}
|
|
|
|
if parsed, parseErr := url.Parse(value); parseErr == nil && parsed.Scheme != "" && parsed.Host != "" {
|
|
path := strings.Trim(parsed.Path, "/")
|
|
if path != "" {
|
|
value = path
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(strings.ToLower(value), "git@") {
|
|
if separator := strings.Index(value, ":"); separator != -1 && separator+1 < len(value) {
|
|
value = value[separator+1:]
|
|
}
|
|
}
|
|
|
|
value = strings.TrimSuffix(value, ".git")
|
|
parts := strings.Split(strings.Trim(value, "/"), "/")
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("repo_full_name must be in owner/repository format")
|
|
}
|
|
if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
|
return "", "", fmt.Errorf("repo_full_name must be in owner/repository format")
|
|
}
|
|
for _, part := range parts {
|
|
if strings.ContainsAny(part, " \t\r\n") {
|
|
return "", "", fmt.Errorf("repo_full_name cannot include whitespace")
|
|
}
|
|
}
|
|
|
|
return parts[1], parts[0] + "/" + parts[1], nil
|
|
}
|
|
|
|
func deriveCloneURL(provider, repoFullName string) string {
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
case "github":
|
|
return fmt.Sprintf("https://github.com/%s.git", repoFullName)
|
|
case "github_app":
|
|
return fmt.Sprintf("https://github.com/%s.git", repoFullName)
|
|
case "gitlab":
|
|
baseURL := strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITLAB_BASE_URL", "https://gitlab.com")), "/")
|
|
return fmt.Sprintf("%s/%s.git", baseURL, repoFullName)
|
|
case "bitbucket":
|
|
baseURL := strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("BITBUCKET_BASE_URL", "https://bitbucket.org")), "/")
|
|
return fmt.Sprintf("%s/%s.git", baseURL, repoFullName)
|
|
case "gitea":
|
|
baseURL := strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "https://gitea.example.com")), "/")
|
|
return fmt.Sprintf("%s/%s.git", baseURL, repoFullName)
|
|
default:
|
|
return repoFullName
|
|
}
|
|
}
|
|
|
|
func generateWebhookSecret() (string, error) {
|
|
buf := make([]byte, 24)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|