Files
2026-08-02 20:14:53 +01:00

257 lines
7.0 KiB
Go

package logbundle
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
)
const (
maxCreateBodyBytes = 8 * 1024
maxUploadBodyBytes = 1024 * 1024
)
type createRequest struct {
Sources []string `json:"sources"`
SessionIDs []string `json:"session_ids"`
}
type uploadEnvelope struct {
BrowserID string `json:"browser_id"`
CaptureStreamID string `json:"capture_stream_id"`
ChunkIndex int `json:"chunk_index"`
Done bool `json:"done"`
StorageMode string `json:"storage_mode"`
CaptureMetadata json.RawMessage `json:"capture_metadata"`
Entries json.RawMessage `json:"entries"`
}
type createResponse struct {
JobView
Reused bool `json:"reused"`
}
type acpSessionResponse struct {
DiagnosticSession
TaskTitle string `json:"task_title,omitempty"`
}
func RegisterRoutes(group *gin.RouterGroup, service *Service) {
group.POST("/logs/bundles", handleCreate(service))
group.GET("/logs/capabilities", handleCapabilities(service))
group.GET("/logs/acp-sessions", handleACPSessions(service))
group.GET("/logs/bundles/:id", handleGet(service))
group.POST("/logs/bundles/:id/frontend", handleUpload(service))
group.GET("/logs/bundles/:id/download", handleDownload(service))
}
func handleCreate(service *Service) gin.HandlerFunc {
return func(c *gin.Context) {
identity, ok := requestIdentity(c)
if !ok {
return
}
var request createRequest
if err := decodeBoundedJSON(c, maxCreateBodyBytes, &request); err != nil {
writeDecodeError(c, err)
return
}
job, reused, err := service.CreateWithIdentity(
c.Request.Context(), identity, request.Sources, request.SessionIDs,
)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusAccepted, createResponse{JobView: job, Reused: reused})
}
}
func handleCapabilities(service *Service) gin.HandlerFunc {
return func(c *gin.Context) {
if _, ok := requestIdentity(c); !ok {
return
}
c.JSON(http.StatusOK, service.Capabilities())
}
}
func handleACPSessions(service *Service) gin.HandlerFunc {
return func(c *gin.Context) {
identity, ok := requestIdentity(c)
if !ok {
return
}
rows, err := service.ListACPSessions(c.Request.Context(), identity)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"sessions": acpSessionResponses(rows)})
}
}
func acpSessionResponses(rows []DiagnosticSession) []acpSessionResponse {
responses := make([]acpSessionResponse, 0, len(rows))
for _, row := range rows {
responses = append(responses, acpSessionResponse{
DiagnosticSession: row,
TaskTitle: row.TaskTitle,
})
}
return responses
}
func handleGet(service *Service) gin.HandlerFunc {
return func(c *gin.Context) {
identity, ok := requestIdentity(c)
if !ok {
return
}
job, err := service.Get(identity.UserID, c.Param("id"))
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, job)
}
}
func handleUpload(service *Service) gin.HandlerFunc {
return func(c *gin.Context) {
identity, ok := requestIdentity(c)
if !ok {
return
}
var envelope uploadEnvelope
if err := decodeBoundedJSON(c, maxUploadBodyBytes, &envelope); err != nil {
writeDecodeError(c, err)
return
}
ignored, claimed, err := service.ClaimStream(
identity.UserID, c.Param("id"), envelope.BrowserID,
envelope.CaptureStreamID, envelope.StorageMode,
)
if err != nil {
writeServiceError(c, err)
return
}
if ignored {
c.Status(http.StatusNoContent)
return
}
var entries []json.RawMessage
if len(envelope.Entries) == 0 {
entries = []json.RawMessage{}
} else if err := json.Unmarshal(envelope.Entries, &entries); err != nil {
if claimed {
service.ReleaseEmptyClaim(identity.UserID, c.Param("id"),
envelope.BrowserID, envelope.CaptureStreamID)
}
c.JSON(http.StatusBadRequest, gin.H{"error": "entries must be a JSON array"})
return
}
_, err = service.UploadChunk(identity.UserID, c.Param("id"), UploadChunk{
BrowserID: envelope.BrowserID, CaptureStreamID: envelope.CaptureStreamID,
ChunkIndex: envelope.ChunkIndex, Done: envelope.Done,
StorageMode: envelope.StorageMode, CaptureMetadata: envelope.CaptureMetadata,
Entries: entries,
})
if err != nil {
if claimed {
service.ReleaseEmptyClaim(identity.UserID, c.Param("id"),
envelope.BrowserID, envelope.CaptureStreamID)
}
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
}
func handleDownload(service *Service) gin.HandlerFunc {
return func(c *gin.Context) {
identity, ok := requestIdentity(c)
if !ok {
return
}
file, _, err := service.OpenArchive(identity.UserID, c.Param("id"))
if err != nil {
writeServiceError(c, err)
return
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to inspect diagnostic bundle"})
return
}
c.Header("Content-Disposition", `attachment; filename="kandev-diagnostic-logs.zip"`)
c.Header("Content-Type", "application/zip")
http.ServeContent(c.Writer, c.Request, "kandev-diagnostic-logs.zip", info.ModTime(), file)
}
}
func requestIdentity(c *gin.Context) (authn.Identity, bool) {
identity, ok := authn.FromGin(c)
if !ok || identity.UserID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return authn.Identity{}, false
}
return identity, true
}
func decodeBoundedJSON(c *gin.Context, limit int64, destination any) error {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("request body must contain one JSON object")
}
return err
}
return nil
}
func writeDecodeError(c *gin.Context, err error) {
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "request body too large"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
}
func writeServiceError(c *gin.Context, err error) {
status := http.StatusInternalServerError
switch {
case IsKind(err, ErrorInvalid):
status = http.StatusBadRequest
case IsKind(err, ErrorNotFound):
status = http.StatusNotFound
case IsKind(err, ErrorGone):
status = http.StatusGone
case IsKind(err, ErrorConflict):
status = http.StatusConflict
case IsKind(err, ErrorIdentityBusy), IsKind(err, ErrorProfileLimit):
status = http.StatusTooManyRequests
c.Header("Retry-After", strconv.Itoa(5))
case IsKind(err, ErrorSaturated):
status = http.StatusServiceUnavailable
c.Header("Retry-After", strconv.Itoa(5))
case IsKind(err, ErrorTooLarge):
status = http.StatusRequestEntityTooLarge
}
c.JSON(status, gin.H{"error": err.Error()})
}