package main import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strconv" "strings" "time" ) // Releases relays the app's own releases from the private forge repository to the car, for // the in-app updater. The repository stays private and the forge token stays here: the car // asks this server with its own token and never holds a forge credential. // // GET /v1/app/releases the repo's releases, GitHub-shaped // GET /v1/app/assets/{release}/{asset}/{name} one attachment, streamed through // // The car still checks everything itself (SHA-256 sidecar, package name, signature by the // installer); this only carries bytes. Off unless FORGE_URL, FORGE_REPO and FORGE_TOKEN are set. type Releases struct { ForgeURL string // e.g. https://git.stinnesbeck.com Repo string // owner/name Token string // read-only on the repository Client *http.Client } const ( maxReleaseListBytes = 1 << 20 maxAssetBytes = 64 << 20 // An APK over a slow mobile link takes longer than the server's general write timeout. assetWriteTimeout = 10 * time.Minute ) func (rel *Releases) enabled() bool { return rel != nil && rel.ForgeURL != "" && rel.Repo != "" && rel.Token != "" } func (rel *Releases) client() *http.Client { if rel.Client != nil { return rel.Client } return &http.Client{Timeout: 5 * time.Minute, CheckRedirect: rel.sameHostOnly} } // sameHostOnly keeps the token on the forge: a redirect elsewhere is refused, not followed // with the Authorization header. func (rel *Releases) sameHostOnly(req *http.Request, via []*http.Request) error { forge, err := url.Parse(rel.ForgeURL) if err != nil || req.URL.Host != forge.Host || req.URL.Scheme != forge.Scheme { return errors.New("redirect away from the forge refused") } if len(via) > 5 { return errors.New("too many redirects") } return nil } func (rel *Releases) get(ctx context.Context, path string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(rel.ForgeURL, "/")+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "token "+rel.Token) req.Header.Set("Accept", "application/json") return rel.client().Do(req) } type forgeAsset struct { ID int64 `json:"id"` Name string `json:"name"` Size int64 `json:"size"` } type forgeRelease struct { ID int64 `json:"id"` TagName string `json:"tag_name"` Prerelease bool `json:"prerelease"` Draft bool `json:"draft"` Assets []forgeAsset `json:"assets"` } // carRelease is what the car's updater reads: GitHub's field names, with download URLs that // point back at this server. type carRelease struct { TagName string `json:"tag_name"` Prerelease bool `json:"prerelease"` Draft bool `json:"draft"` Assets []carAsset `json:"assets"` } type carAsset struct { Name string `json:"name"` Size int64 `json:"size"` BrowserDownloadURL string `json:"browser_download_url"` } func (a *API) releaseRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/app/releases", a.ingest(a.appReleases)) mux.HandleFunc("GET /v1/app/assets/{release}/{asset}/{name}", a.ingest(a.appAsset)) } func (a *API) releasesOff(w http.ResponseWriter) bool { if a.Releases.enabled() { return false } writeJSON(w, http.StatusNotFound, map[string]string{"error": "app updates are not enabled"}) return true } func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { if a.releasesOff(w) { return nil } resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20") if err != nil { return fmt.Errorf("forge releases: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { slog.Warn("forge refused the release list", "status", resp.StatusCode) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) return nil } var releases []forgeRelease if err := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseListBytes)).Decode(&releases); err != nil { return fmt.Errorf("forge releases: %w", err) } base := a.publicURL(r) + "/v1/app/assets/" out := make([]carRelease, 0, len(releases)) for _, rel := range releases { cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}} for _, as := range rel.Assets { cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size, BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) + "/" + url.PathEscape(as.Name)}) } out = append(out, cr) } writeJSON(w, http.StatusOK, out) return nil } // appAsset streams one release attachment. The forge's own metadata names the file, so a car // cannot ask for anything but an attachment of this repository's releases. func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { if a.releasesOff(w) { return nil } releaseID, err1 := strconv.ParseInt(r.PathValue("release"), 10, 64) assetID, err2 := strconv.ParseInt(r.PathValue("asset"), 10, 64) if err1 != nil || err2 != nil || releaseID <= 0 || assetID <= 0 { return invalid("release or asset is invalid") } meta, err := a.Releases.get(r.Context(), fmt.Sprintf("/api/v1/repos/%s/releases/%d/assets/%d", a.Releases.Repo, releaseID, assetID)) if err != nil { return fmt.Errorf("forge asset: %w", err) } var asset struct { Name string `json:"name"` UUID string `json:"uuid"` } decodeErr := json.NewDecoder(io.LimitReader(meta.Body, 64<<10)).Decode(&asset) meta.Body.Close() if meta.StatusCode == http.StatusNotFound { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"}) return nil } if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" { slog.Warn("forge refused the asset", "status", meta.StatusCode) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) return nil } if asset.Name != r.PathValue("name") { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"}) return nil } file, err := a.Releases.get(r.Context(), "/attachments/"+url.PathEscape(asset.UUID)) if err != nil { return fmt.Errorf("forge download: %w", err) } defer file.Body.Close() if file.StatusCode != http.StatusOK { slog.Warn("forge refused the download", "status", file.StatusCode) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) return nil } if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(assetWriteTimeout)); err != nil { slog.Warn("asset write deadline not extended", "error", err) } h := w.Header() h.Set("Content-Type", "application/octet-stream") h.Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(asset.Name, `"`, "")+`"`) h.Set("Cache-Control", "no-store") if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes { h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10)) } w.WriteHeader(http.StatusOK) if _, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes)); err != nil { slog.Warn("asset transfer cut short", "asset", asset.Name, "error", err) } return nil }