init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
270
internal/releases/service.go
Normal file
270
internal/releases/service.go
Normal 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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue