The PublicLatestAPK endpoint now reads the expoConfig.json from the OTA bundle to extract the actual semantic version (expo.version) instead of returning the runtimeVersion as the version name. Falls back to runtimeVersion if expoConfig.json is not available.
392 lines
12 KiB
Go
392 lines
12 KiB
Go
package handlers
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"expo-updates-server-go/internal/model"
|
||
"expo-updates-server-go/internal/service"
|
||
"expo-updates-server-go/internal/storage"
|
||
)
|
||
|
||
type Handler struct {
|
||
svc *service.UpdateService
|
||
adminToken string
|
||
}
|
||
|
||
func NewHandler(svc *service.UpdateService, adminToken string) *Handler {
|
||
return &Handler{svc: svc, adminToken: adminToken}
|
||
}
|
||
|
||
func (h *Handler) Healthz(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("ok"))
|
||
}
|
||
|
||
func (h *Handler) Manifest(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||
return
|
||
}
|
||
|
||
protocolVersion := 0
|
||
if p := r.Header.Get("expo-protocol-version"); p != "" {
|
||
v, err := strconv.Atoi(p)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Unsupported protocol version. Expected either 0 or 1."})
|
||
return
|
||
}
|
||
protocolVersion = v
|
||
}
|
||
platform := firstNonEmpty(r.Header.Get("expo-platform"), r.URL.Query().Get("platform"))
|
||
if platform != "ios" && platform != "android" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Unsupported platform. Expected either ios or android."})
|
||
return
|
||
}
|
||
runtimeVersion := firstNonEmpty(r.Header.Get("expo-runtime-version"), r.URL.Query().Get("runtime-version"))
|
||
if runtimeVersion == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "No runtimeVersion provided."})
|
||
return
|
||
}
|
||
|
||
resp, err := h.svc.BuildManifestResponse(service.ManifestRequest{
|
||
Platform: platform,
|
||
RuntimeVersion: runtimeVersion,
|
||
ProtocolVersion: protocolVersion,
|
||
CurrentUpdateID: r.Header.Get("expo-current-update-id"),
|
||
EmbeddedUpdateID: r.Header.Get("expo-embedded-update-id"),
|
||
ExpectSignature: r.Header.Get("expo-expect-signature") != "",
|
||
})
|
||
if err != nil {
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
|
||
body, contentType, err := service.BuildMultipartMixed(resp.Parts)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
w.Header().Set("expo-protocol-version", strconv.Itoa(protocolVersion))
|
||
if hasDirective(resp.Parts) {
|
||
w.Header().Set("expo-protocol-version", "1")
|
||
}
|
||
w.Header().Set("expo-sfv-version", "0")
|
||
w.Header().Set("cache-control", "private, max-age=0")
|
||
w.Header().Set("content-type", contentType)
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(body)
|
||
}
|
||
|
||
func (h *Handler) Asset(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
assetName := q.Get("asset")
|
||
runtimeVersion := q.Get("runtimeVersion")
|
||
platform := q.Get("platform")
|
||
|
||
if assetName == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "No asset name provided."})
|
||
return
|
||
}
|
||
if platform != "ios" && platform != "android" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "No platform provided. Expected \"ios\" or \"android\"."})
|
||
return
|
||
}
|
||
if runtimeVersion == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "No runtimeVersion provided."})
|
||
return
|
||
}
|
||
|
||
data, ct, err := h.svc.AssetContent(runtimeVersion, platform, assetName)
|
||
if err != nil {
|
||
if errors.Is(err, storage.ErrUnsupportedRuntime) {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
if strings.Contains(err.Error(), "no such file") {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{"error": fmt.Sprintf("Asset \"%s\" does not exist.", assetName)})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
w.Header().Set("content-type", ct)
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(data)
|
||
}
|
||
|
||
func (h *Handler) AdminPublish(w http.ResponseWriter, r *http.Request) {
|
||
if !h.authorizeAdmin(w, r) {
|
||
return
|
||
}
|
||
if r.Method != http.MethodPost {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected POST."})
|
||
return
|
||
}
|
||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||
platform := r.URL.Query().Get("platform")
|
||
if runtimeVersion == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||
return
|
||
}
|
||
if platform != "ios" && platform != "android" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "platform is required and must be ios or android"})
|
||
return
|
||
}
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
p, err := h.svc.PublishZip(runtimeVersion, platform, bytes.NewReader(body), int64(len(body)))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"runtimeVersion": runtimeVersion,
|
||
"releaseId": platform,
|
||
"platform": platform,
|
||
"path": p,
|
||
})
|
||
}
|
||
|
||
func (h *Handler) AdminRollback(w http.ResponseWriter, r *http.Request) {
|
||
if !h.authorizeAdmin(w, r) {
|
||
return
|
||
}
|
||
if r.Method != http.MethodPost {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected POST."})
|
||
return
|
||
}
|
||
var payload struct {
|
||
RuntimeVersion string `json:"runtimeVersion"`
|
||
Platform string `json:"platform"`
|
||
ReleaseID string `json:"releaseId"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
|
||
return
|
||
}
|
||
platform := payload.Platform
|
||
if platform == "" {
|
||
// Backward compatibility: accept releaseId as platform name.
|
||
platform = payload.ReleaseID
|
||
}
|
||
if payload.RuntimeVersion == "" || (platform != "ios" && platform != "android") {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion and platform(ios|android) are required"})
|
||
return
|
||
}
|
||
path, err := h.svc.CreateRollback(payload.RuntimeVersion, platform)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"rollbackMarker": filepath.Clean(path), "platform": platform})
|
||
}
|
||
|
||
func (h *Handler) AdminReleases(w http.ResponseWriter, r *http.Request) {
|
||
if !h.authorizeAdmin(w, r) {
|
||
return
|
||
}
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||
return
|
||
}
|
||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||
if runtimeVersion == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||
return
|
||
}
|
||
releases, err := h.svc.ListReleases(runtimeVersion)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"releases": releases})
|
||
}
|
||
|
||
func (h *Handler) AdminUploadAPK(w http.ResponseWriter, r *http.Request) {
|
||
if !h.authorizeAdmin(w, r) {
|
||
return
|
||
}
|
||
if r.Method != http.MethodPost {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected POST."})
|
||
return
|
||
}
|
||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||
if runtimeVersion == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||
return
|
||
}
|
||
contentType := r.Header.Get("Content-Type")
|
||
if !strings.HasPrefix(contentType, "application/vnd.android.package-archive") &&
|
||
!strings.HasPrefix(contentType, "application/octet-stream") {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Content-Type must be application/vnd.android.package-archive or application/octet-stream"})
|
||
return
|
||
}
|
||
maxSize := int64(500 * 1024 * 1024)
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
||
path, err := h.svc.SaveAPK(runtimeVersion, r.Body)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"runtimeVersion": runtimeVersion,
|
||
"path": path,
|
||
"message": "APK uploaded successfully",
|
||
})
|
||
}
|
||
|
||
func (h *Handler) AdminDownloadAPK(w http.ResponseWriter, r *http.Request) {
|
||
if !h.authorizeAdmin(w, r) {
|
||
return
|
||
}
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||
return
|
||
}
|
||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||
if runtimeVersion == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||
return
|
||
}
|
||
apkPath, err := h.svc.GetAPKPath(runtimeVersion)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{"error": fmt.Sprintf("APK for runtimeVersion %s not found", runtimeVersion)})
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.apk", runtimeVersion))
|
||
http.ServeFile(w, r, apkPath)
|
||
}
|
||
|
||
func (h *Handler) AdminListAPKs(w http.ResponseWriter, r *http.Request) {
|
||
if !h.authorizeAdmin(w, r) {
|
||
return
|
||
}
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||
return
|
||
}
|
||
apks, err := h.svc.ListAPKs()
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"apks": apks})
|
||
}
|
||
|
||
func (h *Handler) PublicLatestAPK(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||
return
|
||
}
|
||
apk, err := h.svc.GetLatestAPK()
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
if apk == nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "No APK available"})
|
||
return
|
||
}
|
||
|
||
// versionName 优先从同 runtimeVersion 的 OTA bundle 里读取 expoConfig.json
|
||
// (CI 在 publish OTA 时会把 expoConfig.json 打入 zip)
|
||
versionName := h.svc.GetAPKVersionName(apk.RuntimeVersion)
|
||
if versionName == "" {
|
||
// 兜底:没有 expoConfig 时退回 runtimeVersion(不应该发生,但保底)
|
||
versionName = apk.RuntimeVersion
|
||
}
|
||
|
||
resp := model.LatestAPKResponse{
|
||
RuntimeVersion: apk.RuntimeVersion,
|
||
VersionName: versionName,
|
||
Size: apk.Size,
|
||
DownloadURL: fmt.Sprintf("%s/api/apk/download?runtimeVersion=%s", h.svc.Hostname(), apk.RuntimeVersion),
|
||
CreatedAt: apk.CreatedAt.Format(time.RFC3339),
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (h *Handler) PublicDownloadAPK(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||
return
|
||
}
|
||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||
if runtimeVersion == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||
return
|
||
}
|
||
apkPath, err := h.svc.GetAPKPath(runtimeVersion)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{"error": fmt.Sprintf("APK for runtimeVersion %s not found", runtimeVersion)})
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=carrot-bbs-%s.apk", runtimeVersion))
|
||
http.ServeFile(w, r, apkPath)
|
||
}
|
||
|
||
func (h *Handler) authorizeAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||
if h.adminToken == "" {
|
||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "ADMIN_TOKEN is not configured"})
|
||
return false
|
||
}
|
||
authHeader := r.Header.Get("Authorization")
|
||
expected := "Bearer " + h.adminToken
|
||
if authHeader != expected {
|
||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func writeServiceError(w http.ResponseWriter, err error) {
|
||
if errors.Is(err, storage.ErrUnsupportedRuntime) {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
if strings.Contains(err.Error(), "Code signing requested but no key supplied") {
|
||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
|
||
}
|
||
|
||
func hasDirective(parts []service.MultipartPart) bool {
|
||
for _, p := range parts {
|
||
if p.Name == "directive" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, data any) {
|
||
w.Header().Set("content-type", "application/json")
|
||
w.WriteHeader(code)
|
||
_ = json.NewEncoder(w).Encode(data)
|
||
}
|
||
|