mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
Security:
- Graceful shutdown: http.Server + signal.NotifyContext, 10s drain,
retention sweeper cancelled before pool close
- Scoped API keys (migration 00006): ingest/read/write/admin enforced in
auth middleware; scope picker + badges in Settings UI; scopeless legacy
keys act as admin. Read-only keys now get 403 on ingest (verified live)
- nginx: baseline security headers on all responses, CSP scoped to SPA;
fixed add_header inheritance shadowing
- better-auth rate limit enabled unconditionally (was gated on NODE_ENV,
which the shipped .env sets to development)
- Mailpit moved to docker-compose.dev.yml; prod compose cannot expose it
- setup.sh generates PRIMORA_ENCRYPTION_KEY
Reliability/ops:
- Retention: per-project retention_{events,audit,webhook}_days
(migration 00007, 0=off) + hourly RetentionSweeper; edit UI in Projects
- GET /api/v1/metrics Prometheus exposition
- compose: healthchecks + restart policies everywhere, dragonfly pinned
to v2.0.0, depends_on service_healthy
- backup.sh/restore.sh (pg_dump -Fc + storage volume); restore verified
against scratch postgres+volume
- .dockerignore: exclude target dirs (build context was 4.7GB)
Correctness/testing:
- DBX transfer endpoint: POST /db-connections/:id/transfer — SQL or Redis
target, 1000-row bound, server-side creds (verified live pg→dragonfly)
- Pagination audit: ListComponents capped at 500 (auto-registers)
- CI: goose up/reset/up job + compose e2e-smoke job (smoke-e2e.sh
verified locally: signup, bootstrap, scoped keys, ingest, metrics)
- k6 load sanity script: 6650 reqs, ~217/s, 0 real failures, p95 221ms
(429s = configured rate limiter working)
Docs: DEPLOYMENT_GUIDE.md, PRODUCTION_READINESS.md; verify script
repaired (stale refs + set -e arithmetic abort). ROADMAP: phase 2 +
phase 6 marked complete.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
266 lines
7.2 KiB
Go
266 lines
7.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
|
)
|
|
|
|
func (h *HTTPHandler) dbxStatus(c *gin.Context) {
|
|
actor, ok := middleware.RequireActor(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.DBXStatus(c.Request.Context(), actor)
|
|
if err != nil {
|
|
h.handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) listDBConnections(c *gin.Context) {
|
|
actor, projectID, ok := h.actorAndProject(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.ListDBConnections(c.Request.Context(), actor, projectID)
|
|
if err != nil {
|
|
h.handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
|
}
|
|
|
|
func (h *HTTPHandler) createDBConnection(c *gin.Context) {
|
|
actor, projectID, ok := h.actorAndProject(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body services.CreateDBConnectionInput
|
|
if !h.bindAndValidate(c, &body) {
|
|
return
|
|
}
|
|
result, err := h.Platform.CreateDBConnection(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "already exists") {
|
|
apperrors.Abort(c, http.StatusConflict, "name_conflict", err.Error())
|
|
return
|
|
}
|
|
h.handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) deleteDBConnection(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.Platform.DeleteDBConnection(c.Request.Context(), actor, projectID, connectionID, middleware.RequestIDFromContext(c)); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "connection not found")
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "platform-managed") {
|
|
apperrors.Abort(c, http.StatusConflict, "managed_connection", err.Error())
|
|
return
|
|
}
|
|
h.handleError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *HTTPHandler) testDBConnection(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.TestDBConnection(c.Request.Context(), actor, projectID, connectionID)
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxDatabases(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.ListDBXDatabases(c.Request.Context(), actor, projectID, connectionID)
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxTables(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.ListDBXTables(c.Request.Context(), actor, projectID, connectionID,
|
|
strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxDescribe(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.DescribeDBXTable(c.Request.Context(), actor, projectID, connectionID,
|
|
strings.TrimSpace(c.Query("table")), strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxSchema(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.DBXSchemaContext(c.Request.Context(), actor, projectID, connectionID,
|
|
strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxForeignKeys(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.Platform.ListDBXForeignKeys(c.Request.Context(), actor, projectID, connectionID,
|
|
strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxQuery(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
SQL string `json:"sql" validate:"required"`
|
|
Database string `json:"database"`
|
|
}
|
|
if !h.bindAndValidate(c, &body) {
|
|
return
|
|
}
|
|
result, err := h.Platform.ExecuteDBXQuery(c.Request.Context(), actor, projectID, connectionID, body.SQL, strings.TrimSpace(body.Database))
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxRedis(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Command string `json:"command" validate:"required"`
|
|
DB *int `json:"db"`
|
|
}
|
|
if !h.bindAndValidate(c, &body) {
|
|
return
|
|
}
|
|
result, err := h.Platform.ExecuteDBXRedis(c.Request.Context(), actor, projectID, connectionID, body.Command, body.DB)
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *HTTPHandler) dbxTransfer(c *gin.Context) {
|
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body services.TransferInput
|
|
if !h.bindAndValidate(c, &body) {
|
|
return
|
|
}
|
|
result, err := h.Platform.TransferDBRows(c.Request.Context(), actor, projectID, connectionID, body, middleware.RequestIDFromContext(c))
|
|
if err != nil {
|
|
h.dbxError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// actorAndProject resolves the actor and :projectID param.
|
|
func (h *HTTPHandler) actorAndProject(c *gin.Context) (*models.Actor, uuid.UUID, bool) {
|
|
actor, ok := middleware.RequireActor(c)
|
|
if !ok {
|
|
return nil, uuid.Nil, false
|
|
}
|
|
projectID, ok := parseUUIDParam(c, "projectID")
|
|
if !ok {
|
|
return nil, uuid.Nil, false
|
|
}
|
|
return actor, projectID, true
|
|
}
|
|
|
|
// actorProjectConn resolves the actor, :projectID and :connectionID params.
|
|
func (h *HTTPHandler) actorProjectConn(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
|
|
actor, projectID, ok := h.actorAndProject(c)
|
|
if !ok {
|
|
return nil, uuid.Nil, uuid.Nil, false
|
|
}
|
|
connectionID, ok := parseUUIDParam(c, "connectionID")
|
|
if !ok {
|
|
return nil, uuid.Nil, uuid.Nil, false
|
|
}
|
|
return actor, projectID, connectionID, true
|
|
}
|
|
|
|
// dbxError maps DBX subprocess/tool failures to 502, not-found rows to 404.
|
|
func (h *HTTPHandler) dbxError(c *gin.Context, err error) {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "connection not found")
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "access denied") || strings.Contains(err.Error(), "insufficient") || strings.Contains(err.Error(), "authentication required") {
|
|
h.handleError(c, err)
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "table required") {
|
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
apperrors.Abort(c, http.StatusBadGateway, "dbx_error", err.Error())
|
|
}
|