The PublicLatestAPK endpoint now reads the expoConfig.json from the OTA bundle to extract the actual semantic version (expo.version) instead of returning the runtimeVersion as the version name. Falls back to runtimeVersion if expoConfig.json is not available.
384 lines
11 KiB
Go
384 lines
11 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"
|
|
"io"
|
|
"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) Hostname() string {
|
|
return s.hostname
|
|
}
|
|
|
|
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) SaveAPK(runtimeVersion string, reader io.Reader) (string, error) {
|
|
return s.store.SaveAPK(runtimeVersion, reader)
|
|
}
|
|
|
|
func (s *UpdateService) GetAPKPath(runtimeVersion string) (string, error) {
|
|
return s.store.GetAPKPath(runtimeVersion)
|
|
}
|
|
|
|
func (s *UpdateService) ListAPKs() ([]model.APKInfo, error) {
|
|
return s.store.ListAPKs()
|
|
}
|
|
|
|
func (s *UpdateService) GetLatestAPK() (*model.APKInfo, error) {
|
|
return s.store.GetLatestAPK()
|
|
}
|
|
|
|
// GetAPKVersionName 返回 APK 对应 runtimeVersion 的 OTA bundle 中的语义版本号。
|
|
// 读取路径:<root>/<runtimeVersion>/android/expoConfig.json -> expo.version
|
|
// 若 OTA bundle 尚未发布或 expoConfig.json 缺失,返回空字符串(调用方应兜底)。
|
|
func (s *UpdateService) GetAPKVersionName(runtimeVersion string) string {
|
|
releasePath, err := s.store.LatestReleasePath(runtimeVersion, "android")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
cfg, err := s.store.ReadExpoConfig(releasePath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
expo, ok := cfg["expo"].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if v, ok := expo["version"].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|