commit a0ef7f430db738413de5bd12fba46b7247cc31a4 Author: lan Date: Mon Mar 9 21:33:34 2026 +0800 Initial updates server repository commit. Reinitialize repository history and exclude generated OTA artifact outputs. Made-with: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96b4b47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Build artifacts +server +*.tar +updates/ + +# Local environment files +.env +.env.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..86515fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# 使用Debian bookworm-slim作为生产镜像 +FROM debian:bookworm-slim + +# 安装必要的运行时依赖 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# 设置工作目录 +WORKDIR /app + +# 从宿主机复制编译好的二进制文件 +COPY server ./expo-updates-server + +# 给可执行文件添加执行权限 +RUN chmod +x ./expo-updates-server + +# 暴露端口 +EXPOSE 3001 + +# 默认命令 +CMD ["./expo-updates-server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..25f721b --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Expo Updates Server (Go) + +这个目录是 `expo-updates-server` 的 Go 版本实现,目标是: + +- 保持客户端接口兼容:`GET /api/manifest`、`GET /api/assets` +- 增加便于上传和 CI 的管理接口:`/admin/publish`、`/admin/rollback`、`/admin/releases` + +## 运行 + +```bash +cd expo-updates-server-go +go run ./cmd/server +``` + +默认监听 `:3001`,可通过环境变量覆盖: + +- `PORT`:默认 `3001` +- `HOSTNAME`:默认 `http://localhost:3001`,用于拼接 assets URL +- `UPDATES_ROOT`:默认 `updates` +- `PRIVATE_KEY_PATH`:可选;若客户端请求 `expo-expect-signature`,必须配置 +- `ADMIN_TOKEN`:管理接口 Bearer Token + +示例(复用现有 `expo-updates-server/updates`): + +```bash +cd expo-updates-server-go +PORT=3001 \ +HOSTNAME=http://localhost:3001 \ +UPDATES_ROOT=../expo-updates-server/updates \ +PRIVATE_KEY_PATH=../expo-updates-server/code-signing-keys/private-key.pem \ +ADMIN_TOKEN=dev-token \ +go run ./cmd/server +``` + +生产环境建议将 `HOSTNAME` 配置为: + +```text +https://updates.littlelan.cn +``` + +## 兼容接口 + +- `GET /api/manifest` + - 支持 `expo-platform`、`expo-runtime-version`、`expo-protocol-version` + - 支持 `rollBackToEmbedded`、`noUpdateAvailable` + - 支持 `expo-expect-signature` +- `GET /api/assets?asset=...&runtimeVersion=...&platform=...` + +## 管理接口(上传/CI) + +### 1) 发布 zip + +`POST /admin/publish?runtimeVersion=&platform=` + +- Header:`Authorization: Bearer ` +- Body:`expo export` 产物 zip(zip 根目录需包含 `metadata.json`) + +```bash +curl -X POST \ + "https://updates.littlelan.cn/admin/publish?runtimeVersion=2&platform=ios" \ + -H "Authorization: Bearer dev-token" \ + -H "Content-Type: application/zip" \ + --data-binary @dist.zip +``` + +### 2) 创建回滚标记 + +`POST /admin/rollback` + +```bash +curl -X POST "https://updates.littlelan.cn/admin/rollback" \ + -H "Authorization: Bearer dev-token" \ + -H "Content-Type: application/json" \ + -d '{"runtimeVersion":"2","platform":"ios"}' +``` + +### 3) 查看发布列表 + +`GET /admin/releases?runtimeVersion=2` + +```bash +curl "https://updates.littlelan.cn/admin/releases?runtimeVersion=2" \ + -H "Authorization: Bearer dev-token" +``` + +## CI 示例思路 + +1. 在客户端跑 `npx expo export` +2. 打包 `dist` 为 zip +3. 调用 `/admin/publish` + +可直接参考根目录新增工作流:`.github/workflows/go-updates-server-ci.yml` + +## Docker 打包与运行 + +### 1) 构建镜像并导出 tar + +在 `expo-updates-server-go` 目录执行: + +```bash +./build-docker-tar.sh +``` + +可指定 tag: + +```bash +./build-docker-tar.sh 20260309-ota +``` + +执行后会生成: + +- 镜像:`carrot-bbs-updates-server:` +- tar:`carrot-bbs-updates-server-.tar` + +### 2) 运行容器(挂载更新目录和密钥) + +```bash +docker run -d \ + --name carrot-bbs-updates-server \ + -p 3001:3001 \ + -e PORT=3001 \ + -e HOSTNAME=https://updates.littlelan.cn \ + -e UPDATES_ROOT=/data/updates \ + -e PRIVATE_KEY_PATH=/data/privatekey.pem \ + -e ADMIN_TOKEN=dev-token \ + -v /opt/carrot-bbs/updates:/data/updates \ + -v /opt/carrot-bbs/keys/privatekey.pem:/data/privatekey.pem:ro \ + carrot-bbs-updates-server:latest +``` + diff --git a/build-docker-tar.sh b/build-docker-tar.sh new file mode 100755 index 0000000..05ff246 --- /dev/null +++ b/build-docker-tar.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +TAG=${1:-$(date +%Y%m%d-%H%M%S)} +IMAGE="carrot-bbs-updates-server:${TAG}" +TAR_NAME="carrot-bbs-updates-server-${TAG}.tar" + +echo "[1/3] 构建 Linux 二进制..." +go build -o server ./cmd/server + +echo "[2/3] 构建 Docker 镜像: ${IMAGE}" +docker build -t "${IMAGE}" . + +echo "[3/3] 导出镜像 tar: ${TAR_NAME}" +docker save -o "${TAR_NAME}" "${IMAGE}" + +echo "完成:" +echo " 镜像: ${IMAGE}" +echo " 归档: $(pwd)/${TAR_NAME}" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d1aab12 --- /dev/null +++ b/go.mod @@ -0,0 +1,4 @@ +module expo-updates-server-go + +go 1.25 + diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..dc57ca8 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,22 @@ +package app + +import "os" + +func LoadConfig() Config { + return Config{ + Port: envOrDefault("PORT", "3001"), + Hostname: envOrDefault("HOSTNAME", "http://localhost:3001"), + UpdatesRoot: envOrDefault("UPDATES_ROOT", "updates"), + PrivateKey: os.Getenv("PRIVATE_KEY_PATH"), + AdminToken: os.Getenv("ADMIN_TOKEN"), + } +} + +func envOrDefault(key string, fallback string) string { + v := os.Getenv(key) + if v == "" { + return fallback + } + return v +} + diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..6d8285e --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,41 @@ +package app + +import ( + "net/http" + + "expo-updates-server-go/internal/handlers" + "expo-updates-server-go/internal/service" + "expo-updates-server-go/internal/storage" +) + +type Config struct { + Port string + Hostname string + UpdatesRoot string + PrivateKey string + AdminToken string +} + +type Server struct { + cfg Config + handlers *handlers.Handler +} + +func NewServer(cfg Config) *Server { + store := storage.NewLocalStore(cfg.UpdatesRoot) + svc := service.NewUpdateService(store, cfg.Hostname, cfg.PrivateKey) + h := handlers.NewHandler(svc, cfg.AdminToken) + return &Server{cfg: cfg, handlers: h} +} + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/manifest", s.handlers.Manifest) + mux.HandleFunc("/api/assets", s.handlers.Asset) + mux.HandleFunc("/admin/publish", s.handlers.AdminPublish) + mux.HandleFunc("/admin/rollback", s.handlers.AdminRollback) + mux.HandleFunc("/admin/releases", s.handlers.AdminReleases) + mux.HandleFunc("/healthz", s.handlers.Healthz) + return mux +} + diff --git a/internal/handlers/compat_test.go b/internal/handlers/compat_test.go new file mode 100644 index 0000000..daec823 --- /dev/null +++ b/internal/handlers/compat_test.go @@ -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) + } + } +} + diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..cbad359 --- /dev/null +++ b/internal/handlers/handlers.go @@ -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) +} + diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 0000000..c67c0e0 --- /dev/null +++ b/internal/model/types.go @@ -0,0 +1,47 @@ +package model + +import "time" + +type MetadataFile struct { + Version int `json:"version"` + FileMetadata struct { + Android PlatformMetadata `json:"android"` + IOS PlatformMetadata `json:"ios"` + } `json:"fileMetadata"` +} + +type PlatformMetadata struct { + Bundle string `json:"bundle"` + Assets []MetadataAssetEl `json:"assets"` +} + +type MetadataAssetEl struct { + Path string `json:"path"` + Ext string `json:"ext"` +} + +type AssetMetadata struct { + Hash string `json:"hash"` + Key string `json:"key"` + FileExtension string `json:"fileExtension"` + ContentType string `json:"contentType"` + URL string `json:"url"` +} + +type Manifest struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt"` + RuntimeVersion string `json:"runtimeVersion"` + Assets []AssetMetadata `json:"assets"` + LaunchAsset AssetMetadata `json:"launchAsset"` + Metadata map[string]any `json:"metadata"` + Extra map[string]any `json:"extra"` +} + +type ReleaseInfo struct { + RuntimeVersion string `json:"runtimeVersion"` + ReleaseID string `json:"releaseId"` + Path string `json:"path"` + CreatedAt time.Time `json:"createdAt"` +} + diff --git a/internal/service/update_service.go b/internal/service/update_service.go new file mode 100644 index 0000000..2013ac0 --- /dev/null +++ b/internal/service/update_service.go @@ -0,0 +1,341 @@ +package service + +import ( + "bytes" + "crypto" + "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "expo-updates-server-go/internal/model" + "expo-updates-server-go/internal/storage" +) + +var ErrNoUpdateAvailable = errors.New("no update available") + +type UpdateService struct { + store *storage.LocalStore + hostname string + privateKeyPath string +} + +type ManifestRequest struct { + Platform string + RuntimeVersion string + ProtocolVersion int + CurrentUpdateID string + EmbeddedUpdateID string + ExpectSignature bool +} + +type ManifestResponse struct { + Parts []MultipartPart +} + +type MultipartPart struct { + Name string + Body []byte + Signature string +} + +func NewUpdateService(store *storage.LocalStore, hostname string, privateKeyPath string) *UpdateService { + return &UpdateService{store: store, hostname: hostname, privateKeyPath: privateKeyPath} +} + +func (s *UpdateService) PublishZip(runtimeVersion string, platform string, zipReader *bytes.Reader, size int64) (string, error) { + return s.store.PublishZip(runtimeVersion, platform, zipReader, size) +} + +func (s *UpdateService) CreateRollback(runtimeVersion string, platform string) (string, error) { + return s.store.CreateRollback(runtimeVersion, platform) +} + +func (s *UpdateService) ListReleases(runtimeVersion string) ([]model.ReleaseInfo, error) { + return s.store.ListReleases(runtimeVersion) +} + +func (s *UpdateService) BuildManifestResponse(req ManifestRequest) (ManifestResponse, error) { + releasePath, err := s.store.LatestReleasePath(req.RuntimeVersion, req.Platform) + if err != nil { + return ManifestResponse{}, err + } + + if s.store.HasRollbackMarker(releasePath) { + return s.buildRollbackResponse(req, releasePath) + } + return s.buildNormalManifestResponse(req, releasePath) +} + +func (s *UpdateService) buildNormalManifestResponse(req ManifestRequest, releasePath string) (ManifestResponse, error) { + metadata, metadataRaw, createdAt, err := s.store.ReadMetadata(releasePath) + if err != nil { + return ManifestResponse{}, fmt.Errorf("No update found with runtime version: %s. Error: %w", req.RuntimeVersion, err) + } + idHex := sha256Hex(metadataRaw) + id := convertSHA256HashToUUID(idHex) + if req.ProtocolVersion == 1 && req.CurrentUpdateID == id { + return s.buildNoUpdateAvailableResponse(req) + } + + var platformMeta model.PlatformMetadata + switch req.Platform { + case "ios": + platformMeta = metadata.FileMetadata.IOS + case "android": + platformMeta = metadata.FileMetadata.Android + default: + return ManifestResponse{}, fmt.Errorf("Unsupported platform. Expected either ios or android.") + } + + expoConfig, err := s.store.ReadExpoConfig(releasePath) + if err != nil { + return ManifestResponse{}, err + } + + assets := make([]model.AssetMetadata, 0, len(platformMeta.Assets)) + for _, a := range platformMeta.Assets { + m, err := s.assetMetadata(releasePath, a.Path, a.Ext, req.RuntimeVersion, req.Platform, false) + if err != nil { + return ManifestResponse{}, err + } + assets = append(assets, m) + } + launchAsset, err := s.assetMetadata(releasePath, platformMeta.Bundle, "", req.RuntimeVersion, req.Platform, true) + if err != nil { + return ManifestResponse{}, err + } + + manifest := model.Manifest{ + ID: id, + CreatedAt: createdAt.Format(time.RFC3339), + RuntimeVersion: req.RuntimeVersion, + Assets: assets, + LaunchAsset: launchAsset, + Metadata: map[string]any{}, + Extra: map[string]any{"expoClient": expoConfig}, + } + manifestBody, _ := json.Marshal(manifest) + + signature := "" + if req.ExpectSignature { + signature, err = s.sign(manifestBody) + if err != nil { + return ManifestResponse{}, err + } + } + + assetReqHeaders := map[string]map[string]string{} + for _, a := range append(assets, launchAsset) { + assetReqHeaders[a.Key] = map[string]string{"test-header": "test-header-value"} + } + extensionsBody, _ := json.Marshal(map[string]any{"assetRequestHeaders": assetReqHeaders}) + return ManifestResponse{ + Parts: []MultipartPart{ + {Name: "manifest", Body: manifestBody, Signature: signature}, + {Name: "extensions", Body: extensionsBody}, + }, + }, nil +} + +func (s *UpdateService) buildRollbackResponse(req ManifestRequest, releasePath string) (ManifestResponse, error) { + if req.ProtocolVersion == 0 { + return ManifestResponse{}, errors.New("Rollbacks not supported on protocol version 0") + } + if req.EmbeddedUpdateID == "" { + return ManifestResponse{}, errors.New("Invalid Expo-Embedded-Update-ID request header specified.") + } + if req.CurrentUpdateID == req.EmbeddedUpdateID { + return s.buildNoUpdateAvailableResponse(req) + } + + fi, err := os.Stat(filepath.Join(releasePath, "rollback")) + if err != nil { + return ManifestResponse{}, err + } + directiveBody, _ := json.Marshal(map[string]any{ + "type": "rollBackToEmbedded", + "parameters": map[string]any{ + "commitTime": fi.ModTime().Format(time.RFC3339), + }, + }) + signature := "" + if req.ExpectSignature { + signature, err = s.sign(directiveBody) + if err != nil { + return ManifestResponse{}, err + } + } + return ManifestResponse{Parts: []MultipartPart{{Name: "directive", Body: directiveBody, Signature: signature}}}, nil +} + +func (s *UpdateService) buildNoUpdateAvailableResponse(req ManifestRequest) (ManifestResponse, error) { + if req.ProtocolVersion == 0 { + return ManifestResponse{}, errors.New("NoUpdateAvailable directive not available in protocol version 0") + } + body, _ := json.Marshal(map[string]any{"type": "noUpdateAvailable"}) + signature := "" + var err error + if req.ExpectSignature { + signature, err = s.sign(body) + if err != nil { + return ManifestResponse{}, err + } + } + return ManifestResponse{Parts: []MultipartPart{{Name: "directive", Body: body, Signature: signature}}}, nil +} + +func (s *UpdateService) AssetContent(runtimeVersion string, platform string, assetPath string) ([]byte, string, error) { + releasePath, err := s.store.LatestReleasePath(runtimeVersion, platform) + if err != nil { + return nil, "", err + } + metadata, _, _, err := s.store.ReadMetadata(releasePath) + if err != nil { + return nil, "", err + } + stripped := strings.TrimPrefix(assetPath, releasePath+"/") + isLaunch := false + var ext string + var assets []model.MetadataAssetEl + switch platform { + case "ios": + isLaunch = metadata.FileMetadata.IOS.Bundle == stripped + assets = metadata.FileMetadata.IOS.Assets + case "android": + isLaunch = metadata.FileMetadata.Android.Bundle == stripped + assets = metadata.FileMetadata.Android.Assets + default: + return nil, "", fmt.Errorf("No platform provided. Expected \"ios\" or \"android\".") + } + if !isLaunch { + for _, a := range assets { + if a.Path == stripped { + ext = a.Ext + break + } + } + } + + data, err := s.store.OpenAssetSafe(assetPath) + if err != nil { + return nil, "", err + } + if isLaunch { + return data, "application/javascript", nil + } + ct := mime.TypeByExtension("." + ext) + if ct == "" { + ct = "application/octet-stream" + } + return data, ct, nil +} + +func (s *UpdateService) assetMetadata(releasePath string, relPath string, ext string, runtimeVersion string, platform string, isLaunch bool) (model.AssetMetadata, error) { + fullPath := filepath.Join(releasePath, relPath) + b, err := os.ReadFile(fullPath) + if err != nil { + return model.AssetMetadata{}, err + } + + hashRaw := sha256.Sum256(b) + hash := strings.TrimRight(base64.URLEncoding.EncodeToString(hashRaw[:]), "=") + md5Raw := md5.Sum(b) + key := hex.EncodeToString(md5Raw[:]) + + fileExt := "." + ext + ct := mime.TypeByExtension("." + ext) + if isLaunch { + fileExt = ".bundle" + ct = "application/javascript" + } + if ct == "" { + ct = "application/octet-stream" + } + + return model.AssetMetadata{ + Hash: hash, + Key: key, + FileExtension: fileExt, + ContentType: ct, + URL: fmt.Sprintf("%s/api/assets?asset=%s&runtimeVersion=%s&platform=%s", s.hostname, fullPath, runtimeVersion, platform), + }, nil +} + +func (s *UpdateService) sign(payload []byte) (string, error) { + if s.privateKeyPath == "" { + return "", errors.New("Code signing requested but no key supplied when starting server.") + } + b, err := os.ReadFile(filepath.Clean(s.privateKeyPath)) + if err != nil { + return "", err + } + block, _ := pem.Decode(b) + if block == nil { + return "", errors.New("invalid private key PEM") + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + pkcs8, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) + if pkcs8Err != nil { + return "", err + } + var ok bool + key, ok = pkcs8.(*rsa.PrivateKey) + if !ok { + return "", errors.New("private key is not RSA") + } + } + + h := sha256.Sum256(payload) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h[:]) + if err != nil { + return "", err + } + // Compatible enough for expo-signature header usage. + return fmt.Sprintf("sig=\"%s\", keyid=\"main\"", base64.StdEncoding.EncodeToString(sig)), nil +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func convertSHA256HashToUUID(value string) string { + clean := value + if len(clean) < 32 { + clean += strings.Repeat("0", 32-len(clean)) + } + return fmt.Sprintf("%s-%s-%s-%s-%s", clean[0:8], clean[8:12], clean[12:16], clean[16:20], clean[20:32]) +} + +func BuildMultipartMixed(parts []MultipartPart) ([]byte, string, error) { + boundary := fmt.Sprintf("expo-%d", time.Now().UnixNano()) + var buf bytes.Buffer + for _, p := range parts { + buf.WriteString("--" + boundary + "\r\n") + buf.WriteString(fmt.Sprintf("content-disposition: form-data; name=\"%s\"\r\n", p.Name)) + buf.WriteString("content-type: application/json; charset=utf-8\r\n") + if p.Signature != "" { + buf.WriteString(fmt.Sprintf("expo-signature: %s\r\n", p.Signature)) + } + buf.WriteString("\r\n") + buf.Write(p.Body) + buf.WriteString("\r\n") + } + buf.WriteString("--" + boundary + "--") + return buf.Bytes(), "multipart/mixed; boundary=" + boundary, nil +} + diff --git a/internal/storage/local.go b/internal/storage/local.go new file mode 100644 index 0000000..be14d24 --- /dev/null +++ b/internal/storage/local.go @@ -0,0 +1,169 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "expo-updates-server-go/internal/model" +) + +var ErrUnsupportedRuntime = errors.New("Unsupported runtime version") +var ErrUnsupportedPlatform = errors.New("Unsupported platform") + +type LocalStore struct { + root string +} + +func NewLocalStore(root string) *LocalStore { + return &LocalStore{root: root} +} + +func (s *LocalStore) UpdatesRoot() string { + return s.root +} + +func (s *LocalStore) LatestReleasePath(runtimeVersion string, platform string) (string, error) { + if platform != "ios" && platform != "android" { + return "", ErrUnsupportedPlatform + } + runtimeDir := filepath.Join(s.root, runtimeVersion) + runtimeStat, err := os.Stat(runtimeDir) + if err != nil || !runtimeStat.IsDir() { + return "", ErrUnsupportedRuntime + } + platformDir := filepath.Join(runtimeDir, platform) + platformStat, err := os.Stat(platformDir) + if err != nil || !platformStat.IsDir() { + return "", ErrUnsupportedPlatform + } + return platformDir, nil +} + +func (s *LocalStore) HasRollbackMarker(releasePath string) bool { + _, err := os.Stat(filepath.Join(releasePath, "rollback")) + return err == nil +} + +func (s *LocalStore) ReadMetadata(releasePath string) (model.MetadataFile, []byte, time.Time, error) { + var out model.MetadataFile + metaPath := filepath.Join(releasePath, "metadata.json") + b, err := os.ReadFile(metaPath) + if err != nil { + return out, nil, time.Time{}, err + } + if err := json.Unmarshal(b, &out); err != nil { + return out, nil, time.Time{}, err + } + fi, err := os.Stat(metaPath) + if err != nil { + return out, nil, time.Time{}, err + } + return out, b, fi.ModTime(), nil +} + +func (s *LocalStore) ReadExpoConfig(releasePath string) (map[string]any, error) { + filePath := filepath.Join(releasePath, "expoConfig.json") + b, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *LocalStore) OpenAssetSafe(asset string) ([]byte, error) { + clean := filepath.Clean(asset) + if strings.HasPrefix(clean, "..") { + return nil, fmt.Errorf("invalid asset path") + } + return os.ReadFile(clean) +} + +func (s *LocalStore) PublishZip(runtimeVersion string, platform string, zipReader io.ReaderAt, size int64) (string, error) { + if platform != "ios" && platform != "android" { + return "", fmt.Errorf("%w: %s", ErrUnsupportedPlatform, platform) + } + runtimeDir := filepath.Join(s.root, runtimeVersion) + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + return "", err + } + + targetDir := filepath.Join(runtimeDir, platform) + tmpDir := targetDir + ".tmp" + _ = os.RemoveAll(tmpDir) + _ = os.RemoveAll(targetDir) + + if err := unzipToDir(zipReader, size, tmpDir); err != nil { + return "", err + } + + manifestPath := filepath.Join(tmpDir, "metadata.json") + if _, err := os.Stat(manifestPath); err != nil { + return "", fmt.Errorf("published zip must contain metadata.json at root") + } + + if err := os.Rename(tmpDir, targetDir); err != nil { + return "", err + } + return targetDir, nil +} + +func (s *LocalStore) CreateRollback(runtimeVersion string, platform string) (string, error) { + if platform != "ios" && platform != "android" { + return "", fmt.Errorf("%w: %s", ErrUnsupportedPlatform, platform) + } + releaseDir := filepath.Join(s.root, runtimeVersion, platform) + if _, err := os.Stat(releaseDir); err != nil { + return "", err + } + path := filepath.Join(releaseDir, "rollback") + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + return "", err + } + return path, nil +} + +func (s *LocalStore) ListReleases(runtimeVersion string) ([]model.ReleaseInfo, error) { + runtimeDir := filepath.Join(s.root, runtimeVersion) + runtimeStat, err := os.Stat(runtimeDir) + if err != nil || !runtimeStat.IsDir() { + return nil, ErrUnsupportedRuntime + } + entries, err := os.ReadDir(runtimeDir) + if err != nil { + return nil, err + } + out := make([]model.ReleaseInfo, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + if e.Name() != "ios" && e.Name() != "android" { + continue + } + full := filepath.Join(runtimeDir, e.Name()) + fi, err := os.Stat(full) + if err != nil { + continue + } + out = append(out, model.ReleaseInfo{ + RuntimeVersion: runtimeVersion, + ReleaseID: e.Name(), + Path: full, + CreatedAt: fi.ModTime(), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].ReleaseID < out[j].ReleaseID }) + return out, nil +} + diff --git a/internal/storage/zip.go b/internal/storage/zip.go new file mode 100644 index 0000000..925c11c --- /dev/null +++ b/internal/storage/zip.go @@ -0,0 +1,55 @@ +package storage + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func unzipToDir(readerAt io.ReaderAt, size int64, target string) error { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + zr, err := zip.NewReader(readerAt, size) + if err != nil { + return err + } + for _, f := range zr.File { + name := filepath.Clean(f.Name) + if strings.HasPrefix(name, "..") { + return fmt.Errorf("invalid zip entry path: %s", f.Name) + } + fullPath := filepath.Join(target, name) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(fullPath, 0o755); err != nil { + return err + } + continue + } + + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + return err + } + src, err := f.Open() + if err != nil { + return err + } + dst, err := os.Create(fullPath) + if err != nil { + src.Close() + return err + } + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + src.Close() + return err + } + dst.Close() + src.Close() + } + return nil +} + diff --git a/start-docker.sh b/start-docker.sh new file mode 100755 index 0000000..1ac85d9 --- /dev/null +++ b/start-docker.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# expo-updates-server-go Docker 启动脚本 +# 使用前请确保已经构建好镜像: docker build -t carrot-bbs-updates-server:latest . + +docker run -d \ + --name carrot-bbs-updates-server \ + -p 3001:3001 \ + -e PORT=3001 \ + -e HOSTNAME=http://127.0.0.1:3001 \ + -e UPDATES_ROOT=/data/updates \ + -e PRIVATE_KEY_PATH=/data/privatekey.pem \ + -e ADMIN_TOKEN=dev-token \ + -v /opt/carrot-bbs/updates:/data/updates \ + -v /opt/carrot-bbs/keys/privatekey.pem:/data/privatekey.pem:ro \ + carrot-bbs-updates-server:latest