2
0
Files
gitcaddy-vault/routes/routes.go
logikonline 04d0d02962
All checks were successful
Build and Release / Tests (push) Successful in 1m37s
Build and Release / Lint (push) Successful in 1m53s
Build and Release / Create Release (push) Successful in 0s
chore(ci): update Go dependencies
Updates multiple dependencies including minio-go (v7.0.95 -> v7.0.98), klauspost/compress (v1.18.0 -> v1.18.2), tinylib/msgp (v1.4.0 -> v1.6.1), and various golang.org/x packages (crypto, net, sync, sys, text, mod, tools). Adds klauspost/crc32 v1.3.0 and go.yaml.in/yaml/v3 v3.0.4.
2026-01-26 00:58:05 -05:00

2007 lines
54 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// Proprietary and confidential.
package routes
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"code.gitcaddy.com/server/v3/models/perm"
"code.gitcaddy.com/server/v3/models/unit"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/plugins"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/timeutil"
"code.gitcaddy.com/server/v3/services/context"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
"git.marketally.com/gitcaddy/gitcaddy-vault/models"
"git.marketally.com/gitcaddy/gitcaddy-vault/services"
"github.com/go-chi/chi/v5"
)
// Version is set by the main vault package at init time
var Version = "dev"
// Template names for vault pages
const (
tplVaultList templates.TplName = "repo/vault/list"
tplVaultView templates.TplName = "repo/vault/view"
tplVaultNew templates.TplName = "repo/vault/new"
tplVaultVersions templates.TplName = "repo/vault/versions"
tplVaultCompare templates.TplName = "repo/vault/compare"
tplVaultAudit templates.TplName = "repo/vault/audit"
tplVaultTokens templates.TplName = "repo/vault/tokens"
)
// API Response types
// SecretResponse represents a secret in API responses (no encrypted values)
type SecretResponse struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
CurrentVersion int `json:"current_version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
IsDeleted bool `json:"is_deleted,omitempty"`
}
// SecretValueResponse represents a secret with its decrypted value
type SecretValueResponse struct {
SecretResponse
Value string `json:"value"`
}
// SecretVersionResponse represents a version of a secret
type SecretVersionResponse struct {
Version int `json:"version"`
Comment string `json:"comment,omitempty"`
CreatedBy int64 `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
// TokenResponse represents a token in API responses
type TokenResponse struct {
ID int64 `json:"id"`
Description string `json:"description"`
Scope string `json:"scope"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt int64 `json:"last_used_at,omitempty"`
UsedCount int64 `json:"used_count"`
IsRevoked bool `json:"is_revoked"`
}
// TokenCreateResponse includes the raw token (only shown once)
type TokenCreateResponse struct {
TokenResponse
Token string `json:"token"`
}
// AuditEntryResponse represents an audit log entry
type AuditEntryResponse struct {
ID int64 `json:"id"`
SecretID int64 `json:"secret_id,omitempty"`
SecretName string `json:"secret_name,omitempty"`
Action string `json:"action"`
UserID int64 `json:"user_id"`
Username string `json:"username,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// TokenInfoResponse represents token information for introspection
type TokenInfoResponse struct {
Scope string `json:"scope"`
Description string `json:"description,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
CanRead bool `json:"can_read"`
CanWrite bool `json:"can_write"`
IsAdmin bool `json:"is_admin"`
}
// API Request types
// CreateSecretRequest is the request body for creating a secret
type CreateSecretRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" binding:"required"`
Comment string `json:"comment"` // Used when updating existing secret
}
// UpdateSecretRequest is the request body for updating a secret
type UpdateSecretRequest struct {
Value string `json:"value" binding:"required"`
Type string `json:"type"`
Comment string `json:"comment"`
}
// CreateTokenRequest is the request body for creating a token
type CreateTokenRequest struct {
Description string `json:"description" binding:"required"`
Scope string `json:"scope"` // read, write, read:secret_name, etc.
TTL string `json:"ttl"` // e.g., "24h", "7d", "30d"
}
// RollbackRequest is the request body for rolling back a secret
type RollbackRequest struct {
Version int `json:"version" binding:"required"`
}
// RegisterRepoWebRoutes registers vault web routes under /{owner}/{repo}/vault
func RegisterRepoWebRoutes(r plugins.PluginRouter, lic *license.Manager) {
r.Group("/vault", func(r plugins.PluginRouter) {
// Web UI routes - render HTML pages
// IMPORTANT: More specific routes must come before less specific ones
r.Get("/", webListSecrets(lic))
r.Get("/audit", webViewAudit(lic))
r.Get("/tokens", webListTokens(lic))
r.Post("/tokens", webCreateToken(lic))
r.Post("/tokens/{id}/revoke", webRevokeToken(lic))
// Static secret routes first
r.Get("/secrets/new", webNewSecretForm(lic))
r.Post("/secrets/new", webCreateSecret(lic))
// Parameterized routes with suffixes before bare parameter routes
r.Get("/secrets/{name}/versions", webListVersions(lic))
r.Get("/secrets/{name}/compare", webCompareVersions(lic))
r.Post("/secrets/{name}/delete", webDeleteSecret(lic))
r.Post("/secrets/{name}/restore", webRestoreSecret(lic))
r.Post("/secrets/{name}/rollback", webRollbackSecret(lic))
// Bare parameter routes last (least specific)
r.Get("/secrets/{name}", webViewSecret(lic))
r.Post("/secrets/{name}", webUpdateSecret(lic))
})
}
// RegisterRepoAPIRoutes registers vault API routes under /api/v1/repos/{owner}/{repo}/vault
func RegisterRepoAPIRoutes(r plugins.PluginRouter, lic *license.Manager) {
r.Group("/vault", func(r plugins.PluginRouter) {
// Secrets CRUD
r.Get("/secrets", apiListSecrets(lic))
r.Get("/secrets/{name}", apiGetSecret(lic))
r.Put("/secrets/{name}", apiPutSecret(lic))
r.Delete("/secrets/{name}", apiDeleteSecret(lic))
// Restore
r.Post("/secrets/{name}/restore", apiRestoreSecret(lic))
// Versions
r.Get("/secrets/{name}/versions", apiListVersions(lic))
// Rollback
r.Post("/secrets/{name}/rollback", apiRollbackSecret(lic))
// Audit
r.Get("/audit", apiGetAudit(lic))
// Tokens
r.Get("/tokens", apiListTokens(lic))
r.Post("/tokens", apiCreateToken(lic))
r.Delete("/tokens/{id}", apiRevokeToken(lic))
// Token introspection (uses Bearer auth)
r.Get("/token/info", apiGetTokenInfo(lic))
// Key rotation (enterprise)
r.Post("/rotate-key", apiRotateKey(lic))
})
}
// Helper functions
// requireWebLicense checks license for web routes and renders HTML error page
func requireWebLicense(lic *license.Manager, r *http.Request) bool {
if err := lic.Validate(); err != nil {
ctx := getWebContext(r)
if ctx != nil {
ctx.Data["Title"] = "License Required"
ctx.HTML(http.StatusPaymentRequired, "repo/vault/not_installed")
return false
}
return false
}
return true
}
func getRepoContext(r *http.Request) *context.APIContext {
return context.GetAPIContext(r)
}
func getWebContext(r *http.Request) *context.Context {
return context.GetWebContext(r.Context())
}
func requireRepoAdmin(ctx *context.APIContext) bool {
if !ctx.Repo.IsAdmin() {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Repository admin access required",
})
return false
}
return true
}
func requireRepoWrite(ctx *context.APIContext) bool {
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Repository write access required",
})
return false
}
return true
}
func secretToResponse(s *models.VaultSecret) SecretResponse {
return SecretResponse{
Name: s.Name,
Description: s.Description,
Type: string(s.Type),
CurrentVersion: s.CurrentVersion,
CreatedAt: int64(s.CreatedUnix),
UpdatedAt: int64(s.UpdatedUnix),
IsDeleted: s.DeletedUnix > 0,
}
}
func tokenToResponse(t *models.VaultToken) TokenResponse {
return TokenResponse{
ID: t.ID,
Description: t.Description,
Scope: string(t.Scope),
CreatedAt: int64(t.CreatedUnix),
ExpiresAt: int64(t.ExpiresUnix),
LastUsedAt: int64(t.LastUsedUnix),
UsedCount: t.UsedCount,
IsRevoked: t.RevokedUnix > 0,
}
}
// VaultAuthResult contains authentication result from vault token
type VaultAuthResult struct {
Token *models.VaultToken
Success bool
Error string
}
// authenticateVaultToken validates a vault token from Authorization header
// and checks if it has the required permission for the given repository.
// Returns the token if valid, or an error description.
func authenticateVaultToken(ctx *context.APIContext, r *http.Request, requireWrite bool) *VaultAuthResult {
auth := r.Header.Get("Authorization")
if auth == "" {
return &VaultAuthResult{Success: false, Error: "Authorization header required"}
}
// Support "Bearer gvt_xxx" or just "gvt_xxx"
rawToken := auth
if strings.HasPrefix(auth, "Bearer ") {
rawToken = strings.TrimPrefix(auth, "Bearer ")
} else if strings.HasPrefix(auth, "token ") {
rawToken = strings.TrimPrefix(auth, "token ")
}
// Only accept vault tokens (gvt_)
if !strings.HasPrefix(rawToken, "gvt_") {
return &VaultAuthResult{Success: false, Error: "Invalid token format - expected vault token (gvt_)"}
}
// Validate the token
token, err := services.GetTokenInfo(ctx, rawToken)
if err != nil {
switch err {
case services.ErrInvalidToken:
return &VaultAuthResult{Success: false, Error: "Invalid vault token"}
case services.ErrTokenExpired:
return &VaultAuthResult{Success: false, Error: "Vault token has expired"}
case services.ErrTokenRevoked:
return &VaultAuthResult{Success: false, Error: "Vault token has been revoked"}
default:
return &VaultAuthResult{Success: false, Error: "Token validation failed"}
}
}
// Verify token is for this repo
if token.RepoID != ctx.Repo.Repository.ID {
return &VaultAuthResult{Success: false, Error: "Token not valid for this repository"}
}
// Check permissions
if requireWrite && !token.Scope.CanWrite("*") {
return &VaultAuthResult{Success: false, Error: "Token does not have write permission"}
} else if !requireWrite && !token.Scope.CanRead("*") {
return &VaultAuthResult{Success: false, Error: "Token does not have read permission"}
}
return &VaultAuthResult{Token: token, Success: true}
}
// ============================================================================
// API Handlers
// ============================================================================
func apiListSecrets(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Try vault token authentication if no Gitea user
var userID int64
if ctx.Doer != nil {
userID = ctx.Doer.ID
} else {
// Try vault token auth
authResult := authenticateVaultToken(ctx, r, false)
if !authResult.Success {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": authResult.Error,
})
return
}
userID = 0 // Token-based access, no user ID
}
includeDeleted := r.URL.Query().Get("include_deleted") == "true"
secrets, err := services.ListSecrets(ctx, ctx.Repo.Repository.ID, includeDeleted)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
// Log the access
_ = services.CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: ctx.Repo.Repository.ID,
Action: models.AuditActionList,
UserID: userID,
IPAddress: ctx.RemoteAddr(),
Success: true,
Timestamp: timeutil.TimeStampNow(),
})
response := make([]SecretResponse, len(secrets))
for i, s := range secrets {
response[i] = secretToResponse(s)
}
ctx.JSON(http.StatusOK, response)
}
}
func apiGetSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Try vault token authentication if no Gitea user
var userID int64
if ctx.Doer != nil {
userID = ctx.Doer.ID
} else {
// Try vault token auth
authResult := authenticateVaultToken(ctx, r, false)
if !authResult.Success {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": authResult.Error,
})
return
}
userID = 0 // Token-based access, no user ID
}
name := chi.URLParam(r, "name")
versionStr := r.URL.Query().Get("version")
version := 0
if versionStr != "" {
var err error
version, err = strconv.Atoi(versionStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_version",
"message": "Version must be a number",
})
return
}
}
// Get the secret metadata
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
// Get the decrypted value
value, err := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, version)
if err != nil {
log.Error("Failed to decrypt secret %s: %v", name, err)
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "decryption_failed",
"message": "Failed to decrypt secret",
})
return
}
// Log the access
_ = services.CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: ctx.Repo.Repository.ID,
SecretID: secret.ID,
Action: models.AuditActionRead,
UserID: userID,
IPAddress: ctx.RemoteAddr(),
Success: true,
Timestamp: timeutil.TimeStampNow(),
})
response := SecretValueResponse{
SecretResponse: secretToResponse(secret),
Value: value,
}
ctx.JSON(http.StatusOK, response)
}
}
func apiPutSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Try Gitea user auth first, then fall back to vault token auth
var userID int64
if ctx.Doer != nil {
// Gitea user - check write permission
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Repository write access required",
})
return
}
userID = ctx.Doer.ID
} else {
// Try vault token auth with write requirement
authResult := authenticateVaultToken(ctx, r, true)
if !authResult.Success {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": authResult.Error,
})
return
}
userID = 0 // Token-based access
}
name := chi.URLParam(r, "name")
var req CreateSecretRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Invalid JSON body",
})
return
}
// Check if secret exists
existing, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err == services.ErrSecretNotFound {
// Create new secret
if req.Name == "" {
req.Name = name
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
secret, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Value: req.Value,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
switch err {
case services.ErrSecretExists:
ctx.JSON(http.StatusConflict, map[string]any{
"error": "already_exists",
"message": "Secret already exists",
})
case services.ErrSecretLimitReached:
ctx.JSON(http.StatusPaymentRequired, map[string]any{
"error": "limit_reached",
"message": "Secret limit reached for this tier. Upgrade your license for more secrets.",
})
default:
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusCreated, secretToResponse(secret))
return
} else if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
// Update existing secret
secretType := req.Type
if secretType == "" {
secretType = string(existing.Type)
}
secret, err := services.UpdateSecret(ctx, ctx.Repo.Repository.ID, name, services.UpdateSecretOptions{
Type: secretType,
Value: req.Value,
Comment: req.Comment,
UpdaterID: userID,
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
ctx.JSON(http.StatusOK, secretToResponse(secret))
}
}
func apiDeleteSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Try vault token authentication if no Gitea user
var userID int64
if ctx.Doer != nil {
if !requireRepoWrite(ctx) {
return
}
userID = ctx.Doer.ID
} else {
// Try vault token auth with write permission
authResult := authenticateVaultToken(ctx, r, true)
if !authResult.Success {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": authResult.Error,
})
return
}
userID = 0 // Token-based access, no user ID
}
name := chi.URLParam(r, "name")
if err := services.DeleteSecret(ctx, ctx.Repo.Repository.ID, name, userID); err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Secret deleted",
})
}
}
func apiRestoreSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Try Gitea user auth first, then fall back to vault token auth
if ctx.Doer != nil {
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Repository write access required",
})
return
}
} else {
authResult := authenticateVaultToken(ctx, r, true)
if !authResult.Success {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": authResult.Error,
})
return
}
}
name := chi.URLParam(r, "name")
if err := services.RestoreSecret(ctx, ctx.Repo.Repository.ID, name); err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Secret restored",
})
}
}
func apiListVersions(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Try Gitea user auth first, then fall back to vault token auth
if ctx.Doer == nil {
authResult := authenticateVaultToken(ctx, r, false)
if !authResult.Success {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": authResult.Error,
})
return
}
}
name := chi.URLParam(r, "name")
versions, err := services.ListVersions(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
response := make([]SecretVersionResponse, len(versions))
for i, v := range versions {
response[i] = SecretVersionResponse{
Version: v.Version,
Comment: v.Comment,
CreatedBy: v.CreatedBy,
CreatedAt: int64(v.CreatedUnix),
}
}
ctx.JSON(http.StatusOK, response)
}
}
func apiRollbackSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Try Gitea user auth first, then fall back to vault token auth
var userID int64
if ctx.Doer != nil {
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Repository write access required",
})
return
}
userID = ctx.Doer.ID
} else {
authResult := authenticateVaultToken(ctx, r, true)
if !authResult.Success {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": authResult.Error,
})
return
}
userID = 0
}
name := chi.URLParam(r, "name")
var req RollbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Invalid JSON body",
})
return
}
if req.Version <= 0 {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_version",
"message": "Version must be a positive number",
})
return
}
if err := services.RollbackSecret(ctx, ctx.Repo.Repository.ID, name, req.Version, userID); err != nil {
switch err {
case services.ErrSecretNotFound:
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
case services.ErrVersionNotFound:
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "version_not_found",
"message": "Version not found",
})
default:
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Secret rolled back to version " + strconv.Itoa(req.Version),
})
}
}
func apiGetAudit(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 50
}
entries, total, err := services.ListAuditEntries(ctx, ctx.Repo.Repository.ID, page, pageSize)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
response := make([]AuditEntryResponse, len(entries))
for i, e := range entries {
response[i] = AuditEntryResponse{
ID: e.ID,
SecretID: e.SecretID,
Action: string(e.Action),
UserID: e.UserID,
IPAddress: e.IPAddress,
Success: e.Success,
Message: e.FailReason,
Timestamp: int64(e.Timestamp),
}
}
ctx.JSON(http.StatusOK, map[string]any{
"entries": response,
"total": total,
"page": page,
"pages": (total + int64(pageSize) - 1) / int64(pageSize),
})
}
}
func apiListTokens(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
tokens, err := services.ListTokens(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
response := make([]TokenResponse, len(tokens))
for i, t := range tokens {
response[i] = tokenToResponse(t)
}
ctx.JSON(http.StatusOK, response)
}
}
func apiCreateToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
var req CreateTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Invalid JSON body",
})
return
}
if req.Description == "" {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Description is required",
})
return
}
// Default values
if req.Scope == "" {
req.Scope = "read"
}
if req.TTL == "" {
req.TTL = "30d"
}
// Parse TTL string (support days)
ttl := req.TTL
if len(ttl) > 0 && ttl[len(ttl)-1] == 'd' {
days, err := strconv.Atoi(ttl[:len(ttl)-1])
if err == nil {
ttl = strconv.Itoa(days*24) + "h"
}
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: req.Description,
Scope: req.Scope,
TTL: ttl,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
if err == services.ErrTokenLimitReached {
ctx.JSON(http.StatusPaymentRequired, map[string]any{
"error": "limit_reached",
"message": "Token limit reached for this tier. Upgrade your license for more tokens.",
})
return
}
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
response := TokenCreateResponse{
TokenResponse: tokenToResponse(token),
Token: rawToken,
}
ctx.JSON(http.StatusCreated, response)
}
}
func apiRevokeToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_id",
"message": "Invalid token ID",
})
return
}
if err := services.RevokeToken(ctx, ctx.Repo.Repository.ID, id); err != nil {
if err == services.ErrTokenNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Token not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Token revoked",
})
}
}
func apiGetTokenInfo(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Get token from Authorization header
auth := r.Header.Get("Authorization")
if auth == "" {
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
"message": "Authorization header required",
})
return
}
// Support "Bearer gvt_xxx" or just "gvt_xxx"
rawToken := auth
if strings.HasPrefix(auth, "Bearer ") {
rawToken = strings.TrimPrefix(auth, "Bearer ")
} else if strings.HasPrefix(auth, "token ") {
rawToken = strings.TrimPrefix(auth, "token ")
}
// Validate the token without checking permissions
token, err := services.GetTokenInfo(ctx, rawToken)
if err != nil {
switch err {
case services.ErrInvalidToken:
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "invalid_token",
"message": "Invalid vault token",
})
case services.ErrTokenExpired:
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "token_expired",
"message": "Vault token has expired",
})
case services.ErrTokenRevoked:
ctx.JSON(http.StatusUnauthorized, map[string]any{
"error": "token_revoked",
"message": "Vault token has been revoked",
})
default:
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
// Verify token is for this repo
if token.RepoID != ctx.Repo.Repository.ID {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Token not valid for this repository",
})
return
}
ctx.JSON(http.StatusOK, TokenInfoResponse{
Scope: string(token.Scope),
Description: token.Description,
ExpiresAt: int64(token.ExpiresUnix),
CanRead: token.Scope.CanRead("*"),
CanWrite: token.Scope.CanWrite("*"),
IsAdmin: token.Scope.IsAdmin(),
})
}
}
func apiRotateKey(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
info := lic.Info()
if info == nil || info.Tier != "enterprise" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "enterprise_required",
"message": "Key rotation requires an Enterprise license",
})
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
// TODO: Implement key rotation
// This involves:
// 1. Generate new DEK
// 2. Re-encrypt all secret versions with new DEK
// 3. Update the repo key
// This should be done in a transaction
ctx.JSON(http.StatusNotImplemented, map[string]any{
"error": "not_implemented",
"message": "Key rotation coming soon",
})
}
}
// ============================================================================
// Web Handlers (HTML rendering)
// ============================================================================
func webListSecrets(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
ctx.Data["Title"] = ctx.Tr("vault.secrets")
ctx.Data["PageIsVaultSecrets"] = true
includeDeleted := r.URL.Query().Get("include_deleted") == "true"
secrets, err := services.ListSecrets(ctx, ctx.Repo.Repository.ID, includeDeleted)
if err != nil {
ctx.ServerError("ListSecrets", err)
return
}
ctx.Data["Secrets"] = secrets
ctx.Data["IncludeDeleted"] = includeDeleted
// Group secrets by type for template
secretsByType := make(map[string][]*models.VaultSecret)
for _, s := range secrets {
secretsByType[string(s.Type)] = append(secretsByType[string(s.Type)], s)
}
ctx.Data["SecretsByType"] = secretsByType
// Check permissions - use multiple fallbacks for team-based access
isOwner := ctx.Repo.Repository.OwnerID == ctx.Doer.ID
hasWriteAccess := ctx.Repo.CanWrite(unit.TypeCode)
isAdmin := ctx.Repo.IsAdmin()
hasAccess := ctx.Repo.AccessMode >= perm.AccessModeWrite
ctx.Data["ShowDeleted"] = isAdmin || isOwner || hasAccess
ctx.Data["CanWrite"] = hasWriteAccess || isOwner || hasAccess
ctx.Data["IsRepoAdmin"] = isAdmin || isOwner || hasAccess
// License info for display
licInfo := lic.Info()
if licInfo != nil {
ctx.Data["LicenseTier"] = licInfo.Tier
ctx.Data["LicenseValid"] = licInfo.Valid
}
ctx.Data["VaultVersion"] = Version
// Check vault configuration
ctx.Data["VaultConfigured"] = crypto.HasMasterKey()
if !crypto.HasMasterKey() {
ctx.Data["VaultConfigError"] = "no master key configured"
}
ctx.HTML(http.StatusOK, tplVaultList)
}
}
func webViewSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
name := chi.URLParam(r, "name")
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
if err == services.ErrSecretNotFound {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetSecret", err)
}
return
}
value, err := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, 0)
if err != nil {
log.Error("Failed to decrypt secret %s: %v", name, err)
ctx.ServerError("GetSecretValue", err)
return
}
versions, err := services.ListVersions(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
log.Warn("Failed to list versions for %s: %v", name, err)
}
// Log access
_ = services.CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: ctx.Repo.Repository.ID,
SecretID: secret.ID,
Action: models.AuditActionRead,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
Success: true,
Timestamp: timeutil.TimeStampNow(),
})
// Populate creator info for versions
for _, v := range versions {
if v.CreatedBy > 0 {
user, err := user_model.GetUserByID(ctx, v.CreatedBy)
if err == nil && user != nil {
v.CreatorName = user.Name
v.Creator = user
}
}
}
ctx.Data["Title"] = name + " - " + ctx.Locale.TrString("vault.secrets")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["Secret"] = secret
ctx.Data["SecretValue"] = value
ctx.Data["Versions"] = versions
// Permission checks - match webListSecrets for consistency
isOwner := ctx.Repo.Repository.OwnerID == ctx.Doer.ID
hasWriteAccess := ctx.Repo.CanWrite(unit.TypeCode)
hasAccess := ctx.Repo.AccessMode >= perm.AccessModeWrite
ctx.Data["CanWrite"] = hasWriteAccess || isOwner || hasAccess
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() || isOwner || hasAccess
ctx.HTML(http.StatusOK, tplVaultView)
}
}
func webUpdateSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
value := r.FormValue("value")
secretType := r.FormValue("type")
comment := r.FormValue("comment")
if value == "" {
ctx.Flash.Error(ctx.Tr("vault.error_value_required"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
return
}
_, err := services.UpdateSecret(ctx, ctx.Repo.Repository.ID, name, services.UpdateSecretOptions{
Type: secretType,
Value: value,
Comment: comment,
UpdaterID: ctx.Doer.ID,
})
if err != nil {
ctx.Flash.Error(ctx.Tr("vault.error_update_failed"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_updated"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webNewSecretForm(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["CanWrite"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
ctx.HTML(http.StatusOK, tplVaultNew)
}
}
func webCreateSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := r.FormValue("name")
description := r.FormValue("description")
secretType := r.FormValue("type")
value := r.FormValue("value")
if name == "" || value == "" {
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["Err_Name"] = name == ""
ctx.Data["Err_Value"] = value == ""
ctx.Data["name"] = name
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_required_fields"))
ctx.HTML(http.StatusOK, tplVaultNew)
return
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
_, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: name,
Description: description,
Type: secretType,
Value: value,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
switch err {
case services.ErrSecretExists:
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["Err_Name"] = true
ctx.Data["name"] = name
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_secret_exists"))
ctx.HTML(http.StatusOK, tplVaultNew)
case services.ErrSecretLimitReached:
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["name"] = name
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_secret_limit"))
ctx.HTML(http.StatusOK, tplVaultNew)
default:
ctx.ServerError("CreateSecret", err)
}
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_created"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webDeleteSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
if err := services.DeleteSecret(ctx, ctx.Repo.Repository.ID, name, ctx.Doer.ID); err != nil {
if err == services.ErrSecretNotFound {
ctx.NotFound(nil)
} else {
ctx.ServerError("DeleteSecret", err)
}
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_deleted"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault")
}
}
func webRestoreSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
if err := services.RestoreSecret(ctx, ctx.Repo.Repository.ID, name); err != nil {
if err == services.ErrSecretNotFound {
ctx.NotFound(nil)
} else {
ctx.ServerError("RestoreSecret", err)
}
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_restored"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webListVersions(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
name := chi.URLParam(r, "name")
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
if err == services.ErrSecretNotFound {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetSecret", err)
}
return
}
versions, err := services.ListVersions(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
ctx.ServerError("ListVersions", err)
return
}
// Populate creator info for each version
for _, v := range versions {
if v.CreatedBy > 0 {
user, err := user_model.GetUserByID(ctx, v.CreatedBy)
if err == nil && user != nil {
v.CreatorName = user.Name
v.Creator = user
}
}
}
ctx.Data["Title"] = name + " - " + ctx.Locale.TrString("vault.version_history")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["Secret"] = secret
ctx.Data["Versions"] = versions
// Permission checks - match webListSecrets for consistency
isOwner := ctx.Repo.Repository.OwnerID == ctx.Doer.ID
hasWriteAccess := ctx.Repo.CanWrite(unit.TypeCode)
hasAccess := ctx.Repo.AccessMode >= perm.AccessModeWrite
ctx.Data["CanWrite"] = hasWriteAccess || isOwner || hasAccess
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() || isOwner || hasAccess
ctx.HTML(http.StatusOK, tplVaultVersions)
}
}
func webCompareVersions(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
name := chi.URLParam(r, "name")
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
if err == services.ErrSecretNotFound {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetSecret", err)
}
return
}
versions, err := services.ListVersions(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
ctx.ServerError("ListVersions", err)
return
}
// Populate creator info for versions
for _, v := range versions {
if v.CreatedBy > 0 {
user, err := user_model.GetUserByID(ctx, v.CreatedBy)
if err == nil && user != nil {
v.CreatorName = user.Name
v.Creator = user
}
}
}
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
fromVersion := 0
toVersion := secret.CurrentVersion
if fromStr != "" {
fromVersion, _ = strconv.Atoi(fromStr)
}
if toStr != "" {
toVersion, _ = strconv.Atoi(toStr)
}
// Default from to first non-current version if not specified
if fromVersion == 0 && len(versions) > 0 {
for _, v := range versions {
if v.Version != secret.CurrentVersion {
fromVersion = v.Version
break
}
}
if fromVersion == 0 {
fromVersion = secret.CurrentVersion
}
}
ctx.Data["Title"] = name + " - " + ctx.Locale.TrString("vault.compare_versions")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["Secret"] = secret
ctx.Data["Versions"] = versions
ctx.Data["FromVersion"] = fromVersion
ctx.Data["ToVersion"] = toVersion
// If both versions specified and different, show the diff
if fromVersion > 0 && toVersion > 0 && fromVersion != toVersion {
fromValue, err1 := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, fromVersion)
toValue, err2 := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, toVersion)
if err1 == nil && err2 == nil {
ctx.Data["ShowDiff"] = true
ctx.Data["FromValue"] = fromValue
ctx.Data["ToValue"] = toValue
ctx.Data["DiffHTML"] = computeHTMLDiff(fromValue, toValue, fromVersion, toVersion)
}
}
ctx.HTML(http.StatusOK, tplVaultCompare)
}
}
func computeHTMLDiff(text1, text2 string, v1, v2 int) string {
lines1 := strings.Split(text1, "\n")
lines2 := strings.Split(text2, "\n")
var result strings.Builder
result.WriteString(fmt.Sprintf("<span class=\"diff-header\">@@ v%d -> v%d @@</span>\n", v1, v2))
i, j := 0, 0
for i < len(lines1) || j < len(lines2) {
if i >= len(lines1) {
result.WriteString(fmt.Sprintf("<span class=\"diff-add\">+ %s</span>\n", escapeHTML(lines2[j])))
j++
} else if j >= len(lines2) {
result.WriteString(fmt.Sprintf("<span class=\"diff-del\">- %s</span>\n", escapeHTML(lines1[i])))
i++
} else if lines1[i] == lines2[j] {
result.WriteString(fmt.Sprintf(" %s\n", escapeHTML(lines1[i])))
i++
j++
} else {
result.WriteString(fmt.Sprintf("<span class=\"diff-del\">- %s</span>\n", escapeHTML(lines1[i])))
result.WriteString(fmt.Sprintf("<span class=\"diff-add\">+ %s</span>\n", escapeHTML(lines2[j])))
i++
j++
}
}
return result.String()
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
func webRollbackSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
versionStr := r.FormValue("version")
version, err := strconv.Atoi(versionStr)
if err != nil || version <= 0 {
ctx.Flash.Error(ctx.Tr("vault.error_invalid_version"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
return
}
if err := services.RollbackSecret(ctx, ctx.Repo.Repository.ID, name, version, ctx.Doer.ID); err != nil {
switch err {
case services.ErrSecretNotFound:
ctx.NotFound(nil)
case services.ErrVersionNotFound:
ctx.Flash.Error(ctx.Tr("vault.error_version_not_found"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
default:
ctx.ServerError("RollbackSecret", err)
}
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_rolled_back", version))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webViewAudit(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page <= 0 {
page = 1
}
pageSize := 50
entries, total, err := services.ListAuditEntries(ctx, ctx.Repo.Repository.ID, page, pageSize)
if err != nil {
ctx.ServerError("ListAuditEntries", err)
return
}
totalPages := (int(total) + pageSize - 1) / pageSize
// Generate page numbers for pagination
var pages []int
for i := 1; i <= totalPages; i++ {
pages = append(pages, i)
}
ctx.Data["Title"] = ctx.Tr("vault.audit")
ctx.Data["PageIsVaultAudit"] = true
ctx.Data["AuditEntries"] = entries
ctx.Data["Page"] = page
ctx.Data["TotalPages"] = totalPages
ctx.Data["Pages"] = pages
ctx.Data["IsRepoAdmin"] = true
ctx.HTML(http.StatusOK, tplVaultAudit)
}
}
func webListTokens(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
tokens, err := services.ListTokens(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("ListTokens", err)
return
}
ctx.Data["Title"] = ctx.Tr("vault.tokens")
ctx.Data["PageIsVaultTokens"] = true
ctx.Data["Tokens"] = tokens
ctx.Data["IsRepoAdmin"] = true
ctx.HTML(http.StatusOK, tplVaultTokens)
}
}
func webCreateToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if ctx.Doer == nil {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
description := r.FormValue("description")
scope := r.FormValue("scope")
ttl := r.FormValue("ttl")
if description == "" {
ctx.Flash.Error(ctx.Tr("vault.error_description_required"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
if scope == "" {
scope = "read"
}
if ttl == "" {
ttl = "30d"
}
// Convert days to hours for the service
if len(ttl) > 0 && ttl[len(ttl)-1] == 'd' {
days, err := strconv.Atoi(ttl[:len(ttl)-1])
if err == nil {
ttl = strconv.Itoa(days*24) + "h"
}
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: description,
Scope: scope,
TTL: ttl,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
if err == services.ErrTokenLimitReached {
ctx.Flash.Error(ctx.Tr("vault.error_token_limit"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
ctx.Flash.Error(ctx.Tr("vault.error_create_token_failed"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
// Get all tokens for display
tokens, _ := services.ListTokens(ctx, ctx.Repo.Repository.ID)
ctx.Data["Title"] = ctx.Tr("vault.tokens")
ctx.Data["PageIsVaultTokens"] = true
ctx.Data["Tokens"] = tokens
ctx.Data["NewToken"] = rawToken
ctx.Data["CreatedToken"] = token
ctx.Data["IsRepoAdmin"] = true
ctx.HTML(http.StatusOK, tplVaultTokens)
}
}
func webRevokeToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
ctx.Flash.Error(ctx.Tr("vault.error_invalid_token_id"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
if err := services.RevokeToken(ctx, ctx.Repo.Repository.ID, id); err != nil {
if err == services.ErrTokenNotFound {
ctx.Flash.Error(ctx.Tr("vault.error_token_not_found"))
} else {
ctx.Flash.Error(ctx.Tr("vault.error_revoke_failed"))
}
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
ctx.Flash.Success(ctx.Tr("vault.token_revoked"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
}
}