Live video on the AT Protocol
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}