Reinitialize repository history and exclude generated OTA artifact outputs. Made-with: Cursor
342 lines
9.7 KiB
Go
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
|
|
}
|
|
|