mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
* 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> * feat: activity feed — enriched audit events, global + per-project timelines audit_logs gains severity/category/label columns derived from resource+action at write time; legacy rows are classified on read and backfilled at boot in batches. - GET /activity: global feed scoped to the caller's visible projects (admins see all); severity/category/resource/project_id/since filters, page+limit - GET /projects/:id/activity: timeline correlated to the project's services, deployments, databases and the project record; access mirrors project read rules - Retention worker prunes rows older than AUDIT_RETENTION_DAYS (default 90, 0 disables), running once at boot then daily - project.create/delete now audited so timelines aren't empty - CLI `containr activity` with filter flags; MCP tools containr_activity_list + containr_project_activity - Frontend: /activity page (severity chips, icon per category) under Operate nav; project workspace gains an Activity view tab - OpenAPI + generated types + CLI docs 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>
429 lines
12 KiB
Go
429 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
"containr/internal/deployment"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type Project struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
OwnerID string `json:"owner_id"`
|
|
IsApproved bool `json:"is_approved"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type ProjectStats struct {
|
|
ServiceCount int `json:"service_count"`
|
|
DeploymentCount int `json:"deployment_count"`
|
|
RunningServices int `json:"running_services"`
|
|
LastDeployment *string `json:"last_deployment"`
|
|
}
|
|
|
|
type ProjectWithStats struct {
|
|
Project
|
|
Stats ProjectStats `json:"stats"`
|
|
}
|
|
|
|
type CreateProjectRequest struct {
|
|
Name string `json:"name" binding:"required,min=2"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type UpdateProjectRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
}
|
|
|
|
func handleGetProjects(c *gin.Context) {
|
|
userID := optionalUserUUID(c)
|
|
isAdmin := contextIsAdmin(c)
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
queries := sqlcdb.New(db.DB)
|
|
|
|
offset, limit := pageWindow(c, 10, 100)
|
|
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
|
|
|
searchParam := sql.NullString{}
|
|
if search != "" {
|
|
searchParam = sql.NullString{String: search, Valid: true}
|
|
}
|
|
|
|
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
|
|
UserID: userID,
|
|
IsAdmin: isAdmin,
|
|
Search: searchParam,
|
|
LimitCount: int32(limit),
|
|
OffsetCount: int32(offset),
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database query error"})
|
|
return
|
|
}
|
|
|
|
projects := make([]ProjectWithStats, 0, len(rows))
|
|
for _, row := range rows {
|
|
projects = append(projects, mapProjectWithStatsRow(row))
|
|
}
|
|
|
|
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
|
|
UserID: userID,
|
|
IsAdmin: isAdmin,
|
|
Search: searchParam,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"projects": projects,
|
|
"pagination": paginationMeta(offset, limit, int(total)),
|
|
})
|
|
}
|
|
|
|
func handleCreateProject(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
queries := sqlcdb.New(db.DB)
|
|
ctx := c.Request.Context()
|
|
|
|
var req CreateProjectRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if len(req.Name) < 2 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be at least 2 characters"})
|
|
return
|
|
}
|
|
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
txQueries := queries.WithTx(tx)
|
|
projectRow, err := txQueries.CreateProject(ctx, sqlcdb.CreateProjectParams{
|
|
Name: req.Name,
|
|
Description: nullableText(strings.TrimSpace(req.Description)),
|
|
OwnerID: userID,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
|
|
return
|
|
}
|
|
|
|
environments := []string{"production", "preview", "development"}
|
|
for _, env := range environments {
|
|
err = txQueries.InsertProjectEnvironment(ctx, sqlcdb.InsertProjectEnvironmentParams{
|
|
Name: env,
|
|
ProjectID: projectRow.ID,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create environments"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
|
|
return
|
|
}
|
|
|
|
LogAuditWithRequest(c, "project", projectRow.ID.String(), "project.create", map[string]interface{}{"name": req.Name})
|
|
c.JSON(http.StatusCreated, mapSQLCProject(projectRow))
|
|
}
|
|
|
|
func handleGetProject(c *gin.Context) {
|
|
userID := optionalUserUUID(c)
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
queries := sqlcdb.New(db.DB)
|
|
|
|
projectID, ok := parseProjectIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
|
ProjectID: projectID,
|
|
UserID: userID,
|
|
IsAdmin: contextIsAdmin(c),
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, mapSQLCProject(projectRow))
|
|
}
|
|
|
|
func handleUpdateProject(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
queries := sqlcdb.New(db.DB)
|
|
|
|
projectID, ok := parseProjectIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
|
|
ProjectID: projectID,
|
|
UserID: userID,
|
|
IsAdmin: contextIsAdmin(c),
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
var req UpdateProjectRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Name == nil && req.Description == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
|
return
|
|
}
|
|
|
|
nameParam := sql.NullString{}
|
|
if req.Name != nil {
|
|
trimmedName := strings.TrimSpace(*req.Name)
|
|
if len(trimmedName) < 2 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be at least 2 characters"})
|
|
return
|
|
}
|
|
nameParam = sql.NullString{String: trimmedName, Valid: true}
|
|
}
|
|
|
|
descriptionParam := sql.NullString{}
|
|
if req.Description != nil {
|
|
descriptionParam = sql.NullString{String: strings.TrimSpace(*req.Description), Valid: true}
|
|
}
|
|
|
|
updatedRows, err := queries.UpdateProjectByID(c.Request.Context(), sqlcdb.UpdateProjectByIDParams{
|
|
Name: nameParam,
|
|
Description: descriptionParam,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
|
|
return
|
|
}
|
|
if updatedRows == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
|
|
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
|
|
ProjectID: projectID,
|
|
UserID: userID,
|
|
IsAdmin: contextIsAdmin(c),
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, mapSQLCProject(projectRow))
|
|
}
|
|
|
|
func handleDeleteProject(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
queries := sqlcdb.New(db.DB)
|
|
|
|
projectID, ok := parseProjectIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ownerID, err := queries.GetProjectOwnerByID(c.Request.Context(), projectID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
if ownerID != userID && !contextIsAdmin(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
|
|
return
|
|
}
|
|
|
|
// Managed databases provisioned into this project (graph templates,
|
|
// compose imports) carry project_id and must be torn down first — their
|
|
// containers are not labelled containr.project and hold references to the
|
|
// project network.
|
|
if dbHandlerValue, exists := c.Get("database_handler"); exists && dbHandlerValue != nil {
|
|
if dbHandler, ok := dbHandlerValue.(*DatabaseHandler); ok {
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
|
projectDBs, listErr := dbHandler.queries.ListDatabaseServicesByProject(ctx, uuid.NullUUID{UUID: projectID, Valid: true})
|
|
if listErr == nil {
|
|
for _, projectDB := range projectDBs {
|
|
if projectDB.Provider != "external" {
|
|
if err := dbHandler.deleteManagedDatabaseRuntime(projectDB.ID); err != nil {
|
|
log.Printf("delete project %s: database %s runtime removal failed: %v", projectID, projectDB.ID, err)
|
|
}
|
|
}
|
|
_ = dbHandler.queries.DeleteDatabaseServiceByID(ctx, projectDB.ID)
|
|
}
|
|
}
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// Remove live containers and the project network before dropping rows.
|
|
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
engine := engineValue.(*deployment.DeploymentEngine)
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
|
_ = engine.RemoveProjectContainers(ctx, projectID.String())
|
|
_ = engine.RemoveProjectNetwork(ctx, projectID.String())
|
|
cancel()
|
|
}
|
|
|
|
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
|
|
return
|
|
}
|
|
if deletedRows == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
|
|
LogAuditWithRequest(c, "project", projectID.String(), "project.delete", nil)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
|
|
}
|
|
|
|
func requireAuthenticatedUserUUID(c *gin.Context) (uuid.UUID, bool) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return uuid.Nil, false
|
|
}
|
|
|
|
parsedUserID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
|
return uuid.Nil, false
|
|
}
|
|
|
|
return parsedUserID, true
|
|
}
|
|
|
|
func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
|
|
projectID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
|
return uuid.Nil, false
|
|
}
|
|
return projectID, true
|
|
}
|
|
|
|
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
|
|
result := ProjectWithStats{
|
|
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
|
|
Stats: ProjectStats{
|
|
ServiceCount: int(row.ServiceCount),
|
|
DeploymentCount: int(row.DeploymentCount),
|
|
RunningServices: int(row.RunningServices),
|
|
LastDeployment: nullableTimestampStringPtr(row.LastDeployment),
|
|
},
|
|
}
|
|
return result
|
|
}
|
|
|
|
func mapSQLCProject(project sqlcdb.Project) Project {
|
|
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
|
|
}
|
|
|
|
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
|
|
return Project{
|
|
ID: id.String(),
|
|
Name: name,
|
|
Description: nullableStringValue(description),
|
|
OwnerID: ownerID.String(),
|
|
IsApproved: isApproved,
|
|
CreatedAt: nullableTimestampString(createdAt),
|
|
UpdatedAt: nullableTimestampString(updatedAt),
|
|
}
|
|
}
|
|
|
|
func nullableText(value string) sql.NullString {
|
|
if value == "" {
|
|
return sql.NullString{}
|
|
}
|
|
return sql.NullString{String: value, Valid: true}
|
|
}
|
|
|
|
func nullableStringValue(value sql.NullString) string {
|
|
if value.Valid {
|
|
return value.String
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nullableTimestampString(value sql.NullTime) string {
|
|
if value.Valid {
|
|
return value.Time.Format(time.RFC3339)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nullableTimestampStringPtr(value sql.NullTime) *string {
|
|
if value.Valid {
|
|
formatted := value.Time.Format(time.RFC3339)
|
|
return &formatted
|
|
}
|
|
return nil
|
|
}
|