Files
kandev/apps/backend/internal/canvas/distribution_export.go
T

480 lines
17 KiB
Go

package canvas
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"sync"
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/webapp"
)
var (
ErrExportInvalid = errors.New("invalid canvas export")
ErrExportStale = errors.New("canvas export is stale")
)
type CanvasReader interface {
Get(context.Context, string) (*Canvas, error)
}
type ReleaseReader interface {
GetRelease(context.Context, string) (plugininstances.Release, error)
}
type ArtifactReader interface {
ReadFiles(webapp.Artifact) (map[string][]byte, error)
}
type WorkspaceAuthorizer func(context.Context, string) error
// CatalogResolver resolves a server-selected catalog entry. It returns only
// trusted server metadata; the browser never supplies a package URL for this
// path.
type CatalogResolver interface {
ResolveCanvasPackage(context.Context, string, string, string, string) (string, string, error)
}
type ExportMetadata struct {
PackageID string `json:"package_id"`
Version string `json:"version"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Author string `json:"author"`
License string `json:"license"`
SourceMode string `json:"source_mode"`
MinKandevVersion string `json:"min_kandev_version"`
RepoURL string `json:"repo_url,omitempty"`
}
// MinimumCanvasDistributionVersion is the first tagged Kandev release with
// portable canvas distribution support.
const MinimumCanvasDistributionVersion = "0.95.0"
type ExportDefaults struct {
ExpectedReleaseID string `json:"expected_release_id"`
Metadata ExportMetadata `json:"metadata"`
MissingRequired []string `json:"missing_required"`
}
// GetExportDefaults derives a temporary share draft from the active release.
func (s *DistributionService) GetExportDefaults(ctx context.Context, userID, workspaceID, canvasID string) (ExportDefaults, error) {
if err := s.validateReady(userID, workspaceID, canvasID); err != nil {
return ExportDefaults{}, err
}
if err := s.authorizeWorkspace(ctx, workspaceID); err != nil {
return ExportDefaults{}, err
}
item, err := s.canvases.Get(ctx, canvasID)
if err != nil || !validExportCanvas(item, ExportRequest{WorkspaceID: workspaceID}) {
return ExportDefaults{}, ErrCanvasNotFound
}
_, release, files, err := s.loadExportSnapshot(ctx, ExportRequest{WorkspaceID: workspaceID, CanvasID: canvasID, ExpectedReleaseID: item.ActiveReleaseID})
if err != nil {
return ExportDefaults{}, err
}
current := releaseManifest(release)
metadata := fillExportMetadata(ExportMetadata{}, current)
if current.Distribution == nil && !staticExportAvailable(current, files) {
metadata.SourceMode = ""
}
if metadata.DisplayName == "" {
metadata.DisplayName = item.Title
}
if metadata.MinKandevVersion == "" {
metadata.MinKandevVersion = MinimumCanvasDistributionVersion
}
missing := make([]string, 0, 8)
for _, field := range []struct{ key, value string }{
{"package_id", metadata.PackageID}, {"version", metadata.Version}, {"display_name", metadata.DisplayName},
{"description", metadata.Description}, {"author", metadata.Author}, {"license", metadata.License},
{"source_mode", metadata.SourceMode}, {"min_kandev_version", metadata.MinKandevVersion},
} {
if strings.TrimSpace(field.value) == "" {
missing = append(missing, field.key)
}
}
return ExportDefaults{ExpectedReleaseID: release.ID, Metadata: metadata, MissingRequired: missing}, nil
}
func staticExportAvailable(current manifest.Manifest, files map[string][]byte) bool {
if !current.IsStaticWebAppOnly() {
return false
}
for _, app := range current.UI.WebApps {
if _, ok := files[app.Entry]; !ok {
return false
}
}
return true
}
type ExportRequest struct {
UserID string
WorkspaceID string
CanvasID string
ExpectedReleaseID string
Metadata ExportMetadata
}
type ExportReview struct {
PreparationID string `json:"preparation_id"`
CanvasID string `json:"canvas_id"`
WorkspaceID string `json:"workspace_id"`
ReleaseID string `json:"release_id"`
Metadata ExportMetadata `json:"metadata"`
Digest string `json:"sha256"`
Files []ExportFile `json:"files"`
BundleBytes int64 `json:"bundle_bytes"`
SourceBytes int64 `json:"source_bytes"`
ExpiresAt string `json:"expires_at"`
BundleDownload string `json:"bundle_download"`
SourceDownload string `json:"source_download"`
}
type ExportDownload struct {
Data []byte
Filename string
ContentType string
}
type DistributionService struct {
canvases CanvasReader
releases ReleaseReader
artifacts ArtifactReader
authorize WorkspaceAuthorizer
preparations *PreparationStore
installMu sync.Mutex
artifactQuota ArtifactQuota
receipts map[string]InstallReceipt
receiptStore InstallReceiptStore
httpClient *http.Client
catalog CatalogResolver
kandevVersion string
}
// SetArtifactQuota wires the durable storage admission store used by
// marketplace installs. Production composition supplies the same instance
// store used by canvas authoring.
func (s *DistributionService) SetArtifactQuota(quota ArtifactQuota) {
if s == nil {
return
}
s.installMu.Lock()
s.artifactQuota = quota
s.installMu.Unlock()
}
func NewDistributionService(canvases CanvasReader, releases ReleaseReader, artifacts ArtifactReader, authorize WorkspaceAuthorizer, preparations *PreparationStore) *DistributionService {
return &DistributionService{canvases: canvases, releases: releases, artifacts: artifacts, authorize: authorize, preparations: preparations, receipts: make(map[string]InstallReceipt), httpClient: newDistributionHTTPClient()}
}
func (s *DistributionService) SetInstallReceiptStore(store InstallReceiptStore) {
if s == nil {
return
}
s.installMu.Lock()
s.receiptStore = store
s.installMu.Unlock()
}
// SetKandevVersion supplies the stamped server version used by the same
// compatibility gate as native plugin installation.
func (s *DistributionService) SetKandevVersion(version string) {
if s == nil {
return
}
s.installMu.Lock()
s.kandevVersion = version
s.installMu.Unlock()
}
// SetInstallHTTPClient is intended for deterministic local transport tests.
// Production composition leaves the destination-validating client installed.
func (s *DistributionService) SetInstallHTTPClient(client *http.Client) {
if s == nil || client == nil {
return
}
s.installMu.Lock()
s.httpClient = client
s.installMu.Unlock()
}
func (s *DistributionService) SetCatalogResolver(resolver CatalogResolver) {
if s == nil {
return
}
s.installMu.Lock()
s.catalog = resolver
s.installMu.Unlock()
}
func (s *DistributionService) PrepareExport(ctx context.Context, request ExportRequest) (ExportReview, error) {
if err := s.validateReady(request.UserID, request.WorkspaceID, request.CanvasID); err != nil {
return ExportReview{}, err
}
if err := s.authorizeWorkspace(ctx, request.WorkspaceID); err != nil {
return ExportReview{}, err
}
item, release, files, err := s.loadExportSnapshot(ctx, request)
if err != nil {
return ExportReview{}, err
}
current := releaseManifest(release)
metadata, err := normalizeExportMetadata(request.Metadata, current)
if err != nil {
return ExportReview{}, err
}
if metadata.SourceMode == manifest.SourceModeStatic && !staticExportAvailable(current, files) {
return ExportReview{}, fmt.Errorf("%w: static export is unavailable", ErrExportInvalid)
}
manifestValue, err := exportManifest(release.ManifestJSON, metadata)
if err != nil {
return ExportReview{}, err
}
if _, ok := files["README.md"]; !ok {
files["README.md"] = []byte(fmt.Sprintf("# %s\n\n%s\n\nBuild and publish the retained canvas files.\n", metadata.DisplayName, metadata.Description))
}
pkg, err := webapp.BuildDistributionPackage(manifestValue, files)
if err != nil {
if errors.Is(err, webapp.ErrSourceUnavailable) {
return ExportReview{}, err
}
return ExportReview{}, fmt.Errorf("%w: %v", ErrExportInvalid, err)
}
archives, err := webapp.BuildDistributionArchives(pkg)
if err != nil {
return ExportReview{}, fmt.Errorf("%w: %v", ErrExportInvalid, err)
}
preparation, err := s.preparations.Create(ctx, Preparation{
UserID: request.UserID, WorkspaceID: item.WorkspaceID, CanvasID: item.ID, ReleaseID: release.ID,
PackageID: metadata.PackageID, PackageVersion: metadata.Version, PackageDigest: pkg.Digest,
Metadata: metadata,
OriginKind: "export", Bundle: archives.Bundle, Source: archives.Source,
Files: distributionInventory(pkg.Files),
})
if err != nil {
return ExportReview{}, err
}
return exportReview(preparation, metadata, pkg, len(archives.Bundle), len(archives.Source)), nil
}
func (s *DistributionService) GetExport(ctx context.Context, userID, preparationID string) (ExportReview, error) {
preparation, err := s.preparations.Get(ctx, userID, preparationID)
if err != nil {
return ExportReview{}, err
}
if err := s.verifyCurrentRelease(ctx, preparation); err != nil {
return ExportReview{}, err
}
return preparationReview(preparation), nil
}
func (s *DistributionService) DownloadExport(ctx context.Context, userID, preparationID string, kind ExportKind) (ExportDownload, error) {
preparation, err := s.preparations.Get(ctx, userID, preparationID)
if err != nil {
return ExportDownload{}, err
}
if err := s.verifyCurrentRelease(ctx, preparation); err != nil {
return ExportDownload{}, err
}
data, err := s.preparations.Open(ctx, userID, preparationID, kind)
if err != nil {
return ExportDownload{}, err
}
extension, contentType := ".tar.gz", "application/gzip"
if kind == ExportSource {
extension, contentType = ".zip", "application/zip"
} else if kind != ExportBundle {
return ExportDownload{}, ErrExportInvalid
}
return ExportDownload{Data: data, Filename: preparation.PackageID + "-" + preparation.PackageVersion + extension, ContentType: contentType}, nil
}
func (s *DistributionService) CancelExport(ctx context.Context, userID, preparationID string) error {
return s.preparations.Delete(ctx, userID, preparationID)
}
func (s *DistributionService) validateReady(userID, workspaceID, canvasID string) error {
if s == nil || s.canvases == nil || s.releases == nil || s.artifacts == nil || s.preparations == nil ||
strings.TrimSpace(userID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(canvasID) == "" {
return ErrExportInvalid
}
return nil
}
func (s *DistributionService) authorizeWorkspace(ctx context.Context, workspaceID string) error {
if s.authorize == nil {
return nil
}
return s.authorize(ctx, workspaceID)
}
func (s *DistributionService) loadExportSnapshot(ctx context.Context, request ExportRequest) (*Canvas, plugininstances.Release, map[string][]byte, error) {
item, err := s.canvases.Get(ctx, request.CanvasID)
if err != nil || !validExportCanvas(item, request) {
return nil, plugininstances.Release{}, nil, ErrCanvasNotFound
}
if !matchesExpectedRelease(item, request.ExpectedReleaseID) {
return nil, plugininstances.Release{}, nil, ErrExportStale
}
release, err := s.releases.GetRelease(ctx, item.ActiveReleaseID)
if err != nil || !validExportRelease(item, release, request.ExpectedReleaseID) {
return nil, plugininstances.Release{}, nil, ErrExportStale
}
files, err := s.artifacts.ReadFiles(webapp.Artifact{Digest: release.PackageDigest, RelativePath: release.ArtifactPath, Bytes: release.ArtifactBytes, Available: true})
if err != nil {
return nil, plugininstances.Release{}, nil, webapp.ErrArtifactUnavailable
}
return item, release, cloneFiles(files), nil
}
func validExportCanvas(item *Canvas, request ExportRequest) bool {
return item != nil && item.WorkspaceID == request.WorkspaceID &&
(item.ScopeKind == ScopeWorkspace || item.ScopeKind == ScopeTask) && item.Status == StatusActive &&
item.ActiveReleaseID != "" && item.ActiveReleaseStatus == ValidationValid
}
func matchesExpectedRelease(item *Canvas, expectedReleaseID string) bool {
return expectedReleaseID != "" && item != nil && expectedReleaseID == item.ActiveReleaseID
}
func validExportRelease(item *Canvas, release plugininstances.Release, expectedReleaseID string) bool {
if release.ID != expectedReleaseID {
return false
}
return release.InstanceID == "" || item.PluginInstanceID == "" || release.InstanceID == item.PluginInstanceID
}
func (s *DistributionService) verifyCurrentRelease(ctx context.Context, preparation Preparation) error {
if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil {
return err
}
item, err := s.canvases.Get(ctx, preparation.CanvasID)
if err != nil || item == nil || item.WorkspaceID != preparation.WorkspaceID || item.ActiveReleaseID != preparation.ReleaseID || item.ActiveReleaseStatus != ValidationValid || item.Status != StatusActive {
return ErrExportStale
}
return nil
}
func releaseManifest(release plugininstances.Release) manifest.Manifest {
var value manifest.Manifest
_ = json.Unmarshal(release.ManifestJSON, &value)
return value
}
func normalizeExportMetadata(input ExportMetadata, current manifest.Manifest) (ExportMetadata, error) {
input = fillExportMetadata(input, current)
if exportMetadataIncomplete(input) {
return ExportMetadata{}, fmt.Errorf("%w: package identity, description, author, license, and compatibility are required", ErrExportInvalid)
}
return input, nil
}
func fillExportMetadata(input ExportMetadata, current manifest.Manifest) ExportMetadata {
if input.PackageID == "" {
input.PackageID = current.ID
}
if input.Version == "" {
input.Version = current.Version
}
if input.DisplayName == "" {
input.DisplayName = current.DisplayName
}
if input.Description == "" {
input.Description = current.Description
}
if input.Author == "" {
input.Author = current.Author
}
if input.License == "" && current.Distribution != nil {
input.License = current.Distribution.License
}
if input.SourceMode == "" {
input.SourceMode = manifest.SourceModeStatic
if current.Distribution != nil {
input.SourceMode = current.Distribution.SourceMode
}
}
if input.MinKandevVersion == "" {
input.MinKandevVersion = current.MinKandevVersion
}
if input.RepoURL == "" {
input.RepoURL = current.RepoURL
}
return input
}
func exportMetadataIncomplete(input ExportMetadata) bool {
return input.PackageID == "" || input.Version == "" || input.DisplayName == "" ||
input.Description == "" || input.Author == "" || input.License == "" || input.MinKandevVersion == ""
}
func exportManifest(data json.RawMessage, metadata ExportMetadata) (*manifest.Manifest, error) {
var value manifest.Manifest
if err := json.Unmarshal(data, &value); err != nil {
return nil, fmt.Errorf("%w: release manifest is invalid", ErrExportInvalid)
}
value.ID, value.Version = metadata.PackageID, metadata.Version
value.DisplayName, value.Description, value.Author = metadata.DisplayName, metadata.Description, metadata.Author
value.MinKandevVersion, value.RepoURL = metadata.MinKandevVersion, metadata.RepoURL
if len(value.UI.WebApps) > 0 && !containsString(value.UI.WebApps[0].Placements, manifest.WebAppPlacementWorkspace) {
value.UI.WebApps[0].Placements = append(value.UI.WebApps[0].Placements, manifest.WebAppPlacementWorkspace)
}
value.Distribution = &manifest.Distribution{SchemaVersion: manifest.DistributionSchemaVersion, Kind: manifest.DistributionKindCanvas, License: metadata.License, SourceMode: metadata.SourceMode}
return &value, nil
}
func cloneFiles(input map[string][]byte) map[string][]byte {
output := make(map[string][]byte, len(input))
for name, data := range input {
output[name] = append([]byte(nil), data...)
}
return output
}
func distributionInventory(files map[string][]byte) []ExportFile {
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
result := make([]ExportFile, 0, len(names))
for _, name := range names {
result = append(result, ExportFile{Path: name, Bytes: int64(len(files[name]))})
}
return result
}
func exportReview(preparation Preparation, metadata ExportMetadata, pkg *webapp.Package, bundleBytes, sourceBytes int) ExportReview {
review := preparationReview(preparation)
review.Metadata, review.Digest, review.Files = metadata, pkg.Digest, distributionInventory(pkg.Files)
review.BundleBytes, review.SourceBytes = int64(bundleBytes), int64(sourceBytes)
review.BundleDownload = "/api/v1/canvases/exports/" + preparation.ID + "/bundle"
review.SourceDownload = "/api/v1/canvases/exports/" + preparation.ID + "/source"
return review
}
func preparationReview(preparation Preparation) ExportReview {
metadata := preparation.Metadata
if metadata.PackageID == "" {
metadata.PackageID, metadata.Version = preparation.PackageID, preparation.PackageVersion
}
return ExportReview{PreparationID: preparation.ID, CanvasID: preparation.CanvasID, WorkspaceID: preparation.WorkspaceID, ReleaseID: preparation.ReleaseID, Metadata: metadata, Digest: preparation.PackageDigest, Files: append([]ExportFile(nil), preparation.Files...), ExpiresAt: preparation.ExpiresAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")}
}
func (s *DistributionService) preparationsCount() int {
if s == nil || s.preparations == nil {
return 0
}
s.preparations.mu.Lock()
defer s.preparations.mu.Unlock()
return len(s.preparations.items)
}