Live video on the AT Protocol
at eli/further-gitlab-ci 325 lines 8.4 kB view raw
1package api 2 3import ( 4 "context" 5 "crypto" 6 "crypto/rand" 7 "crypto/rsa" 8 "crypto/sha256" 9 "encoding/base64" 10 "encoding/json" 11 "fmt" 12 "io" 13 "mime" 14 "net/http" 15 "strings" 16 17 "stream.place/streamplace/js/app" 18 "stream.place/streamplace/pkg/config" 19 "stream.place/streamplace/pkg/log" 20 21 "github.com/dunglas/httpsfv" 22) 23 24const IOS = "ios" 25const ANDROID = "android" 26 27type UpdateManifest struct { 28 ID string `json:"id"` 29 CreatedAt string `json:"createdAt"` 30 RuntimeVersion string `json:"runtimeVersion"` 31 LaunchAsset UpdateAsset `json:"launchAsset"` 32 Assets []UpdateAsset `json:"assets"` 33 Metadata map[string]string `json:"metadata"` 34 Extra map[string]any `json:"extra"` 35} 36 37type UpdateAsset struct { 38 Hash string `json:"hash,omitempty"` 39 Key string `json:"key"` 40 ContentType string `json:"contentType"` 41 FileExtension string `json:"fileExtension,omitempty"` 42 URL string `json:"url"` 43 Path string `json:"-"` 44} 45 46type ExpoMetadata struct { 47 Version int `json:"version"` 48 Bundler string `json:"bundler"` 49 FileMetadata struct { 50 IOS ExpoMetadataPlatform `json:"ios"` 51 Android ExpoMetadataPlatform `json:"android"` 52 } `json:"fileMetadata"` 53} 54 55type ExpoMetadataPlatform struct { 56 Bundle string `json:"bundle"` 57 Assets []struct { 58 Path string `json:"path"` 59 Ext string `json:"ext"` 60 } `json:"assets"` 61} 62 63type Updater struct { 64 Metadata ExpoMetadata 65 Extra map[string]any 66 CLI *config.CLI 67 SigningKey *rsa.PrivateKey 68 RuntimeVersion string 69} 70 71func (u *Updater) GetManifest(platform, runtime, prefix string) (*UpdateManifest, error) { 72 var plat ExpoMetadataPlatform 73 if platform == IOS { 74 plat = u.Metadata.FileMetadata.IOS 75 } else if platform == ANDROID { 76 plat = u.Metadata.FileMetadata.Android 77 } else { 78 return nil, fmt.Errorf("unknown platform: %s", platform) 79 } 80 assets := []UpdateAsset{} 81 for _, ass := range plat.Assets { 82 ext := fmt.Sprintf(".%s", ass.Ext) 83 loadEmbeddedMimes() 84 typ := mime.TypeByExtension(ext) 85 86 if typ == "" { 87 return nil, fmt.Errorf("unknown content-type for file extention %s", ext) 88 } 89 parts := strings.Split(ass.Path, "/") 90 hash, err := hashFile(ass.Path) 91 if err != nil { 92 return nil, err 93 } 94 assets = append(assets, UpdateAsset{ 95 Hash: hash, 96 Key: parts[len(parts)-1], 97 Path: ass.Path, 98 URL: fmt.Sprintf("%s/%s", prefix, ass.Path), 99 ContentType: typ, 100 FileExtension: ass.Ext, 101 }) 102 } 103 dashParts := strings.Split(plat.Bundle, "-") 104 dotParts := strings.Split(dashParts[len(dashParts)-1], ".") 105 hash, err := hashFile(plat.Bundle) 106 if err != nil { 107 return nil, err 108 } 109 man := UpdateManifest{ 110 ID: u.CLI.Build.UUID, 111 CreatedAt: u.CLI.Build.BuildTimeStrExpo(), 112 RuntimeVersion: runtime, 113 LaunchAsset: UpdateAsset{ 114 Hash: hash, 115 Key: dotParts[0], 116 Path: plat.Bundle, 117 URL: fmt.Sprintf("%s/%s", prefix, plat.Bundle), 118 ContentType: "application/javascript", 119 }, 120 Assets: assets, 121 Metadata: map[string]string{}, 122 Extra: u.Extra, 123 } 124 return &man, nil 125} 126 127var DefaultKey = "main" 128 129// get keyid, with a default if there's not one 130func getKeyID(header string) string { 131 d, err := httpsfv.UnmarshalDictionary([]string{header}) 132 if err != nil { 133 return DefaultKey 134 } 135 key, ok := d.Get("keyid") 136 if !ok { 137 return DefaultKey 138 } 139 keystr, ok := key.(httpsfv.Item).Value.(string) 140 if !ok { 141 return DefaultKey 142 } 143 return keystr 144} 145 146func (u *Updater) GetManifestBytes(platform, runtime, signing, prefix string) ([]byte, string, error) { 147 if runtime != u.RuntimeVersion { 148 return nil, "", fmt.Errorf("runtime version mismatch client=%s server=%s", runtime, u.RuntimeVersion) 149 } 150 manifest, err := u.GetManifest(platform, runtime, prefix) 151 if err != nil { 152 return nil, "", err 153 } 154 bs, err := json.Marshal(manifest) 155 if err != nil { 156 return nil, "", err 157 } 158 var header string 159 if u.SigningKey != nil { 160 keyid := getKeyID(signing) 161 msgHash := sha256.New() 162 _, err = msgHash.Write(bs) 163 if err != nil { 164 return nil, "", fmt.Errorf("error getting sha256 hash of manifest: %w", err) 165 } 166 msgHashSum := msgHash.Sum(nil) 167 signature, err := rsa.SignPKCS1v15(rand.Reader, u.SigningKey, crypto.SHA256, msgHashSum) 168 if err != nil { 169 return nil, "", fmt.Errorf("error signing manifest: %w", err) 170 } 171 sigString := base64.StdEncoding.EncodeToString(signature) 172 dict := httpsfv.NewDictionary() 173 dict.Add("sig", httpsfv.NewItem(sigString)) 174 dict.Add("keyid", httpsfv.NewItem(keyid)) 175 header, err = httpsfv.Marshal(dict) 176 if err != nil { 177 return nil, "", fmt.Errorf("error marshalling dict: %w", err) 178 } 179 } 180 return bs, header, nil 181} 182 183// get MIME types of built-in update files 184func (u *Updater) GetMimes() (map[string]string, error) { 185 assets := []UpdateAsset{} 186 ios, err := u.GetManifest(IOS, "", "") 187 if err != nil { 188 return nil, err 189 } 190 assets = append(assets, ios.LaunchAsset) 191 assets = append(assets, ios.Assets...) 192 android, err := u.GetManifest(ANDROID, "", "") 193 if err != nil { 194 return nil, err 195 } 196 assets = append(assets, android.LaunchAsset) 197 assets = append(assets, android.Assets...) 198 m := map[string]string{} 199 for _, ass := range assets { 200 if ass.Path == "" { 201 return nil, fmt.Errorf("asset has no path! asset=%v", ass) 202 } 203 m[ass.Path] = ass.ContentType 204 } 205 return m, nil 206} 207 208func PrepareUpdater(cli *config.CLI) (*Updater, error) { 209 fs, err := app.Files() 210 if err != nil { 211 return nil, err 212 } 213 file, err := fs.Open("metadata.json") 214 if err != nil { 215 return nil, fmt.Errorf("couldn't read metadata.json, did you run `make app`? error=%w", err) 216 } 217 bs, err := io.ReadAll(file) 218 if err != nil { 219 return nil, err 220 } 221 metadata := ExpoMetadata{} 222 err = json.Unmarshal(bs, &metadata) 223 if err != nil { 224 return nil, err 225 } 226 227 extra, err := app.PackageJSON() 228 if err != nil { 229 return nil, fmt.Errorf("package.json failed") 230 } 231 232 rt, ok := extra["runtimeVersion"] 233 if !ok { 234 return nil, fmt.Errorf("package.json missing runtimeVersion") 235 } 236 runtimeVersion, ok := rt.(string) 237 if !ok { 238 return nil, fmt.Errorf("package.json has runtimeVersion that's not a string") 239 } 240 241 var privateKey *rsa.PrivateKey 242 if cli.SigningKeyPath != "" { 243 privateKey, err = cli.ParseSigningKey() 244 if err != nil { 245 return nil, err 246 } 247 } 248 249 return &Updater{ 250 CLI: cli, 251 Metadata: metadata, 252 Extra: extra, 253 SigningKey: privateKey, 254 RuntimeVersion: runtimeVersion, 255 }, nil 256} 257 258func (a *StreamplaceAPI) HandleAppUpdates(ctx context.Context) http.HandlerFunc { 259 return func(w http.ResponseWriter, req *http.Request) { 260 prefix := fmt.Sprintf("http://%s", req.Host) 261 if req.TLS != nil { 262 prefix = fmt.Sprintf("https://%s", req.Host) 263 } 264 log.Log(ctx, "got app-updates request", "method", req.Method, "headers", req.Header) 265 plat := req.Header.Get("expo-platform") 266 if plat == "" { 267 log.Log(ctx, "app-updates request missing Expo-Platform") 268 w.WriteHeader(400) 269 return 270 } 271 runtime := req.Header.Get("expo-runtime-version") 272 if runtime == "" { 273 log.Log(ctx, "app-updates request missing Expo-Runtime-Version") 274 w.WriteHeader(400) 275 return 276 } 277 signing := req.Header.Get("expo-expect-signature") 278 if signing != "" { 279 if a.Updater.SigningKey == nil { 280 log.Log(ctx, "signing requested but we don't have a key", "expo-expect-signature", signing) 281 w.WriteHeader(501) 282 return 283 } 284 } 285 bs, header, err := a.Updater.GetManifestBytes(plat, runtime, signing, prefix) 286 if err != nil { 287 log.Log(ctx, "app-updates request errored getting manfiest", "error", err) 288 w.WriteHeader(400) 289 return 290 } 291 if signing != "" { 292 w.Header().Set("expo-signature", header) 293 } 294 w.Header().Set("content-type", "application/json") 295 w.Header().Set("expo-protocol-version", "1") 296 w.Header().Set("expo-sfv-version", "0") 297 w.WriteHeader(http.StatusOK) 298 if _, err := w.Write(bs); err != nil { 299 log.Error(ctx, "error writing response", "error", err) 300 } 301 } 302} 303 304func hashFile(path string) (string, error) { 305 fs, err := app.Files() 306 if err != nil { 307 return "", err 308 } 309 file, err := fs.Open(path) 310 if err != nil { 311 return "", err 312 } 313 bs, err := io.ReadAll(file) 314 if err != nil { 315 return "", err 316 } 317 h := sha256.New() 318 319 h.Write(bs) 320 321 outbs := h.Sum(nil) 322 323 sEnc := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(outbs) 324 return sEnc, nil 325}