Initial updates server repository commit.

Reinitialize repository history and exclude generated OTA artifact outputs.

Made-with: Cursor
This commit is contained in:
2026-03-09 21:33:34 +08:00
commit a0ef7f430d
14 changed files with 1466 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Build artifacts
server
*.tar
updates/
# Local environment files
.env
.env.*

24
Dockerfile Normal file
View File

@@ -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"]

130
README.md Normal file
View File

@@ -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=<rv>&platform=<ios|android>`
- Header`Authorization: Bearer <ADMIN_TOKEN>`
- Body`expo export` 产物 zipzip 根目录需包含 `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:<tag>`
- tar`carrot-bbs-updates-server-<tag>.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
```

19
build-docker-tar.sh Executable file
View File

@@ -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}"

4
go.mod Normal file
View File

@@ -0,0 +1,4 @@
module expo-updates-server-go
go 1.25

22
internal/app/config.go Normal file
View File

@@ -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
}

41
internal/app/server.go Normal file
View File

@@ -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
}

View 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)
}
}
}

View 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)
}

47
internal/model/types.go Normal file
View File

@@ -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"`
}

View File

@@ -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
}

169
internal/storage/local.go Normal file
View File

@@ -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
}

55
internal/storage/zip.go Normal file
View File

@@ -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
}

16
start-docker.sh Executable file
View File

@@ -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