package service import ( "archive/zip" "bytes" "context" "encoding/base64" "encoding/json" "io" "net" "net/http" "net/http/httptest" "path/filepath" "reflect" "strings" "sync/atomic" "testing" "github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/utils/xpack" "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers" "github.com/gin-gonic/gin" goi18n "github.com/nicksnyder/go-i18n/v2/i18n" "golang.org/x/text/language" "gorm.io/gorm" ) type iconSyncTransport struct { providers.MultiNodeProvider transport *http.Transport } func (p iconSyncTransport) LoadRequestTransport() *http.Transport { return p.transport } func iconSyncDB(t *testing.T) *gorm.DB { t.Helper() db := appCacheTestDB(t) if err := db.AutoMigrate(&model.Tag{}, &model.AppInstall{}, &model.AppInstallResource{}); err != nil { t.Fatal(err) } if err := db.Create(&[]model.Setting{{Key: "SystemVersion", Value: "2.4.0"}, {Key: "AppStoreSyncStatus", Value: constant.StatusSyncSuccess}}).Error; err != nil { t.Fatal(err) } oldI18n := global.I18n global.I18n = goi18n.NewLocalizer(goi18n.NewBundle(language.English), "en") t.Cleanup(func() { global.I18n = oldI18n }) return db } func TestOfficialIconSyncNeverDownloadsImages(t *testing.T) { iconSyncDB(t) var calls atomic.Int32 cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) w.WriteHeader(http.StatusServiceUnavailable) })) t.Cleanup(cdn.Close) taskLog := &task.Task{Logger: global.LOG} for _, oldIcon := range []string{"", "app_demo.png?etag=old", "b2xk", cdn.URL + "/old.png"} { t.Run(oldIcon, func(t *testing.T) { icon := cdn.URL + "/demo.png?signature=a%2Bb&v=one" def := dto.AppDefine{Icon: icon, AppProperty: dto.AppProperty{Key: "demo", Type: "website"}, Versions: []dto.AppConfigVersion{{Name: "1.0", AppForm: dto.AppForm{}}}} apps := getApps([]model.App{{Key: "demo", Icon: oldIcon}}, []dto.AppDefine{def}, "2.4.0", taskLog) ctx := appSyncContext{baseRemoteUrl: cdn.URL, list: &dto.AppList{LastModified: 42}} result := ctx.processOneApp(appWorkItem{app: apps["demo"], appDef: def}) if result.Icon != icon || len(result.Details) != 1 || result.Details[0].Version != "1.0" || calls.Load() != 0 { t.Fatalf("sync changed metadata or fetched a CDN image: %+v, calls=%d", result, calls.Load()) } }) } for _, invalid := range []string{"", "javascript:alert(1)", "https://user:secret@example.com/a.png", "//example.com/a.png", "https://"} { def := dto.AppDefine{Icon: invalid, AppProperty: dto.AppProperty{Key: "demo"}} apps := getApps([]model.App{{Key: "demo", Icon: "b2xk"}}, []dto.AppDefine{def}, "2.4.0", taskLog) if apps["demo"].Icon != "b2xk" { t.Fatalf("invalid icon %q erased history", invalid) } } } func TestOfficialIconSameRevisionRefreshIsReadOnlyUntilSync(t *testing.T) { db := iconSyncDB(t) apps := []model.App{ {Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true}, {Key: "demo", Name: "original", Type: "website", Status: constant.AppNormal, Icon: "b2xk", LastModified: 7}, {Key: "future", Type: "website", Status: constant.AppNormal, Icon: "b2xk"}, {Key: "local-demo", Type: "website", Resource: constant.AppResourceLocal, Status: constant.AppNormal, Icon: "bG9jYWw="}, {Key: "custom-demo", Type: "website", Resource: constant.AppResourceCustom, Status: constant.AppNormal, Icon: "Y3VzdG9t"}, {Key: "down", Type: "website", Status: constant.AppTakeDown, Icon: "b2xk"}, } if err := db.Create(&apps).Error; err != nil { t.Fatal(err) } detail := model.AppDetail{AppId: apps[1].ID, Version: "1.0", Params: "keep", DockerCompose: "keep", Update: false} if err := db.Create(&detail).Error; err != nil { t.Fatal(err) } if err := db.First(&detail, detail.ID).Error; err != nil { t.Fatal(err) } var imageCalls, indexCalls atomic.Int32 var failIndex atomic.Bool list := dto.AppList{LastModified: 42} for _, app := range apps { version := 0.0 if app.Key == "future" { version = 9.9 } list.Apps = append(list.Apps, dto.AppDefine{Icon: "https://cdn.example/" + app.Key + ".png?signature=a%2Bb", AppProperty: dto.AppProperty{Key: app.Key, Version: version}}) } var zipped bytes.Buffer zw := zip.NewWriter(&zipped) w, err := zw.Create("1panel.json") if err != nil { t.Fatal(err) } if err := json.NewEncoder(w).Encode(list); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"): _, _ = io.WriteString(w, "42") case strings.HasSuffix(r.URL.Path, "1panel.json.zip"): indexCalls.Add(1) if failIndex.Load() { w.WriteHeader(http.StatusServiceUnavailable) return } _, _ = w.Write(zipped.Bytes()) default: imageCalls.Add(1) w.WriteHeader(http.StatusServiceUnavailable) } })) t.Cleanup(server.Close) transport := server.Client().Transport.(*http.Transport).Clone() transport.TLSClientConfig.InsecureSkipVerify = true transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String()) } oldProvider := xpack.MultiNodeProvider xpack.MultiNodeProvider = iconSyncTransport{MultiNodeProvider: oldProvider, transport: transport} t.Cleanup(func() { xpack.MultiNodeProvider = oldProvider; transport.CloseIdleConnections() }) global.Dir.ResourceDir = t.TempDir() global.Dir.IconCacheDir = t.TempDir() appCacheTestWrite(t, filepath.Join(global.Dir.IconCacheDir, "app_mysql.png"), "keep old image") encodedList, err := json.Marshal(list) if err != nil { t.Fatal(err) } appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), string(encodedList)) res, err := (AppService{}).GetAppUpdate() if err != nil || res.CanUpdate || res.AppList == nil || indexCalls.Load() != 0 { t.Fatalf("same revision/missing files forced a full sync: %+v, %v", res, err) } var original model.App if err := db.First(&original, apps[1].ID).Error; err != nil || original.Icon != apps[1].Icon { t.Fatalf("update check modified icon: %+v, %v", original, err) } var writes atomic.Int32 if err := db.Callback().Update().After("gorm:update").Register("test:icon-writes", func(tx *gorm.DB) { if values, ok := tx.Statement.Dest.(map[string]interface{}); ok && tx.Statement.Table == "apps" && values["icon"] != nil { writes.Add(1) } }); err != nil { t.Fatal(err) } var shared *appSyncContext for i := 0; i < 2; i++ { if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err != nil { t.Fatal(err) } if shared == nil || !shared.skipMetaSync || writes.Load() != 2 || imageCalls.Load() != 0 || indexCalls.Load() != 0 { t.Fatalf("unexpected same revision sync: writes=%d, image calls=%d, index calls=%d, context=%+v", writes.Load(), imageCalls.Load(), indexCalls.Load(), shared) } } var stored model.App if err := db.First(&stored, apps[1].ID).Error; err != nil { t.Fatal(err) } want := original want.Icon = list.Apps[1].Icon if !reflect.DeepEqual(stored, want) { t.Fatalf("light refresh changed non-icon fields: got %+v, want %+v", stored, want) } for _, original := range apps[2:] { stored = model.App{} if err := db.First(&stored, original.ID).Error; err != nil { t.Fatal(err) } if stored.Icon != original.Icon || stored.Status != original.Status { t.Fatalf("filtered app was changed: %+v", stored) } } var storedDetail model.AppDetail if err := db.First(&storedDetail, detail.ID).Error; err != nil || !reflect.DeepEqual(storedDetail, detail) { t.Fatalf("light refresh touched app details: %+v, %v", storedDetail, err) } failIndex.Store(true) appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), "{invalid") if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err == nil || writes.Load() != 2 || indexCalls.Load() != 1 { t.Fatal("failed index read was accepted or modified icons") } } func TestAppIconURLsReachListDetailAndInstalled(t *testing.T) { db := iconSyncDB(t) for _, icon := range []string{"https://cdn.example/icon.png?signature=a%2Bb", "app_legacy.png?etag=old", "bG9jYWw="} { app := model.App{Key: "demo-" + icon[:4], Name: "demo", Type: "website", Status: constant.AppNormal, Icon: icon} app.Name = app.Key if err := db.Create(&app).Error; err != nil { t.Fatal(err) } ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil) got, err := (AppService{}).PageApp(ctx, request.AppSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 10}, Name: app.Key}) if err != nil || len(got.Items) != 1 { t.Fatalf("list failed: %+v, %v", got, err) } want := "" if strings.HasPrefix(icon, "https:") { want = icon } if got.Items[0].Icon != want { t.Fatalf("list icon = %q, want %q", got.Items[0].Icon, want) } detail, err := (AppService{}).GetApp(ctx, app.Key) if err != nil || detail.Icon != icon { t.Fatalf("detail icon = %+v, %v", detail, err) } installed, err := handleInstalled([]model.AppInstall{{App: app, Status: constant.StatusRunning}}, false, false, false) if err != nil || len(installed) != 1 || installed[0].Icon != want { t.Fatalf("installed icon = %+v, %v", installed, err) } } } func TestLocalAppLogoKeepsBase64Delivery(t *testing.T) { db := iconSyncDB(t) dir := t.TempDir() const image = "\x89PNG\r\n\x1a\nlocal-icon" appCacheTestWrite(t, filepath.Join(dir, "data.yml"), "additionalProperties:\n key: demo\n name: Local Demo\n type: website\n") appCacheTestWrite(t, filepath.Join(dir, "README.md"), "local application") appCacheTestWrite(t, filepath.Join(dir, "logo.png"), image) app, err := handleLocalApp(dir) if err != nil || app.Resource != constant.AppResourceLocal || app.Icon != base64.StdEncoding.EncodeToString([]byte(image)) { t.Fatalf("local logo parsing changed: %+v, %v", app, err) } if err := db.Create(app).Error; err != nil { t.Fatal(err) } data, source, etag, err := (AppService{}).GetAppIcon(app.Key) if err != nil || string(data) != image || source != "" || etag != "" { t.Fatalf("local logo no longer uses historical delivery: %q, %q, %q, %v", data, source, etag, err) } }