feat: implement app icon management and caching mechanism (#11719)

* feat: implement app icon management and caching mechanism

* feat: enhance app synchronization and icon management

- Refactored app synchronization tasks to improve structure and clarity.
- Introduced shared context for managing app sync state and metadata.
- Updated icon handling to ensure proper content type and caching.
- Adjusted cache control settings for app icons to extend cache duration.
- Improved error handling and logging during app sync processes.

* refactor: streamline app icon retrieval by removing unused fileName return

- Removed the fileName return value from GetAppIcon function as it was not utilized.
- Enhanced the GetAppIcon method in BaseApi to improve clarity and maintainability.
- Ensured proper caching headers are set for app icons.
This commit is contained in:
KOMATA
2026-01-27 14:02:38 +08:00
committed by GitHub
parent 1767b1a9c1
commit fc5e583481
8 changed files with 405 additions and 116 deletions

View File

@@ -2,12 +2,12 @@ package v2
import (
"net/http"
"time"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/gin-gonic/gin"
)
@@ -210,15 +210,28 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
helper.BadRequest(c, err)
return
}
iconBytes, err := appService.GetAppIcon(appKey)
iconBytes, _, etag, err := appService.GetAppIcon(appKey)
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
c.Data(http.StatusOK, "image/png", iconBytes)
if len(iconBytes) == 0 {
c.Status(http.StatusNoContent)
return
}
c.Header("Cache-Control", "public, max-age=2592000")
if etag != "" {
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified)
return
}
}
c.Data(http.StatusOK, appicon.ContentTypePNG, iconBytes)
}
// @Tags App

View File

@@ -25,6 +25,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
@@ -46,7 +47,7 @@ type IAppService interface {
GetAppUpdate() (*response.AppUpdateRes, error)
GetAppDetailByID(id uint) (*response.AppDetailDTO, error)
SyncAppListFromLocal(taskID string)
GetAppIcon(key string) ([]byte, error)
GetAppIcon(key string) ([]byte, string, string, error)
GetAppDetailByKey(appKey, version string) (response.AppDetailSimpleDTO, error)
}
@@ -925,26 +926,43 @@ func (a AppService) SyncAppListFromRemote(taskID string) (err error) {
if err != nil {
return err
}
syncTask.AddSubTask(task.GetTaskName(i18n.GetMsgByKey("App"), task.TaskSync, task.TaskScopeAppStore), a.syncAppStoreTask, nil)
var sharedCtx *appSyncContext
syncTask.AddSubTask(task.GetTaskName(i18n.GetMsgByKey("App"), task.TaskSync, task.TaskScopeAppStore), a.createSyncAppStoreTask(&sharedCtx), nil)
syncTask.AddSubTask(i18n.GetMsgByKey("SyncAppDetail"), a.createSyncAppStoreMetaTask(&sharedCtx), nil)
go func() {
if err := syncTask.Execute(); err != nil {
_ = NewISettingService().Update("AppStoreLastModified", "0")
_ = NewISettingService().Update("AppStoreSyncStatus", constant.StatusError)
return
}
}()
return nil
}
func (a AppService) GetAppIcon(key string) ([]byte, error) {
func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
app, err := appRepo.GetFirst(appRepo.WithKey(key))
if err != nil {
return nil, err
return nil, "", "", err
}
if appicon.IsIconFile(app.Icon) {
fileName, etag := appicon.ParseIconField(app.Icon)
iconBytes, err := appicon.ReadIconFile(fileName)
if err != nil {
global.LOG.Warnf("[AppIcon] read icon file failed key=%s, file=%s, err=%v", key, fileName, err)
return nil, "", "", nil
}
return iconBytes, fileName, etag, nil
}
iconBytes, err := base64.StdEncoding.DecodeString(app.Icon)
if err != nil {
return nil, err
global.LOG.Warnf("[AppIcon] decode base64 icon failed key=%s, err=%v", key, err)
return nil, "", "", nil
}
return iconBytes, nil
return iconBytes, "", "", nil
}

View File

