This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

View file

@ -0,0 +1,270 @@
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"
}