150 lines
5.0 KiB
Go
150 lines
5.0 KiB
Go
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))
|
||
|
|
}
|