实现APK文件的上传、下载和列表功能 - 新增APKInfo数据结构 - 添加/admin/apk/upload、/admin/apk/download和/admin/apk/list路由 - 实现APK存储服务方法 - 添加相关测试用例
249 lines
6.3 KiB
Go
249 lines
6.3 KiB
Go
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
|
|
}
|
|
|
|
func (s *LocalStore) SaveAPK(runtimeVersion string, reader io.Reader) (string, error) {
|
|
apkDir := filepath.Join(s.root, "apk")
|
|
if err := os.MkdirAll(apkDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
filename := fmt.Sprintf("%s.apk", runtimeVersion)
|
|
apkPath := filepath.Join(apkDir, filename)
|
|
tmpPath := apkPath + ".tmp"
|
|
|
|
f, err := os.Create(tmpPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
_, copyErr := io.Copy(f, reader)
|
|
syncErr := f.Sync()
|
|
closeErr := f.Close()
|
|
|
|
if copyErr != nil {
|
|
os.Remove(tmpPath)
|
|
return "", copyErr
|
|
}
|
|
if syncErr != nil {
|
|
os.Remove(tmpPath)
|
|
return "", syncErr
|
|
}
|
|
if closeErr != nil {
|
|
os.Remove(tmpPath)
|
|
return "", closeErr
|
|
}
|
|
if err := os.Rename(tmpPath, apkPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return "", err
|
|
}
|
|
return apkPath, nil
|
|
}
|
|
|
|
func (s *LocalStore) GetAPKPath(runtimeVersion string) (string, error) {
|
|
filename := fmt.Sprintf("%s.apk", runtimeVersion)
|
|
apkPath := filepath.Join(s.root, "apk", filename)
|
|
if _, err := os.Stat(apkPath); err != nil {
|
|
return "", err
|
|
}
|
|
return apkPath, nil
|
|
}
|
|
|
|
func (s *LocalStore) ListAPKs() ([]model.APKInfo, error) {
|
|
apkDir := filepath.Join(s.root, "apk")
|
|
if _, err := os.Stat(apkDir); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []model.APKInfo{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
entries, err := os.ReadDir(apkDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.APKInfo, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".apk") {
|
|
continue
|
|
}
|
|
runtimeVersion := strings.TrimSuffix(e.Name(), ".apk")
|
|
full := filepath.Join(apkDir, e.Name())
|
|
fi, err := os.Stat(full)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, model.APKInfo{
|
|
RuntimeVersion: runtimeVersion,
|
|
Path: full,
|
|
Size: fi.Size(),
|
|
CreatedAt: fi.ModTime(),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].RuntimeVersion > out[j].RuntimeVersion })
|
|
return out, nil
|
|
}
|