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 DEFAULT_KEY = "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 DEFAULT_KEY
134 }
135 key, ok := d.Get("keyid")
136 if !ok {
137 return DEFAULT_KEY
138 }
139 keystr, ok := key.(httpsfv.Item).Value.(string)
140 if !ok {
141 return DEFAULT_KEY
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
229 rt, ok := extra["runtimeVersion"]
230 if !ok {
231 return nil, fmt.Errorf("package.json missing runtimeVersion")
232 }
233 runtimeVersion, ok := rt.(string)
234 if !ok {
235 return nil, fmt.Errorf("package.json has runtimeVersion that's not a string")
236 }
237
238 var privateKey *rsa.PrivateKey
239 if cli.SigningKeyPath != "" {
240 privateKey, err = cli.ParseSigningKey()
241 if err != nil {
242 return nil, err
243 }
244 }
245
246 return &Updater{
247 CLI: cli,
248 Metadata: metadata,
249 Extra: extra,
250 SigningKey: privateKey,
251 RuntimeVersion: runtimeVersion,
252 }, nil
253}
254
255func (a *StreamplaceAPI) HandleAppUpdates(ctx context.Context) http.HandlerFunc {
256 return func(w http.ResponseWriter, req *http.Request) {
257 prefix := fmt.Sprintf("http://%s", req.Host)
258 if req.TLS != nil {
259 prefix = fmt.Sprintf("https://%s", req.Host)
260 }
261 log.Log(ctx, "got app-updates request", "method", req.Method, "headers", req.Header)
262 plat := req.Header.Get("expo-platform")
263 if plat == "" {
264 log.Log(ctx, "app-updates request missing Expo-Platform")
265 w.WriteHeader(400)
266 return
267 }
268 runtime := req.Header.Get("expo-runtime-version")
269 if runtime == "" {
270 log.Log(ctx, "app-updates request missing Expo-Runtime-Version")
271 w.WriteHeader(400)
272 return
273 }
274 signing := req.Header.Get("expo-expect-signature")
275 if signing != "" {
276 if a.Updater.SigningKey == nil {
277 log.Log(ctx, "signing requested but we don't have a key", "expo-expect-signature", signing)
278 w.WriteHeader(501)
279 return
280 }
281 }
282 bs, header, err := a.Updater.GetManifestBytes(plat, runtime, signing, prefix)
283 if err != nil {
284 log.Log(ctx, "app-updates request errored getting manfiest", "error", err)
285 w.WriteHeader(400)
286 return
287 }
288 if signing != "" {
289 w.Header().Set("expo-signature", header)
290 }
291 w.Header().Set("content-type", "application/json")
292 w.Header().Set("expo-protocol-version", "1")
293 w.Header().Set("expo-sfv-version", "0")
294 w.WriteHeader(http.StatusOK)
295 w.Write(bs)
296 }
297}
298
299func hashFile(path string) (string, error) {
300 fs, err := app.Files()
301 if err != nil {
302 return "", err
303 }
304 file, err := fs.Open(path)
305 if err != nil {
306 return "", err
307 }
308 bs, err := io.ReadAll(file)
309 if err != nil {
310 return "", err
311 }
312 h := sha256.New()
313
314 h.Write(bs)
315
316 outbs := h.Sum(nil)
317
318 sEnc := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(outbs)
319 return sEnc, nil
320}