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" }