Files
browser/platform/service/artifact_download.go
T

274 lines
9.2 KiB
Go

package service
import (
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const artifactDownloadStorageBehavior = "platform-durable-artifact-store"
func (svc *CoreService) GetArtifactForSession(sessionID string, artifactID string) (domain.Artifact, error) {
artifact, err := svc.store.Artifacts().Get(strings.TrimSpace(artifactID))
if err != nil {
return domain.Artifact{}, err
}
if err := svc.authorizeArtifactAccess(sessionID, artifact); err != nil {
return domain.Artifact{}, err
}
return domain.CopyArtifact(artifact), nil
}
func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error) {
request = domain.CopyArtifactDownloadReferenceRequest(request)
if err := validator.ValidateArtifactDownloadReferenceRequest(request); err != nil {
return domain.ArtifactDownloadReference{}, err
}
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
if err != nil {
return domain.ArtifactDownloadReference{}, err
}
if artifact.State != domain.ArtifactStateAvailable {
return domain.ArtifactDownloadReference{}, validationError("artifact must be available before download")
}
filename, contentType := svc.artifactDownloadPresentation(artifact)
reference := domain.ArtifactDownloadReference{
ArtifactID: artifact.ID,
OwnerKind: artifact.OwnerKind,
OwnerID: artifact.OwnerID,
Filename: filename,
ContentType: contentType,
SizeBytes: artifact.SizeBytes,
Checksum: artifact.Checksum,
State: artifact.State,
DownloadURL: "/api/v1/artifacts/" + url.PathEscape(artifact.ID) + "/content",
ExpiresAt: svc.now().Add(15 * time.Minute),
RangeSupported: true,
ChunkSizeBytes: validator.MaxArtifactDownloadBytes,
StorageBehavior: artifactDownloadStorageBehavior,
}
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
return domain.ArtifactDownloadReference{}, err
}
return domain.CopyArtifactDownloadReference(reference), nil
}
func (svc *CoreService) ReadArtifactContentForSession(sessionID string, request domain.ArtifactContentRequest) (domain.ArtifactContent, error) {
request = domain.CopyArtifactContentRequest(request)
if err := validator.ValidateArtifactContentRequest(request); err != nil {
return domain.ArtifactContent{}, err
}
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
if err != nil {
return domain.ArtifactContent{}, err
}
if artifact.State != domain.ArtifactStateAvailable {
return domain.ArtifactContent{}, validationError("artifact must be available before download")
}
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.ArtifactContent{}, err
}
if int64(len(payload)) != artifact.SizeBytes {
return domain.ArtifactContent{}, validationError("artifact content size does not match metadata")
}
if checksum := validator.BytesChecksum(payload); checksum != artifact.Checksum {
return domain.ArtifactContent{}, validationError("artifact content checksum does not match metadata")
}
if request.Offset >= artifact.SizeBytes {
return domain.ArtifactContent{}, validationError("offset must be inside artifact content")
}
limit := request.Limit
if limit == 0 {
limit = validator.MaxArtifactDownloadBytes
}
remaining := artifact.SizeBytes - request.Offset
if int64(limit) > remaining {
limit = int(remaining)
}
end := int(request.Offset) + limit
part := domain.CopyBytes(payload[int(request.Offset):end])
filename, contentType := svc.artifactDownloadPresentation(artifact)
content := domain.ArtifactContent{
ArtifactID: artifact.ID,
Filename: filename,
ContentType: contentType,
Offset: request.Offset,
SizeBytes: int64(len(part)),
TotalSizeBytes: artifact.SizeBytes,
Checksum: artifact.Checksum,
ContentChecksum: validator.BytesChecksum(part),
Partial: request.Offset != 0 || int64(len(part)) != artifact.SizeBytes,
RangeSupported: true,
Payload: part,
StorageBehavior: artifactDownloadStorageBehavior,
ServedAt: svc.now(),
}
if err := validator.ValidateArtifactContent(content); err != nil {
return domain.ArtifactContent{}, err
}
return domain.CopyArtifactContent(content), nil
}
func (svc *CoreService) authorizeArtifactAccess(sessionID string, artifact domain.Artifact) error {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return err
}
switch artifact.OwnerKind {
case domain.ArtifactOwnerKindJob:
job, err := svc.store.Jobs().Get(artifact.OwnerID)
if err != nil {
return err
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
if !canAccessServer(user, instance) {
return ErrForbidden
}
case domain.ArtifactOwnerKindServerInstance:
instance, err := svc.store.ServerInstances().Get(artifact.OwnerID)
if err != nil {
return err
}
if !canAccessServer(user, instance) {
return ErrForbidden
}
case domain.ArtifactOwnerKindPlatform, domain.ArtifactOwnerKindPlugin:
if !isPlatformAdmin(user) {
return ErrForbidden
}
default:
return validationError("artifact ownerKind is invalid")
}
return nil
}
func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if payload, exists := svc.artifactPayloads[artifactID]; exists {
return domain.CopyBytes(payload), nil
}
if payload, err := svc.artifactStore.GetPayload(artifactID); err == nil {
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
return payload, nil
} else if !errors.Is(err, repo.ErrNotFound) {
return nil, err
}
sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers))
for _, session := range svc.artifactTransfers {
if session.ArtifactID == artifactID && session.Completed {
sessions = append(sessions, domain.CopyArtifactTransferSession(session))
}
}
if len(sessions) == 0 {
return nil, validationError("artifact content is not available from platform storage")
}
sort.Slice(sessions, func(i int, j int) bool { return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) })
session := sessions[0]
payload := make([]byte, 0, int(session.SizeBytes))
for index := 0; index < session.TotalChunks; index++ {
record, exists := session.ReceivedChunks[index]
if !exists {
return nil, validationError("artifact content has missing chunks")
}
payload = append(payload, record.Payload...)
}
if int64(len(payload)) != session.SizeBytes {
return nil, validationError("artifact content size does not match transfer")
}
if err := svc.artifactStore.PutPayload(artifactID, payload); err != nil {
return nil, err
}
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
return payload, nil
}
func artifactDownloadFilename(artifactID string) string {
name := strings.TrimSpace(artifactID)
if name == "" || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "://") {
return "artifact.bin"
}
return fmt.Sprintf("%s.bin", name)
}
func (svc *CoreService) artifactDownloadPresentation(artifact domain.Artifact) (string, string) {
if filename, contentType, ok := svc.distributionArtifactDownloadPresentation(artifact.ID); ok {
return filename, contentType
}
return artifactDownloadFilename(artifact.ID), "application/octet-stream"
}
func (svc *CoreService) distributionArtifactDownloadPresentation(artifactID string) (string, string, bool) {
runs, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{})
if err == nil {
for _, distribution := range runs {
if distribution.ArtifactID == artifactID {
return distributionPackageFilename("run", distribution.TargetOS, distribution.TargetArch, distribution.PackageFormat), packageContentType(distribution.PackageFormat), true
}
}
}
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
if err == nil {
for _, distribution := range clients {
if distribution.ArtifactID == artifactID {
format := packageFormatForTarget(distribution.TargetOS)
return distributionPackageFilename(distribution.ProfileKey, distribution.TargetOS, distribution.TargetArch, format), packageContentType(format), true
}
}
}
return "", "", false
}
func distributionPackageFilename(base string, targetOS string, targetArch string, packageFormat string) string {
name := sanitizeIDPart(base)
os := sanitizeIDPart(targetOS)
arch := sanitizeIDPart(targetArch)
format := strings.TrimPrefix(strings.TrimSpace(packageFormat), ".")
if name == "" {
name = "artifact"
}
if os != "" {
name += "-" + os
}
if arch != "" {
name += "-" + arch
}
if format == "raw-executable" {
if os == "windows" {
return name + ".exe"
}
return name
}
if format == "" {
format = "bin"
}
return name + "." + format
}
func packageContentType(packageFormat string) string {
switch strings.TrimSpace(packageFormat) {
case "zip":
return "application/zip"
case "tar.gz", "tgz":
return "application/gzip"
case "raw-executable":
return "application/octet-stream"
default:
return "application/octet-stream"
}
}