feat: mvp

This commit is contained in:
2026-05-27 19:31:47 +08:00
parent f96048a837
commit 93505fdaab
7 changed files with 2069 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
package voxyimport
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ===== default implementation =====
// DefaultVoxyService is the production implementation of VoxyService.
type DefaultVoxyService struct {
SourcesAPIURL string
}
// NewDefaultVoxyService creates a new DefaultVoxyService with the standard API URL.
func NewDefaultVoxyService() *DefaultVoxyService {
return &DefaultVoxyService{
SourcesAPIURL: "http://127.0.0.1:3131/api/v1/voxy_import/",
}
}
// ===== SearchDirs =====
func (s *DefaultVoxyService) SearchDirs(serverAddr string) ([]string, error) {
var dirs []string
suffix := filepath.Join(".voxy", "saves", serverAddr)
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
clean := filepath.ToSlash(path)
s := filepath.ToSlash(suffix)
if strings.HasSuffix(clean, "/"+s) || clean == s {
dirs = append(dirs, path)
return filepath.SkipDir
}
}
return nil
})
sort.Strings(dirs)
return dirs, err
}
// ===== FetchSources =====
func (s *DefaultVoxyService) FetchSources() ([]Source, error) {
resp, err := http.Get(s.SourcesAPIURL)
if err != nil {
return nil, fmt.Errorf("获取下载源失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("获取下载源 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var urls []string
if err := json.Unmarshal(body, &urls); err != nil {
// not a plain array — try {"urls": [...]}
var wrapper struct {
URLs []string `json:"urls"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return nil, fmt.Errorf("解析下载源数据失败: %w", err)
}
urls = wrapper.URLs
}
var sources []Source
if len(urls) > 0 {
sources = append(sources, Source{Name: "默认源 (arinera.fun)", URL: urls[0]})
}
if len(urls) > 1 {
sources = append(sources, Source{Name: "CloudFlare 源 (arinera.space)", URL: urls[1]})
}
return sources, nil
}
// ===== DownloadFile =====
func (s *DefaultVoxyService) DownloadFile(url string, destPath string, onProgress func(current, total int64)) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("create dest file: %w", err)
}
defer out.Close()
buf := make([]byte, 32*1024)
var current int64
total := resp.ContentLength
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return fmt.Errorf("write dest file: %w", writeErr)
}
current += int64(n)
if onProgress != nil {
onProgress(current, total)
}
}
if readErr != nil {
if readErr != io.EOF {
return fmt.Errorf("read response: %w", readErr)
}
break
}
}
return out.Close()
}
// ===== BackupDir =====
func (s *DefaultVoxyService) BackupDir(dir string) (string, error) {
timestamp := time.Now().Format("2006-01-02_150405")
backupName := timestamp + ".bak.zip"
backupPath := filepath.Join(dir, backupName)
f, err := os.Create(backupPath)
if err != nil {
return "", fmt.Errorf("create backup: %w", err)
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if path == backupPath {
return nil
}
relPath, err := filepath.Rel(dir, path)
if err != nil {
return nil
}
relPath = filepath.ToSlash(relPath)
entry, err := w.Create(relPath)
if err != nil {
return fmt.Errorf("create zip entry %s: %w", relPath, err)
}
src, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer src.Close()
if _, err := io.Copy(entry, src); err != nil {
return fmt.Errorf("write %s: %w", relPath, err)
}
return nil
})
if err != nil {
return "", fmt.Errorf("backup walk: %w", err)
}
return backupName, nil
}
// ===== CleanupDir =====
func (s *DefaultVoxyService) CleanupDir(dir string, keepName string) error {
keepPath := filepath.Join(dir, keepName)
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == dir {
return nil
}
if path == keepPath {
return nil
}
return os.RemoveAll(path)
})
}
// ===== ExtractZip =====
func (s *DefaultVoxyService) ExtractZip(zipPath, dir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer r.Close()
for _, f := range r.File {
if err := extractEntry(f, dir); err != nil {
return err
}
}
return nil
}
func extractEntry(f *zip.File, destDir string) error {
targetPath := filepath.Join(destDir, f.Name)
cleanDest, _ := filepath.Abs(destDir)
cleanTarget, _ := filepath.Abs(targetPath)
if !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) && cleanTarget != cleanDest {
return fmt.Errorf("path traversal detected: %s", f.Name)
}
if f.FileInfo().IsDir() {
return os.MkdirAll(targetPath, os.ModePerm)
}
if err := os.MkdirAll(filepath.Dir(targetPath), os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(targetPath), err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("open zip entry %s: %w", f.Name, err)
}
defer rc.Close()
out, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create %s: %w", targetPath, err)
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return fmt.Errorf("write %s: %w", f.Name, err)
}
return nil
}