270 lines
5.9 KiB
Go
270 lines
5.9 KiB
Go
package releases
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"update_server/internal/db"
|
|
"update_server/internal/storage"
|
|
)
|
|
|
|
type Service struct {
|
|
store *db.Store
|
|
artifacts *storage.LocalStore
|
|
}
|
|
|
|
type UploadParams struct {
|
|
ProjectID int64
|
|
Version string
|
|
Build string
|
|
ReleaseNotes string
|
|
OriginalFilename string
|
|
DeclaredType string
|
|
Reader io.Reader
|
|
UploadedByUserID *int64
|
|
}
|
|
|
|
type UploadResult struct {
|
|
Project *db.Project
|
|
Release *db.Release
|
|
}
|
|
|
|
func NewService(store *db.Store, artifacts *storage.LocalStore) *Service {
|
|
return &Service{
|
|
store: store,
|
|
artifacts: artifacts,
|
|
}
|
|
}
|
|
|
|
func (s *Service) Artifact(storagePath string) (*os.File, error) {
|
|
file, err := s.artifacts.Open(storagePath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, db.ErrNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("open artifact: %w", err)
|
|
}
|
|
|
|
return file, nil
|
|
}
|
|
|
|
func (s *Service) Upload(ctx context.Context, params UploadParams) (*UploadResult, error) {
|
|
project, err := s.store.Projects.GetByID(ctx, params.ProjectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load upload project: %w", err)
|
|
}
|
|
|
|
version := strings.TrimSpace(params.Version)
|
|
if version == "" {
|
|
return nil, fmt.Errorf("release version is required")
|
|
}
|
|
|
|
sanitizedFilename := sanitizeFilename(params.OriginalFilename)
|
|
if sanitizedFilename == "" {
|
|
return nil, fmt.Errorf("uploaded file name is invalid")
|
|
}
|
|
|
|
tempFile, tempPath, err := s.artifacts.CreateTemp("upload-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create temp artifact: %w", err)
|
|
}
|
|
|
|
removeTemp := func() {
|
|
if tempFile != nil {
|
|
_ = tempFile.Close()
|
|
}
|
|
_ = s.artifacts.RemoveTemp(tempPath)
|
|
}
|
|
|
|
hash := sha256.New()
|
|
multiWriter := io.MultiWriter(tempFile, hash)
|
|
|
|
sniffBuffer := make([]byte, 0, 512)
|
|
copyBuffer := make([]byte, 32*1024)
|
|
var sizeBytes int64
|
|
|
|
for {
|
|
n, readErr := params.Reader.Read(copyBuffer)
|
|
if n > 0 {
|
|
chunk := copyBuffer[:n]
|
|
if len(sniffBuffer) < 512 {
|
|
remaining := 512 - len(sniffBuffer)
|
|
if remaining > n {
|
|
remaining = n
|
|
}
|
|
sniffBuffer = append(sniffBuffer, chunk[:remaining]...)
|
|
}
|
|
|
|
written, writeErr := multiWriter.Write(chunk)
|
|
sizeBytes += int64(written)
|
|
if writeErr != nil {
|
|
removeTemp()
|
|
return nil, fmt.Errorf("write temp artifact: %w", writeErr)
|
|
}
|
|
}
|
|
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if readErr != nil {
|
|
removeTemp()
|
|
return nil, fmt.Errorf("read upload stream: %w", readErr)
|
|
}
|
|
}
|
|
|
|
if sizeBytes == 0 {
|
|
removeTemp()
|
|
return nil, fmt.Errorf("uploaded artifact is empty")
|
|
}
|
|
|
|
if err := tempFile.Close(); err != nil {
|
|
removeTemp()
|
|
return nil, fmt.Errorf("close temp artifact: %w", err)
|
|
}
|
|
tempFile = nil
|
|
|
|
contentType := detectContentType(sniffBuffer, params.DeclaredType)
|
|
storagePath := buildStoragePath(project.Slug, version, params.Build, sanitizedFilename)
|
|
|
|
if err := s.artifacts.CommitTemp(tempPath, storagePath); err != nil {
|
|
removeTemp()
|
|
return nil, fmt.Errorf("store artifact: %w", err)
|
|
}
|
|
|
|
cleanupFinal := func() {
|
|
_ = s.artifacts.Remove(storagePath)
|
|
}
|
|
|
|
release, err := s.store.Releases.Create(ctx, db.CreateReleaseParams{
|
|
ProjectID: project.ID,
|
|
Version: version,
|
|
Build: strings.TrimSpace(params.Build),
|
|
Filename: sanitizedFilename,
|
|
StoragePath: storagePath,
|
|
ChecksumSHA256: hex.EncodeToString(hash.Sum(nil)),
|
|
SizeBytes: sizeBytes,
|
|
ContentType: contentType,
|
|
ReleaseNotes: strings.TrimSpace(params.ReleaseNotes),
|
|
UploadedByUserID: params.UploadedByUserID,
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
cleanupFinal()
|
|
return nil, fmt.Errorf("create release metadata: %w", err)
|
|
}
|
|
|
|
return &UploadResult{
|
|
Project: project,
|
|
Release: release,
|
|
}, nil
|
|
}
|
|
|
|
func sanitizeFilename(raw string) string {
|
|
filename := filepath.Base(strings.ReplaceAll(strings.TrimSpace(raw), "\\", "/"))
|
|
if filename == "." || filename == "" {
|
|
return ""
|
|
}
|
|
|
|
ext := filepath.Ext(filename)
|
|
name := strings.TrimSuffix(filename, ext)
|
|
name = sanitizePathSegment(name)
|
|
if name == "" {
|
|
name = "artifact"
|
|
}
|
|
|
|
ext = sanitizeExtension(ext)
|
|
return strings.Trim(name+ext, ".")
|
|
}
|
|
|
|
func sanitizeExtension(ext string) string {
|
|
if ext == "" {
|
|
return ""
|
|
}
|
|
|
|
ext = strings.ToLower(ext)
|
|
var builder strings.Builder
|
|
for _, r := range ext {
|
|
switch {
|
|
case r == '.':
|
|
builder.WriteRune(r)
|
|
case unicode.IsLetter(r), unicode.IsDigit(r):
|
|
builder.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
if builder.Len() <= 1 {
|
|
return ""
|
|
}
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
func sanitizePathSegment(raw string) string {
|
|
raw = strings.TrimSpace(strings.ToLower(raw))
|
|
var builder strings.Builder
|
|
lastDash := false
|
|
|
|
for _, r := range raw {
|
|
switch {
|
|
case unicode.IsLetter(r), unicode.IsDigit(r):
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
case r == '.', r == '_', r == '-':
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
default:
|
|
if !lastDash && builder.Len() > 0 {
|
|
builder.WriteRune('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
}
|
|
|
|
value := strings.Trim(builder.String(), "-._")
|
|
if len(value) > 160 {
|
|
value = strings.Trim(value[:160], "-._")
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func buildStoragePath(projectSlug, version, build, filename string) string {
|
|
versionSegment := sanitizePathSegment(version)
|
|
if versionSegment == "" {
|
|
versionSegment = "release"
|
|
}
|
|
|
|
parts := []string{sanitizePathSegment(projectSlug), versionSegment}
|
|
if build = sanitizePathSegment(build); build != "" {
|
|
parts = append(parts, build)
|
|
}
|
|
parts = append(parts, filename)
|
|
|
|
return filepath.ToSlash(filepath.Join(parts...))
|
|
}
|
|
|
|
func detectContentType(sniff []byte, declared string) string {
|
|
if detected := http.DetectContentType(sniff); detected != "" && detected != "application/octet-stream" {
|
|
return detected
|
|
}
|
|
|
|
if declared = strings.TrimSpace(declared); declared != "" {
|
|
if mediaType, _, err := mime.ParseMediaType(declared); err == nil && mediaType != "" {
|
|
return mediaType
|
|
}
|
|
}
|
|
|
|
return "application/octet-stream"
|
|
}
|