feat: mvp
This commit is contained in:
264
modules/voxyimport/service.go
Normal file
264
modules/voxyimport/service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user