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 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}