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 }