Files
updates/internal/storage/local.go
lan a0ef7f430d Initial updates server repository commit.
Reinitialize repository history and exclude generated OTA artifact outputs.

Made-with: Cursor
2026-03-09 21:33:34 +08:00

170 lines
4.4 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
}