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.
2007 lines
54 KiB
Go
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, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
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")
|
|
}
|
|
}
|