257 lines
7.0 KiB
Go
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()})
|
|
}
|