265 lines
7.9 KiB
Go
265 lines
7.9 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
|