194 lines
6.2 KiB
Go
194 lines
6.2 KiB
Go
package share
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// Service coordinates snapshot construction, backend upload, and persistence.
|
|
type Service struct {
|
|
repo *Repository
|
|
taskRepo TaskReader
|
|
authorizer TaskAccessAuthorizer
|
|
backend Backend
|
|
log *logger.Logger
|
|
kandevVer string
|
|
}
|
|
|
|
// New constructs a Service. All arguments are required.
|
|
func New(repo *Repository, taskRepo TaskReader, authorizer TaskAccessAuthorizer, backend Backend, log *logger.Logger, kandevVer string) *Service {
|
|
return &Service{
|
|
repo: repo,
|
|
taskRepo: taskRepo,
|
|
authorizer: authorizer,
|
|
backend: backend,
|
|
log: log,
|
|
kandevVer: kandevVer,
|
|
}
|
|
}
|
|
|
|
// PreviewSnapshot builds and returns the redacted snapshot that would be
|
|
// uploaded for the given session, without uploading or persisting anything.
|
|
// Returns ErrSessionNotCompleted if the session is not yet completed.
|
|
func (s *Service) PreviewSnapshot(ctx context.Context, taskID, taskSessionID string) (*Snapshot, error) {
|
|
if err := s.authorizeTaskSessionAccess(ctx, taskID, taskSessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
return BuildSnapshot(ctx, s.taskRepo, taskSessionID, s.kandevVer)
|
|
}
|
|
|
|
// CreateShare builds a snapshot, uploads it via the configured backend, and
|
|
// records the row in the repository.
|
|
//
|
|
// locale comes from the creating request and fixes the language of the
|
|
// published artifact; the artifact is static, so this is the only point at
|
|
// which a locale can be chosen. Pass i18n.DefaultLocale when there is no
|
|
// request to resolve one from.
|
|
func (s *Service) CreateShare(ctx context.Context, taskID, taskSessionID, locale string) (*Share, error) {
|
|
if err := s.authorizeTaskSessionAccess(ctx, taskID, taskSessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
workspaceID, err := s.workspaceForSession(ctx, taskSessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
snap, err := BuildSnapshot(ctx, s.taskRepo, taskSessionID, s.kandevVer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Use the same indented marshal as the gist backend so the size we
|
|
// surface to the UI matches the bytes actually uploaded to GitHub.
|
|
body, err := json.MarshalIndent(snap, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal snapshot: %w", err)
|
|
}
|
|
extID, extURL, err := s.backend.Upload(ctx, workspaceID, snap, locale)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upload snapshot: %w", err)
|
|
}
|
|
row := &Share{
|
|
ID: uuid.New().String(),
|
|
TaskSessionID: taskSessionID,
|
|
Backend: s.backend.Name(),
|
|
ExternalID: extID,
|
|
ExternalURL: extURL,
|
|
SnapshotSizeBytes: int64(len(body)),
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
if err := s.repo.Create(ctx, row); err != nil {
|
|
// The gist exists on GitHub but we failed to persist the row.
|
|
// Best-effort: try to clean up the orphaned gist; log if that
|
|
// also fails so the user can manually delete it.
|
|
if delErr := s.backend.Delete(ctx, workspaceID, extID); delErr != nil {
|
|
s.logWarn("orphaned gist after row insert failed",
|
|
zap.String("gist_id", extID), zap.Error(delErr))
|
|
}
|
|
return nil, fmt.Errorf("persist share: %w", err)
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// RevokeShare deletes the underlying gist and marks the row revoked. A
|
|
// 404 from the gist backend (gist already deleted on GitHub) is treated
|
|
// as success, logged at INFO, and still marks the row revoked.
|
|
func (s *Service) RevokeShare(ctx context.Context, shareID string) error {
|
|
row, err := s.repo.GetByID(ctx, shareID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.authorizeSessionAccess(ctx, row.TaskSessionID); err != nil {
|
|
return err
|
|
}
|
|
if row.IsRevoked() {
|
|
return nil
|
|
}
|
|
workspaceID, err := s.workspaceForSession(ctx, row.TaskSessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if delErr := s.backend.Delete(ctx, workspaceID, row.ExternalID); delErr != nil {
|
|
if !IsAlreadyGone(delErr) {
|
|
return fmt.Errorf("delete from backend: %w", delErr)
|
|
}
|
|
s.logInfo("gist already gone on upstream; marking share revoked anyway",
|
|
zap.String("share_id", shareID),
|
|
zap.String("external_id", row.ExternalID))
|
|
}
|
|
if err := s.repo.MarkRevoked(ctx, shareID, time.Now().UTC()); err != nil {
|
|
// Repository failure after a successful upstream delete leaves an
|
|
// inconsistent row. Surface the error so the caller can retry.
|
|
return fmt.Errorf("mark revoked: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CheckBackendAccess resolves the task's workspace and validates its backend
|
|
// identity. Dry-run previews intentionally do not call this method.
|
|
func (s *Service) CheckBackendAccess(ctx context.Context, taskID, taskSessionID string) error {
|
|
if err := s.authorizeTaskSessionAccess(ctx, taskID, taskSessionID); err != nil {
|
|
return err
|
|
}
|
|
workspaceID, err := s.workspaceForSession(ctx, taskSessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.backend.CheckAccess(ctx, workspaceID)
|
|
}
|
|
|
|
func (s *Service) workspaceForSession(ctx context.Context, taskSessionID string) (string, error) {
|
|
session, err := s.taskRepo.GetTaskSession(ctx, taskSessionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("load session workspace: %w", err)
|
|
}
|
|
if session == nil {
|
|
return "", ErrNotFound
|
|
}
|
|
task, err := s.taskRepo.GetTask(ctx, session.TaskID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("load task workspace: %w", err)
|
|
}
|
|
if task == nil {
|
|
return "", ErrNotFound
|
|
}
|
|
workspaceID := strings.TrimSpace(task.WorkspaceID)
|
|
if workspaceID == "" {
|
|
return "", ErrWorkspaceRequired
|
|
}
|
|
return workspaceID, nil
|
|
}
|
|
|
|
// ListBySession returns every share row for a session (including revoked).
|
|
func (s *Service) ListBySession(ctx context.Context, taskID, taskSessionID string) ([]*Share, error) {
|
|
if err := s.authorizeTaskSessionAccess(ctx, taskID, taskSessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.ListByTaskSession(ctx, taskSessionID)
|
|
}
|
|
|
|
// GetByID exposes repo.GetByID for handlers that need to inspect a row.
|
|
func (s *Service) GetByID(ctx context.Context, shareID string) (*Share, error) {
|
|
return s.repo.GetByID(ctx, shareID)
|
|
}
|
|
|
|
func (s *Service) logInfo(msg string, fields ...zap.Field) {
|
|
if s.log == nil {
|
|
return
|
|
}
|
|
s.log.Info(msg, fields...)
|
|
}
|
|
|
|
func (s *Service) logWarn(msg string, fields ...zap.Field) {
|
|
if s.log == nil {
|
|
return
|
|
}
|
|
s.log.Warn(msg, fields...)
|
|
}
|