326 lines
11 KiB
Go
326 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
const artifactDownloadStorageBehavior = "platform-durable-artifact-store"
|
|
const artifactPayloadCacheLimit = 4 * 1024 * 1024
|
|
|
|
type ArtifactContentStream struct {
|
|
Content domain.ArtifactContent
|
|
Body io.ReadCloser
|
|
}
|
|
|
|
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")
|
|
}
|
|
offset, length, partial, err := artifactContentBounds(artifact, request)
|
|
if err != nil {
|
|
return domain.ArtifactContent{}, err
|
|
}
|
|
payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, offset, int(length))
|
|
if err != nil {
|
|
return domain.ArtifactContent{}, err
|
|
}
|
|
part := domain.CopyBytes(payload)
|
|
filename, contentType := svc.artifactDownloadPresentation(artifact)
|
|
content := domain.ArtifactContent{
|
|
ArtifactID: artifact.ID,
|
|
Filename: filename,
|
|
ContentType: contentType,
|
|
Offset: offset,
|
|
SizeBytes: int64(len(part)),
|
|
TotalSizeBytes: artifact.SizeBytes,
|
|
Checksum: artifact.Checksum,
|
|
ContentChecksum: validator.BytesChecksum(part),
|
|
Partial: partial,
|
|
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) OpenArtifactContentStreamForSession(sessionID string, request domain.ArtifactContentRequest) (ArtifactContentStream, error) {
|
|
request = domain.CopyArtifactContentRequest(request)
|
|
if err := validator.ValidateArtifactContentRequest(request); err != nil {
|
|
return ArtifactContentStream{}, err
|
|
}
|
|
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
|
|
if err != nil {
|
|
return ArtifactContentStream{}, err
|
|
}
|
|
if artifact.State != domain.ArtifactStateAvailable {
|
|
return ArtifactContentStream{}, validationError("artifact must be available before download")
|
|
}
|
|
offset, length, partial, err := artifactContentBounds(artifact, request)
|
|
if err != nil {
|
|
return ArtifactContentStream{}, err
|
|
}
|
|
contentChecksum := artifact.Checksum
|
|
if partial {
|
|
payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, offset, int(length))
|
|
if err != nil {
|
|
return ArtifactContentStream{}, err
|
|
}
|
|
contentChecksum = validator.BytesChecksum(payload)
|
|
}
|
|
reader, err := svc.artifactStore.OpenPayloadRange(artifact.ID, offset, length)
|
|
if err != nil {
|
|
return ArtifactContentStream{}, err
|
|
}
|
|
filename, contentType := svc.artifactDownloadPresentation(artifact)
|
|
content := domain.ArtifactContent{ArtifactID: artifact.ID, Filename: filename, ContentType: contentType, Offset: offset, SizeBytes: length, TotalSizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, ContentChecksum: contentChecksum, Partial: partial, RangeSupported: true, StorageBehavior: artifactDownloadStorageBehavior, ServedAt: svc.now()}
|
|
if err := validator.ValidateArtifactContent(content); err != nil {
|
|
_ = reader.Close()
|
|
return ArtifactContentStream{}, err
|
|
}
|
|
return ArtifactContentStream{Content: domain.CopyArtifactContent(content), Body: reader}, nil
|
|
}
|
|
|
|
func artifactContentBounds(artifact domain.Artifact, request domain.ArtifactContentRequest) (int64, int64, bool, error) {
|
|
if request.Offset > artifact.SizeBytes {
|
|
return 0, 0, false, validationError("artifact range exceeds metadata")
|
|
}
|
|
length := int64(request.Limit)
|
|
if request.Limit == 0 {
|
|
if request.Offset == 0 {
|
|
length = artifact.SizeBytes
|
|
} else {
|
|
length = int64(validator.MaxArtifactDownloadBytes)
|
|
}
|
|
}
|
|
remaining := artifact.SizeBytes - request.Offset
|
|
if length > remaining {
|
|
length = remaining
|
|
}
|
|
if length <= 0 {
|
|
return 0, 0, false, validationError("artifact range is empty")
|
|
}
|
|
partial := request.Offset != 0 || length != artifact.SizeBytes
|
|
return request.Offset, length, partial, 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.cacheArtifactPayload(artifactID, 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.cacheArtifactPayload(artifactID, payload)
|
|
return payload, nil
|
|
}
|
|
|
|
func (svc *CoreService) cacheArtifactPayload(artifactID string, payload []byte) {
|
|
if len(payload) > artifactPayloadCacheLimit {
|
|
delete(svc.artifactPayloads, artifactID)
|
|
return
|
|
}
|
|
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
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"
|
|
}
|
|
}
|