Files
updates/internal/handlers/handlers.go
lafay 3ac1a2ccf5
Some checks failed
Build Updates Server / build (push) Failing after 10s
Build Updates Server / build-docker (push) Has been skipped
feat(apk): 增加公开APK管理接口和CI/CD工作流
- 新增获取最新APK版本信息和下载APK的公开接口
- 更新README文档,添加APK管理接口的使用说明
- 实现CI/CD工作流,支持自动构建和部署
- 优化APK存储逻辑,确保只保留最新APK文件
2026-03-26 03:29:44 +08:00

383 lines
12 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"expo-updates-server-go/internal/model"
"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) AdminUploadAPK(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")
if runtimeVersion == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
return
}
contentType := r.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "application/vnd.android.package-archive") &&
!strings.HasPrefix(contentType, "application/octet-stream") {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Content-Type must be application/vnd.android.package-archive or application/octet-stream"})
return
}
maxSize := int64(500 * 1024 * 1024)
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
path, err := h.svc.SaveAPK(runtimeVersion, r.Body)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"runtimeVersion": runtimeVersion,
"path": path,
"message": "APK uploaded successfully",
})
}
func (h *Handler) AdminDownloadAPK(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
}
apkPath, err := h.svc.GetAPKPath(runtimeVersion)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": fmt.Sprintf("APK for runtimeVersion %s not found", runtimeVersion)})
return
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.apk", runtimeVersion))
http.ServeFile(w, r, apkPath)
}
func (h *Handler) AdminListAPKs(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
}
apks, err := h.svc.ListAPKs()
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"apks": apks})
}
func (h *Handler) PublicLatestAPK(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
return
}
apk, err := h.svc.GetLatestAPK()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
if apk == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "No APK available"})
return
}
resp := model.LatestAPKResponse{
RuntimeVersion: apk.RuntimeVersion,
VersionName: apk.RuntimeVersion,
Size: apk.Size,
DownloadURL: fmt.Sprintf("%s/api/apk/download?runtimeVersion=%s", h.svc.Hostname(), apk.RuntimeVersion),
CreatedAt: apk.CreatedAt.Format(time.RFC3339),
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) PublicDownloadAPK(w http.ResponseWriter, r *http.Request) {
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
}
apkPath, err := h.svc.GetAPKPath(runtimeVersion)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": fmt.Sprintf("APK for runtimeVersion %s not found", runtimeVersion)})
return
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=carrot-bbs-%s.apk", runtimeVersion))
http.ServeFile(w, r, apkPath)
}
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 any) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(data)
}