package api import ( "archive/zip" "crypto/sha256" "crypto/subtle" _ "embed" "encoding/hex" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/ponzischeme89/memby/server/internal/appupdate" ) const maxReleaseSize = 250 << 20 //go:embed install.html var installPageSource string var installPage = template.Must(template.New("install").Parse(installPageSource)) 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 } mandatory, validMandatory := parseMandatoryRelease(r.FormValue("mandatory")) if !validMandatory { writeError(w, http.StatusBadRequest, "mandatory must be true or false") 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.loggerFor(r.Context()).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.loggerFor(r.Context()).Error("release temp file failed", "error", err) writeError(w, http.StatusInternalServerError, "release storage unavailable") return } tempName := temp.Name() defer os.Remove(tempName) digest := sha256.New() written, copyErr := io.Copy(io.MultiWriter(temp, digest), source) syncErr := temp.Sync() closeErr := temp.Close() if copyErr != nil || closeErr != nil || written < 4 { writeError(w, http.StatusBadRequest, "could not store APK") return } if syncErr != nil { writeError(w, http.StatusInternalServerError, "could not safely store APK") return } actualSHA256 := hex.EncodeToString(digest.Sum(nil)) if expected := strings.ToLower(strings.TrimSpace(r.FormValue("sha256"))); expected != "" && (expected != actualSHA256 || len(expected) != sha256.Size*2) { writeError(w, http.StatusBadRequest, "APK checksum does not match") return } // Parse the archive, rather than checking only its first two bytes. AndroidManifest.xml // is compulsory in an APK; this rejects truncated ZIPs and renamed logs/HTML. archive, err := zip.OpenReader(tempName) if err != nil { writeError(w, http.StatusBadRequest, "uploaded file is not an APK") return } hasManifest := false for _, entry := range archive.File { if entry.Name == "AndroidManifest.xml" { hasManifest = true break } } archive.Close() if !hasManifest { writeError(w, http.StatusBadRequest, "uploaded APK has no Android manifest") return } filename := fmt.Sprintf("memby-%s.apk", version) destination := filepath.Join(s.cfg.ReleaseDir, filename) newFile := true if existingSHA256, hashErr := fileSHA256(destination); hashErr == nil { if existingSHA256 != actualSHA256 { writeError(w, http.StatusConflict, "that version already exists with different APK contents; publish a new version") return } newFile = false } else if !os.IsNotExist(hashErr) { s.loggerFor(r.Context()).Error("existing release could not be verified", "error", hashErr) writeError(w, http.StatusInternalServerError, "could not verify existing release") return } if newFile { if err := os.Rename(tempName, destination); err != nil { s.loggerFor(r.Context()).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.loggerFor(r.Context()).Warn("release permissions could not be tightened", "error", err) } policy := appupdate.Policy{ Enabled: true, LatestVersion: version, MinimumVersion: current.MinimumVersion, DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename), SHA256: actualSHA256, SizeBytes: written, Notes: strings.TrimSpace(r.FormValue("notes")), } if mandatory { // Setting the floor to the release being published makes every older client // receive a mandatory verdict, which has no dismiss/skip path on the TV. policy.MinimumVersion = version } if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil { if newFile { _ = os.Remove(destination) } s.loggerFor(r.Context()).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.loggerFor(r.Context()).Error("release policy reload failed", "error", err) writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded") return } s.loggerFor(r.Context()).Info("release published", "version", version, "mandatory", mandatory, "bytes", written, "file", filename) writeJSON(w, http.StatusCreated, s.updatePolicy.get()) } func parseMandatoryRelease(value string) (mandatory, valid bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "0", "false": return false, true case "1", "true": return true, true default: return false, false } } func fileSHA256(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer file.Close() digest := sha256.New() if _, err := io.Copy(digest, file); err != nil { return "", err } return hex.EncodeToString(digest.Sum(nil)), nil } type installPageData struct { Authenticated bool Ready bool Version string Notes string DownloadURL string Size string Error string LoginNext string } func preventDiscovery(w http.ResponseWriter) { // These cover general search engines, crawler-specific implementations and caches. // They are intentionally also applied to APK responses so a discovered download URL // does not appear as a searchable binary result. w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet, noimageindex") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") } func handleRobots(w http.ResponseWriter, _ *http.Request) { preventDiscovery(w) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = io.WriteString(w, "User-agent: *\nDisallow: /\n") } // handleInstallPage is deliberately public: it is the bootstrap path for a television // that does not have Memby yet. It exposes only the signed APK and operator-authored // release notes, never household or Emby data. func (s *Server) handleInstallPage(w http.ResponseWriter, r *http.Request) { s.renderInstallPage(w, r, "", http.StatusOK) } func (s *Server) renderInstallPage( w http.ResponseWriter, r *http.Request, message string, status int, ) { if len(s.installerSecret()) == 0 { http.NotFound(w, r) return } authenticated := s.validInstallerSession(r) if !authenticated { s.renderAccessLogin(w, r, message, status, "/install") return } policy := s.updatePolicy.get() version := strings.TrimSpace(policy.LatestVersion) filename := fmt.Sprintf("memby-%s.apk", version) info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename)) ready := authenticated && releaseVersionPattern.MatchString(version) && err == nil && !info.IsDir() data := installPageData{ Authenticated: true, Ready: ready, Version: version, Notes: strings.TrimSpace(policy.Notes), Error: message, } if ready { data.DownloadURL = "/updates/latest.apk" data.Size = fmt.Sprintf("%.1f MB", float64(info.Size())/(1024*1024)) } s.writeInstallPage(w, data, status) } func (s *Server) renderAccessLogin( w http.ResponseWriter, r *http.Request, message string, status int, next string, ) { if len(s.installerSecret()) == 0 { http.NotFound(w, r) return } s.writeInstallPage(w, installPageData{ Error: message, LoginNext: cleanInstallerDestination(next), }, status) } func (s *Server) writeInstallPage(w http.ResponseWriter, data installPageData, status int) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "+ "base-uri 'none'; frame-ancestors 'none'") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()") preventDiscovery(w) if status == http.StatusOK && data.Authenticated && !data.Ready { status = http.StatusServiceUnavailable } if status != http.StatusOK { w.WriteHeader(status) } if err := installPage.Execute(w, data); err != nil && s.log != nil { s.log.Error("install page render failed", "error", err) } } // handleLatestReleaseDownload gives first-time installers a stable address. Serve the // package directly: some Android TV downloaders hand both sides of an HTTP redirect to // the package installer, causing a successful install followed by a spurious parse error. func (s *Server) handleLatestReleaseDownload(w http.ResponseWriter, r *http.Request) { if !s.validInstallerSession(r) { http.NotFound(w, r) return } version := strings.TrimSpace(s.updatePolicy.get().LatestVersion) if !releaseVersionPattern.MatchString(version) { http.NotFound(w, r) return } filename := fmt.Sprintf("memby-%s.apk", version) if info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename)); err != nil || info.IsDir() { http.NotFound(w, r) return } preventDiscovery(w) w.Header().Set("Content-Type", "application/vnd.android.package-archive") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) w.Header().Set("Cache-Control", "private, no-store") http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename)) } // handleReleaseDownload serves immutable, signed APKs. Access requires either a short // browser installer session or the signed release URL returned to an authenticated app. func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) { filename := r.PathValue("filename") if !releaseFilenamePattern.MatchString(filename) || !s.allowedReleaseDownload(r, filename) { // A 404 does not confirm whether a guessed version exists. http.NotFound(w, r) return } preventDiscovery(w) w.Header().Set("Content-Type", "application/vnd.android.package-archive") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) w.Header().Set("Cache-Control", "private, no-store") http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename)) }