Initial updates server repository commit.
Reinitialize repository history and exclude generated OTA artifact outputs. Made-with: Cursor
This commit is contained in:
326
internal/handlers/compat_test.go
Normal file
326
internal/handlers/compat_test.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"expo-updates-server-go/internal/app"
|
||||
)
|
||||
|
||||
func writeTestPrivateKey(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate rsa key: %v", err)
|
||||
}
|
||||
privateKeyPath := filepath.Join(root, "privatekey.pem")
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(privateKeyPath, pemBytes, 0o600); err != nil {
|
||||
t.Fatalf("failed to write private key: %v", err)
|
||||
}
|
||||
return privateKeyPath
|
||||
}
|
||||
|
||||
func writeTestRelease(t *testing.T, updatesRoot string, platform string) {
|
||||
t.Helper()
|
||||
releaseDir := filepath.Join(updatesRoot, "test", platform)
|
||||
if err := os.MkdirAll(filepath.Join(releaseDir, "assets"), 0o755); err != nil {
|
||||
t.Fatalf("failed to create assets dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(releaseDir, "bundles"), 0o755); err != nil {
|
||||
t.Fatalf("failed to create bundles dir: %v", err)
|
||||
}
|
||||
metadata := `{
|
||||
"version": 0,
|
||||
"fileMetadata": {
|
||||
"ios": {
|
||||
"bundle": "bundles/ios-9d01842d6ee1224f7188971c5d397115.js",
|
||||
"assets": [
|
||||
{
|
||||
"path": "assets/4f1cb2cac2370cd5050681232e8575a8",
|
||||
"ext": "png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"bundle": "bundles/ios-9d01842d6ee1224f7188971c5d397115.js",
|
||||
"assets": [
|
||||
{
|
||||
"path": "assets/4f1cb2cac2370cd5050681232e8575a8",
|
||||
"ext": "png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(releaseDir, "metadata.json"), []byte(metadata), 0o644); err != nil {
|
||||
t.Fatalf("failed to write metadata: %v", err)
|
||||
}
|
||||
expoConfig := `{"name":"compat-test","slug":"compat-test"}`
|
||||
if err := os.WriteFile(filepath.Join(releaseDir, "expoConfig.json"), []byte(expoConfig), 0o644); err != nil {
|
||||
t.Fatalf("failed to write expoConfig: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(releaseDir, "assets", "4f1cb2cac2370cd5050681232e8575a8"),
|
||||
[]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A},
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to write test png asset: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(releaseDir, "bundles", "ios-9d01842d6ee1224f7188971c5d397115.js"),
|
||||
[]byte("console.log('compat test bundle');"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to write test js bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRollbackRelease(t *testing.T, updatesRoot string, platform string) {
|
||||
t.Helper()
|
||||
releaseDir := filepath.Join(updatesRoot, "testrollback", platform)
|
||||
if err := os.MkdirAll(releaseDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create rollback release dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(releaseDir, "rollback"), []byte{}, 0o644); err != nil {
|
||||
t.Fatalf("failed to write rollback marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestFixtures(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
tmpRoot := t.TempDir()
|
||||
updatesRoot := filepath.Join(tmpRoot, "updates")
|
||||
if err := os.MkdirAll(updatesRoot, 0o755); err != nil {
|
||||
t.Fatalf("failed to create updates root: %v", err)
|
||||
}
|
||||
writeTestRelease(t, updatesRoot, "ios")
|
||||
writeTestRelease(t, updatesRoot, "android")
|
||||
writeRollbackRelease(t, updatesRoot, "ios")
|
||||
writeRollbackRelease(t, updatesRoot, "android")
|
||||
privateKeyPath := writeTestPrivateKey(t, tmpRoot)
|
||||
return updatesRoot, privateKeyPath
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
updatesRoot, privateKeyPath := setupTestFixtures(t)
|
||||
|
||||
cfg := app.Config{
|
||||
Port: "0",
|
||||
Hostname: "http://localhost:3001",
|
||||
UpdatesRoot: updatesRoot,
|
||||
PrivateKey: privateKeyPath,
|
||||
AdminToken: "dev-token",
|
||||
}
|
||||
return app.NewServer(cfg).Routes(), updatesRoot
|
||||
}
|
||||
|
||||
func doReq(t *testing.T, h http.Handler, method string, target string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func mustBodyString(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
b, err := io.ReadAll(rec.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestManifestReturns405WithPOST(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
rec := doReq(t, h, http.MethodPost, "/api/manifest", nil)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestReturns400WithUnsupportedPlatform(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{
|
||||
"expo-platform": "unsupported-platform",
|
||||
"expo-runtime-version": "test",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestReturns404WithUnknownRuntimeVersion(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{
|
||||
"expo-platform": "ios",
|
||||
"expo-runtime-version": "999",
|
||||
})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestReturnsLatestIOSAndAndroid(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
|
||||
cases := []string{"ios", "android"}
|
||||
for _, platform := range cases {
|
||||
for _, protocol := range []string{"0", "1"} {
|
||||
rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{
|
||||
"expo-platform": platform,
|
||||
"expo-runtime-version": "test",
|
||||
"expo-protocol-version": protocol,
|
||||
"expo-expect-signature": "true",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d for platform=%s protocol=%s", rec.Code, platform, protocol)
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("content-type")
|
||||
if !strings.HasPrefix(contentType, "multipart/mixed; boundary=") {
|
||||
t.Fatalf("unexpected content-type: %s", contentType)
|
||||
}
|
||||
|
||||
body := mustBodyString(t, rec)
|
||||
if !strings.Contains(body, "name=\"manifest\"") {
|
||||
t.Fatalf("manifest part missing for platform=%s protocol=%s", platform, protocol)
|
||||
}
|
||||
if !strings.Contains(body, "\"runtimeVersion\":\"test\"") {
|
||||
t.Fatalf("runtimeVersion mismatch for platform=%s protocol=%s", platform, protocol)
|
||||
}
|
||||
if !strings.Contains(body, "expo-signature:") {
|
||||
t.Fatalf("signature missing for platform=%s protocol=%s", platform, protocol)
|
||||
}
|
||||
if !strings.Contains(body, "\"launchAsset\"") || !strings.Contains(body, "\"assets\"") {
|
||||
t.Fatalf("assets payload missing for platform=%s protocol=%s", platform, protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestReturnsRollbackForProtocol1(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
for _, platform := range []string{"ios", "android"} {
|
||||
rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{
|
||||
"expo-platform": platform,
|
||||
"expo-runtime-version": "testrollback",
|
||||
"expo-protocol-version": "1",
|
||||
"expo-expect-signature": "true",
|
||||
"expo-embedded-update-id": "123",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d for platform=%s", rec.Code, platform)
|
||||
}
|
||||
body := mustBodyString(t, rec)
|
||||
if !strings.Contains(body, "name=\"directive\"") || !strings.Contains(body, "rollBackToEmbedded") {
|
||||
t.Fatalf("rollback directive missing for platform=%s", platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestRollbackWithProtocol0Returns404(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
for _, platform := range []string{"ios", "android"} {
|
||||
rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{
|
||||
"expo-platform": platform,
|
||||
"expo-runtime-version": "testrollback",
|
||||
"expo-expect-signature": "true",
|
||||
"expo-embedded-update-id": "123",
|
||||
})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d for platform=%s", rec.Code, platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsReturnsAssetAndLaunchAssetFile(t *testing.T) {
|
||||
h, updatesRoot := newTestServer(t)
|
||||
assetPath := filepath.Join(updatesRoot, "test", "ios", "assets", "4f1cb2cac2370cd5050681232e8575a8")
|
||||
launchPath := filepath.Join(updatesRoot, "test", "ios", "bundles", "ios-9d01842d6ee1224f7188971c5d397115.js")
|
||||
|
||||
assetReq := doReq(
|
||||
t,
|
||||
h,
|
||||
http.MethodGet,
|
||||
"/api/assets?runtimeVersion=test&asset="+assetPath+"&platform=ios",
|
||||
nil,
|
||||
)
|
||||
if assetReq.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d for image asset", assetReq.Code)
|
||||
}
|
||||
if assetReq.Header().Get("content-type") != "image/png" {
|
||||
t.Fatalf("expected image/png, got %s", assetReq.Header().Get("content-type"))
|
||||
}
|
||||
|
||||
launchReq := doReq(
|
||||
t,
|
||||
h,
|
||||
http.MethodGet,
|
||||
"/api/assets?runtimeVersion=test&asset="+launchPath+"&platform=ios",
|
||||
nil,
|
||||
)
|
||||
if launchReq.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d for launch asset", launchReq.Code)
|
||||
}
|
||||
if launchReq.Header().Get("content-type") != "application/javascript" {
|
||||
t.Fatalf("expected application/javascript, got %s", launchReq.Header().Get("content-type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsErrorCases(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
code int
|
||||
}{
|
||||
{
|
||||
name: "no asset",
|
||||
target: "/api/assets?runtimeVersion=test&platform=ios",
|
||||
code: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "no runtime version",
|
||||
target: "/api/assets?asset=updates/1/assets/does-not-exist.png&platform=ios",
|
||||
code: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "no platform",
|
||||
target: "/api/assets?asset=updates/1/assets/does-not-exist.png&runtimeVersion=test",
|
||||
code: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "asset not exists",
|
||||
target: "/api/assets?asset=updates/1/assets/does-not-exist.png&runtimeVersion=test&platform=ios",
|
||||
code: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
rec := doReq(t, h, http.MethodGet, c.target, nil)
|
||||
if rec.Code != c.code {
|
||||
t.Fatalf("%s: expected %d, got %d", c.name, c.code, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
264
internal/handlers/handlers.go
Normal file
264
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,264 @@
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user