package handlers import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "path/filepath" "strconv" "strings" "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) 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 map[string]any) { w.Header().Set("content-type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(data) }