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 }