package releases import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "reflect" "regexp" "sort" "strconv" "strings" "sync" "time" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" "ymhut-box/server/unified-management/internal/notices" ) type Service struct { cfg *config.Config store *db.Store notices *notices.Service hashMu sync.Mutex hashes map[string]cachedFileHash hashFile func(string) string packageMu sync.RWMutex packages []Package reconcileMu sync.Mutex callbackMu sync.RWMutex onChange func() stop chan struct{} stopOnce sync.Once } type cachedFileHash struct { size int64 modifiedAt int64 sha256 string } type Package struct { ID string `json:"id"` Name string `json:"name"` Version string `json:"version"` Platform string `json:"platform"` Arch string `json:"arch"` URL string `json:"url"` SHA256 string `json:"sha256"` Size int64 `json:"size"` Required bool `json:"required"` Enabled bool `json:"enabled"` FileName string `json:"fileName"` UpdatedAt string `json:"updatedAt"` } type UploadOptions struct { FileName string Version string Platform string Arch string Channel string Notes string UpdateManifest bool } type UploadedPackageFile struct { TempPath string Size int64 SHA256 string } var ( ErrUploadedPackageMissing = errors.New("uploaded file is missing") ErrUploadedPackageEmpty = errors.New("uploaded file is empty") ErrUploadedPackageStorageFailed = errors.New("upload storage failed") ErrUploadedPackageManifestFailed = errors.New("manifest update failed") ErrUploadedPackageIndexFailed = errors.New("package index update failed") ErrUnsupportedPackage = errors.New("unsupported package extension") ErrUnsafePackageName = errors.New("unsafe package file name") ) func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service { return newService(cfg, store, sha256File, noticeService...) } func newService(cfg *config.Config, store *db.Store, hashFile func(string) string, noticeService ...*notices.Service) *Service { service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}, hashFile: hashFile, stop: make(chan struct{})} if len(noticeService) > 0 { service.notices = noticeService[0] } _ = service.Reconcile(context.Background()) return service } func (s *Service) Start(ctx context.Context) { go func() { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ticker.C: _ = s.Reconcile(context.Background()) case <-ctx.Done(): return case <-s.stop: return } } }() } func (s *Service) Stop() { s.stopOnce.Do(func() { close(s.stop) }) } func (s *Service) SetChangeCallback(callback func()) { s.callbackMu.Lock() s.onChange = callback s.callbackMu.Unlock() } func (s *Service) notifyChanged() { s.callbackMu.RLock() callback := s.onChange s.callbackMu.RUnlock() if callback != nil { callback() } } func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any { payload := s.legacyUpdateBase() manifest := s.Manifest(r) for _, key := range []string{"app_version", "download_url", "download_mirrors", "detected_product", "detected_packages", "packages", "modules", "manifest_version", "release_notes", "release_notes_md", "message", "message_md", "notices", "latest_notice"} { if value, ok := manifest[key]; ok { payload[key] = value } } return payload } func (s *Service) Manifest(r *http.Request) map[string]any { payload := s.legacyUpdateBase() packages := s.ScanPackages(r) modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"] if modules == nil { modules = []any{} } payload["manifest_version"] = 2 payload["service_version"] = config.Version payload["modules"] = modules payload["assets"] = []any{} payload["generated_at"] = time.Now().UTC().Format(time.RFC3339) if s.notices != nil { if items, err := s.notices.List(50); err == nil && len(items) > 0 { publicNotices := notices.PublicList(items) payload["notices"] = publicNotices payload["latest_notice"] = publicNotices[0] latestNotice := items[0] setIfMissing(payload, "app_version", latestNotice.Version) setIfMissing(payload, "title", latestNotice.Title) setIfMissing(payload, "message", latestNotice.Message) setIfMissing(payload, "message_md", latestNotice.MessageMD) setIfMissing(payload, "release_notes", latestNotice.ReleaseNotes) setIfMissing(payload, "release_notes_md", latestNotice.ReleaseNotesMD) setIfMissing(payload, "download_url", latestNotice.DownloadURL) } } preferredVersion := manifestString(payload, "app_version") packages = prioritizePackages(packages, preferredVersion) payload["packages"] = packages if len(packages) > 0 { latest := packages[0] if preferredVersion == "" { preferredVersion = latest.Version payload["app_version"] = preferredVersion } if sameVersion(latest.Version, preferredVersion) { payload["download_url"] = latest.URL payload["download_mirrors"] = []map[string]any{{ "id": "primary", "name": "官方直连", "url": latest.URL, "type": "direct", "sha256": latest.SHA256, "enabled": true, }} payload["detected_product"] = latest.Name } } return payload } func manifestString(payload map[string]any, key string) string { value, _ := payload[key].(string) return strings.TrimSpace(value) } func prioritizePackages(items []Package, preferredVersion string) []Package { if strings.TrimSpace(preferredVersion) == "" { return items } sort.SliceStable(items, func(i, j int) bool { left := sameVersion(items[i].Version, preferredVersion) right := sameVersion(items[j].Version, preferredVersion) return left && !right }) return items } func sameVersion(a, b string) bool { a = strings.TrimSpace(a) b = strings.TrimSpace(b) if a == "" || b == "" { return false } if a == b { return true } return versionPattern.FindString(a) == a && versionPattern.FindString(b) == b && compareVersion(a, b) == 0 } func (s *Service) PublishLegacyUpdateInfo(r *http.Request, actor string) error { payload := s.LegacyUpdateInfo(r) data, err := json.MarshalIndent(payload, "", " ") if err != nil { return err } path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") if err := atomicWrite(path, append(data, '\n')); err != nil { return err } _, _ = s.store.SaveLegacyRevision("update-info", string(append(data, '\n')), "generated from release database", firstNonEmpty(actor, "system")) return nil } func setIfMissing(payload map[string]any, key, value string) { if strings.TrimSpace(value) == "" { return } if existing, ok := payload[key].(string); !ok || strings.TrimSpace(existing) == "" { payload[key] = value } } func (s *Service) ScanPackages(r *http.Request) []Package { base := firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) s.packageMu.RLock() items := append([]Package(nil), s.packages...) s.packageMu.RUnlock() for index := range items { items[index].URL = base + "/downloads/" + items[index].FileName } return items } // Reconcile keeps request-time manifest generation independent from package I/O. // Hashes are reused from the persistent index whenever size and mtime still match. func (s *Service) Reconcile(ctx context.Context) error { s.reconcileMu.Lock() defer s.reconcileMu.Unlock() if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil { return err } indexed := map[string]db.ReleasePackage{} var firstErr error if s.store != nil { rows, err := s.store.ListReleasePackages() if err != nil { firstErr = err } else { for _, item := range rows { indexed[item.FileName] = item } } } entries, err := os.ReadDir(s.cfg.DownloadsDir) if err != nil { return err } items := make([]Package, 0, len(entries)) seen := map[string]bool{} for _, entry := range entries { if err := ctx.Err(); err != nil { return err } if entry.IsDir() || !isSupportedPackageName(entry.Name()) { continue } info, err := entry.Info() if err != nil { if firstErr == nil { firstErr = err } continue } name := entry.Name() seen[name] = true modifiedAt := info.ModTime().UTC().Format(time.RFC3339Nano) record, cached := indexed[name] var item Package if cached && record.SizeBytes == info.Size() && record.UpdatedAt == modifiedAt && strings.TrimSpace(record.SHA256) != "" { item = packageFromRecord(record) s.hashMu.Lock() s.hashes[filepath.Join(s.cfg.DownloadsDir, name)] = cachedFileHash{size: info.Size(), modifiedAt: info.ModTime().UnixNano(), sha256: record.SHA256} s.hashMu.Unlock() } else { version := detectVersion(name) platform, arch := detectPlatform(name) product := detectProduct(name) hashValue := s.cachedSHA256(filepath.Join(s.cfg.DownloadsDir, name), info) if strings.TrimSpace(hashValue) == "" { if firstErr == nil { firstErr = fmt.Errorf("hash package %q: empty SHA-256", name) } continue } item = Package{ ID: packageID(product, platform, arch, version), Name: product, Version: version, Platform: platform, Arch: arch, SHA256: hashValue, Size: info.Size(), Required: strings.Contains(strings.ToLower(product), "ymhut"), Enabled: true, FileName: name, UpdatedAt: modifiedAt, } if s.store != nil { if _, err := s.store.UpsertReleasePackage(releasePackageRecord(item)); err != nil && firstErr == nil { firstErr = err } } } items = append(items, item) } if s.store != nil { for name := range indexed { if !seen[name] { if err := s.store.DeleteReleasePackage(name); err != nil && firstErr == nil { firstErr = err } } } } if firstErr != nil { return firstErr } sort.Slice(items, func(i, j int) bool { return compareVersion(items[i].Version, items[j].Version) > 0 }) s.packageMu.Lock() changed := !reflect.DeepEqual(s.packages, items) s.packages = items s.packageMu.Unlock() if changed { s.notifyChanged() } return firstErr } func packageFromRecord(item db.ReleasePackage) Package { return Package{ ID: packageID(item.Product, item.Platform, item.Arch, item.Version), Name: item.Product, Version: item.Version, Platform: item.Platform, Arch: item.Arch, URL: item.URL, SHA256: item.SHA256, Size: item.SizeBytes, Required: strings.Contains(strings.ToLower(item.Product), "ymhut"), Enabled: item.Enabled, FileName: item.FileName, UpdatedAt: item.UpdatedAt, } } func releasePackageRecord(item Package) db.ReleasePackage { return db.ReleasePackage{ Product: item.Name, Version: item.Version, Platform: item.Platform, Arch: item.Arch, FileName: item.FileName, URL: item.URL, SHA256: item.SHA256, SizeBytes: item.Size, Enabled: item.Enabled, UpdatedAt: item.UpdatedAt, } } func packageID(product, platform, arch, version string) string { return strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")) } func isSupportedPackageName(name string) bool { lower := strings.ToLower(name) for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} { if strings.HasSuffix(lower, suffix) { return true } } return false } func (s *Service) cachedSHA256(path string, info os.FileInfo) string { modifiedAt := info.ModTime().UnixNano() s.hashMu.Lock() if cached, ok := s.hashes[path]; ok && cached.size == info.Size() && cached.modifiedAt == modifiedAt { s.hashMu.Unlock() return cached.sha256 } s.hashMu.Unlock() value := s.hashFile(path) s.hashMu.Lock() s.hashes[path] = cachedFileHash{size: info.Size(), modifiedAt: modifiedAt, sha256: value} for cachedPath := range s.hashes { if cachedPath != path { if _, err := os.Stat(cachedPath); errors.Is(err, os.ErrNotExist) { delete(s.hashes, cachedPath) } } } s.hashMu.Unlock() return value } func (s *Service) StaticJSON(name string) map[string]any { return readJSON(filepath.Join(s.cfg.UpdatePublicDir, name)) } func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts UploadOptions, actor string) (Package, error) { name, err := safePackageName(opts.FileName) if err != nil { return Package{}, err } if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil { return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } tmp, err := os.CreateTemp(s.cfg.DownloadsDir, "."+name+".*.upload") if err != nil { return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } tmpName := tmp.Name() defer os.Remove(tmpName) hash := sha256.New() written, err := io.Copy(io.MultiWriter(tmp, hash), reader) if err == nil { err = tmp.Sync() } if closeErr := tmp.Close(); err == nil { err = closeErr } if err != nil { return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } if written <= 0 { return Package{}, ErrUploadedPackageEmpty } opts.FileName = name return s.SavePreparedPackage(r, UploadedPackageFile{TempPath: tmpName, Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, opts, actor) } func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageFile, opts UploadOptions, actor string) (Package, error) { name, err := safePackageName(opts.FileName) if err != nil { return Package{}, err } if uploaded.TempPath == "" { return Package{}, ErrUploadedPackageMissing } if uploaded.Size <= 0 { return Package{}, ErrUploadedPackageEmpty } if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil { return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } target := filepath.Join(s.cfg.DownloadsDir, name) resolved, err := filepath.Abs(target) if err != nil { return Package{}, err } base, _ := filepath.Abs(s.cfg.DownloadsDir) if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) { return Package{}, errors.New("path escape rejected") } if err := os.Chmod(uploaded.TempPath, 0o640); err != nil { return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } backup, err := backupExistingPackage(target) if err != nil { return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } if err := os.Rename(uploaded.TempPath, target); err != nil { _ = restorePackageBackup(target, backup) return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } rollbackFile := func(cause error) error { if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil { return fmt.Errorf("%w; rollback failed: %v", cause, rollbackErr) } return cause } info, err := os.Stat(target) if err != nil { return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)) } if info.Size() != uploaded.Size { return Package{}, rollbackFile(fmt.Errorf("%w: uploaded size changed during commit", ErrUploadedPackageStorageFailed)) } version := firstNonEmpty(opts.Version, detectVersion(name)) platform, arch := detectPlatform(name) platform = firstNonEmpty(opts.Platform, platform) arch = firstNonEmpty(opts.Arch, arch) product := detectProduct(name) pkg := Package{ ID: packageID(product, platform, arch, version), Name: product, Version: version, Platform: platform, Arch: arch, URL: firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) + "/downloads/" + name, SHA256: strings.ToLower(uploaded.SHA256), Size: uploaded.Size, Required: strings.Contains(strings.ToLower(product), "ymhut"), Enabled: true, FileName: name, UpdatedAt: info.ModTime().UTC().Format(time.RFC3339Nano), } var previousManifest fileSnapshot if opts.UpdateManifest { previousManifest, err = captureFile(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")) if err != nil { return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)) } if err := s.updateLegacyManifest(pkg, opts); err != nil { return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)) } } if s.store != nil { if _, err := s.store.UpsertReleasePackage(releasePackageRecord(pkg)); err != nil { cause := fmt.Errorf("%w: %v", ErrUploadedPackageIndexFailed, err) if opts.UpdateManifest { if restoreErr := previousManifest.restore(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")); restoreErr != nil { cause = fmt.Errorf("%w; manifest rollback failed: %v", cause, restoreErr) } } return Package{}, rollbackFile(cause) } } if backup != "" { _ = os.Remove(backup) } s.upsertPackageSnapshot(pkg) if s.store != nil { _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(uploaded.Size))}) } return pkg, nil } type fileSnapshot struct { data []byte mode os.FileMode existed bool } func captureFile(path string) (fileSnapshot, error) { info, err := os.Stat(path) if errors.Is(err, os.ErrNotExist) { return fileSnapshot{}, nil } if err != nil { return fileSnapshot{}, err } data, err := os.ReadFile(path) if err != nil { return fileSnapshot{}, err } return fileSnapshot{data: data, mode: info.Mode(), existed: true}, nil } func (s fileSnapshot) restore(path string) error { if !s.existed { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } mode := s.mode if mode == 0 { mode = 0o640 } return os.WriteFile(path, s.data, mode) } func (s *Service) upsertPackageSnapshot(item Package) { s.packageMu.Lock() replaced := false for index := range s.packages { if s.packages[index].FileName == item.FileName { s.packages[index] = item replaced = true break } } if !replaced { s.packages = append(s.packages, item) } sort.Slice(s.packages, func(i, j int) bool { return compareVersion(s.packages[i].Version, s.packages[j].Version) > 0 }) s.packageMu.Unlock() s.notifyChanged() } func backupExistingPackage(target string) (string, error) { if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) { return "", nil } else if err != nil { return "", err } file, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".backup-*") if err != nil { return "", err } backup := file.Name() if err := file.Close(); err != nil { _ = os.Remove(backup) return "", err } if err := os.Remove(backup); err != nil { return "", err } if err := os.Rename(target, backup); err != nil { return "", err } return backup, nil } func restorePackageBackup(target, backup string) error { if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { return err } if backup == "" { return nil } return os.Rename(backup, target) } func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error { path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") payload := s.legacyUpdateBase() payload["app_version"] = pkg.Version payload["download_url"] = pkg.URL payload["package_sha256"] = pkg.SHA256 payload["package_size"] = pkg.Size payload["updated_at"] = time.Now().UTC().Format(time.RFC3339) payload["download_mirrors"] = []map[string]any{{ "id": "primary", "name": "官方直连", "url": pkg.URL, "type": "direct", "sha256": pkg.SHA256, "enabled": true, }} if strings.TrimSpace(opts.Notes) != "" { payload["release_notes"] = opts.Notes } data, err := json.MarshalIndent(payload, "", " ") if err != nil { return err } return atomicWrite(path, append(data, '\n')) } func (s *Service) legacyUpdateBase() map[string]any { payload := map[string]any{} for _, path := range []string{ filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"), filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"), } { for key, value := range readJSON(path) { payload[key] = value } } if payload["app_version"] == nil { if value, ok := payload["appVersion"]; ok { payload["app_version"] = value } else if value, ok := payload["latestVersion"]; ok { payload["app_version"] = value } } if payload["manifest_version"] == nil { if value, ok := payload["manifestVersion"]; ok { payload["manifest_version"] = value } } return payload } func readJSON(path string) map[string]any { data, err := os.ReadFile(path) if err != nil { return map[string]any{} } var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { return map[string]any{} } return payload } func atomicWrite(path string, data []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o750); err != nil { return err } tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(tmpName, 0o640); err != nil { return err } return os.Rename(tmpName, path) } func requestBaseURL(r *http.Request, fallback string) string { if r != nil { scheme := firstForwardedHeader(r.Header.Get("X-Forwarded-Proto")) if scheme != "http" && scheme != "https" { if r.TLS != nil { scheme = "https" } else { scheme = "http" } } host := firstForwardedHeader(r.Header.Get("X-Forwarded-Host")) if !validForwardedHost(host) { host = strings.TrimSpace(r.Host) } if validForwardedHost(host) { return scheme + "://" + host } } return strings.TrimRight(fallback, "/") } func firstForwardedHeader(value string) string { return strings.ToLower(strings.TrimSpace(strings.Split(value, ",")[0])) } func validForwardedHost(value string) bool { if value == "" || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\\\\\r\n\t ") { return false } parsed, err := url.Parse("http://" + value) if err != nil || parsed.Host != value || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { return false } if port := parsed.Port(); port != "" { value, err := strconv.Atoi(port) if err != nil || value < 1 || value > 65535 { return false } } return true } var versionPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:\.\d+)?`) func detectVersion(name string) string { match := versionPattern.FindString(name) if match == "" { return "0.0.0" } return match } func detectPlatform(name string) (string, string) { lower := strings.ToLower(name) platform := "windows" if strings.Contains(lower, "appinstaller") || strings.HasSuffix(lower, ".msix") || strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msi") { platform = "windows" } arch := "x64" if strings.Contains(lower, "arm64") { arch = "arm64" } else if strings.Contains(lower, "x86") && !strings.Contains(lower, "x64") { arch = "x86" } return platform, arch } func detectProduct(name string) string { if strings.Contains(strings.ToLower(name), "ymhut") { return "YMhut Box" } return "YMhut Package" } func compareVersion(a, b string) int { as := strings.Split(a, ".") bs := strings.Split(b, ".") for len(as) < 4 { as = append(as, "0") } for len(bs) < 4 { bs = append(bs, "0") } for i := 0; i < 4; i++ { ai, _ := strconv.Atoi(as[i]) bi, _ := strconv.Atoi(bs[i]) if ai > bi { return 1 } if ai < bi { return -1 } } return 0 } func sha256File(path string) string { file, err := os.Open(path) if err != nil { return "" } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "" } return hex.EncodeToString(hash.Sum(nil)) } func safePackageName(name string) (string, error) { original := strings.TrimSpace(name) if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) { return "", ErrUnsafePackageName } name = filepath.Base(original) if isSupportedPackageName(name) { return name, nil } return "", ErrUnsupportedPackage } func formatBytes(value int64) string { if value < 1024 { return fmt.Sprintf("%d B", value) } next := float64(value) / 1024 for _, unit := range []string{"KB", "MB", "GB"} { if next < 1024 || unit == "GB" { return fmt.Sprintf("%.1f %s", next, unit) } next /= 1024 } return fmt.Sprintf("%d B", value) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }