Files
certctl/internal/api/handler/export.go
T
shankar0123 36e722ba12 WIP: M-1 handler sentinel error mapping (checkpoint before branch cleanup)
Uncommitted migration work at the time of branch cleanup. Tagged as
checkpoint/m1-migration-wip so the commit survives git gc --prune=now.

Session context: Phase 3 Part B+C of the M-1 sentinel error migration
was in progress. 38 modified files, 4 new files (errors.go + errors_test.go
in internal/service/ and internal/api/handler/). Resume from this commit
via 'git checkout checkpoint/m1-migration-wip'.
2026-04-24 00:35:12 +00:00

170 lines
6.0 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service"
)
// ExportService defines the service interface for certificate export operations.
type ExportService interface {
ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error)
ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error)
}
// ExportHandler handles HTTP requests for certificate export operations.
type ExportHandler struct {
svc ExportService
}
// NewExportHandler creates a new ExportHandler with a service dependency.
func NewExportHandler(svc ExportService) ExportHandler {
return ExportHandler{svc: svc}
}
// ExportPEM exports a certificate and its chain in PEM format.
// GET /api/v1/certificates/{id}/export/pem
func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pem
id := extractCertIDFromExportPath(r.URL.Path)
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
result, err := h.svc.ExportPEM(r.Context(), id)
if err != nil {
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch
// classified 404 via strings.Contains(err.Error(), "not found"), which
// gave false positives on any error whose rendered text happened to
// contain "not found" — notably a transient DB failure when the service
// layer wrapped every certRepo.Get error with "certificate not found".
// Post-M-1: service/export.go now wraps with "failed to get certificate"
// and only the genuine sql.ErrNoRows path surfaces repository.ErrNotFound
// through the wrap chain, so errors.Is(err, repository.ErrNotFound) picks
// up the real 404s and everything else — including transient DB errors —
// correctly surfaces as 500 with server-side slog.Error capture (F-002
// redacted-500 pattern preserved).
status := errToStatus(err)
if status == http.StatusInternalServerError {
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
}
msg := "Failed to export certificate"
if status == http.StatusNotFound {
msg = "Certificate not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
// Check if client wants file download via Accept header or ?download=true query param
if r.URL.Query().Get("download") == "true" {
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.pem\"")
w.WriteHeader(http.StatusOK)
w.Write([]byte(result.FullPEM))
return
}
JSON(w, http.StatusOK, result)
}
// ExportPKCS12 exports a certificate and chain in PKCS#12 format.
// POST /api/v1/certificates/{id}/export/pkcs12
// Body: { "password": "optional-password" }
func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pkcs12
id := extractCertIDFromExportPath(r.URL.Path)
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
// Parse optional password from request body (may be empty)
var req struct {
Password string `json:"password"`
}
// Body is optional — empty body means empty password
_ = parseJSONBody(r, &req)
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
if err != nil {
// M-1 (P2): dispatch routes through errToStatus. The pre-M-1 3-term
// substring net (`"not found"|"cannot be parsed"|"no certificates
// found"`) is replaced with sentinel dispatch:
// - repository.ErrNotFound (from certificate.go Get/GetLatestVersion
// sql.ErrNoRows wrap) → 404
// - service.ErrUnprocessable (from service/export.go ExportPKCS12's
// parsePEMCertificates-failure and empty-chain wraps) → 422 —
// semantically correct because the caller's request is fine; our
// stored PEM chain is what cannot be processed
// - everything else → 500 with slog.Error capture (F-002 redacted-500
// pattern preserved)
// A transient DB failure that pre-M-1 would have been swept into the
// 404 substring branch (because the service wrapped every certRepo.Get
// error with "certificate not found") now correctly surfaces as 500.
status := errToStatus(err)
if status == http.StatusInternalServerError {
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
}
msg := "Failed to export PKCS#12"
switch status {
case http.StatusNotFound:
msg = "Certificate not found"
case http.StatusUnprocessableEntity:
msg = "Certificate data cannot be parsed as X.509"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
w.Header().Set("Content-Type", "application/x-pkcs12")
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.p12\"")
w.WriteHeader(http.StatusOK)
w.Write(pfxData)
}
// extractCertIDFromExportPath extracts the certificate ID from an export path.
// Path format: /api/v1/certificates/{id}/export/pem or /api/v1/certificates/{id}/export/pkcs12
func extractCertIDFromExportPath(path string) string {
prefix := "/api/v1/certificates/"
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
// rest should be "{id}/export/pem" or "{id}/export/pkcs12"
parts := strings.Split(rest, "/")
if len(parts) < 3 || parts[1] != "export" {
return ""
}
return parts[0]
}
// parseJSONBody is a helper that decodes JSON from the request body.
// Returns an error if the body is malformed, nil if body is empty.
func parseJSONBody(r *http.Request, v interface{}) error {
if r.Body == nil {
return nil
}
return json.NewDecoder(r.Body).Decode(v)
}