Files
Tomas Dvorak 355a97bab4 overhaul
2026-04-14 18:04:48 +02:00

785 lines
23 KiB
Go

package api
import (
"bytes"
"containr/internal/database"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GitProvider represents a Git provider (GitHub, GitLab, Bitbucket)
type GitProvider struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` // github, gitlab, bitbucket
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 string `json:"created_at" db:"created_at"`
UpdatedAt string `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 string `json:"created_at" db:"created_at"`
UpdatedAt string `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
RemoteWebhookID string `json:"remote_webhook_id" db:"remote_webhook_id"`
Active bool `json:"active" db:"active"`
BranchFilter string `json:"branch_filter,omitempty" db:"branch_filter"`
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `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"`
DisplayName string `json:"display_name" binding:"required"`
AccessToken string `json:"access_token" binding:"required"`
}
// 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"`
}
type GitBranch struct {
Name string `json:"name"`
CommitHash string `json:"commit_hash"`
IsDefault bool `json:"is_default"`
}
type githubRepo struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
}
type githubBranch struct {
Name string `json:"name"`
Commit struct {
SHA string `json:"sha"`
} `json:"commit"`
}
type githubUser struct {
Login string `json:"login"`
}
func handleGetGitProviders(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
rows, err := 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
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
var providers []GitProvider
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 handleCreateGitProvider(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateGitProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate the access token by making a test API call
if !validateGitToken(req.Name, req.AccessToken) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid access token for " + req.Name})
return
}
provider := GitProvider{
ID: uuid.New().String(),
Name: req.Name,
DisplayName: req.DisplayName,
AccessToken: req.AccessToken,
UserID: userID,
}
// Set provider-specific URLs
switch req.Name {
case "github":
provider.APIUrl = "https://api.github.com"
provider.WebhookUrl = "https://api.github.com"
case "gitlab":
provider.APIUrl = "https://gitlab.com/api/v4"
provider.WebhookUrl = "https://gitlab.com"
case "bitbucket":
provider.APIUrl = "https://api.bitbucket.org/2.0"
provider.WebhookUrl = "https://api.bitbucket.org/2.0"
}
_, err := 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)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Git provider"})
return
}
// Return provider without access token
provider.AccessToken = ""
c.JSON(http.StatusCreated, provider)
}
func handleGetGitRepositories(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
providerID := c.Param("providerId")
// Validate UUID
if _, err := uuid.Parse(providerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
// Get provider info
var provider GitProvider
err := db.QueryRow(`
SELECT id, name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
return
}
// Fetch repositories from the Git provider
repos, err := fetchGitRepositories(provider.Name, provider.AccessToken, provider.APIUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repositories"})
return
}
c.JSON(http.StatusOK, gin.H{"repositories": repos})
}
func handleConnectGitRepository(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateGitRepoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate UUID
if _, err := uuid.Parse(req.ProviderID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
// Get provider info
var provider GitProvider
err := db.QueryRow(`
SELECT id, name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, req.ProviderID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
return
}
// Fetch repository details from Git provider
repoDetails, err := fetchGitRepositoryDetails(provider.Name, req.RepoFullName, provider.AccessToken, provider.APIUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repository details"})
return
}
// Check if repository is already connected
var existingID string
err = db.QueryRow(`
SELECT id FROM git_repositories
WHERE provider_id = $1 AND full_name = $2
`, req.ProviderID, req.RepoFullName).Scan(&existingID)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Repository already connected", "repository_id": existingID})
return
}
// Create repository record
repo := GitRepository{
ID: uuid.New().String(),
ProviderID: req.ProviderID,
Name: repoDetails["name"].(string),
FullName: req.RepoFullName,
Description: repoDetails["description"].(string),
CloneURL: repoDetails["clone_url"].(string),
DefaultBranch: repoDetails["default_branch"].(string),
IsPrivate: repoDetails["private"].(bool),
UserID: userID,
}
_, 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())
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description,
repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
return
}
c.JSON(http.StatusCreated, repo)
}
func handleCreateWebhook(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate UUIDs
if _, err := uuid.Parse(req.RepoID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
return
}
// Get repository and provider info
var repo GitRepository
var provider GitProvider
err := db.QueryRow(`
SELECT r.id, r.provider_id, r.full_name, r.user_id,
p.id, p.name, p.access_token, p.api_url, p.webhook_url
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.id = $1 AND r.user_id = $2
`, req.RepoID, userID).Scan(&repo.ID, &repo.ProviderID, &repo.FullName, &repo.UserID,
&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl, &provider.WebhookUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
// Convert events to JSON
eventsJSON, _ := json.Marshal(req.Events)
webhookSecret := generateWebhookSecret()
// Create webhook on Git provider
webhookURL := fmt.Sprintf("%s/api/v1/webhooks/git/%s", publicBaseURL(c), req.RepoID)
remoteWebhookID, err := createGitWebhook(provider.Name, repo.FullName, provider.AccessToken,
provider.APIUrl, webhookURL, req.Events, webhookSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook on Git provider: " + err.Error()})
return
}
// Create webhook record
webhook := GitWebhook{
ID: uuid.New().String(),
RepoID: req.RepoID,
ProviderID: provider.ID,
Events: string(eventsJSON),
Secret: webhookSecret,
RemoteWebhookID: remoteWebhookID,
Active: true,
BranchFilter: req.Branch,
}
_, err = db.Exec(`
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, remote_webhook_id, active, branch_filter, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, webhook.RemoteWebhookID, webhook.Active, nullableString(webhook.BranchFilter))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
return
}
c.JSON(http.StatusCreated, gin.H{
"webhook": webhook,
"remote_webhook_id": remoteWebhookID,
})
}
func handleGetConnectedRepositories(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset := (page - 1) * limit
rows, err := db.Query(`
SELECT r.id, r.provider_id, r.name, r.full_name, 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
`, userID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
var repositories []map[string]interface{}
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,
},
})
}
// Get total count
var total int
err = db.QueryRow(`
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1
`, userID).Scan(&total)
if 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,
},
})
}
func handleGetRepositoryBranches(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
repoID := c.Param("repoId")
if _, err := uuid.Parse(repoID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
return
}
var repo GitRepository
var provider GitProvider
err := db.QueryRow(`
SELECT r.id, r.full_name, r.default_branch,
p.id, p.name, p.access_token, p.api_url
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.id = $1 AND r.user_id = $2
`, repoID, userID).Scan(&repo.ID, &repo.FullName, &repo.DefaultBranch,
&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
branches, err := fetchGitBranches(provider.Name, provider.AccessToken, provider.APIUrl, repo.FullName, repo.DefaultBranch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch branches: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"branches": branches})
}
// Helper functions (these would need to be implemented with actual Git provider API calls)
func validateGitToken(provider, token string) bool {
if strings.TrimSpace(token) == "" {
return false
}
if provider != "github" {
return true
}
body, status, err := gitProviderRequest("GET", "https://api.github.com", token, "/user", nil)
if err != nil || status < 200 || status >= 300 {
return false
}
var user githubUser
return json.Unmarshal(body, &user) == nil && user.Login != ""
}
func fetchGitRepositories(provider, token, apiUrl string) ([]map[string]interface{}, error) {
if provider != "github" {
return nil, fmt.Errorf("%s repository listing is not implemented yet", provider)
}
body, status, err := gitProviderRequest(
"GET",
apiUrl,
token,
"/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member",
nil,
)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var repos []githubRepo
if err := json.Unmarshal(body, &repos); err != nil {
return nil, err
}
result := make([]map[string]interface{}, 0, len(repos))
for _, repo := range repos {
result = append(result, map[string]interface{}{
"name": repo.Name,
"full_name": repo.FullName,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"private": repo.Private,
"html_url": repo.HTMLURL,
})
}
return result, nil
}
func fetchGitRepositoryDetails(provider, repoFullName, token, apiUrl string) (map[string]interface{}, error) {
if provider != "github" {
return nil, fmt.Errorf("%s repository details are not implemented yet", provider)
}
path, err := githubRepoPath(repoFullName, "")
if err != nil {
return nil, err
}
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var repo githubRepo
if err := json.Unmarshal(body, &repo); err != nil {
return nil, err
}
return map[string]interface{}{
"name": repo.Name,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"private": repo.Private,
"html_url": repo.HTMLURL,
}, nil
}
func fetchGitBranches(provider, token, apiUrl, repoFullName, defaultBranch string) ([]GitBranch, error) {
if provider != "github" {
return nil, fmt.Errorf("%s branch listing is not implemented yet", provider)
}
path, err := githubRepoPath(repoFullName, "/branches?per_page=100")
if err != nil {
return nil, err
}
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var rawBranches []githubBranch
if err := json.Unmarshal(body, &rawBranches); err != nil {
return nil, err
}
branches := make([]GitBranch, 0, len(rawBranches))
for _, branch := range rawBranches {
branches = append(branches, GitBranch{
Name: branch.Name,
CommitHash: branch.Commit.SHA,
IsDefault: branch.Name == defaultBranch,
})
}
return branches, nil
}
func createGitWebhook(provider, repoFullName, token, apiUrl, targetURL string, events []string, secret string) (string, error) {
if provider != "github" {
return "", fmt.Errorf("%s webhooks are not implemented yet", provider)
}
path, err := githubRepoPath(repoFullName, "/hooks")
if err != nil {
return "", err
}
payload := map[string]interface{}{
"name": "web",
"active": true,
"events": events,
"config": map[string]string{
"url": targetURL,
"content_type": "json",
"secret": secret,
"insecure_ssl": "0",
},
}
data, _ := json.Marshal(payload)
body, status, err := gitProviderRequest("POST", apiUrl, token, path, bytes.NewReader(data))
if err != nil {
return "", err
}
if status < 200 || status >= 300 {
return "", providerStatusError(status, body)
}
var response struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(body, &response); err != nil {
return "", err
}
if response.ID == 0 {
return "", fmt.Errorf("GitHub returned an empty webhook ID")
}
return strconv.FormatInt(response.ID, 10), nil
}
func generateWebhookSecret() string {
return "webhook-secret-" + uuid.New().String()
}
func gitProviderRequest(method, apiUrl, token, path string, body io.Reader) ([]byte, int, error) {
base := strings.TrimRight(apiUrl, "/")
if base == "" {
base = "https://api.github.com"
}
req, err := http.NewRequest(method, base+path, body)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/vnd.github+json")
if strings.TrimSpace(token) != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return data, resp.StatusCode, nil
}
func githubRepoPath(repoFullName, suffix string) (string, error) {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("repository must use owner/name format")
}
return "/repos/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]) + suffix, nil
}
func providerStatusError(status int, body []byte) error {
var response struct {
Message string `json:"message"`
}
if err := json.Unmarshal(body, &response); err == nil && response.Message != "" {
return fmt.Errorf("provider returned %d: %s", status, response.Message)
}
return fmt.Errorf("provider returned %d", status)
}
func publicBaseURL(c *gin.Context) string {
for _, key := range []string{"CONTAINR_PUBLIC_URL", "PUBLIC_URL", "APP_URL"} {
if value := strings.TrimRight(os.Getenv(key), "/"); value != "" {
return value
}
}
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := c.Request.Host
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
return scheme + "://" + host
}
func nullableString(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}
func fetchGitHubFile(provider, token, apiUrl, repoFullName, branch, filePath string) ([]byte, error) {
if provider != "github" {
return nil, fmt.Errorf("%s file fetch is not implemented yet", provider)
}
suffix := "/contents/" + strings.TrimLeft(filePath, "/")
if branch != "" {
suffix += "?ref=" + url.QueryEscape(branch)
}
path, err := githubRepoPath(repoFullName, suffix)
if err != nil {
return nil, err
}
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var response struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
if response.Encoding != "base64" {
return nil, fmt.Errorf("unsupported GitHub content encoding: %s", response.Encoding)
}
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(response.Content, "\n", ""))
if err != nil {
return nil, err
}
return decoded, nil
}