2026-07-29 15:26:40 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-02 22:10:19 +12:00
|
|
|
"archive/zip"
|
|
|
|
|
"crypto/sha256"
|
2026-07-29 15:26:40 +12:00
|
|
|
"crypto/subtle"
|
2026-08-02 22:10:19 +12:00
|
|
|
_ "embed"
|
|
|
|
|
"encoding/hex"
|
2026-07-29 15:26:40 +12:00
|
|
|
"fmt"
|
2026-08-02 22:10:19 +12:00
|
|
|
"html/template"
|
2026-07-29 15:26:40 +12:00
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const maxReleaseSize = 250 << 20
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
//go:embed install.html
|
|
|
|
|
var installPageSource string
|
|
|
|
|
|
|
|
|
|
var installPage = template.Must(template.New("install").Parse(installPageSource))
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
mandatory, validMandatory := parseMandatoryRelease(r.FormValue("mandatory"))
|
|
|
|
|
if !validMandatory {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "mandatory must be true or false")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
|
|
|
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 {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("release directory unavailable", "error", err)
|
2026-07-29 15:26:40 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "release storage unavailable")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
temp, err := os.CreateTemp(s.cfg.ReleaseDir, ".memby-upload-*")
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("release temp file failed", "error", err)
|
2026-07-29 15:26:40 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "release storage unavailable")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tempName := temp.Name()
|
|
|
|
|
defer os.Remove(tempName)
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
digest := sha256.New()
|
|
|
|
|
written, copyErr := io.Copy(io.MultiWriter(temp, digest), source)
|
|
|
|
|
syncErr := temp.Sync()
|
2026-07-29 15:26:40 +12:00
|
|
|
closeErr := temp.Close()
|
|
|
|
|
if copyErr != nil || closeErr != nil || written < 4 {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "could not store APK")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if syncErr != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not safely store APK")
|
2026-07-29 15:26:40 +12:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
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 {
|
2026-07-29 15:26:40 +12:00
|
|
|
writeError(w, http.StatusBadRequest, "uploaded file is not an APK")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
|
|
|
filename := fmt.Sprintf("memby-%s.apk", version)
|
|
|
|
|
destination := filepath.Join(s.cfg.ReleaseDir, filename)
|
2026-08-02 22:10:19 +12:00
|
|
|
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) {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("existing release could not be verified", "error", hashErr)
|
2026-08-02 22:10:19 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not verify existing release")
|
2026-07-29 15:26:40 +12:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if newFile {
|
|
|
|
|
if err := os.Rename(tempName, destination); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("release publish rename failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not publish APK")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
if err := os.Chmod(destination, 0o640); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("release permissions could not be tightened", "error", err)
|
2026-07-29 15:26:40 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
policy := appupdate.Policy{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
LatestVersion: version,
|
|
|
|
|
MinimumVersion: current.MinimumVersion,
|
2026-08-02 22:10:19 +12:00
|
|
|
DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename),
|
|
|
|
|
SHA256: actualSHA256,
|
|
|
|
|
SizeBytes: written,
|
2026-07-29 15:26:40 +12:00
|
|
|
Notes: strings.TrimSpace(r.FormValue("notes")),
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
|
2026-08-02 22:10:19 +12:00
|
|
|
if newFile {
|
|
|
|
|
_ = os.Remove(destination)
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("release policy write failed", "error", err)
|
2026-07-29 15:26:40 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("release policy reload failed", "error", err)
|
2026-07-29 15:26:40 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Info("release published", "version", version, "mandatory", mandatory,
|
2026-08-02 22:10:19 +12:00
|
|
|
"bytes", written, "file", filename)
|
2026-07-29 15:26:40 +12:00
|
|
|
writeJSON(w, http.StatusCreated, s.updatePolicy.get())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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 {
|
2026-07-29 15:26:40 +12:00
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
2026-07-29 15:26:40 +12:00
|
|
|
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
2026-08-02 22:10:19 +12:00
|
|
|
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")
|
2026-07-29 15:26:40 +12:00
|
|
|
http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
|
|
|
|
|
}
|