package api import ( "crypto/subtle" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/ponzischeme89/memby/server/internal/appupdate" ) const maxReleaseSize = 250 << 20 var ( releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) releaseFilenamePattern = regexp.MustCompile(`^memby-\d+\.\d+\.\d+\.apk$`) ) // releasePublishAuth is deliberately separate from adminAuth: CI can publish an APK but // cannot take the service offline, force an update, or read household analytics. func (s *Server) releasePublishAuth(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.cfg.ReleasePublishToken == "" { http.NotFound(w, r) return } presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.ReleasePublishToken)) != 1 { writeError(w, http.StatusUnauthorized, "invalid release token") return } h(w, r) }) } // handleReleasePublish accepts the signed APK produced by Gitea Actions, persists it, // and atomically makes it the version offered to TVs. func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxReleaseSize) if err := r.ParseMultipartForm(16 << 20); err != nil { writeError(w, http.StatusBadRequest, "invalid release upload") return } version := strings.TrimSpace(r.FormValue("version")) if !releaseVersionPattern.MatchString(version) { writeError(w, http.StatusBadRequest, "version must look like 0.1.54") return } current := s.updatePolicy.get() if current.LatestVersion != "" && appupdate.CompareVersions(version, current.LatestVersion) < 0 { writeError(w, http.StatusConflict, "refusing to publish an older version") return } source, _, err := r.FormFile("apk") if err != nil { writeError(w, http.StatusBadRequest, "signed APK is required") return } defer source.Close() if err := os.MkdirAll(s.cfg.ReleaseDir, 0o750); err != nil { s.log.Error("release directory unavailable", "error", err) writeError(w, http.StatusInternalServerError, "release storage unavailable") return } temp, err := os.CreateTemp(s.cfg.ReleaseDir, ".memby-upload-*") if err != nil { s.log.Error("release temp file failed", "error", err) writeError(w, http.StatusInternalServerError, "release storage unavailable") return } tempName := temp.Name() defer os.Remove(tempName) written, copyErr := io.Copy(temp, source) closeErr := temp.Close() if copyErr != nil || closeErr != nil || written < 4 { writeError(w, http.StatusBadRequest, "could not store APK") return } // APKs are ZIP archives. This catches accidentally uploaded logs or HTML error pages // before they become an update every TV is invited to install. stored, err := os.Open(tempName) if err != nil { writeError(w, http.StatusInternalServerError, "could not verify APK") return } var magic [4]byte _, readErr := io.ReadFull(stored, magic[:]) stored.Close() if readErr != nil || string(magic[:2]) != "PK" { writeError(w, http.StatusBadRequest, "uploaded file is not an APK") return } filename := fmt.Sprintf("memby-%s.apk", version) destination := filepath.Join(s.cfg.ReleaseDir, filename) if err := os.Rename(tempName, destination); err != nil { s.log.Error("release publish rename failed", "error", err) writeError(w, http.StatusInternalServerError, "could not publish APK") return } if err := os.Chmod(destination, 0o640); err != nil { s.log.Warn("release permissions could not be tightened", "error", err) } policy := appupdate.Policy{ Enabled: true, LatestVersion: version, MinimumVersion: current.MinimumVersion, DownloadURL: s.cfg.PublicURL + "/updates/" + filename, Notes: strings.TrimSpace(r.FormValue("notes")), } if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil { s.log.Error("release policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved") return } if err := s.LoadUpdatePolicy(r.Context()); err != nil { s.log.Error("release policy reload failed", "error", err) writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded") return } s.log.Info("release published", "version", version, "bytes", written, "file", filename) writeJSON(w, http.StatusCreated, s.updatePolicy.get()) } // handleReleaseDownload serves immutable, signed APKs. They carry no household secrets, // so downloads do not need a TV session and continue working through Android's installer. func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) { filename := r.PathValue("filename") if !releaseFilenamePattern.MatchString(filename) { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/vnd.android.package-archive") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename)) }