Files
Containr/app/backend/internal/api/git_receiver.go
T
Tomáš DvořákandDevin 00e69b3733 feat(git): GitHub App manifest self-provisioning + app-level webhook (#44)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:43:12 +02:00

239 lines
7.2 KiB
Go

package api
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// handleGitWebhookPush is the public push receiver. Providers call
// POST /api/git/webhooks/:id with a signed payload; the HMAC against
// git_webhooks.webhook_secret replaces session auth.
func handleGitWebhookPush(c *gin.Context) {
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbValue.(*database.DB)
webhookID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(webhookID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"})
return
}
var (
secret string
repoID string
providerID string
branchFilter string
active bool
)
err := db.QueryRow(`
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active
FROM git_webhooks WHERE id = $1`, webhookID).
Scan(&secret, &repoID, &providerID, &branchFilter, &active)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
return
}
if !active {
c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"})
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"})
return
}
var providerName string
_ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName)
if !verifyGitWebhookSignature(providerName, secret, c.Request, body) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
return
}
var payload struct {
Ref string `json:"ref"`
After string `json:"after"`
Repository struct {
FullName string `json:"full_name"`
PathWithNamespace string `json:"path_with_namespace"`
} `json:"repository"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
} `json:"project"`
HeadCommit struct {
ID string `json:"id"`
} `json:"head_commit"`
}
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
return
}
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
if branchFilter != "" && branchFilter != branch {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"})
return
}
commit := payload.HeadCommit.ID
if commit == "" {
commit = payload.After
}
var cloneURL, fullName, repoUserID string
err = db.QueryRow(
`SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID,
).Scan(&cloneURL, &fullName, &repoUserID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, repoUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
}
// dispatchPushToServices enqueues a deployment for every service tracking
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
// GitHub App webhook endpoint.
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
rows, err := db.Query(`
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at
FROM services s
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`,
branch, cloneURL, fullName)
if err != nil {
return 0, err
}
defer rows.Close()
var services []Service
for rows.Next() {
var s Service
if err := rows.Scan(
&s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command,
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
&s.CreatedAt, &s.UpdatedAt,
); err != nil {
return 0, err
}
services = append(services, s)
}
enqueued := 0
for _, service := range services {
now := time.Now()
var commitHash *string
if commit != "" {
commitHash = &commit
}
d := DeploymentModel{
ID: uuid.New(),
ServiceID: service.ID,
CommitHash: commitHash,
Status: "pending",
CreatedAt: now,
UpdatedAt: now,
}
if _, err := db.Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
); err != nil {
continue
}
if engine == nil {
failedAt := time.Now()
failure := "Deployment engine unavailable. Docker may not be configured on this server."
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
failure, failedAt, d.ID,
)
continue
}
_, _ = db.Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
// Webhook pushes default to a clean build — stale layers are the
// classic silent-rollback failure (dflow convention).
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
},
}); pos > 0 {
_, _ = db.Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
}
enqueued++
}
return enqueued, nil
}
// verifyGitWebhookSignature checks the push signature per provider:
// GitHub X-Hub-Signature-256 and Gitea X-Gitea-Signature are HMAC-SHA256 hex;
// GitLab compares X-Gitlab-Token to the stored secret directly.
func verifyGitWebhookSignature(provider, secret string, r *http.Request, body []byte) bool {
if strings.EqualFold(provider, "gitlab") {
token := r.Header.Get("X-Gitlab-Token")
return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
sum := mac.Sum(nil)
for _, header := range []string{"X-Hub-Signature-256", "X-Gitea-Signature"} {
sig := strings.TrimPrefix(r.Header.Get(header), "sha256=")
if decoded, err := hex.DecodeString(sig); err == nil && len(decoded) > 0 && hmac.Equal(decoded, sum) {
return true
}
}
return false
}