fork
Configure Feed
Select the types of activity you want to include in your feed.
Live video on the AT Protocol
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}