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 }