@@ -2,11 +2,11 @@ package service
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -15,6 +15,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
@@ -30,92 +31,110 @@ type appSyncContext struct {
list *dto.AppList
oldAppIds []uint
appTags []*model.AppTag
skipMetaSync bool
pendingIcons map[string]string
}
func (a AppService) syncAppStoreTask(t *task.Task) (err error) {
updateRes, err := a.GetAppUpdate()
if err != nil {
return err
}
if !updateRes.CanUpdate {
if updateRes.IsSyncing {
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
return nil
}
global.LOG.Infof("[AppStore] Appstore is up to date")
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
return nil
}
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("AppStore") + " " + i18n.GetMsgByKey("TaskSync"))
list := &dto.AppList{}
if updateRes.AppList == nil {
list, err = getAppList()
updateRes, err := a.GetAppUpdate()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("CheckAppStoreUpdate"), err)
return err
}
} else {
list = updateRes.AppList
if !updateRes.CanUpdate {
if updateRes.IsSyncing {
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
return nil
}
global.LOG.Infof("[AppStore] Appstore is up to date")
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
*sharedCtx = &appSyncContext{skipMetaSync: true}
t.LogSuccess(i18n.GetMsgByKey("AppStore") + " " + i18n.GetMsgByKey("TaskSync"))
return nil
}
list := &dto.AppList{}
if updateRes.AppList == nil {
list, err = getAppList()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("DownloadAppList"), err)
return err
}
} else {
list = updateRes.AppList
}
settingService := NewISettingService()
_ = settingService.Update("AppStoreSyncStatus", constant.StatusSyncing)
setting, err := settingService.GetSettingInfo()
if err != nil {
t.LogFailedWithErr("GetSettingInfo", err)
return err
}
ctx := &appSyncContext{
task: t,
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.LoadRequestTransport()},
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.CONF.RemoteURL.AppRepo, global.CONF.Base.Mode),
systemVersion: setting.SystemVersion,
settingService: settingService,
list: list,
appTags: make([]*model.AppTag, 0),
pendingIcons: make(map[string]string),
}
if err = SyncTags(list.Extra); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncTags"), err)
return err
}
deleteCustomApp()
oldApps, err := appRepo.GetBy(appRepo.WithNotLocal())
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("LoadLocalApps"), err)
return err
}
ctx.oldAppIds = make([]uint, 0, len(oldApps))
for _, old := range oldApps {
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
}
ctx.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t)
var addCount, updateCount, deleteCount int
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("PersistApps"), err)
return err
}
_ = settingService.Update("AppStoreSyncStatus", constant.StatusSyncSuccess)
_ = settingService.Update("AppStoreLastModified", strconv.Itoa(list.LastModified))
global.LOG.Infof("[AppStore] Appstore sync completed")
*sharedCtx = ctx
t.Logf("App store sync completed: total=%d, add=%d, update=%d, delete=%d", len(ctx.appsMap), addCount, updateCount, deleteCount)
t.LogSuccess(i18n.GetMsgByKey("AppStore") + " " + i18n.GetMsgByKey("TaskSync"))
return nil
}
settingService := NewISettingService()
_ = settingService.Update("AppStoreSyncStatus", constant.StatusSyncing)
setting, err := settingService.GetSettingInfo()
if err != nil {
return err
}
ctx := &appSyncContext{
task: t,
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.LoadRequestTransport()},
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.CONF.RemoteURL.AppRepo, global.CONF.Base.Mode),
systemVersion: setting.SystemVersion,
settingService: settingService,
list: list,
appTags: make([]*model.AppTag, 0),
}
if err = SyncTags(list.Extra); err != nil {
return err
}
deleteCustomApp()
oldApps, err := appRepo.GetBy(appRepo.WithNotLocal())
if err != nil {
return err
}
ctx.oldAppIds = make([]uint, 0, len(oldApps))
for _, old := range oldApps {
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
}
ctx.appsMap = getApps(oldApps, list.Apps, setting.SystemVersion, t)
if err = ctx.syncAppIconsAndDetails(); err != nil {
return err
}
if err = ctx.classifyAndPersistApps(); err != nil {
return err
}
_ = settingService.Update("AppStoreSyncStatus", constant.StatusSyncSuccess)
_ = settingService.Update("AppStoreLastModified", strconv.Itoa(list.LastModified))
global.LOG.Infof("[AppStore] Appstore sync completed")
return nil
}
func (c *appSyncContext) syncAppIconsAndDetails() error {
c.task.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
global.LOG.Infof("[AppStore] sync app detail start, total: %d", len(c.list.Apps))
downloadIconNum := 0
total := len(c.list.Apps)
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
for _, l := range c.list.Apps {
downloadIconNum++
if downloadIconNum%10 == 0 {
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), downloadIconNum, total)
var (
icon200Count = 0
icon304Count = 0
iconFailCount = 0
)
for i, l := range c.list.Apps {
if (i+1)%10 == 0 {
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, total)
}
app, ok := c.appsMap[l.AppProperty.Key]
@@ -123,11 +142,20 @@ func (c *appSyncContext) syncAppIconsAndDetails() error {
continue
}
iconStr := c.downloadAppIcon(l.Icon)
if iconStr == "" {
global.LOG.Infof("[AppStore] save failed url=%s", l.Icon)
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
if hasPending {
status, iconField := c.downloadAppIcon(iconUrl, l.AppProperty.Key, app.Icon)
switch status {
case http.StatusOK:
app.Icon = iconField
icon200Count++
case http.StatusNotModified:
icon304Count++
default:
global.LOG.Warnf("[AppStore] download icon failed url=%s, appKey=%s", iconUrl, l.AppProperty.Key)
iconFailCount++
}
}
app.Icon = iconStr
app.TagsKey = l.AppProperty.Tags
if l.AppProperty.Recommend > 0 {
@@ -180,32 +208,113 @@ func (c *appSyncContext) syncAppIconsAndDetails() error {
c.appsMap[l.AppProperty.Key] = app
}
global.LOG.Infof("[AppStore] download icon success: %d, total: %d",
downloadIconNum, total)
global.LOG.Infof("[AppStore] icon download completed - total: %d, success(200): %d, cached(304): %d, failed: %d",
total, icon200Count, icon304Count, iconFailCount)
c.task.LogSuccess(i18n.GetMsgByKey("SyncAppDetail"))
return nil
}
func (c *appSyncContext) downloadAppIcon(iconUrl string) string {
iconStr := ""
func (c *appSyncContext) downloadAppIcon(iconUrl, appKey, oldIcon string) (status int, iconField string) {
existingEtag := appicon.GetETagFromIconField(oldIcon)
code, iconRes, err := req_helper.HandleRequestWithClient(&c.httpClient, iconUrl, http.MethodGet, constant.TimeOut20s)
if err == nil {
if code == http.StatusOK {
if len(iconRes) > 0 {
if iconRes[0] != '<' {
iconStr = base64.StdEncoding.EncodeToString(iconRes)
}
}
} else {
global.LOG.Infof("[AppStore] download failed status=%d", code)
}
reqHeaders := make(map[string]string)
if existingEtag != "" {
reqHeaders["If-None-Match"] = existingEtag
}
resp, err := req_helper.HandleRequestWithHeaders(&c.httpClient, iconUrl, http.MethodGet, constant.TimeOut20s, reqHeaders)
if err != nil {
global.LOG.Warnf("[AppStore] request icon failed url=%s, err=%v", iconUrl, err)
return 0, ""
}
if resp.StatusCode == http.StatusNotModified {
return http.StatusNotModified, ""
}
if resp.StatusCode != http.StatusOK {
global.LOG.Warnf("[AppStore] download icon failed url=%s, status=%d", iconUrl, resp.StatusCode)
return 0, ""
}
if len(resp.Body) == 0 {
global.LOG.Warnf("[AppStore] download icon empty body url=%s", iconUrl)
return 0, ""
}
if resp.Body[0] == '<' {
global.LOG.Warnf("[AppStore] download icon got HTML response url=%s", iconUrl)
return 0, ""
}
contentType := resp.Header.Get("Content-Type")
ct := strings.TrimSpace(strings.Split(contentType, ";")[0])
if strings.ToLower(ct) != "image/png" {
global.LOG.Warnf("[AppStore] unexpected icon content-type: %s, expected image/png, url=%s", ct, iconUrl)
}
fileName, err := appicon.WriteIconFile(appKey, resp.Body)
if err != nil {
global.LOG.Warnf("[AppStore] write icon file failed appKey=%s, err=%v", appKey, err)
return 0, ""
}
newEtag := resp.Header.Get("ETag")
iconField = appicon.BuildIconField(fileName, newEtag)
return http.StatusOK, iconField
}
func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error {
return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
ctx := *sharedCtx
if ctx == nil {
global.LOG.Warnf("[AppStore] meta sync skipped: shared context is nil")
t.Log(i18n.GetMsgByKey("SyncAppDetail") + " skipped: shared context is nil")
return nil
}
if ctx.skipMetaSync {
global.LOG.Infof("[AppStore] meta sync skipped: no update needed")
t.Log(i18n.GetMsgByKey("SyncAppDetail") + " skipped: no update needed")
return nil
}
if ctx.list == nil || ctx.appsMap == nil {
global.LOG.Errorf("[AppStore] meta sync failed: shared context data not initialized")
err := fmt.Errorf("shared context data not initialized")
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err
}
t.Logf("%s: %d apps", i18n.GetMsgByKey("SyncAppDetail"), len(ctx.list.Apps))
ctx.task = t
ctx.appTags = make([]*model.AppTag, 0)
if err = ctx.syncAppIconsAndDetails(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err
}
if err = ctx.classifyAndPersistApps(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("PersistAppDetails"), err)
return err
}
global.LOG.Infof("[AppStore] Appstore meta sync completed")
t.LogSuccess(i18n.GetMsgByKey("SyncAppDetail"))
return nil
}
return iconStr
}
func (c *appSyncContext) classifyAndPersistApps() (err error) {
var addCount, updateCount, deleteCount int
return c.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount)
}
func (c *appSyncContext) classifyAndPersistAppsWithStats(addCount, updateCount, deleteCount *int) (err error) {
tags, _ := tagRepo.All()
var (
addAppArray []model.App
@@ -233,6 +342,10 @@ func (c *appSyncContext) classifyAndPersistApps() (err error) {
}
}
*addCount = len(addAppArray)
*updateCount = len(updateAppArray)
*deleteCount = len(deleteAppArray)
tx, ctx := getTxAndContext()
defer func() {
if err != nil {
@@ -260,9 +373,11 @@ func (c *appSyncContext) classifyAndPersistApps() (err error) {
tagMap[tag.Key] = tag.ID
}
for _, update := range updateAppArray {
if err = appRepo.Save(ctx, &update); err != nil {
return
if len(updateAppArray) > 0 {
for _, update := range updateAppArray {
if err = appRepo.Save(ctx, &update); err != nil {
return
}
}
}
@@ -274,6 +389,7 @@ func (c *appSyncContext) classifyAndPersistApps() (err error) {
deleteDetails []model.AppDetail
)
totalDetails := 0
for _, app := range apps {
for _, tag := range app.TagsKey {
tagId, ok := tagMap[tag]
@@ -289,6 +405,7 @@ func (c *appSyncContext) classifyAndPersistApps() (err error) {
}
for _, d := range app.Details {
totalDetails++
d.AppId = app.ID
if d.ID == 0 {
addDetails = append(addDetails, d)
@@ -324,9 +441,11 @@ func (c *appSyncContext) classifyAndPersistApps() (err error) {
}
}
for _, u := range updateDetails {
if err = appDetailRepo.Update(ctx, u); err != nil {
return
if len(updateDetails) > 0 {
for _, u := range updateDetails {
if err = appDetailRepo.Update(ctx, u); err != nil {
return
}
}
}

View File

@@ -1295,8 +1295,9 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
return appDetails
}
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App {
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) (map[string]model.App, map[string]string) {
apps := make(map[string]model.App, len(oldApps))
pendingIcons := make(map[string]string, len(items))
for _, old := range oldApps {
old.Status = constant.AppTakeDown
apps[old.Key] = old
@@ -1337,9 +1338,12 @@ func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, t
app.Architectures = strings.Join(config.Architectures, ",")
app.GpuSupport = config.GpuSupport
app.BatchInstallSupport = config.BatchInstallSupport
if item.Icon != "" {
pendingIcons[key] = item.Icon
}
apps[key] = app
}
return apps
return apps, pendingIcons
}
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {

View File

@@ -34,6 +34,7 @@ type SystemDir struct {
AppDir string
ResourceDir string
IconCacheDir string
AppResourceDir string
AppInstallDir string
LocalAppResourceDir string

View File

@@ -21,6 +21,7 @@ func Init() {
global.Dir.AppDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/apps"))
global.Dir.ResourceDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/resource"))
global.Dir.IconCacheDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/resource/icon"))
global.Dir.AppResourceDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/resource/apps"))
global.Dir.AppInstallDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/apps"))
global.Dir.LocalAppResourceDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/resource/apps/local"))

View File

@@ -0,0 +1,93 @@
package appicon
import (
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/global"
)
const IconPrefix = "app_"
func IsIconFile(icon string) bool {
return strings.HasPrefix(icon, IconPrefix)
}
func ParseIconField(icon string) (fileName, etag string) {
if !IsIconFile(icon) {
return "", ""
}
parts := strings.SplitN(icon, "?", 2)
fileName = parts[0]
if len(parts) == 2 {
values, err := url.ParseQuery(parts[1])
if err == nil {
etag = values.Get("etag")
}
}
return
}
func BuildIconField(fileName, etag string) string {
if etag == "" {
return fileName
}
return fmt.Sprintf("%s?etag=%s", fileName, url.QueryEscape(etag))
}
func GetIconFilePath(fileName string) string {
return path.Join(global.Dir.IconCacheDir, fileName)
}
func BuildIconFileName(appKey, ext string) string {
return fmt.Sprintf("%s%s.%s", IconPrefix, appKey, ext)
}
const ContentTypePNG = "image/png"
func WriteIconFile(appKey string, data []byte) (fileName string, err error) {
fileName = BuildIconFileName(appKey, "png")
filePath := GetIconFilePath(fileName)
_ = CleanOldIconFiles(appKey)
err = os.WriteFile(filePath, data, 0644)
return
}
func CleanOldIconFiles(appKey string) error {
pattern := path.Join(global.Dir.IconCacheDir, fmt.Sprintf("%s%s.*", IconPrefix, appKey))
matches, err := filepath.Glob(pattern)
if err != nil {
return err
}
keepFileName := BuildIconFileName(appKey, "png")
for _, match := range matches {
baseName := filepath.Base(match)
if baseName == keepFileName {
continue
}
_ = os.Remove(match)
}
return nil
}
func ReadIconFile(fileName string) ([]byte, error) {
filePath := GetIconFilePath(fileName)
return os.ReadFile(filePath)
}
func IconFileExists(fileName string) bool {
filePath := GetIconFilePath(fileName)
_, err := os.Stat(filePath)
return err == nil
}
func GetETagFromIconField(icon string) string {
_, etag := ParseIconField(icon)
return etag
}

View File

@@ -83,6 +83,46 @@ func HandleRequestWithClient(client *http.Client, url, method string, timeout in
return resp.StatusCode, body, nil
}
type RequestResponse struct {
StatusCode int
Body []byte
Header http.Header
}
func HandleRequestWithHeaders(client *http.Client, url, method string, timeout int, reqHeaders map[string]string) (*RequestResponse, error) {
defer func() {
if r := recover(); r != nil {
global.LOG.Errorf("handle request failed, error message: %v", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return nil, err
}
for k, v := range reqHeaders {
request.Header.Set(k, v)
}
resp, err := client.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return &RequestResponse{
StatusCode: resp.StatusCode,
Body: body,
Header: resp.Header,
}, nil
}
func RequestFile(url, method string, timeout int) (io.ReadCloser, context.CancelFunc, error) {
defer func() {
if r := recover(); r != nil {