Initial updates server repository commit.
Reinitialize repository history and exclude generated OTA artifact outputs. Made-with: Cursor
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Build artifacts
|
||||
server
|
||||
*.tar
|
||||
updates/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal 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
130
README.md
Normal 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` 产物 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:<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
19
build-docker-tar.sh
Executable 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}"
|
||||
22
internal/app/config.go
Normal file
22
internal/app/config.go
Normal 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
41
internal/app/server.go
Normal 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
47
internal/model/types.go
Normal file
47
internal/model/types.go
Normal 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"`
|
||||
}
|
||||
|
||||
341
internal/service/update_service.go
Normal file
341
internal/service/update_service.go
Normal 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
169
internal/storage/local.go
Normal 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
55
internal/storage/zip.go
Normal 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
16
start-docker.sh
Executable 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
|
||||
Reference in New Issue
Block a user