Files
updates/internal/service/update_service.go
lan a0ef7f430d Initial updates server repository commit.
Reinitialize repository history and exclude generated OTA artifact outputs.

Made-with: Cursor
2026-03-09 21:33:34 +08:00

342 lines
9.7 KiB
Go

package service
import (
"bytes"
"crypto"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"mime"
"os"
"path/filepath"
"strings"
"time"
"expo-updates-server-go/internal/model"
"expo-updates-server-go/internal/storage"
)
var ErrNoUpdateAvailable = errors.New("no update available")
type UpdateService struct {
store *storage.LocalStore
hostname string
privateKeyPath string
}
type ManifestRequest struct {
Platform string
RuntimeVersion string
ProtocolVersion int
CurrentUpdateID string
EmbeddedUpdateID string
ExpectSignature bool
}
type ManifestResponse struct {
Parts []MultipartPart
}
type MultipartPart struct {
Name string
Body []byte
Signature string
}
func NewUpdateService(store *storage.LocalStore, hostname string, privateKeyPath string) *UpdateService {
return &UpdateService{store: store, hostname: hostname, privateKeyPath: privateKeyPath}
}
func (s *UpdateService) PublishZip(runtimeVersion string, platform string, zipReader *bytes.Reader, size int64) (string, error) {
return s.store.PublishZip(runtimeVersion, platform, zipReader, size)
}
func (s *UpdateService) CreateRollback(runtimeVersion string, platform string) (string, error) {
return s.store.CreateRollback(runtimeVersion, platform)
}
func (s *UpdateService) ListReleases(runtimeVersion string) ([]model.ReleaseInfo, error) {
return s.store.ListReleases(runtimeVersion)
}
func (s *UpdateService) BuildManifestResponse(req ManifestRequest) (ManifestResponse, error) {
releasePath, err := s.store.LatestReleasePath(req.RuntimeVersion, req.Platform)
if err != nil {
return ManifestResponse{}, err
}
if s.store.HasRollbackMarker(releasePath) {
return s.buildRollbackResponse(req, releasePath)
}
return s.buildNormalManifestResponse(req, releasePath)
}
func (s *UpdateService) buildNormalManifestResponse(req ManifestRequest, releasePath string) (ManifestResponse, error) {
metadata, metadataRaw, createdAt, err := s.store.ReadMetadata(releasePath)
if err != nil {
return ManifestResponse{}, fmt.Errorf("No update found with runtime version: %s. Error: %w", req.RuntimeVersion, err)
}
idHex := sha256Hex(metadataRaw)
id := convertSHA256HashToUUID(idHex)
if req.ProtocolVersion == 1 && req.CurrentUpdateID == id {
return s.buildNoUpdateAvailableResponse(req)
}
var platformMeta model.PlatformMetadata
switch req.Platform {
case "ios":
platformMeta = metadata.FileMetadata.IOS
case "android":
platformMeta = metadata.FileMetadata.Android
default:
return ManifestResponse{}, fmt.Errorf("Unsupported platform. Expected either ios or android.")
}
expoConfig, err := s.store.ReadExpoConfig(releasePath)
if err != nil {
return ManifestResponse{}, err
}
assets := make([]model.AssetMetadata, 0, len(platformMeta.Assets))
for _, a := range platformMeta.Assets {
m, err := s.assetMetadata(releasePath, a.Path, a.Ext, req.RuntimeVersion, req.Platform, false)
if err != nil {
return ManifestResponse{}, err
}
assets = append(assets, m)
}
launchAsset, err := s.assetMetadata(releasePath, platformMeta.Bundle, "", req.RuntimeVersion, req.Platform, true)
if err != nil {
return ManifestResponse{}, err
}
manifest := model.Manifest{
ID: id,
CreatedAt: createdAt.Format(time.RFC3339),
RuntimeVersion: req.RuntimeVersion,
Assets: assets,
LaunchAsset: launchAsset,
Metadata: map[string]any{},
Extra: map[string]any{"expoClient": expoConfig},
}
manifestBody, _ := json.Marshal(manifest)
signature := ""
if req.ExpectSignature {
signature, err = s.sign(manifestBody)
if err != nil {
return ManifestResponse{}, err
}
}
assetReqHeaders := map[string]map[string]string{}
for _, a := range append(assets, launchAsset) {
assetReqHeaders[a.Key] = map[string]string{"test-header": "test-header-value"}
}
extensionsBody, _ := json.Marshal(map[string]any{"assetRequestHeaders": assetReqHeaders})
return ManifestResponse{
Parts: []MultipartPart{
{Name: "manifest", Body: manifestBody, Signature: signature},
{Name: "extensions", Body: extensionsBody},
},
}, nil
}
func (s *UpdateService) buildRollbackResponse(req ManifestRequest, releasePath string) (ManifestResponse, error) {
if req.ProtocolVersion == 0 {
return ManifestResponse{}, errors.New("Rollbacks not supported on protocol version 0")
}
if req.EmbeddedUpdateID == "" {
return ManifestResponse{}, errors.New("Invalid Expo-Embedded-Update-ID request header specified.")
}
if req.CurrentUpdateID == req.EmbeddedUpdateID {
return s.buildNoUpdateAvailableResponse(req)
}
fi, err := os.Stat(filepath.Join(releasePath, "rollback"))
if err != nil {
return ManifestResponse{}, err
}
directiveBody, _ := json.Marshal(map[string]any{
"type": "rollBackToEmbedded",
"parameters": map[string]any{
"commitTime": fi.ModTime().Format(time.RFC3339),
},
})
signature := ""
if req.ExpectSignature {
signature, err = s.sign(directiveBody)
if err != nil {
return ManifestResponse{}, err
}
}
return ManifestResponse{Parts: []MultipartPart{{Name: "directive", Body: directiveBody, Signature: signature}}}, nil
}
func (s *UpdateService) buildNoUpdateAvailableResponse(req ManifestRequest) (ManifestResponse, error) {
if req.ProtocolVersion == 0 {
return ManifestResponse{}, errors.New("NoUpdateAvailable directive not available in protocol version 0")
}
body, _ := json.Marshal(map[string]any{"type": "noUpdateAvailable"})
signature := ""
var err error
if req.ExpectSignature {
signature, err = s.sign(body)
if err != nil {
return ManifestResponse{}, err
}
}
return ManifestResponse{Parts: []MultipartPart{{Name: "directive", Body: body, Signature: signature}}}, nil
}
func (s *UpdateService) AssetContent(runtimeVersion string, platform string, assetPath string) ([]byte, string, error) {
releasePath, err := s.store.LatestReleasePath(runtimeVersion, platform)
if err != nil {
return nil, "", err
}
metadata, _, _, err := s.store.ReadMetadata(releasePath)
if err != nil {
return nil, "", err
}
stripped := strings.TrimPrefix(assetPath, releasePath+"/")
isLaunch := false
var ext string
var assets []model.MetadataAssetEl
switch platform {
case "ios":
isLaunch = metadata.FileMetadata.IOS.Bundle == stripped
assets = metadata.FileMetadata.IOS.Assets
case "android":
isLaunch = metadata.FileMetadata.Android.Bundle == stripped
assets = metadata.FileMetadata.Android.Assets
default:
return nil, "", fmt.Errorf("No platform provided. Expected \"ios\" or \"android\".")
}
if !isLaunch {
for _, a := range assets {
if a.Path == stripped {
ext = a.Ext
break
}
}
}
data, err := s.store.OpenAssetSafe(assetPath)
if err != nil {
return nil, "", err
}
if isLaunch {
return data, "application/javascript", nil
}
ct := mime.TypeByExtension("." + ext)
if ct == "" {
ct = "application/octet-stream"
}
return data, ct, nil
}
func (s *UpdateService) assetMetadata(releasePath string, relPath string, ext string, runtimeVersion string, platform string, isLaunch bool) (model.AssetMetadata, error) {
fullPath := filepath.Join(releasePath, relPath)
b, err := os.ReadFile(fullPath)
if err != nil {
return model.AssetMetadata{}, err
}
hashRaw := sha256.Sum256(b)
hash := strings.TrimRight(base64.URLEncoding.EncodeToString(hashRaw[:]), "=")
md5Raw := md5.Sum(b)
key := hex.EncodeToString(md5Raw[:])
fileExt := "." + ext
ct := mime.TypeByExtension("." + ext)
if isLaunch {
fileExt = ".bundle"
ct = "application/javascript"
}
if ct == "" {
ct = "application/octet-stream"
}
return model.AssetMetadata{
Hash: hash,
Key: key,
FileExtension: fileExt,
ContentType: ct,
URL: fmt.Sprintf("%s/api/assets?asset=%s&runtimeVersion=%s&platform=%s", s.hostname, fullPath, runtimeVersion, platform),
}, nil
}
func (s *UpdateService) sign(payload []byte) (string, error) {
if s.privateKeyPath == "" {
return "", errors.New("Code signing requested but no key supplied when starting server.")
}
b, err := os.ReadFile(filepath.Clean(s.privateKeyPath))
if err != nil {
return "", err
}
block, _ := pem.Decode(b)
if block == nil {
return "", errors.New("invalid private key PEM")
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
pkcs8, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes)
if pkcs8Err != nil {
return "", err
}
var ok bool
key, ok = pkcs8.(*rsa.PrivateKey)
if !ok {
return "", errors.New("private key is not RSA")
}
}
h := sha256.Sum256(payload)
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h[:])
if err != nil {
return "", err
}
// Compatible enough for expo-signature header usage.
return fmt.Sprintf("sig=\"%s\", keyid=\"main\"", base64.StdEncoding.EncodeToString(sig)), nil
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func convertSHA256HashToUUID(value string) string {
clean := value
if len(clean) < 32 {
clean += strings.Repeat("0", 32-len(clean))
}
return fmt.Sprintf("%s-%s-%s-%s-%s", clean[0:8], clean[8:12], clean[12:16], clean[16:20], clean[20:32])
}
func BuildMultipartMixed(parts []MultipartPart) ([]byte, string, error) {
boundary := fmt.Sprintf("expo-%d", time.Now().UnixNano())
var buf bytes.Buffer
for _, p := range parts {
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString(fmt.Sprintf("content-disposition: form-data; name=\"%s\"\r\n", p.Name))
buf.WriteString("content-type: application/json; charset=utf-8\r\n")
if p.Signature != "" {
buf.WriteString(fmt.Sprintf("expo-signature: %s\r\n", p.Signature))
}
buf.WriteString("\r\n")
buf.Write(p.Body)
buf.WriteString("\r\n")
}
buf.WriteString("--" + boundary + "--")
return buf.Bytes(), "multipart/mixed; boundary=" + boundary, nil
}