Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v0.8.1 344 lines 9.0 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 expoConfig, err := fs.Open("expoConfig.json") 242 if err != nil { 243 return nil, fmt.Errorf("couldn't open expoConfig.json: %w", err) 244 } 245 expoConfigBytes, err := io.ReadAll(expoConfig) 246 if err != nil { 247 return nil, fmt.Errorf("couldn't read expoConfig.json: %w", err) 248 } 249 expoConfigJSON := map[string]any{} 250 err = json.Unmarshal(expoConfigBytes, &expoConfigJSON) 251 if err != nil { 252 return nil, fmt.Errorf("couldn't parse expoConfig.json: %w", err) 253 } 254 extra["expoClient"] = expoConfigJSON 255 256 var privateKey *rsa.PrivateKey 257 if cli.SigningKeyPath != "" { 258 privateKey, err = cli.ParseSigningKey() 259 if err != nil { 260 return nil, err 261 } 262 } 263 264 return &Updater{ 265 CLI: cli, 266 Metadata: metadata, 267 Extra: extra, 268 SigningKey: privateKey, 269 RuntimeVersion: runtimeVersion, 270 }, nil 271} 272 273func (a *StreamplaceAPI) HandleAppUpdates(ctx context.Context) http.HandlerFunc { 274 return func(w http.ResponseWriter, req *http.Request) { 275 proto := "http" 276 if req.TLS != nil { 277 proto = "https" 278 } 279 if xfproto := req.Header.Get("x-forwarded-proto"); xfproto == "https" { 280 proto = "https" 281 } 282 prefix := fmt.Sprintf("%s://%s", proto, req.Host) 283 log.Log(ctx, "got app-updates request", "method", req.Method, "headers", req.Header) 284 plat := req.Header.Get("expo-platform") 285 if plat == "" { 286 log.Log(ctx, "app-updates request missing Expo-Platform") 287 w.WriteHeader(400) 288 return 289 } 290 runtime := req.Header.Get("expo-runtime-version") 291 if runtime == "" { 292 log.Log(ctx, "app-updates request missing Expo-Runtime-Version") 293 w.WriteHeader(400) 294 return 295 } 296 signing := req.Header.Get("expo-expect-signature") 297 if signing != "" { 298 if a.Updater.SigningKey == nil { 299 log.Log(ctx, "signing requested but we don't have a key", "expo-expect-signature", signing) 300 w.WriteHeader(501) 301 return 302 } 303 } 304 bs, header, err := a.Updater.GetManifestBytes(plat, runtime, signing, prefix) 305 if err != nil { 306 log.Log(ctx, "app-updates request errored getting manfiest", "error", err) 307 w.WriteHeader(400) 308 return 309 } 310 if signing != "" { 311 w.Header().Set("expo-signature", header) 312 } 313 w.Header().Set("content-type", "application/json") 314 w.Header().Set("expo-protocol-version", "1") 315 w.Header().Set("expo-sfv-version", "0") 316 w.WriteHeader(http.StatusOK) 317 if _, err := w.Write(bs); err != nil { 318 log.Error(ctx, "error writing response", "error", err) 319 } 320 } 321} 322 323func hashFile(path string) (string, error) { 324 fs, err := app.Files() 325 if err != nil { 326 return "", err 327 } 328 file, err := fs.Open(path) 329 if err != nil { 330 return "", err 331 } 332 bs, err := io.ReadAll(file) 333 if err != nil { 334 return "", err 335 } 336 h := sha256.New() 337 338 h.Write(bs) 339 340 outbs := h.Sum(nil) 341 342 sEnc := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(outbs) 343 return sEnc, nil 344}