Files
updates/internal/handlers/handlers.go
lafay 7fa2431a4a
All checks were successful
Build Updates Server / build (push) Successful in 1m1s
Build Updates Server / build-docker (push) Successful in 36m50s
feat(api): enhance public APK endpoint to return semantic version from OTA bundle
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.
2026-06-17 20:06:07 +08:00

392 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}