Files
updates/internal/service/update_service.go
lafay 7fa2431a4a
All checks were successful
Build Updates Server / build (push) Successful in 1m1s
Build Updates Server / build-docker (push) Successful in 36m50s
feat(api): enhance public APK endpoint to return semantic version from OTA bundle
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.
2026-06-17 20:06:07 +08:00

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
}