mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-30 08:05:43 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5888e9ea33 | ||
|
|
8bae2f6779 |
@@ -207,6 +207,7 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Param key path string true "app key"
|
// @Param key path string true "app key"
|
||||||
// @Success 200 {file} file "app icon"
|
// @Success 200 {file} file "app icon"
|
||||||
|
// @Success 302 "redirect to the official icon URL"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /apps/icon/:key [get]
|
// @Router /apps/icon/:key [get]
|
||||||
@@ -216,12 +217,18 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
|
|||||||
helper.BadRequest(c, err)
|
helper.BadRequest(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
iconBytes, filename, etag, err := appService.GetAppIcon(appKey)
|
iconBytes, source, etag, err := appService.GetAppIcon(appKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if appicon.IsRemoteURL(source) {
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Redirect(http.StatusFound, source)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(iconBytes) == 0 {
|
if len(iconBytes) == 0 {
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@@ -229,7 +236,7 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
|
|||||||
|
|
||||||
c.Header("Cache-Control", "public, max-age=2592000")
|
c.Header("Cache-Control", "public, max-age=2592000")
|
||||||
|
|
||||||
if etag != "" && filename != "" {
|
if etag != "" && source != "" {
|
||||||
c.Header("ETag", etag)
|
c.Header("ETag", etag)
|
||||||
if c.GetHeader("If-None-Match") == etag {
|
if c.GetHeader("If-None-Match") == etag {
|
||||||
c.Status(http.StatusNotModified)
|
c.Status(http.StatusNotModified)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppIconEndpointPreservesRemoteAndHistoricalSources(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "icons.db")), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
|
||||||
|
global.DB = db
|
||||||
|
global.Dir.IconCacheDir = t.TempDir()
|
||||||
|
global.LOG = logrus.New()
|
||||||
|
global.LOG.SetOutput(io.Discard)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
var imageRequests atomic.Int32
|
||||||
|
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
imageRequests.Add(1)
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
t.Cleanup(cdn.Close)
|
||||||
|
remoteURL := cdn.URL + "/icon.png?token=a%2Bb&v=42#image"
|
||||||
|
image := "\x89PNG\r\n\x1a\noriginal-icon"
|
||||||
|
fileName := "app_php.png"
|
||||||
|
if err := os.WriteFile(filepath.Join(global.Dir.IconCacheDir, fileName), []byte(image), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := model.App{Key: "php", Resource: constant.AppResourceRemote}
|
||||||
|
if err := db.Create(&app).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/v2/apps/icon/:key", (&BaseApi{}).GetAppIcon)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
icon string
|
||||||
|
ifNoneMatch string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
etag string
|
||||||
|
}{
|
||||||
|
{"remote URL", remoteURL, "", http.StatusFound, "", ""},
|
||||||
|
{"remote URL ignores legacy etag", remoteURL, `"old"`, http.StatusFound, "", ""},
|
||||||
|
{"historical file", appicon.BuildIconField(fileName, `"42"`), "", http.StatusOK, image, `"42"`},
|
||||||
|
{"historical file cached", appicon.BuildIconField(fileName, `"42"`), `"42"`, http.StatusNotModified, "", `"42"`},
|
||||||
|
{"historical file changed", appicon.BuildIconField(fileName, `"42"`), `"old"`, http.StatusOK, image, `"42"`},
|
||||||
|
{"historical base64", base64.StdEncoding.EncodeToString([]byte(image)), "", http.StatusOK, image, ""},
|
||||||
|
{"missing file", "app_missing.png?etag=old", "", http.StatusNoContent, "", ""},
|
||||||
|
{"invalid base64", "not-an-icon", "", http.StatusNoContent, "", ""},
|
||||||
|
{"invalid URL", "javascript:alert(1)", "", http.StatusNoContent, "", ""},
|
||||||
|
{"empty icon", "", "", http.StatusNoContent, "", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if err := db.Model(&app).UpdateColumn("icon", tc.icon).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v2/apps/icon/php?operateNode=child", nil)
|
||||||
|
req.Header.Set("If-None-Match", tc.ifNoneMatch)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
if res.Code != tc.status {
|
||||||
|
t.Fatalf("status = %d, want %d; body = %s", res.Code, tc.status, res.Body.String())
|
||||||
|
}
|
||||||
|
if res.Header().Get("ETag") != tc.etag {
|
||||||
|
t.Fatalf("etag = %q, want %q", res.Header().Get("ETag"), tc.etag)
|
||||||
|
}
|
||||||
|
if tc.status == http.StatusFound {
|
||||||
|
if res.Header().Get("Location") != remoteURL || res.Header().Get("Cache-Control") != "no-cache" {
|
||||||
|
t.Fatalf("redirect URL or cache policy was changed: %v", res.Header())
|
||||||
|
}
|
||||||
|
} else if res.Body.String() != tc.body {
|
||||||
|
t.Fatalf("body = %q, want %q", res.Body.String(), tc.body)
|
||||||
|
}
|
||||||
|
if imageRequests.Load() != 0 {
|
||||||
|
t.Fatalf("icon endpoint fetched the image %d times", imageRequests.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,7 @@ func (b *BaseApi) GetServices(c *gin.Context) {
|
|||||||
// @Tags App
|
// @Tags App
|
||||||
// @Summary Search app update version by install id
|
// @Summary Search app update version by install id
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
// @Param request body request.AppUpdateVersion true "request"
|
||||||
// @Success 200 {array} dto.AppVersion
|
// @Success 200 {array} dto.AppVersion
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ type PortUpdate struct {
|
|||||||
type AppUpdateVersion struct {
|
type AppUpdateVersion struct {
|
||||||
AppInstallID uint `json:"appInstallID" validate:"required"`
|
AppInstallID uint `json:"appInstallID" validate:"required"`
|
||||||
UpdateVersion string `json:"updateVersion"`
|
UpdateVersion string `json:"updateVersion"`
|
||||||
|
LoadCompose *bool `json:"loadCompose,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppInstallSortItem struct {
|
type AppInstallSortItem struct {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type AppDTO struct {
|
|||||||
type AppItem struct {
|
type AppItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -194,5 +195,6 @@ type AppConfig struct {
|
|||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||||
Params []AppParam `json:"params"`
|
Params []AppParam `json:"params"`
|
||||||
RawCompose string `json:"rawCompose"`
|
RawCompose string `json:"rawCompose"`
|
||||||
|
IsEdit bool `json:"isEdit"`
|
||||||
request.AppContainerConfig
|
request.AppContainerConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,7 @@ type IAppRepo interface {
|
|||||||
BatchCreate(ctx context.Context, apps []model.App) error
|
BatchCreate(ctx context.Context, apps []model.App) error
|
||||||
Create(ctx context.Context, app *model.App) error
|
Create(ctx context.Context, app *model.App) error
|
||||||
Save(ctx context.Context, app *model.App) error
|
Save(ctx context.Context, app *model.App) error
|
||||||
|
UpdateIcon(id uint, icon string) error
|
||||||
BatchDelete(ctx context.Context, apps []model.App) error
|
BatchDelete(ctx context.Context, apps []model.App) error
|
||||||
DeleteByIDs(ctx context.Context, ids []uint) error
|
DeleteByIDs(ctx context.Context, ids []uint) error
|
||||||
DeleteBy(opts ...DBOption) error
|
DeleteBy(opts ...DBOption) error
|
||||||
@@ -41,6 +43,10 @@ func NewIAppRepo() IAppRepo {
|
|||||||
return &AppRepo{}
|
return &AppRepo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AppRepo) UpdateIcon(id uint, icon string) error {
|
||||||
|
return getDb(WithByID(id), a.WithResource(constant.AppResourceRemote)).Model(&model.App{}).UpdateColumn("icon", icon).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (a AppRepo) WithByLikeName(name string) DBOption {
|
func (a AppRepo) WithByLikeName(name string) DBOption {
|
||||||
return func(g *gorm.DB) *gorm.DB {
|
return func(g *gorm.DB) *gorm.DB {
|
||||||
if len(name) == 0 {
|
if len(name) == 0 {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type IAppDetailRepo interface {
|
|||||||
WithIgnored() DBOption
|
WithIgnored() DBOption
|
||||||
GetFirst(opts ...DBOption) (model.AppDetail, error)
|
GetFirst(opts ...DBOption) (model.AppDetail, error)
|
||||||
Update(ctx context.Context, detail model.AppDetail) error
|
Update(ctx context.Context, detail model.AppDetail) error
|
||||||
|
CacheCompose(detail model.AppDetail, compose string) (bool, error)
|
||||||
|
MarkDownloaded(detail model.AppDetail) (bool, error)
|
||||||
BatchCreate(ctx context.Context, details []model.AppDetail) error
|
BatchCreate(ctx context.Context, details []model.AppDetail) error
|
||||||
DeleteByAppIds(ctx context.Context, appIds []uint) error
|
DeleteByAppIds(ctx context.Context, appIds []uint) error
|
||||||
DeleteByIDs(ctx context.Context, appIds []uint) error
|
DeleteByIDs(ctx context.Context, appIds []uint) error
|
||||||
@@ -57,6 +59,20 @@ func (a AppDetailRepo) Update(ctx context.Context, detail model.AppDetail) error
|
|||||||
return getTx(ctx).Save(&detail).Error
|
return getTx(ctx).Save(&detail).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AppDetailRepo) CacheCompose(detail model.AppDetail, compose string) (bool, error) {
|
||||||
|
result := getDb().Model(&model.AppDetail{}).
|
||||||
|
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
|
||||||
|
UpdateColumn("docker_compose", compose)
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AppDetailRepo) MarkDownloaded(detail model.AppDetail) (bool, error) {
|
||||||
|
result := getDb().Model(&model.AppDetail{}).
|
||||||
|
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
|
||||||
|
UpdateColumn("update", false)
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error {
|
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error {
|
||||||
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
|
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-74
@@ -135,6 +135,7 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
|
|||||||
ID: ap.ID,
|
ID: ap.ID,
|
||||||
Name: ap.Name,
|
Name: ap.Name,
|
||||||
Key: ap.Key,
|
Key: ap.Key,
|
||||||
|
Icon: appicon.RemoteURL(ap.Icon),
|
||||||
Limit: ap.Limit,
|
Limit: ap.Limit,
|
||||||
GpuSupport: ap.GpuSupport,
|
GpuSupport: ap.GpuSupport,
|
||||||
Recommend: ap.Recommend,
|
Recommend: ap.Recommend,
|
||||||
@@ -262,10 +263,20 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
|||||||
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
|
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
|
||||||
return appDetailDTO, err
|
return appDetailDTO, err
|
||||||
}
|
}
|
||||||
if err == nil && app.Resource == constant.AppResourceRemote {
|
packageReady := err == nil
|
||||||
|
if packageReady && app.Resource == constant.AppResourceRemote {
|
||||||
detail.Update = false
|
detail.Update = false
|
||||||
appDetailDTO.Update = false
|
appDetailDTO.Update = false
|
||||||
}
|
}
|
||||||
|
if packageReady && strings.TrimSpace(detail.DockerCompose) == "" {
|
||||||
|
compose, err := fileOp.GetContent(filepath.Join(versionPath, "docker-compose.yml"))
|
||||||
|
if err != nil {
|
||||||
|
return appDetailDTO, err
|
||||||
|
}
|
||||||
|
if err := cacheAppDetailCompose(&detail, compose); err != nil {
|
||||||
|
return appDetailDTO, err
|
||||||
|
}
|
||||||
|
}
|
||||||
switch app.Type {
|
switch app.Type {
|
||||||
case constant.RuntimePHP:
|
case constant.RuntimePHP:
|
||||||
paramMap := make(map[string]interface{})
|
paramMap := make(map[string]interface{})
|
||||||
@@ -296,15 +307,17 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return appDetailDTO, err
|
return appDetailDTO, err
|
||||||
}
|
}
|
||||||
composeMap := make(map[string]interface{})
|
if err := validateAppDetailCompose(compose); err != nil {
|
||||||
|
return appDetailDTO, err
|
||||||
|
}
|
||||||
|
var composeMap struct {
|
||||||
|
Services map[string]interface{} `yaml:"services"`
|
||||||
|
}
|
||||||
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
|
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
|
||||||
return appDetailDTO, err
|
return appDetailDTO, err
|
||||||
}
|
}
|
||||||
if service, ok := composeMap["services"]; ok {
|
for k := range composeMap.Services {
|
||||||
servicesMap := service.(map[string]interface{})
|
appDetailDTO.Image = k
|
||||||
for k := range servicesMap {
|
|
||||||
appDetailDTO.Image = k
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -315,29 +328,10 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
|||||||
appDetailDTO.Params = paramMap
|
appDetailDTO.Params = paramMap
|
||||||
}
|
}
|
||||||
|
|
||||||
if appDetailDTO.DockerCompose == "" {
|
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
||||||
filename := filepath.Base(appDetailDTO.DownloadUrl)
|
return appDetailDTO, err
|
||||||
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
|
|
||||||
if app.Resource == constant.AppResourceRemote {
|
|
||||||
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
|
|
||||||
if err != nil {
|
|
||||||
return appDetailDTO, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
|
||||||
if statusCode == http.StatusNotFound {
|
|
||||||
return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return appDetailDTO, buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
|
||||||
}
|
|
||||||
if statusCode > 200 {
|
|
||||||
return appDetailDTO, buserr.WithDetail("ErrGetCompose", string(composeRes), err)
|
|
||||||
}
|
|
||||||
detail.DockerCompose = string(composeRes)
|
|
||||||
_ = appDetailRepo.Update(context.Background(), detail)
|
|
||||||
appDetailDTO.DockerCompose = string(composeRes)
|
|
||||||
}
|
}
|
||||||
|
appDetailDTO.DockerCompose = detail.DockerCompose
|
||||||
|
|
||||||
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
|
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
|
||||||
|
|
||||||
@@ -355,6 +349,13 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ensureAppDetailCompose(app, &appDetail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
res.AppDetail = appDetail
|
res.AppDetail = appDetail
|
||||||
paramMap := make(map[string]interface{})
|
paramMap := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(appDetail.Params), ¶mMap); err != nil {
|
if err := json.Unmarshal([]byte(appDetail.Params), ¶mMap); err != nil {
|
||||||
@@ -442,31 +443,13 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
|||||||
App: app,
|
App: app,
|
||||||
}
|
}
|
||||||
composeMap := make(map[string]interface{})
|
composeMap := make(map[string]interface{})
|
||||||
var composeRes []byte
|
|
||||||
if req.EditCompose {
|
if req.EditCompose {
|
||||||
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if appDetail.DockerCompose == "" {
|
if err = ensureAppDetailCompose(app, &appDetail); err != nil {
|
||||||
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
|
return
|
||||||
if app.Resource == constant.AppResourceRemote {
|
|
||||||
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var statusCode int
|
|
||||||
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
|
||||||
if statusCode == http.StatusNotFound {
|
|
||||||
err = buserr.New("ErrAppVersionUnavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
appDetail.DockerCompose = string(composeRes)
|
|
||||||
_ = appDetailRepo.Update(context.Background(), appDetail)
|
|
||||||
}
|
}
|
||||||
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
|
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
|
||||||
return
|
return
|
||||||
@@ -922,22 +905,10 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
|
|||||||
res.CanUpdate = true
|
res.CanUpdate = true
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
apps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
|
list, err := readAppList()
|
||||||
for _, app := range apps {
|
if err != nil || list.LastModified != lastModified {
|
||||||
if app.Icon == "" {
|
list, err = getAppList()
|
||||||
res.CanUpdate = true
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if appicon.IsIconFile(app.Icon) {
|
|
||||||
fileName, _ := appicon.ParseIconField(app.Icon)
|
|
||||||
if fileName == "" || !appicon.IconFileExists(fileName) {
|
|
||||||
res.CanUpdate = true
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
list, err := getAppList()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
@@ -968,29 +939,26 @@ func getAppFromRepo(downloadPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getAppList() (*dto.AppList, error) {
|
func getAppList() (*dto.AppList, error) {
|
||||||
list := &dto.AppList{}
|
|
||||||
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
|
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return readAppList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAppList() (*dto.AppList, error) {
|
||||||
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
|
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
|
||||||
file, err := os.Open(listFile)
|
data, err := os.ReadFile(listFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
list := &dto.AppList{}
|
||||||
if err = json.NewDecoder(file).Decode(list); err != nil {
|
if err = json.Unmarshal(data, list); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var InitTypes = map[string]struct{}{
|
|
||||||
"runtime": {},
|
|
||||||
"php": {},
|
|
||||||
"node": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteCustomApp() {
|
func deleteCustomApp() {
|
||||||
installs, err := appInstallRepo.ListBy(context.Background())
|
installs, err := appInstallRepo.ListBy(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1088,6 +1056,10 @@ func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
|
|||||||
return nil, "", "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if appicon.IsRemoteURL(app.Icon) {
|
||||||
|
return nil, app.Icon, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
if appicon.IsIconFile(app.Icon) {
|
if appicon.IsIconFile(app.Icon) {
|
||||||
fileName, etag := appicon.ParseIconField(app.Icon)
|
fileName, etag := appicon.ParseIconField(app.Icon)
|
||||||
iconBytes, err := appicon.ReadIconFile(fileName)
|
iconBytes, err := appicon.ReadIconFile(fileName)
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func appCacheTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "cache.db")), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
|
||||||
|
global.DB = db
|
||||||
|
global.Dir = global.SystemDir{AppResourceDir: t.TempDir()}
|
||||||
|
global.Dir.RemoteAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceRemote)
|
||||||
|
global.Dir.LocalAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceLocal)
|
||||||
|
global.Dir.CustomAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceCustom)
|
||||||
|
global.LOG = logrus.New()
|
||||||
|
global.LOG.SetOutput(io.Discard)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := db.Create(&model.Setting{Key: "AppStoreLastModified", Value: "42"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func appCacheTestArchive(t *testing.T, name, content string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(content))}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(tw, content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appCacheTestWrite(t *testing.T, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(name, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppDownloadURLPreservesSourceQuery(t *testing.T) {
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"https://apps.1panel.pro/php/8/php-8.tar.gz",
|
||||||
|
"https://apps.1panel.pro/php/8/php-8.tar.gz?token=a%2Bb®ion=intl&v=old#archive",
|
||||||
|
} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
got, err := appDownloadURL(rawURL, "42 /next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
original, _ := url.Parse(rawURL)
|
||||||
|
wantQuery := original.Query()
|
||||||
|
wantQuery.Set("v", "42 /next")
|
||||||
|
if !reflect.DeepEqual(parsed.Query(), wantQuery) || parsed.Path != original.Path || parsed.Fragment != original.Fragment {
|
||||||
|
t.Fatalf("download URL lost source fields: %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := appDownloadURL("https://apps.1panel.pro/%zz", "42"); err == nil {
|
||||||
|
t.Fatal("invalid source URL was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadAppRefreshesAllOfficialCachedPackages(t *testing.T) {
|
||||||
|
db := appCacheTestDB(t)
|
||||||
|
app := model.App{Key: "mysql", Name: "MySQL", Type: "website", Resource: constant.AppResourceRemote}
|
||||||
|
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
|
||||||
|
dataPath := filepath.Join(versionDir, "data.yml")
|
||||||
|
stampPath := filepath.Join(versionDir, ".1panel-appstore-version")
|
||||||
|
appCacheTestWrite(t, dataPath, "stale")
|
||||||
|
archives := map[string][]byte{
|
||||||
|
"42": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-42"),
|
||||||
|
"43": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-43"),
|
||||||
|
}
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.URL.Query().Get("token") != "a+b" || r.URL.Query().Get("region") != "intl" {
|
||||||
|
t.Errorf("source query was lost: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
version := r.URL.Query().Get("v")
|
||||||
|
if version != "42" && version != "43" {
|
||||||
|
t.Errorf("unexpected app store version: %q", version)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(archives[version])
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz?token=a%2Bb®ion=intl", Update: false}
|
||||||
|
if err := db.Create(&detail).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, step := range []struct {
|
||||||
|
version string
|
||||||
|
calls int32
|
||||||
|
update bool
|
||||||
|
}{
|
||||||
|
{"42", 1, false}, // An existing directory without a stamp must be refreshed.
|
||||||
|
{"42", 1, false},
|
||||||
|
{"43", 2, false},
|
||||||
|
{"43", 3, true},
|
||||||
|
} {
|
||||||
|
if err := db.Model(&model.Setting{}).Where("key = ?", "AppStoreLastModified").Update("value", step.version).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
detail.Update = step.update
|
||||||
|
if err := db.Model(&detail).UpdateColumn("update", detail.Update).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := downloadApp(app, detail, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != step.calls {
|
||||||
|
t.Fatalf("version %s: HTTP calls = %d, want %d", step.version, calls.Load(), step.calls)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{dataPath: "fresh-" + step.version, stampPath: step.version} {
|
||||||
|
data, err := os.ReadFile(name)
|
||||||
|
if err != nil || string(data) != want {
|
||||||
|
t.Fatalf("%s = %q, %v; want %q", name, data, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stored model.AppDetail
|
||||||
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Update || stored.DownloadUrl != detail.DownloadUrl {
|
||||||
|
t.Fatalf("refresh altered source URL or left update flag: %+v", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadAppFailureKeepsCacheVersionAndUpdateFlag(t *testing.T) {
|
||||||
|
for _, failure := range []string{"download", "decompress"} {
|
||||||
|
t.Run(failure, func(t *testing.T) {
|
||||||
|
db := appCacheTestDB(t)
|
||||||
|
app := model.App{Key: "mysql", Resource: constant.AppResourceRemote}
|
||||||
|
stampPath := filepath.Join(app.GetAppResourcePath(), "8", ".1panel-appstore-version")
|
||||||
|
appCacheTestWrite(t, stampPath, "41")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if failure == "download" {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "not an archive")
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz", Update: true}
|
||||||
|
if err := db.Create(&detail).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
install := model.AppInstall{}
|
||||||
|
if err := downloadApp(app, detail, &install, nil); err == nil {
|
||||||
|
t.Fatal("failed refresh was accepted")
|
||||||
|
}
|
||||||
|
if install.Status != constant.StatusDownloadErr || install.Message == "" {
|
||||||
|
t.Fatalf("installation did not report refresh failure: %+v", install)
|
||||||
|
}
|
||||||
|
stamp, err := os.ReadFile(stampPath)
|
||||||
|
if err != nil || string(stamp) != "41" {
|
||||||
|
t.Fatalf("failed refresh updated stamp: %q, %v", stamp, err)
|
||||||
|
}
|
||||||
|
var stored model.AppDetail
|
||||||
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !stored.Update || stored.DownloadUrl != detail.DownloadUrl {
|
||||||
|
t.Fatalf("failed refresh changed database state: %+v", stored)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPHPAppDetailUsesSynchronizedOfficialParams(t *testing.T) {
|
||||||
|
const oldYAML = "additionalProperties:\n formFields:\n - envKey: PHP_VERSION\n type: select\n default: 8.1.34\n"
|
||||||
|
const newJSON = `{"formFields":[{"envKey":"PHP_VERSION","type":"select","default":"8.1.35"}]}`
|
||||||
|
const compose = "services:\n php:\n image: php:test\n"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, resource, compose string
|
||||||
|
update bool
|
||||||
|
}{
|
||||||
|
{"remote", constant.AppResourceRemote, compose, false},
|
||||||
|
{"local", constant.AppResourceLocal, compose, false},
|
||||||
|
{"custom", constant.AppResourceCustom, compose, false},
|
||||||
|
{"remote-fetches-compose", constant.AppResourceRemote, "", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db := appCacheTestDB(t)
|
||||||
|
app := model.App{Key: "php", Name: "PHP", Type: constant.RuntimePHP, Resource: tc.resource}
|
||||||
|
if err := db.Omit(clause.Associations).Create(&app).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
|
||||||
|
appCacheTestWrite(t, filepath.Join(versionDir, "data.yml"), oldYAML)
|
||||||
|
appCacheTestWrite(t, filepath.Join(versionDir, "docker-compose.yml"), compose)
|
||||||
|
archive := appCacheTestArchive(t, "php/8/data.yml", oldYAML)
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.URL.Query().Get("v") != "42" {
|
||||||
|
t.Errorf("missing app store version: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/docker-compose.yml" {
|
||||||
|
_, _ = io.WriteString(w, compose)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(archive)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
detail := model.AppDetail{AppId: app.ID, Version: "8", Params: newJSON, DockerCompose: tc.compose, Update: tc.update, DownloadUrl: server.URL + "/php-8.tar.gz"}
|
||||||
|
if err := db.Create(&detail).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := (AppService{}).GetAppDetail(app.ID, "8", "runtime")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantVersion, wantCalls := "8.1.34", int32(0)
|
||||||
|
if tc.resource == constant.AppResourceRemote {
|
||||||
|
wantVersion, wantCalls = "8.1.35", 1
|
||||||
|
}
|
||||||
|
want := map[string]interface{}{"formFields": []interface{}{map[string]interface{}{
|
||||||
|
"envKey": "PHP_VERSION", "type": "select", "default": wantVersion,
|
||||||
|
}}}
|
||||||
|
if !reflect.DeepEqual(got.Params, want) || got.Image != "php" || calls.Load() != wantCalls {
|
||||||
|
encoded, _ := json.Marshal(got.Params)
|
||||||
|
t.Fatalf("params=%s, image=%s, HTTP calls=%d; want version %s, calls %d", encoded, got.Image, calls.Load(), wantVersion, wantCalls)
|
||||||
|
}
|
||||||
|
var stored model.AppDetail
|
||||||
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Update || got.Update {
|
||||||
|
t.Fatal("fetching the compose file restored a stale update flag")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessOneAppDefersComposeUntilRequested(t *testing.T) {
|
||||||
|
db := appCacheTestDB(t)
|
||||||
|
const compose = "services:\n php:\n image: php:test\n"
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.URL.Path != "/php/8/docker-compose.yml" || r.URL.Query().Get("v") != "42" {
|
||||||
|
t.Errorf("unversioned initial compose request: %s", r.URL.String())
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, compose)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: constant.AppResourceRemote}
|
||||||
|
if err := db.Create(&app).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := appSyncContext{
|
||||||
|
baseRemoteUrl: server.URL,
|
||||||
|
systemVersion: "2.4.0",
|
||||||
|
list: &dto.AppList{LastModified: 42},
|
||||||
|
}
|
||||||
|
result := ctx.processOneApp(appWorkItem{
|
||||||
|
app: app,
|
||||||
|
appDef: dto.AppDefine{
|
||||||
|
AppProperty: dto.AppProperty{Key: "php", Type: constant.RuntimePHP},
|
||||||
|
Versions: []dto.AppConfigVersion{{Name: "8", AppForm: dto.AppForm{
|
||||||
|
FormFields: []dto.AppFormFields{{EnvKey: "PHP_VERSION", Default: "8.1.35", Type: "select"}},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if calls.Load() != 0 || len(result.Details) != 1 {
|
||||||
|
t.Fatalf("initial compose refresh failed: calls=%d, result=%+v", calls.Load(), result)
|
||||||
|
}
|
||||||
|
detail := result.Details[0]
|
||||||
|
if detail.DockerCompose != "" || detail.DownloadUrl != server.URL+"/php/8/php-8.tar.gz" {
|
||||||
|
t.Fatalf("sync fetched compose or changed the canonical archive URL: %+v", detail)
|
||||||
|
}
|
||||||
|
detail.AppId = app.ID
|
||||||
|
if err := db.Create(&detail).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 || detail.DockerCompose != compose {
|
||||||
|
t.Fatalf("on-demand compose did not download once and cache: calls=%d, detail=%+v", calls.Load(), detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureAppDetailCompose(app model.App, detail *model.AppDetail) error {
|
||||||
|
if strings.TrimSpace(detail.DockerCompose) != "" && validateAppDetailCompose([]byte(detail.DockerCompose)) == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var content []byte
|
||||||
|
var err error
|
||||||
|
if app.IsLocalApp() || app.IsCustomApp() {
|
||||||
|
content, err = os.ReadFile(filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"))
|
||||||
|
} else {
|
||||||
|
var composeURL string
|
||||||
|
if detail.DownloadUrl == "" {
|
||||||
|
composeURL = fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, detail.Version)
|
||||||
|
} else {
|
||||||
|
var source *url.URL
|
||||||
|
source, err = url.Parse(detail.DownloadUrl)
|
||||||
|
if err == nil {
|
||||||
|
source.Path = path.Join(path.Dir(source.Path), "docker-compose.yml")
|
||||||
|
source.RawPath = ""
|
||||||
|
composeURL = source.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
composeURL, err = currentAppDownloadURL(composeURL)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
var status int
|
||||||
|
status, content, err = req_helper.HandleRequest(composeURL, http.MethodGet, constant.TimeOut20s)
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return buserr.New("ErrAppVersionUnavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
||||||
|
}
|
||||||
|
return cacheAppDetailCompose(detail, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAppDetailCompose(content []byte) error {
|
||||||
|
var compose struct {
|
||||||
|
Services map[string]map[string]interface{} `yaml:"services"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(content, &compose); err != nil {
|
||||||
|
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
||||||
|
}
|
||||||
|
if len(compose.Services) == 0 {
|
||||||
|
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has no services", nil)
|
||||||
|
}
|
||||||
|
for name, service := range compose.Services {
|
||||||
|
if strings.TrimSpace(name) == "" || len(service) == 0 {
|
||||||
|
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has an empty service", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheAppDetailCompose(detail *model.AppDetail, content []byte) error {
|
||||||
|
if err := validateAppDetailCompose(content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stored, err := appDetailRepo.CacheCompose(*detail, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
||||||
|
}
|
||||||
|
if !stored {
|
||||||
|
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
|
||||||
|
}
|
||||||
|
detail.DockerCompose = string(content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComposeSyncOnlyInvalidatesChangedVersionResources(t *testing.T) {
|
||||||
|
form := dto.AppForm{FormFields: []dto.AppFormFields{{EnvKey: "PORT", Default: 8080}}}
|
||||||
|
params, err := json.Marshal(form)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_, _ = io.WriteString(w, "services:\n demo:\n image: demo:unexpected\n")
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
for _, appType := range []string{"runtime", "php", "node", "java", "go", "python", "dotnet", "website"} {
|
||||||
|
t.Run(appType, func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*model.AppDetail, *dto.AppConfigVersion)
|
||||||
|
keepCache bool
|
||||||
|
}{
|
||||||
|
{"unchanged", func(_ *model.AppDetail, _ *dto.AppConfigVersion) {}, true},
|
||||||
|
{"package update flag does not expire compose", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Update = true }, true},
|
||||||
|
{"version revision changed", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified++ }, false},
|
||||||
|
{"unknown revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = 0 }, false},
|
||||||
|
{"negative revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = -1 }, false},
|
||||||
|
{"form changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Params = `{"formFields":[]}` }, false},
|
||||||
|
{"source changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.DownloadUrl += ".old" }, false},
|
||||||
|
{"new version", func(d *model.AppDetail, _ *dto.AppConfigVersion) { *d = model.AppDetail{Version: "1.0"} }, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
const cached = "services:\n demo:\n image: demo:cached\n"
|
||||||
|
old := model.AppDetail{Version: "1.0", LastModified: 42, Params: string(params), DownloadUrl: server.URL + "/demo/1.0/demo-1.0.tar.gz", DockerCompose: cached}
|
||||||
|
version := dto.AppConfigVersion{Name: "1.0", LastModified: 42, AppForm: form}
|
||||||
|
tc.mutate(&old, &version)
|
||||||
|
ctx := appSyncContext{baseRemoteUrl: server.URL, systemVersion: "2.4.0"}
|
||||||
|
app := model.App{Key: "demo", Type: appType, Resource: constant.AppResourceRemote, Details: []model.AppDetail{old}}
|
||||||
|
got := ctx.processOneApp(appWorkItem{app: app, appDef: dto.AppDefine{AppProperty: dto.AppProperty{Key: "demo"}, Versions: []dto.AppConfigVersion{version}}})
|
||||||
|
if len(got.Details) != 1 || calls.Load() != 0 {
|
||||||
|
t.Fatalf("sync fetched compose or lost version: %+v, calls=%d", got, calls.Load())
|
||||||
|
}
|
||||||
|
detail := got.Details[0]
|
||||||
|
want := ""
|
||||||
|
if tc.keepCache {
|
||||||
|
want = cached
|
||||||
|
}
|
||||||
|
if detail.DockerCompose != want || detail.LastModified != version.LastModified || detail.Params != string(params) || !detail.Update {
|
||||||
|
t.Fatalf("cache decision or version metadata changed: %+v", detail)
|
||||||
|
}
|
||||||
|
if app.Details[0].DockerCompose != old.DockerCompose {
|
||||||
|
t.Fatal("sync mutated its input template")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const composeTestTemplate = "services:\n demo:\n image: demo:latest\n network_mode: host\n"
|
||||||
|
|
||||||
|
func composeTestDetail(t *testing.T, resource, source string) (*gorm.DB, model.App, model.AppDetail) {
|
||||||
|
t.Helper()
|
||||||
|
db := appCacheTestDB(t)
|
||||||
|
app := model.App{Key: "demo", Name: "Demo", Resource: resource, Type: "website"}
|
||||||
|
if err := db.Create(&app).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
detail := model.AppDetail{AppId: app.ID, Version: "1.0", LastModified: 42, Params: `{"formFields":[]}`, DownloadUrl: source}
|
||||||
|
if err := db.Create(&detail).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db, app, detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeOnDemandLoadsOnceAndPreservesSource(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.URL.Path != "/demo/1.0/docker-compose.yml" || r.URL.Query().Get("signature") != "a+b" || r.URL.Query().Get("v") != "42" {
|
||||||
|
t.Errorf("unexpected Compose source: %s", r.URL)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, composeTestTemplate)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo/1.0/demo-1.0.tar.gz?signature=a%2Bb")
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 || detail.DockerCompose != composeTestTemplate {
|
||||||
|
t.Fatalf("cache miss after loading: calls=%d, detail=%+v", calls.Load(), detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stored model.AppDetail
|
||||||
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.DockerCompose != composeTestTemplate || stored.LastModified != 42 || stored.DownloadUrl != detail.DownloadUrl || stored.Params != detail.Params || !stored.UpdatedAt.Equal(detail.UpdatedAt) {
|
||||||
|
t.Fatalf("cache write altered version metadata: %+v", stored)
|
||||||
|
}
|
||||||
|
res, err := (AppService{}).GetAppDetailByID(detail.ID)
|
||||||
|
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
|
||||||
|
t.Fatalf("ID details did not reuse template/HostMode: %+v, %v, calls=%d", res, err, calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeOnDemandIDDetailsFillMissingTemplate(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_, _ = io.WriteString(w, composeTestTemplate)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
_, _, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
||||||
|
res, err := (AppService{}).GetAppDetailByID(detail.ID)
|
||||||
|
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
|
||||||
|
t.Fatalf("missing template gave wrong ID details: %+v, %v, calls=%d", res, err, calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeOnDemandLocalAndCustomUseTheirDirectories(t *testing.T) {
|
||||||
|
for _, resource := range []string{constant.AppResourceLocal, constant.AppResourceCustom} {
|
||||||
|
t.Run(resource, func(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
_, app, detail := composeTestDetail(t, resource, server.URL+"/must-not-fetch.tar.gz")
|
||||||
|
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), composeTestTemplate)
|
||||||
|
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if detail.DockerCompose != composeTestTemplate || calls.Load() != 0 {
|
||||||
|
t.Fatalf("local resource used remote template: %+v, calls=%d", detail, calls.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeOnDemandRejectsDownloadAndTemplateFailures(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"not found", "missing", http.StatusNotFound},
|
||||||
|
{"server error", "failure", http.StatusServiceUnavailable},
|
||||||
|
{"empty", "", http.StatusOK},
|
||||||
|
{"HTML", "<html>blocked</html>", http.StatusOK},
|
||||||
|
{"malformed YAML", "services: [", http.StatusOK},
|
||||||
|
{"missing services", "version: '3'", http.StatusOK},
|
||||||
|
{"empty services", "services: {}", http.StatusOK},
|
||||||
|
{"empty service", "services:\n demo:", http.StatusOK},
|
||||||
|
{"string service", "services:\n demo: blocked", http.StatusOK},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = io.WriteString(w, tc.body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
||||||
|
if err := ensureAppDetailCompose(app, &detail); err == nil {
|
||||||
|
t.Fatal("invalid template was accepted")
|
||||||
|
}
|
||||||
|
var stored model.AppDetail
|
||||||
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if detail.DockerCompose != "" || stored.DockerCompose != "" {
|
||||||
|
t.Fatal("failed response was cached")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeCacheRejectsConcurrentMetadataUpdates(t *testing.T) {
|
||||||
|
for _, field := range []string{"last_modified", "params", "download_url"} {
|
||||||
|
t.Run(field, func(t *testing.T) {
|
||||||
|
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
|
||||||
|
value := interface{}("new value")
|
||||||
|
if field == "last_modified" {
|
||||||
|
value = 43
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "docker_compose": "new template"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil {
|
||||||
|
t.Fatal("stale template overwrote newer metadata")
|
||||||
|
}
|
||||||
|
var stored model.AppDetail
|
||||||
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.DockerCompose != "new template" || detail.DockerCompose != "" {
|
||||||
|
t.Fatalf("stale cache result was retained: %+v", stored)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeCachePropagatesWriteFailure(t *testing.T) {
|
||||||
|
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
|
||||||
|
if err := db.Exec("CREATE TRIGGER reject_compose BEFORE UPDATE OF docker_compose ON app_details BEGIN SELECT RAISE(FAIL, 'test write failure'); END").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil || detail.DockerCompose != "" {
|
||||||
|
t.Fatal("failed cache write was ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkDownloadedPreservesComposeAndRejectsChangedMetadata(t *testing.T) {
|
||||||
|
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
|
||||||
|
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{"update": true, "docker_compose": composeTestTemplate}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored, err := appDetailRepo.MarkDownloaded(detail)
|
||||||
|
if err != nil || !stored {
|
||||||
|
t.Fatalf("matching download marker failed: stored=%v err=%v", stored, err)
|
||||||
|
}
|
||||||
|
var fresh model.AppDetail
|
||||||
|
if err := db.First(&fresh, detail.ID).Error; err != nil || fresh.Update || fresh.DockerCompose != composeTestTemplate {
|
||||||
|
t.Fatalf("download marker overwrote Compose: %+v, %v", fresh, err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"last_modified", "params", "download_url"} {
|
||||||
|
t.Run(field, func(t *testing.T) {
|
||||||
|
var snapshot model.AppDetail
|
||||||
|
if err := db.First(&snapshot, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value := interface{}("new metadata")
|
||||||
|
if field == "last_modified" {
|
||||||
|
value = snapshot.LastModified + 1
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "update": true}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored, err := appDetailRepo.MarkDownloaded(snapshot)
|
||||||
|
if err != nil || stored {
|
||||||
|
t.Fatalf("stale download marked new resources ready: stored=%v err=%v", stored, err)
|
||||||
|
}
|
||||||
|
if err := db.First(&fresh, detail.ID).Error; err != nil || !fresh.Update || fresh.DockerCompose != composeTestTemplate {
|
||||||
|
t.Fatalf("stale marker overwrote template or update flag: %+v, %v", fresh, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeDetailUsesValidPackageComposeWithoutAnotherGET(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
archive := appCacheTestArchive(t, "demo/1.0/docker-compose.yml", composeTestTemplate)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if !strings.HasSuffix(r.URL.Path, ".tar.gz") {
|
||||||
|
t.Errorf("runtime requested Compose after obtaining a valid package: %s", r.URL)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(archive)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
_, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
||||||
|
app.Type = constant.RuntimeNode
|
||||||
|
if err := global.DB.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
|
||||||
|
if err != nil || res.DockerCompose != composeTestTemplate || !res.HostMode || calls.Load() != 1 {
|
||||||
|
t.Fatalf("package Compose was not reused: %+v, %v, calls=%d", res, err, calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeDetailDoesNotCacheStaleTemplateAfterPackageFailure(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if strings.HasSuffix(r.URL.Path, ".tar.gz") {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, composeTestTemplate)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
||||||
|
app.Type = constant.RuntimeNode
|
||||||
|
if err := db.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), "services:\n stale:\n image: stale:old\n")
|
||||||
|
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
|
||||||
|
if err != nil || res.DockerCompose != composeTestTemplate || calls.Load() != 2 {
|
||||||
|
t.Fatalf("failed package download reused a stale local template: %+v, %v, calls=%d", res, err, calls.Load())
|
||||||
|
}
|
||||||
|
var stored model.AppDetail
|
||||||
|
if err := db.First(&stored, detail.ID).Error; err != nil || stored.DockerCompose != composeTestTemplate {
|
||||||
|
t.Fatalf("stale package contents were cached: %+v, %v", stored, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const composeUpgradeCurrent = `services:
|
||||||
|
demo:
|
||||||
|
image: demo:1.0
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: ${CPUS}
|
||||||
|
memory: ${MEMORY_LIMIT}
|
||||||
|
`
|
||||||
|
|
||||||
|
func composeUpgradeDB(t *testing.T, resourceURL string) (*gorm.DB, model.App, model.AppInstall, []model.AppDetail) {
|
||||||
|
t.Helper()
|
||||||
|
db := iconSyncDB(t)
|
||||||
|
if err := db.AutoMigrate(&model.AppIgnoreUpgrade{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := model.App{Key: "demo", Name: "Demo", Type: "runtime", Resource: constant.AppResourceRemote, CrossVersionUpdate: true}
|
||||||
|
if err := db.Create(&app).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var details []model.AppDetail
|
||||||
|
for _, version := range []string{"1.1", "1.0", "1.3", "1.2"} {
|
||||||
|
detail := model.AppDetail{
|
||||||
|
AppId: app.ID, Version: version, LastModified: 42, Params: `{"formFields":[]}`, Status: constant.AppNormal,
|
||||||
|
DownloadUrl: resourceURL + "/demo/" + version + "/demo-" + version + ".tar.gz",
|
||||||
|
}
|
||||||
|
if err := db.Create(&detail).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
details = append(details, detail)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.AppIgnoreUpgrade{AppID: app.ID, AppDetailID: details[2].ID, Scope: "version"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
install := model.AppInstall{
|
||||||
|
AppId: app.ID, AppDetailId: details[1].ID, Name: "demo-install", Version: "1.0", App: app,
|
||||||
|
ServiceName: "demo", ContainerName: "demo-container", Status: constant.StatusRunning,
|
||||||
|
Env: `{"CONTAINER_NAME":"demo-container"}`, DockerCompose: composeUpgradeCurrent,
|
||||||
|
}
|
||||||
|
if err := db.Omit(clause.Associations).Create(&install).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db, app, install, details
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeComposeLoadsOnlyEffectiveTarget(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
requests := make(map[string]int)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
requests[r.URL.Path]++
|
||||||
|
mu.Unlock()
|
||||||
|
if r.URL.Query().Get("v") != "42" {
|
||||||
|
t.Errorf("missing synchronized version: %s", r.URL)
|
||||||
|
}
|
||||||
|
version := strings.Split(r.URL.Path, "/")[2]
|
||||||
|
_, _ = fmt.Fprintf(w, "services:\n source:\n image: demo:%s\n", version)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, _, install, details := composeUpgradeDB(t, server.URL)
|
||||||
|
// An unselected bad cached template must not prevent listing or default preview.
|
||||||
|
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "invalid: [").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewIAppInstalledService()
|
||||||
|
noCompose := false
|
||||||
|
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, LoadCompose: &noCompose})
|
||||||
|
if err != nil || len(versions) != 2 || versions[0].Version != "1.2" || versions[1].Version != "1.1" {
|
||||||
|
t.Fatalf("pure versions did not preserve filtering/sorting: %+v, %v", versions, err)
|
||||||
|
}
|
||||||
|
for _, version := range versions {
|
||||||
|
if version.DockerCompose != "" {
|
||||||
|
t.Fatal("pure version request returned a rendered template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(requests) != 0 {
|
||||||
|
t.Fatalf("pure version request fetched templates: %v", requests)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
|
||||||
|
if err != nil || len(versions) != 2 || !strings.Contains(versions[0].DockerCompose, "demo:1.2") || versions[1].DockerCompose != "" {
|
||||||
|
t.Fatalf("default target preview = %+v, %v", versions, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requests["/demo/1.2/docker-compose.yml"] != 1 || len(requests) != 1 {
|
||||||
|
t.Fatalf("default preview did not reuse only its own cache: %v", requests)
|
||||||
|
}
|
||||||
|
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.1"})
|
||||||
|
if err != nil || !strings.Contains(versions[1].DockerCompose, "demo:1.1") || versions[0].DockerCompose != "" {
|
||||||
|
t.Fatalf("selected target preview = %+v, %v", versions, err)
|
||||||
|
}
|
||||||
|
if requests["/demo/1.1/docker-compose.yml"] != 1 || len(requests) != 2 {
|
||||||
|
t.Fatalf("selection prefetched other candidates: %v", requests)
|
||||||
|
}
|
||||||
|
if _, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.3"}); err == nil {
|
||||||
|
t.Fatal("ignored target was accepted")
|
||||||
|
}
|
||||||
|
if len(requests) != 2 {
|
||||||
|
t.Fatal("invalid target triggered a template request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeComposeFetchFailurePreservesInstanceAndCache(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, _, install, details := composeUpgradeDB(t, server.URL)
|
||||||
|
if _, err := NewIAppInstalledService().GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID}); err == nil {
|
||||||
|
t.Fatal("failed target template load was accepted")
|
||||||
|
}
|
||||||
|
var stored model.AppInstall
|
||||||
|
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Status != install.Status || stored.Version != install.Version {
|
||||||
|
t.Fatalf("preview modified installed instance: %+v, %v", stored, err)
|
||||||
|
}
|
||||||
|
var detail model.AppDetail
|
||||||
|
if err := db.First(&detail, details[3].ID).Error; err != nil || detail.DockerCompose != "" {
|
||||||
|
t.Fatalf("failed response cached a template: %+v, %v", detail, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradePrepareComposeFailureDoesNotCutover(t *testing.T) {
|
||||||
|
for _, failure := range []string{"package download", "invalid package template"} {
|
||||||
|
t.Run(failure, func(t *testing.T) {
|
||||||
|
archive := appCacheTestArchive(t, "demo/1.2/docker-compose.yml", "services: []")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if failure == "package download" {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(archive)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, _, install, details := composeUpgradeDB(t, server.URL)
|
||||||
|
upgrade := &appUpgradeContext{original: install, detail: details[3]}
|
||||||
|
if err := upgrade.prepare(&task.Task{Logger: global.LOG}); err == nil {
|
||||||
|
t.Fatal("invalid upgrade resources passed prepare")
|
||||||
|
}
|
||||||
|
if upgrade.stopAttempted || upgrade.downAttempted || upgrade.stageDir != "" {
|
||||||
|
t.Fatal("failed template preparation reached live cutover")
|
||||||
|
}
|
||||||
|
var stored model.AppInstall
|
||||||
|
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Env != install.Env || stored.Version != install.Version || stored.Status != install.Status {
|
||||||
|
t.Fatalf("failed prepare modified the instance: %+v, %v", stored, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitConfigComposeDetectsCustomAndReusesCurrentCache(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
custom bool
|
||||||
|
failure bool
|
||||||
|
}{
|
||||||
|
{name: "original"},
|
||||||
|
{name: "custom", custom: true},
|
||||||
|
{name: "fetch failure", failure: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
if r.URL.Path != "/demo/1.0/docker-compose.yml" {
|
||||||
|
t.Errorf("configuration loaded a different version: %s", r.URL)
|
||||||
|
}
|
||||||
|
if tc.failure {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "services:\n source:\n image: demo:1.0\n")
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, _, install, _ := composeUpgradeDB(t, server.URL)
|
||||||
|
if tc.custom {
|
||||||
|
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n environment:\n USER_SETTING: preserve", 1)
|
||||||
|
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isEditCompose(install) || calls != 0 {
|
||||||
|
t.Fatal("list-time custom check fetched a missing template")
|
||||||
|
}
|
||||||
|
result, err := NewIAppInstalledService().GetParams(install.ID)
|
||||||
|
if tc.failure {
|
||||||
|
if err == nil || result != nil {
|
||||||
|
t.Fatal("failed current-template load was treated as an unedited configuration")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || result.RawCompose == "" || result.IsEdit != tc.custom || result.DockerCompose != install.DockerCompose {
|
||||||
|
t.Fatalf("explicit custom state = %+v, %v; want custom=%v", result, err, tc.custom)
|
||||||
|
}
|
||||||
|
if _, err := NewIAppInstalledService().GetParams(install.ID); err != nil || calls != 1 {
|
||||||
|
t.Fatalf("configuration cache was not reused: calls=%d, %v", calls, err)
|
||||||
|
}
|
||||||
|
if isEditCompose(install) != tc.custom || calls != 1 {
|
||||||
|
t.Fatal("cached list-time comparison changed custom state or made network requests")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVllmManagedComposeDoesNotRequireOfficialTemplate(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
db, app, install, _ := composeUpgradeDB(t, server.URL)
|
||||||
|
if err := db.Model(&app).UpdateColumn("key", vllmAppKeyForUpgrade).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n command: [--managed-user-option]", 1)
|
||||||
|
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewIAppInstalledService()
|
||||||
|
config, err := service.GetParams(install.ID)
|
||||||
|
if err != nil || config.RawCompose != install.DockerCompose || config.DockerCompose != install.DockerCompose || config.IsEdit {
|
||||||
|
t.Fatalf("managed configuration lost the instance template: %+v, %v", config, err)
|
||||||
|
}
|
||||||
|
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
|
||||||
|
if err != nil || len(versions) != 2 || versions[0].DockerCompose != install.DockerCompose {
|
||||||
|
t.Fatalf("managed upgrade preview lost the instance template: %+v, %v", versions, err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 0 {
|
||||||
|
t.Fatalf("managed template attempted %d official resource requests", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -31,7 +29,6 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/utils/env"
|
"github.com/1Panel-dev/1Panel/agent/utils/env"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
|
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -743,7 +740,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
}
|
}
|
||||||
if canUpgrade {
|
if canUpgrade {
|
||||||
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
|
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
|
||||||
if feishuTarget && !feishuChecked {
|
if feishuTarget && !feishuChecked && (req.LoadCompose == nil || *req.LoadCompose) {
|
||||||
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
|
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return versions, readErr
|
return versions, readErr
|
||||||
@@ -754,33 +751,6 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
}
|
}
|
||||||
feishuChecked = true
|
feishuChecked = true
|
||||||
}
|
}
|
||||||
var newCompose string
|
|
||||||
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
|
|
||||||
filename := filepath.Base(detail.DownloadUrl)
|
|
||||||
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
|
|
||||||
if app.Resource == constant.AppResourceRemote {
|
|
||||||
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
|
|
||||||
if err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
|
||||||
if statusCode == http.StatusNotFound {
|
|
||||||
return versions, buserr.New("ErrAppVersionUnavailable")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
if statusCode > 200 {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
detail.DockerCompose = string(composeRes)
|
|
||||||
_ = appDetailRepo.Update(context.Background(), detail)
|
|
||||||
}
|
|
||||||
newCompose, err = getUpgradeCompose(install, detail)
|
|
||||||
if err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
if app.Key == constant.AppMysql {
|
if app.Key == constant.AppMysql {
|
||||||
majorVersion := getMajorVersion(install.Version)
|
majorVersion := getMajorVersion(install.Version)
|
||||||
if !strings.HasPrefix(detail.Version, majorVersion) {
|
if !strings.HasPrefix(detail.Version, majorVersion) {
|
||||||
@@ -792,13 +762,46 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
FeishuConfigured: feishuTarget && feishuConfigured,
|
FeishuConfigured: feishuTarget && feishuConfigured,
|
||||||
Version: detail.Version,
|
Version: detail.Version,
|
||||||
DetailId: detail.ID,
|
DetailId: detail.ID,
|
||||||
DockerCompose: newCompose,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(versions, func(i, j int) bool {
|
sort.Slice(versions, func(i, j int) bool {
|
||||||
return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
|
return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
|
||||||
})
|
})
|
||||||
|
if len(versions) == 0 || (req.LoadCompose != nil && !*req.LoadCompose) {
|
||||||
|
return versions, nil
|
||||||
|
}
|
||||||
|
target := 0
|
||||||
|
if req.UpdateVersion != "" {
|
||||||
|
target = -1
|
||||||
|
for i := range versions {
|
||||||
|
if versions[i].Version == req.UpdateVersion {
|
||||||
|
target = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == -1 {
|
||||||
|
return versions, buserr.New("ErrAppVersionUnavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if app.Key == vllmAppKeyForUpgrade {
|
||||||
|
versions[target].DockerCompose = install.DockerCompose
|
||||||
|
return versions, nil
|
||||||
|
}
|
||||||
|
for _, detail := range details {
|
||||||
|
if detail.ID != versions[target].DetailId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
||||||
|
return versions, err
|
||||||
|
}
|
||||||
|
content, err := getUpgradeCompose(install, detail)
|
||||||
|
if err != nil {
|
||||||
|
return versions, err
|
||||||
|
}
|
||||||
|
versions[target].DockerCompose = content
|
||||||
|
break
|
||||||
|
}
|
||||||
return versions, nil
|
return versions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,9 +1010,22 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
|
|||||||
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
|
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
|
||||||
res.WebUI = install.WebUI
|
res.WebUI = install.WebUI
|
||||||
res.Type = install.App.Type
|
res.Type = install.App.Type
|
||||||
if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
|
rawCompose := install.DockerCompose
|
||||||
res.RawCompose = rawCompose
|
if install.App.Key != vllmAppKeyForUpgrade {
|
||||||
|
if err := ensureAppDetailCompose(install.App, &detail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rawCompose, err = getUpgradeCompose(install, detail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
equal, err := composeEqualExceptImage(rawCompose, install.DockerCompose)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.RawCompose = rawCompose
|
||||||
|
res.IsEdit = !equal
|
||||||
return &res, nil
|
return &res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppStoreIndexUsesCacheUntilRevisionChanges(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cache string
|
||||||
|
version int
|
||||||
|
failZIP bool
|
||||||
|
wantZIP int32
|
||||||
|
wantUpdate bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "matching cache", cache: `{"lastModified":42}`, version: 42},
|
||||||
|
{name: "missing cache", version: 42, wantZIP: 1},
|
||||||
|
{name: "malformed cache", cache: `{invalid`, version: 42, wantZIP: 1},
|
||||||
|
{name: "null cache", cache: `null`, version: 42, wantZIP: 1},
|
||||||
|
{name: "trailing corrupt data", cache: `{"lastModified":42} garbage`, version: 42, wantZIP: 1},
|
||||||
|
{name: "outdated cache", cache: `{"lastModified":41}`, version: 42, wantZIP: 1},
|
||||||
|
{name: "remote revision changed", cache: `{"lastModified":42}`, version: 43, wantUpdate: true},
|
||||||
|
{name: "download failure", cache: `{invalid`, version: 42, failZIP: true, wantZIP: 1, wantErr: true},
|
||||||
|
{name: "cached minimum panel version", cache: `{"lastModified":42,"additionalProperties":{"version":"9.9.0"}}`, version: 42, wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db := iconSyncDB(t)
|
||||||
|
if err := db.Create(&model.App{Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
global.Dir.ResourceDir = t.TempDir()
|
||||||
|
cachePath := filepath.Join(global.Dir.ResourceDir, "1panel.json")
|
||||||
|
if tc.cache != "" {
|
||||||
|
appCacheTestWrite(t, cachePath, tc.cache)
|
||||||
|
}
|
||||||
|
list := dto.AppList{LastModified: tc.version, Apps: []dto.AppDefine{{Icon: "https://cdn.example/mysql.png", AppProperty: dto.AppProperty{Key: "mysql"}}}}
|
||||||
|
var archive bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&archive)
|
||||||
|
entry, err := zw.Create("1panel.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(entry).Encode(list); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var versionCalls, zipCalls atomic.Int32
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"):
|
||||||
|
versionCalls.Add(1)
|
||||||
|
_, _ = io.WriteString(w, strconv.Itoa(tc.version))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "1panel.json.zip"):
|
||||||
|
zipCalls.Add(1)
|
||||||
|
if tc.failZIP {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(archive.Bytes())
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected remote resource request: %s", r.URL)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
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() })
|
||||||
|
|
||||||
|
res, err := (AppService{}).GetAppUpdate()
|
||||||
|
if (err != nil) != tc.wantErr || res == nil && !tc.wantErr {
|
||||||
|
t.Fatalf("update check = %+v, %v; want error %v", res, err, tc.wantErr)
|
||||||
|
}
|
||||||
|
if zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 1 {
|
||||||
|
t.Fatalf("first check requests: version=%d, ZIP=%d; want 1/%d", versionCalls.Load(), zipCalls.Load(), tc.wantZIP)
|
||||||
|
}
|
||||||
|
if tc.wantErr {
|
||||||
|
if tc.wantZIP == 0 && !strings.Contains(err.Error(), "ErrVersionTooLow") {
|
||||||
|
t.Fatalf("cached panel-version check was lost: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.CanUpdate != tc.wantUpdate {
|
||||||
|
t.Fatalf("CanUpdate=%v, want %v", res.CanUpdate, tc.wantUpdate)
|
||||||
|
}
|
||||||
|
if tc.wantUpdate {
|
||||||
|
// Full synchronization still fetches the new index after detecting a revision change.
|
||||||
|
fresh, err := getAppList()
|
||||||
|
if err != nil || fresh.LastModified != tc.version || zipCalls.Load() != 1 {
|
||||||
|
t.Fatalf("changed revision did not fetch the new index: %+v, %v, ZIP=%d", fresh, err, zipCalls.Load())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.AppList == nil || res.AppList.LastModified != 42 {
|
||||||
|
t.Fatalf("same-revision icon refresh lost its index: %+v", res)
|
||||||
|
}
|
||||||
|
if _, err := (AppService{}).GetAppUpdate(); err != nil || zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 2 {
|
||||||
|
t.Fatalf("second check redownloaded cached index: %v, version=%d, ZIP=%d", err, versionCalls.Load(), zipCalls.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
@@ -17,15 +12,11 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
"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/common"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type appSyncContext struct {
|
type appSyncContext struct {
|
||||||
task *task.Task
|
task *task.Task
|
||||||
httpClient http.Client
|
|
||||||
baseRemoteUrl string
|
baseRemoteUrl string
|
||||||
systemVersion string
|
systemVersion string
|
||||||
appsMap map[string]model.App
|
appsMap map[string]model.App
|
||||||
@@ -34,7 +25,6 @@ type appSyncContext struct {
|
|||||||
oldAppIds []uint
|
oldAppIds []uint
|
||||||
appTags []*model.AppTag
|
appTags []*model.AppTag
|
||||||
skipMetaSync bool
|
skipMetaSync bool
|
||||||
pendingIcons map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
|
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
|
||||||
@@ -51,6 +41,10 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
|
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := refreshOfficialAppIcons(updateRes.AppList, t); err != nil {
|
||||||
|
t.LogFailedWithErr(i18n.GetMsgByKey("PersistApps"), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
global.LOG.Infof("[AppStore] Appstore is up to date")
|
global.LOG.Infof("[AppStore] Appstore is up to date")
|
||||||
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
|
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
|
||||||
*sharedCtx = &appSyncContext{skipMetaSync: true}
|
*sharedCtx = &appSyncContext{skipMetaSync: true}
|
||||||
@@ -82,13 +76,11 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
|
|
||||||
ctx := &appSyncContext{
|
ctx := &appSyncContext{
|
||||||
task: t,
|
task: t,
|
||||||
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.MultiNodeProvider.LoadRequestTransport()},
|
|
||||||
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
|
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
|
||||||
systemVersion: setting.SystemVersion,
|
systemVersion: setting.SystemVersion,
|
||||||
settingService: settingService,
|
settingService: settingService,
|
||||||
list: list,
|
list: list,
|
||||||
appTags: make([]*model.AppTag, 0),
|
appTags: make([]*model.AppTag, 0),
|
||||||
pendingIcons: make(map[string]string),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = SyncTags(list.Extra); err != nil {
|
if err = SyncTags(list.Extra); err != nil {
|
||||||
@@ -107,7 +99,7 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
|
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t)
|
ctx.appsMap = getApps(oldApps, list.Apps, setting.SystemVersion, t)
|
||||||
|
|
||||||
var addCount, updateCount, deleteCount int
|
var addCount, updateCount, deleteCount int
|
||||||
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
|
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
|
||||||
@@ -129,40 +121,39 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type appWorkItem struct {
|
func refreshOfficialAppIcons(list *dto.AppList, t *task.Task) error {
|
||||||
appDef dto.AppDefine
|
if list == nil {
|
||||||
app model.App
|
return nil
|
||||||
iconUrl string
|
|
||||||
hadIcon bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type appWorkResult struct {
|
|
||||||
appKey string
|
|
||||||
app model.App
|
|
||||||
iconStatus int
|
|
||||||
hadIcon bool
|
|
||||||
httpFailed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
|
|
||||||
app := item.app
|
|
||||||
l := item.appDef
|
|
||||||
result := appWorkResult{
|
|
||||||
appKey: l.AppProperty.Key,
|
|
||||||
hadIcon: item.hadIcon,
|
|
||||||
}
|
}
|
||||||
|
apps, err := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
|
||||||
if item.hadIcon {
|
if err != nil {
|
||||||
status, iconField := c.downloadAppIcon(item.iconUrl, l.AppProperty.Key, app.Icon)
|
return err
|
||||||
result.iconStatus = status
|
}
|
||||||
switch status {
|
systemVersion, err := settingRepo.GetValueByKey("SystemVersion")
|
||||||
case http.StatusOK:
|
if err != nil {
|
||||||
app.Icon = iconField
|
return err
|
||||||
case http.StatusNotModified:
|
}
|
||||||
default:
|
updated := getApps(apps, list.Apps, systemVersion, t)
|
||||||
result.httpFailed = true
|
for _, app := range apps {
|
||||||
|
candidate := updated[app.Key]
|
||||||
|
if app.Status != constant.AppNormal || candidate.Status != constant.AppNormal || candidate.Icon == app.Icon {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := appRepo.UpdateIcon(app.ID, candidate.Icon); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type appWorkItem struct {
|
||||||
|
appDef dto.AppDefine
|
||||||
|
app model.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
|
||||||
|
app := item.app
|
||||||
|
l := item.appDef
|
||||||
|
|
||||||
app.TagsKey = l.AppProperty.Tags
|
app.TagsKey = l.AppProperty.Tags
|
||||||
if l.AppProperty.Recommend > 0 {
|
if l.AppProperty.Recommend > 0 {
|
||||||
@@ -189,24 +180,12 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := InitTypes[app.Type]; ok {
|
downloadURL := fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
|
||||||
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
|
if v.LastModified <= 0 || detail.LastModified != v.LastModified || detail.Params != string(paramByte) || detail.DownloadUrl != downloadURL {
|
||||||
dockerComposeUrl, err := appDownloadURL(dockerComposeUrl, strconv.Itoa(c.list.LastModified))
|
|
||||||
var composeRes []byte
|
|
||||||
if err == nil {
|
|
||||||
_, composeRes, err = req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
detail.DockerCompose = string(composeRes)
|
|
||||||
} else {
|
|
||||||
result.httpFailed = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
detail.DockerCompose = ""
|
detail.DockerCompose = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
detail.Params = string(paramByte)
|
detail.Params = string(paramByte)
|
||||||
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
|
detail.DownloadUrl = downloadURL
|
||||||
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
|
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
|
||||||
detail.Update = true
|
detail.Update = true
|
||||||
detail.LastModified = v.LastModified
|
detail.LastModified = v.LastModified
|
||||||
@@ -219,170 +198,25 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
|
|||||||
}
|
}
|
||||||
app.Details = newDetails
|
app.Details = newDetails
|
||||||
|
|
||||||
result.app = app
|
return app
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appSyncContext) syncAppIconsAndDetails() error {
|
func (c *appSyncContext) syncAppDetails() error {
|
||||||
total := len(c.list.Apps)
|
total := len(c.list.Apps)
|
||||||
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
|
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
|
||||||
|
for i, definition := range c.list.Apps {
|
||||||
workItems := make([]appWorkItem, 0, total)
|
app, ok := c.appsMap[definition.AppProperty.Key]
|
||||||
for _, l := range c.list.Apps {
|
if ok {
|
||||||
app, ok := c.appsMap[l.AppProperty.Key]
|
c.appsMap[app.Key] = c.processOneApp(appWorkItem{appDef: definition, app: app})
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
|
if (i+1)%max(1, total/4) == 0 || i+1 == total {
|
||||||
workItems = append(workItems, appWorkItem{
|
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, total)
|
||||||
appDef: l,
|
|
||||||
app: app,
|
|
||||||
iconUrl: iconUrl,
|
|
||||||
hadIcon: hasPending,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
totalWork := len(workItems)
|
|
||||||
if totalWork == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxWorkers = 4
|
|
||||||
var (
|
|
||||||
failFlag atomic.Bool
|
|
||||||
workCh = make(chan int, maxWorkers)
|
|
||||||
resultCh = make(chan appWorkResult, maxWorkers)
|
|
||||||
wg sync.WaitGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
wg.Add(maxWorkers)
|
|
||||||
for range maxWorkers {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for idx := range workCh {
|
|
||||||
result := c.processOneApp(workItems[idx])
|
|
||||||
resultCh <- result
|
|
||||||
if result.httpFailed {
|
|
||||||
failFlag.Store(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
go func() { wg.Wait(); close(resultCh) }()
|
|
||||||
|
|
||||||
var fed atomic.Int32
|
|
||||||
go func() {
|
|
||||||
for i := range workItems {
|
|
||||||
if failFlag.Load() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
workCh <- i
|
|
||||||
fed.Store(int32(i + 1))
|
|
||||||
}
|
|
||||||
close(workCh)
|
|
||||||
}()
|
|
||||||
|
|
||||||
var (
|
|
||||||
completed int
|
|
||||||
icon200Count int
|
|
||||||
icon304Count int
|
|
||||||
iconFailCount int
|
|
||||||
)
|
|
||||||
milestones := [4]int{totalWork / 4, totalWork / 2, totalWork * 3 / 4, totalWork}
|
|
||||||
nextMS := 0
|
|
||||||
checkProgress := func() {
|
|
||||||
if nextMS < len(milestones) && completed >= milestones[nextMS] {
|
|
||||||
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), completed, totalWork)
|
|
||||||
nextMS++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
global.LOG.Infof("[AppStore] app detail sync completed, total: %d", total)
|
||||||
applyResult := func(result appWorkResult) {
|
|
||||||
c.appsMap[result.appKey] = result.app
|
|
||||||
if result.hadIcon {
|
|
||||||
switch result.iconStatus {
|
|
||||||
case http.StatusOK:
|
|
||||||
icon200Count++
|
|
||||||
case http.StatusNotModified:
|
|
||||||
icon304Count++
|
|
||||||
default:
|
|
||||||
iconFailCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
completed++
|
|
||||||
checkProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
for result := range resultCh {
|
|
||||||
applyResult(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fedCount := int(fed.Load())
|
|
||||||
if failFlag.Load() && fedCount < len(workItems) {
|
|
||||||
global.LOG.Warnf("[AppStore] HTTP failure detected, falling back to serial for remaining %d apps", len(workItems)-fedCount)
|
|
||||||
for i := fedCount; i < len(workItems); i++ {
|
|
||||||
result := c.processOneApp(workItems[i])
|
|
||||||
applyResult(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.LOG.Infof("[AppStore] icon download completed - total: %d, success(200): %d, cached(304): %d, failed: %d",
|
|
||||||
total, icon200Count, icon304Count, iconFailCount)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appSyncContext) downloadAppIcon(iconUrl, appKey, oldIcon string) (status int, iconField string) {
|
|
||||||
iconFileName, existingEtag := appicon.ParseIconField(oldIcon)
|
|
||||||
|
|
||||||
reqHeaders := make(map[string]string)
|
|
||||||
if existingEtag != "" && iconFileName != "" && appicon.IconFileExists(iconFileName) {
|
|
||||||
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 {
|
func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error {
|
||||||
return func(t *task.Task) (err error) {
|
return func(t *task.Task) (err error) {
|
||||||
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
|
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
|
||||||
@@ -411,7 +245,7 @@ func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(
|
|||||||
ctx.task = t
|
ctx.task = t
|
||||||
ctx.appTags = make([]*model.AppTag, 0)
|
ctx.appTags = make([]*model.AppTag, 0)
|
||||||
|
|
||||||
if err = ctx.syncAppIconsAndDetails(); err != nil {
|
if err = ctx.syncAppDetails(); err != nil {
|
||||||
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
|
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,8 +216,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.detail.DockerCompose = string(composeContent)
|
if err := cacheAppDetailCompose(&u.detail, composeContent); err != nil {
|
||||||
_ = appDetailRepo.Update(context.Background(), u.detail)
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
|
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
|
||||||
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
|
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
||||||
@@ -993,8 +994,13 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
|
|||||||
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
|
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
appDetail.Update = false
|
marked, err := appDetailRepo.MarkDownloaded(appDetail)
|
||||||
_ = appDetailRepo.Update(context.Background(), appDetail)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,9 +1274,8 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
|
|||||||
return appDetails
|
return appDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) (map[string]model.App, map[string]string) {
|
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App {
|
||||||
apps := make(map[string]model.App, len(oldApps))
|
apps := make(map[string]model.App, len(oldApps))
|
||||||
pendingIcons := make(map[string]string, len(items))
|
|
||||||
for _, old := range oldApps {
|
for _, old := range oldApps {
|
||||||
old.Status = constant.AppTakeDown
|
old.Status = constant.AppTakeDown
|
||||||
apps[old.Key] = old
|
apps[old.Key] = old
|
||||||
@@ -1311,12 +1316,14 @@ func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, t
|
|||||||
app.Architectures = strings.Join(config.Architectures, ",")
|
app.Architectures = strings.Join(config.Architectures, ",")
|
||||||
app.GpuSupport = config.GpuSupport
|
app.GpuSupport = config.GpuSupport
|
||||||
app.BatchInstallSupport = config.BatchInstallSupport
|
app.BatchInstallSupport = config.BatchInstallSupport
|
||||||
if item.Icon != "" {
|
if appicon.IsRemoteURL(item.Icon) {
|
||||||
pendingIcons[key] = item.Icon
|
app.Icon = item.Icon
|
||||||
|
} else if item.Icon != "" {
|
||||||
|
global.LOG.Warnf("[AppStore] invalid icon URL for app %s, keeping existing icon", key)
|
||||||
}
|
}
|
||||||
apps[key] = app
|
apps[key] = app
|
||||||
}
|
}
|
||||||
return apps, pendingIcons
|
return apps
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
|
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
|
||||||
@@ -1579,6 +1586,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
|
|||||||
HttpPort: installed.HttpPort,
|
HttpPort: installed.HttpPort,
|
||||||
HttpsPort: installed.HttpsPort,
|
HttpsPort: installed.HttpsPort,
|
||||||
AppName: installed.App.Name,
|
AppName: installed.App.Name,
|
||||||
|
Icon: appicon.RemoteURL(installed.App.Icon),
|
||||||
AppKey: installed.App.Key,
|
AppKey: installed.App.Key,
|
||||||
AppType: installed.App.Type,
|
AppType: installed.App.Type,
|
||||||
Path: installed.GetPath(),
|
Path: installed.GetPath(),
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetPHPRuntimeRefreshesVersionOptionsWithoutChangingConfig(t *testing.T) {
|
||||||
|
for _, resource := range []string{constant.AppResourceRemote, constant.AppResourceLocal, constant.AppResourceCustom} {
|
||||||
|
t.Run(resource, func(t *testing.T) {
|
||||||
|
db := appCacheTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&model.Runtime{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: resource}
|
||||||
|
if err := db.Create(&app).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldVersion := dto.AppFormValue{Label: "8.1.34", Value: "8.1.34"}
|
||||||
|
newVersion := dto.AppFormValue{Label: "8.1.35", Value: "8.1.35"}
|
||||||
|
savedForm := dto.AppForm{FormFields: []dto.AppFormFields{
|
||||||
|
{EnvKey: "PHP_VERSION", Type: "select", Default: oldVersion.Value, Values: []dto.AppFormValue{oldVersion}},
|
||||||
|
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Values: []dto.AppFormValue{{Label: "redis", Value: "redis"}}},
|
||||||
|
{EnvKey: "PANEL_APP_PORT_HTTP", Type: "number"},
|
||||||
|
{EnvKey: "CONTAINER_PACKAGE_URL", Type: "select", Values: []dto.AppFormValue{{Label: "custom mirror", Value: "https://mirror.example"}}},
|
||||||
|
}}
|
||||||
|
encode := func(form dto.AppForm) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(form)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
detail := model.AppDetail{AppId: app.ID, Version: "8.1", Params: encode(savedForm), Update: true}
|
||||||
|
if err := db.Create(&detail).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
otherMajor := model.AppDetail{AppId: app.ID, Version: "8.2", Params: `{"formFields":[{"envKey":"PHP_VERSION","values":[{"label":"8.2.31","value":"8.2.31"}]}]}`}
|
||||||
|
if err := db.Create(&otherMajor).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runtime := model.Runtime{
|
||||||
|
Name: "existing-php", Type: constant.RuntimePHP, Resource: constant.ResourceAppstore,
|
||||||
|
Version: detail.Version, AppDetailID: detail.ID, Image: "custom-php:existing", Params: detail.Params,
|
||||||
|
Env: "PHP_VERSION=8.1.34\nPHP_EXTENSIONS=redis,custom-extension\nPANEL_APP_PORT_HTTP=9070\nCONTAINER_PACKAGE_URL=https://mirror.example\nCONTAINER_NAME=my-php\nCUSTOM_SETTING=keep-me\n",
|
||||||
|
DockerCompose: "existing compose", Remark: "keep remark",
|
||||||
|
}
|
||||||
|
if err := db.Create(&runtime).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewRuntimeService()
|
||||||
|
before, err := service.Get(runtime.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
latestForm := dto.AppForm{FormFields: []dto.AppFormFields{
|
||||||
|
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion, oldVersion}},
|
||||||
|
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Default: "opcache"},
|
||||||
|
{EnvKey: "PANEL_APP_PORT_HTTP", Default: 9000},
|
||||||
|
}}
|
||||||
|
removedForm := dto.AppForm{FormFields: []dto.AppFormFields{
|
||||||
|
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion}},
|
||||||
|
}}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
params string
|
||||||
|
values []dto.AppFormValue
|
||||||
|
}{
|
||||||
|
{"new patch on reopen", encode(latestForm), []dto.AppFormValue{newVersion, oldVersion}},
|
||||||
|
{"selected version removed", encode(removedForm), []dto.AppFormValue{newVersion, oldVersion}},
|
||||||
|
{"invalid metadata", "{invalid", []dto.AppFormValue{oldVersion}},
|
||||||
|
{"missing metadata", "", []dto.AppFormValue{oldVersion}},
|
||||||
|
{"missing version field", `{"formFields":[{"envKey":"PHP_EXTENSIONS"}]}`, []dto.AppFormValue{oldVersion}},
|
||||||
|
{"empty version list", `{"formFields":[{"envKey":"PHP_VERSION","values":[]}]}`, []dto.AppFormValue{oldVersion}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if err := db.Model(&detail).UpdateColumn("params", tc.params).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := service.Get(runtime.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := *before
|
||||||
|
want.AppParams = slices.Clone(before.AppParams)
|
||||||
|
want.AppParams[0].Values = tc.values
|
||||||
|
if !reflect.DeepEqual(*got, want) {
|
||||||
|
t.Fatalf("edit response changed configuration or returned stale options:\ngot: %+v\nwant: %+v", *got, want)
|
||||||
|
}
|
||||||
|
var stored model.Runtime
|
||||||
|
if err := db.First(&stored, runtime.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Params != runtime.Params || stored.Env != runtime.Env || stored.Image != runtime.Image || stored.DockerCompose != runtime.DockerCompose || stored.AppDetailID != runtime.AppDetailID || stored.Version != runtime.Version {
|
||||||
|
t.Fatal("opening edit mutated the saved runtime")
|
||||||
|
}
|
||||||
|
var storedDetail model.AppDetail
|
||||||
|
if err := db.First(&storedDetail, detail.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !storedDetail.Update {
|
||||||
|
t.Fatal("opening edit cleared the pending template update")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,42 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
)
|
)
|
||||||
|
|
||||||
const IconPrefix = "app_"
|
const IconPrefix = "app_"
|
||||||
|
|
||||||
|
func IsRemoteURL(icon string) bool {
|
||||||
|
for _, ch := range icon {
|
||||||
|
if unicode.IsSpace(ch) || unicode.IsControl(ch) || ch == '\\' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u, err := url.Parse(icon)
|
||||||
|
if err != nil || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.Hostname() == "" || u.User != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.Port() != "" {
|
||||||
|
port, err := strconv.Atoi(u.Port())
|
||||||
|
if err != nil || port > 65535 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = url.QueryUnescape(u.RawQuery)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoteURL(icon string) string {
|
||||||
|
if IsRemoteURL(icon) {
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func IsIconFile(icon string) bool {
|
func IsIconFile(icon string) bool {
|
||||||
return strings.HasPrefix(icon, IconPrefix)
|
return strings.HasPrefix(icon, IconPrefix)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package appicon
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRemoteURLPreservesValidSources(t *testing.T) {
|
||||||
|
for _, value := range []string{
|
||||||
|
"https://cdn.example/icon.png",
|
||||||
|
"http://cdn.example:8080/icon.png",
|
||||||
|
"HTTPS://cdn.example/icon.png?token=a%2Bb&v=42#image",
|
||||||
|
"https://[::1]:8443/icon.png",
|
||||||
|
} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
if !IsRemoteURL(value) || RemoteURL(value) != value {
|
||||||
|
t.Fatalf("URL was rejected or rewritten: %q", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteURLRejectsOtherIconFormatsAndAmbiguousURLs(t *testing.T) {
|
||||||
|
for _, value := range []string{
|
||||||
|
"", "app_php.png?etag=42", "iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo=",
|
||||||
|
"javascript:alert(1)", "file:///icon.png", "//cdn.example/icon.png", "https:///icon.png",
|
||||||
|
"https://", "https://user:secret@cdn.example/icon.png", "https://@cdn.example/icon.png",
|
||||||
|
" https://cdn.example/icon.png", "https://cdn.example/icon.png ", "https://cdn.example/icon image.png",
|
||||||
|
"https://cdn.example/\nicon.png", "https://cdn.example/icon.png\r\nLocation: https://other.example",
|
||||||
|
"https://cdn.example/\\other.png", "https://cdn.example:bad/icon.png", "https://cdn.example/%zz",
|
||||||
|
"https://cdn.example/icon.png?token=%zz", "https://cdn.example/\u00a0icon.png", "https://cdn.example/\x7ficon.png",
|
||||||
|
"https://cdn.example:65536/icon.png",
|
||||||
|
} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
if IsRemoteURL(value) || RemoteURL(value) != "" {
|
||||||
|
t.Fatalf("invalid or historical icon was classified as a remote URL: %q", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
-2
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
|
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
|
||||||
if _, err := auth.RequireAPIKeySession(c); err != nil {
|
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
|
||||||
helper.BadAuth(c, "ErrNotLogin", nil)
|
helper.BadAuth(c, "ErrNotLogin", nil)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,24 @@ func apiKeyError(c *gin.Context, err error) {
|
|||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swag's legacy || syntax combines schemes in one requirement (AND);
|
||||||
|
// separate @Security lines represent alternative authentication methods.
|
||||||
|
// @Tags API Key
|
||||||
|
// @Summary List the current account's API keys
|
||||||
|
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
|
||||||
|
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
|
||||||
|
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
|
||||||
|
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
||||||
|
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
||||||
|
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
|
||||||
|
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
||||||
|
// @Header 200 {string} Cache-Control "no-store"
|
||||||
|
// @Security ApiKeyAuth || Timestamp
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
|
// @Router /core/auth/api/keys/search [post]
|
||||||
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
||||||
var req dto.APIKeySearch
|
var req dto.APIKeySearch
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -55,6 +73,25 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, result)
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Tags API Key
|
||||||
|
// @Summary Create an API key for the current account
|
||||||
|
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
|
||||||
|
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
|
||||||
|
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
|
||||||
|
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
|
||||||
|
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
|
||||||
|
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
|
||||||
|
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
||||||
|
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
||||||
|
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
|
||||||
|
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
||||||
|
// @Header 200 {string} Cache-Control "no-store"
|
||||||
|
// @Security ApiKeyAuth || Timestamp
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
|
// @Router /core/auth/api/keys/create [post]
|
||||||
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
||||||
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
|
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -69,6 +106,22 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, result)
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Tags API Key
|
||||||
|
// @Summary Update an API key's policy
|
||||||
|
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
|
||||||
|
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
|
||||||
|
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
|
||||||
|
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
|
||||||
|
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
||||||
|
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
||||||
|
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
||||||
|
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
||||||
|
// @Security ApiKeyAuth || Timestamp
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
|
// @Router /core/auth/api/keys/update [post]
|
||||||
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
||||||
var req dto.APIKeyUpdate
|
var req dto.APIKeyUpdate
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -81,6 +134,21 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
|||||||
apiKeyMutationSuccess(c)
|
apiKeyMutationSuccess(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Tags API Key
|
||||||
|
// @Summary Enable or disable an API key
|
||||||
|
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
|
||||||
|
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
|
||||||
|
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
|
||||||
|
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
||||||
|
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
||||||
|
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
||||||
|
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
||||||
|
// @Security ApiKeyAuth || Timestamp
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
|
// @Router /core/auth/api/keys/status [post]
|
||||||
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
||||||
var req dto.APIKeyStatus
|
var req dto.APIKeyStatus
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -93,6 +161,22 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
|||||||
apiKeyMutationSuccess(c)
|
apiKeyMutationSuccess(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Tags API Key
|
||||||
|
// @Summary Revoke an API key
|
||||||
|
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
|
||||||
|
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
|
||||||
|
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
|
||||||
|
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
|
||||||
|
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
||||||
|
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
||||||
|
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
||||||
|
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
||||||
|
// @Security ApiKeyAuth || Timestamp
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
|
// @Router /core/auth/api/keys/revoke [post]
|
||||||
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
||||||
var req dto.APIKeyMutation
|
var req dto.APIKeyMutation
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -106,5 +190,63 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiKeyMutationSuccess(c *gin.Context) {
|
func apiKeyMutationSuccess(c *gin.Context) {
|
||||||
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
|
helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Tags API Key
|
||||||
|
// @Summary Delete an independent API key
|
||||||
|
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
|
||||||
|
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
|
||||||
|
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
|
||||||
|
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
||||||
|
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
||||||
|
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
||||||
|
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
||||||
|
// @Security ApiKeyAuth || Timestamp
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
|
// @Router /core/auth/api/keys/delete [post]
|
||||||
|
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
|
||||||
|
var req dto.APIKeyMutation
|
||||||
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
|
||||||
|
apiKeyError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiKeyMutationSuccess(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Tags API Key
|
||||||
|
// @Summary Reveal an independent API key's plaintext secret
|
||||||
|
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
|
||||||
|
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
|
||||||
|
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
|
||||||
|
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
|
||||||
|
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
|
||||||
|
// @Header 200 {string} Cache-Control "no-store"
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
|
// @Router /core/auth/api/keys/reveal [post]
|
||||||
|
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
if _, err := auth.RequireAPIKeySession(c); err != nil {
|
||||||
|
helper.BadAuth(c, "ErrNotLogin", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.APIKeyMutation
|
||||||
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.NewAPIKeyService().Reveal(c, req)
|
||||||
|
if err != nil {
|
||||||
|
apiKeyError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -417,11 +417,13 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Summary generate api key
|
// @Summary Generate or reset the legacy API key
|
||||||
|
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Success 200 {string} key
|
// @Produce json
|
||||||
// @Security ApiKeyAuth
|
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
|
||||||
// @Security Timestamp
|
// @Header 200 {string} Cache-Control "no-store"
|
||||||
|
// @Security PanelSession || CSRFToken
|
||||||
// @Router /core/auth/api/generate [post]
|
// @Router /core/auth/api/generate [post]
|
||||||
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
|
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
|
||||||
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
||||||
@@ -440,12 +442,13 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Summary Update api config
|
// @Summary Update the legacy API key configuration
|
||||||
|
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
// @Param request body dto.ApiInterfaceConfig true "request"
|
// @Param request body dto.ApiInterfaceConfig true "request"
|
||||||
// @Success 200
|
// @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500"
|
||||||
// @Security ApiKeyAuth
|
// @Security PanelSession || CSRFToken
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /core/auth/api/update [post]
|
// @Router /core/auth/api/update [post]
|
||||||
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
|
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
|
||||||
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
|
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -42,7 +43,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
|
|||||||
ownerProvider = ownerProviders[0]
|
ownerProvider = ownerProviders[0]
|
||||||
}
|
}
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") {
|
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,6 +98,20 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAPIKeyManagementRequest(c *gin.Context) bool {
|
||||||
|
if c.Request.Method != http.MethodPost {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch c.Request.URL.Path {
|
||||||
|
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
|
||||||
|
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
|
||||||
|
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
|
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
|
||||||
settingRepo := repo.NewISettingRepo()
|
settingRepo := repo.NewISettingRepo()
|
||||||
config := APIAuthConfig{}
|
config := APIAuthConfig{}
|
||||||
|
|||||||
@@ -66,6 +66,26 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
|
||||||
|
if c == nil || !c.GetBool("API_AUTH") {
|
||||||
|
return RequireAPIKeySession(c)
|
||||||
|
}
|
||||||
|
if c.GetBool("LOCAL_REQUEST") {
|
||||||
|
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
||||||
|
}
|
||||||
|
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
|
||||||
|
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
|
||||||
|
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
||||||
|
}
|
||||||
|
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
|
||||||
|
value, _ := c.Get(psession.GinContextSessionUserKey)
|
||||||
|
u, ok := value.(psession.SessionUser)
|
||||||
|
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
|
||||||
|
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
func HasAPICredentials(c *gin.Context) bool {
|
func HasAPICredentials(c *gin.Context) bool {
|
||||||
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
|
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
|
||||||
}
|
}
|
||||||
@@ -87,7 +107,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
func APIKeySecret(key model.APIKey) (string, error) {
|
func APIKeySecret(key model.APIKey) (string, error) {
|
||||||
if key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
||||||
return "", buserr.New("ErrApiConfigKeyInvalid")
|
return "", buserr.New("ErrApiConfigKeyInvalid")
|
||||||
}
|
}
|
||||||
master, err := encrypt.APIKeyEncryptionKey()
|
master, err := encrypt.APIKeyEncryptionKey()
|
||||||
@@ -98,7 +118,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
|
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
|
||||||
if key.Status != constant.StatusEnable {
|
if key.DeletedAt != nil || key.Status != constant.StatusEnable {
|
||||||
return buserr.New("ErrApiConfigStatusInvalid")
|
return buserr.New("ErrApiConfigStatusInvalid")
|
||||||
}
|
}
|
||||||
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
|
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
|
||||||
@@ -113,7 +133,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
|
|||||||
|
|
||||||
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
|
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
|
||||||
keyID := c.GetHeader("1Panel-Key-ID")
|
keyID := c.GetHeader("1Panel-Key-ID")
|
||||||
q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision")
|
q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision")
|
||||||
if keyID != "" {
|
if keyID != "" {
|
||||||
q = q.Where("id = ?", keyID)
|
q = q.Where("id = ?", keyID)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+35
-27
@@ -6,41 +6,42 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type APIKeyItem struct {
|
type APIKeyItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name" example:"Deployment automation"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description" example:"Used by the deployment service"`
|
||||||
KeyHint string `json:"keyHint"`
|
KeyHint string `json:"keyHint" example:"abcd••••1234"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
|
||||||
IPWhiteList string `json:"ipWhiteList"`
|
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
|
||||||
APITrustedProxies string `json:"apiTrustedProxies"`
|
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
|
||||||
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
|
||||||
ExpiresAt *time.Time `json:"expiresAt"`
|
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
|
||||||
AllowAppBinding bool `json:"allowAppBinding"`
|
AllowAppBinding bool `json:"allowAppBinding" example:"false"`
|
||||||
Revision uint64 `json:"revision"`
|
Revision uint64 `json:"revision" minimum:"1" example:"1"`
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKeySearch struct {
|
type APIKeySearch struct {
|
||||||
Page int `json:"page"`
|
Page int `json:"page" minimum:"1" example:"1"`
|
||||||
PageSize int `json:"pageSize"`
|
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
|
||||||
ExcludeRevoked bool `json:"excludeRevoked"`
|
ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
|
||||||
}
|
}
|
||||||
type APIKeyPage struct {
|
type APIKeyPage struct {
|
||||||
Items []APIKeyItem `json:"items"`
|
Items []APIKeyItem `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
type APIKeyFields struct {
|
type APIKeyFields struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
|
||||||
IPWhiteList string `json:"ipWhiteList"`
|
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
|
||||||
APITrustedProxies string `json:"apiTrustedProxies"`
|
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
|
||||||
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
|
||||||
ExpiresAt json.RawMessage `json:"expiresAt"`
|
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
|
||||||
AllowAppBinding bool `json:"allowAppBinding"`
|
AllowAppBinding bool `json:"allowAppBinding" default:"false"`
|
||||||
}
|
}
|
||||||
type APIKeyCreate struct {
|
type APIKeyCreate struct {
|
||||||
RequestID string `json:"requestID"`
|
// Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server.
|
||||||
|
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
|
||||||
APIKeyFields
|
APIKeyFields
|
||||||
}
|
}
|
||||||
type APIKeyCreated struct {
|
type APIKeyCreated struct {
|
||||||
@@ -49,8 +50,15 @@ type APIKeyCreated struct {
|
|||||||
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
||||||
}
|
}
|
||||||
type APIKeyMutation struct {
|
type APIKeyMutation struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
||||||
Revision uint64 `json:"revision"`
|
Revision uint64 `json:"revision" minimum:"1" example:"1"`
|
||||||
|
}
|
||||||
|
type APIKeyMutationResult struct {
|
||||||
|
TerminalClosePending bool `json:"terminalClosePending"`
|
||||||
|
}
|
||||||
|
type APIKeyRevealed struct {
|
||||||
|
APIKeyMutation
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
}
|
}
|
||||||
type APIKeyUpdate struct {
|
type APIKeyUpdate struct {
|
||||||
APIKeyMutation
|
APIKeyMutation
|
||||||
@@ -58,5 +66,5 @@ type APIKeyUpdate struct {
|
|||||||
}
|
}
|
||||||
type APIKeyStatus struct {
|
type APIKeyStatus struct {
|
||||||
APIKeyMutation
|
APIKeyMutation
|
||||||
Status string `json:"status"`
|
Status string `json:"status" enums:"Enable,Disable" example:"Disable"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type APIKey struct {
|
|||||||
CreatedAt time.Time `json:"-"`
|
CreatedAt time.Time `json:"-"`
|
||||||
UpdatedAt time.Time `json:"-"`
|
UpdatedAt time.Time `json:"-"`
|
||||||
RevokedAt *time.Time `json:"-"`
|
RevokedAt *time.Time `json:"-"`
|
||||||
|
DeletedAt *time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegacyAPIKeyPolicy struct {
|
type LegacyAPIKeyPolicy struct {
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
|
|||||||
}
|
}
|
||||||
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
|
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
|
||||||
var key model.APIKey
|
var key model.APIKey
|
||||||
err := global.DB.Where("id = ?", id).First(&key).Error
|
err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error
|
||||||
return key, err
|
return key, err
|
||||||
}
|
}
|
||||||
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
|
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
|
||||||
items := make([]model.APIKey, 0)
|
items := make([]model.APIKey, 0)
|
||||||
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
|
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
|
||||||
if excludeRevoked {
|
if excludeRevoked {
|
||||||
q = q.Where("status <> ?", "Revoked")
|
q = q.Where("status <> ?", "Revoked")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
|
|||||||
return s.Provider.CurrentAPIKeyOwner(c)
|
return s.Provider.CurrentAPIKeyOwner(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
|
||||||
|
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
|
||||||
|
return auth.APIKeyOwner{}, err
|
||||||
|
}
|
||||||
|
if s.Provider == nil {
|
||||||
|
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
|
||||||
|
}
|
||||||
|
if !c.GetBool("API_AUTH") {
|
||||||
|
return s.Provider.CurrentAPIKeyOwner(c)
|
||||||
|
}
|
||||||
|
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
|
||||||
|
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return auth.APIKeyOwner{}, err
|
||||||
|
}
|
||||||
|
if owner.Type != ownerType || owner.ID != ownerID {
|
||||||
|
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
|
||||||
|
}
|
||||||
|
return owner, nil
|
||||||
|
}
|
||||||
|
|
||||||
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
|
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
|
||||||
status := key.Status
|
status := key.Status
|
||||||
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
|
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
|
||||||
@@ -75,7 +96,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
|
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
|
||||||
owner, err := s.owner(c)
|
owner, err := s.managementOwner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -146,11 +167,13 @@ func keyHint(secret string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
|
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
|
||||||
owner, err := s.owner(c)
|
owner, err := s.managementOwner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err = uuid.Parse(req.RequestID); err != nil {
|
if req.RequestID == "" {
|
||||||
|
req.RequestID = uuid.NewString()
|
||||||
|
} else if _, err = uuid.Parse(req.RequestID); err != nil {
|
||||||
return nil, buserr.New("ErrInvalidParams")
|
return nil, buserr.New("ErrInvalidParams")
|
||||||
}
|
}
|
||||||
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
|
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
|
||||||
@@ -172,6 +195,9 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
|
|||||||
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
|
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
|
||||||
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
|
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
if key.DeletedAt != nil {
|
||||||
|
return buserr.New("ErrAPIKeyNotFound")
|
||||||
|
}
|
||||||
if key.RequestHash != requestHash {
|
if key.RequestHash != requestHash {
|
||||||
return buserr.New("ErrAPIKeyConflict")
|
return buserr.New("ErrAPIKeyConflict")
|
||||||
}
|
}
|
||||||
@@ -219,7 +245,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
||||||
owner, err := s.owner(c)
|
owner, err := s.managementOwner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -247,7 +273,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
||||||
owner, err := s.owner(c)
|
owner, err := s.managementOwner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -271,7 +297,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
||||||
owner, err := s.owner(c)
|
owner, err := s.managementOwner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -289,21 +315,75 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
|
||||||
|
owner, err := s.managementOwner(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if req.ID == "legacy" {
|
||||||
|
return buserr.New("ErrInvalidParams")
|
||||||
|
}
|
||||||
|
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
|
||||||
|
now := time.Now()
|
||||||
|
key.Status = "Revoked"
|
||||||
|
if key.RevokedAt == nil {
|
||||||
|
key.RevokedAt = &now
|
||||||
|
}
|
||||||
|
key.DeletedAt = &now
|
||||||
|
key.ActiveName = nil
|
||||||
|
key.SecretCiphertext = ""
|
||||||
|
key.KeyHint = ""
|
||||||
|
key.AllowAppBinding = false
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
|
||||||
|
owner, err := s.owner(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
|
||||||
|
return nil, buserr.New("ErrInvalidParams")
|
||||||
|
}
|
||||||
|
// Serialize the snapshot and disclosure audit with revocation/deletion.
|
||||||
|
apiKeyMutationMu.Lock()
|
||||||
|
defer apiKeyMutationMu.Unlock()
|
||||||
|
var key model.APIKey
|
||||||
|
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, buserr.New("ErrAPIKeyNotFound")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key.Revision != req.Revision || key.Status == "Revoked" {
|
||||||
|
return nil, buserr.New("ErrAPIKeyConflict")
|
||||||
|
}
|
||||||
|
secret, err := auth.APIKeySecret(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, buserr.New("ErrApiConfigKeyInvalid")
|
||||||
|
}
|
||||||
|
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
|
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
|
||||||
if req.ID == "" || req.Revision == 0 {
|
if req.ID == "" || req.Revision == 0 {
|
||||||
return buserr.New("ErrInvalidParams")
|
return buserr.New("ErrInvalidParams")
|
||||||
}
|
}
|
||||||
apiKeyMutationMu.Lock()
|
apiKeyMutationMu.Lock()
|
||||||
var key model.APIKey
|
var key model.APIKey
|
||||||
closeTerminals := action == "disable" || action == "revoke"
|
closeTerminals := action == "disable" || action == "revoke" || action == "delete"
|
||||||
err := global.DB.Transaction(func(tx *gorm.DB) error {
|
err := global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil {
|
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return buserr.New("ErrAPIKeyNotFound")
|
return buserr.New("ErrAPIKeyNotFound")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if key.Revision != req.Revision || key.Status == "Revoked" {
|
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
|
||||||
return buserr.New("ErrAPIKeyConflict")
|
return buserr.New("ErrAPIKeyConflict")
|
||||||
}
|
}
|
||||||
before := key
|
before := key
|
||||||
|
|||||||
+2180
-267
File diff suppressed because it is too large
Load Diff
@@ -4542,6 +4542,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"302": {
|
||||||
|
"description": "redirect to the official icon URL"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5339,6 +5342,17 @@
|
|||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "request",
|
||||||
|
"in": "body",
|
||||||
|
"name": "request",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/request.AppUpdateVersion"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -47477,6 +47491,23 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"request.AppUpdateVersion": {
|
||||||
|
"properties": {
|
||||||
|
"appInstallID": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"loadCompose": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"updateVersion": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"appInstallID"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"response.AppConfig": {
|
"response.AppConfig": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -47503,6 +47534,9 @@
|
|||||||
"hostMode": {
|
"hostMode": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"isEdit": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"memoryLimit": {
|
"memoryLimit": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
@@ -47763,6 +47797,9 @@
|
|||||||
"gpuSupport": {
|
"gpuSupport": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import (
|
|||||||
// @schemes http https
|
// @schemes http https
|
||||||
|
|
||||||
// @securityDefinitions.apikey ApiKeyAuth
|
// @securityDefinitions.apikey ApiKeyAuth
|
||||||
// @description Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp).
|
// @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token.
|
||||||
|
// @description Supported formats: hex(HMAC-SHA256(API-Key, '1panel:' + UnixTimestamp)) or md5('1panel' + API-Key + UnixTimestamp).
|
||||||
|
// @description Send the matching Unix timestamp in 1Panel-Timestamp. Optionally set 1Panel-Signature-Version to hmac-sha256, v1 or md5; omission accepts either algorithm.
|
||||||
|
// @description Independent keys may include their ID in 1Panel-Key-ID to select the credential; omit that header for a legacy key.
|
||||||
// @description ```
|
// @description ```
|
||||||
// @description eg:
|
// @description eg:
|
||||||
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
|
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
|
||||||
@@ -27,7 +30,7 @@ import (
|
|||||||
// @description ```
|
// @description ```
|
||||||
// @description - `1Panel-Token` is the key for the panel API Key.
|
// @description - `1Panel-Token` is the key for the panel API Key.
|
||||||
// @type apiKey
|
// @type apiKey
|
||||||
// @in Header
|
// @in header
|
||||||
// @name 1Panel-Token
|
// @name 1Panel-Token
|
||||||
// @securityDefinitions.apikey Timestamp
|
// @securityDefinitions.apikey Timestamp
|
||||||
// @type apiKey
|
// @type apiKey
|
||||||
|
|||||||
@@ -72,5 +72,6 @@ func coreMigrations() []*gormigrate.Migration {
|
|||||||
migrations.UpdateAiMenuOrder,
|
migrations.UpdateAiMenuOrder,
|
||||||
migrations.UpdateAiMenuOrderV2,
|
migrations.UpdateAiMenuOrderV2,
|
||||||
migrations.AddPanelMCP,
|
migrations.AddPanelMCP,
|
||||||
|
migrations.AddAPIKeyDeletedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,8 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
|
|||||||
ID: "20260915-add-operation-log-api-key",
|
ID: "20260915-add-operation-log-api-key",
|
||||||
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
|
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var AddAPIKeyDeletedAt = &gormigrate.Migration{
|
||||||
|
ID: "20261028-api-key-deleted-at",
|
||||||
|
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}) },
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type iconRedirectTransport func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f iconRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||||
|
|
||||||
|
func TestLocalAppIconRedirectIsPassedToBrowser(t *testing.T) {
|
||||||
|
const icon = "https://cdn.example/icon.png?signature=a%2Bb&v=42"
|
||||||
|
calls := 0
|
||||||
|
proxy := newLocalAgentProxy(iconRedirectTransport(func(req *http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if req.URL.Host != "unix" || req.URL.Path != "/api/v2/apps/icon/php" {
|
||||||
|
t.Fatalf("proxy attempted to fetch a remote image: %s", req.URL)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusFound,
|
||||||
|
Header: http.Header{"Location": {icon}, "Cache-Control": {"no-cache"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader("")),
|
||||||
|
}, nil
|
||||||
|
}))
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
proxy.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/v2/apps/icon/php", nil))
|
||||||
|
if rw.Code != http.StatusFound || rw.Header().Get("Location") != icon || rw.Header().Get("Cache-Control") != "no-cache" || calls != 1 {
|
||||||
|
t.Fatalf("local proxy did not preserve the redirect: status=%d headers=%v calls=%d", rw.Code, rw.Header(), calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ func OperationLog() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch c.Request.URL.Path {
|
switch c.Request.URL.Path {
|
||||||
|
case "/api/v2/core/auth/api/keys/reveal":
|
||||||
|
// Disclosure has its own audit and must not enter response-body capture.
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export":
|
case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export":
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
|
|||||||
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
|
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
|
||||||
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
|
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
|
||||||
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
|
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
|
||||||
|
authRouter.POST("/api/keys/delete", baseApi.DeleteAPIKey)
|
||||||
|
authRouter.POST("/api/keys/reveal", baseApi.RevealAPIKey)
|
||||||
|
|
||||||
authRouter.GET("/current", baseApi.GetCurrentUser)
|
authRouter.GET("/current", baseApi.GetCurrentUser)
|
||||||
authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
|
authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
|
|||||||
"enable": {"启用 API Key", "Enable API key"},
|
"enable": {"启用 API Key", "Enable API key"},
|
||||||
"disable": {"停用 API Key", "Disable API key"},
|
"disable": {"停用 API Key", "Disable API key"},
|
||||||
"revoke": {"撤销 API Key", "Revoke API key"},
|
"revoke": {"撤销 API Key", "Revoke API key"},
|
||||||
|
"delete": {"删除 API Key", "Delete API key"},
|
||||||
|
"reveal": {"查看 API Key", "Reveal API key"},
|
||||||
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
|
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
|
||||||
}
|
}
|
||||||
label, ok := labels[event.Action]
|
label, ok := labels[event.Action]
|
||||||
@@ -80,6 +82,17 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
|
|||||||
record.UserAgent = c.Request.UserAgent()
|
record.UserAgent = c.Request.UserAgent()
|
||||||
record.IP = c.ClientIP()
|
record.IP = c.ClientIP()
|
||||||
}
|
}
|
||||||
|
if c.GetBool("API_AUTH") && event.Action != "app_binding" {
|
||||||
|
record.AuthMethod = "api_key"
|
||||||
|
record.APIKeyID = c.GetString("API_AUTH_KEY_ID")
|
||||||
|
record.APIKeyName = c.GetString("API_AUTH_KEY_NAME")
|
||||||
|
if c.GetString("API_AUTH_KEY_KIND") == "legacy" {
|
||||||
|
record.APIKeyID = "legacy:" + c.GetString("API_AUTH_OWNER_TYPE") + ":" + c.GetString("API_AUTH_OWNER_ID")
|
||||||
|
}
|
||||||
|
if clientIP := c.GetString("API_AUTH_CLIENT_IP"); clientIP != "" {
|
||||||
|
record.IP = clientIP
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if event.ClientIP != "" {
|
if event.ClientIP != "" {
|
||||||
record.IP = event.ClientIP
|
record.IP = event.ClientIP
|
||||||
|
|||||||
@@ -30,4 +30,7 @@ export namespace APIKey {
|
|||||||
id: string;
|
id: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
}
|
}
|
||||||
|
export interface Revealed extends Reference {
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export namespace App {
|
|||||||
|
|
||||||
export interface AppItem {
|
export interface AppItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
icon?: string;
|
||||||
key: string;
|
key: string;
|
||||||
id: number;
|
id: number;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -291,6 +292,7 @@ export namespace App {
|
|||||||
feishuConfigured?: boolean;
|
feishuConfigured?: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
detailId: number;
|
detailId: number;
|
||||||
|
dockerCompose?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallParams {
|
export interface InstallParams {
|
||||||
@@ -318,6 +320,7 @@ export namespace App {
|
|||||||
modelAccountBaseURL: string;
|
modelAccountBaseURL: string;
|
||||||
params: InstallParams[];
|
params: InstallParams[];
|
||||||
rawCompose?: string;
|
rawCompose?: string;
|
||||||
|
isEdit?: boolean;
|
||||||
cpuQuota: number;
|
cpuQuota: number;
|
||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
memoryUnit: string;
|
memoryUnit: string;
|
||||||
@@ -341,6 +344,7 @@ export namespace App {
|
|||||||
export interface AppUpdateVersionReq {
|
export interface AppUpdateVersionReq {
|
||||||
appInstallID: number;
|
appInstallID: number;
|
||||||
updateVersion?: string;
|
updateVersion?: string;
|
||||||
|
loadCompose?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppIgnoreReq {
|
export interface AppIgnoreReq {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const prefix = '/core/auth/api/keys';
|
|||||||
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
|
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
|
||||||
return http.post<APIKey.Search>(`${prefix}/search`, params);
|
return http.post<APIKey.Search>(`${prefix}/search`, params);
|
||||||
};
|
};
|
||||||
export const createAPIKey = (params: APIKey.Editable & { requestID: string }) => {
|
export const createAPIKey = (params: APIKey.Editable & { requestID?: string }) => {
|
||||||
return http.post<APIKey.Created>(`${prefix}/create`, params);
|
return http.post<APIKey.Created>(`${prefix}/create`, params);
|
||||||
};
|
};
|
||||||
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
|
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
|
||||||
@@ -18,3 +18,9 @@ export const setAPIKeyStatus = (params: APIKey.Reference & { status: 'Enable' |
|
|||||||
export const revokeAPIKey = (params: APIKey.Reference) => {
|
export const revokeAPIKey = (params: APIKey.Reference) => {
|
||||||
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
|
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
|
||||||
};
|
};
|
||||||
|
export const deleteAPIKey = (params: APIKey.Reference) => {
|
||||||
|
return http.post<{ terminalClosePending: boolean }>(`${prefix}/delete`, params);
|
||||||
|
};
|
||||||
|
export const revealAPIKey = (params: APIKey.Reference) => {
|
||||||
|
return http.post<APIKey.Revealed>(`${prefix}/reveal`, params);
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import http from '@/api';
|
|||||||
import { ResPage } from '../interface';
|
import { ResPage } from '../interface';
|
||||||
import { App } from '../interface/app';
|
import { App } from '../interface/app';
|
||||||
import { TimeoutEnum } from '@/enums/http-enum';
|
import { TimeoutEnum } from '@/enums/http-enum';
|
||||||
|
import { resolveAppIconUrl } from '@/utils/app-icon';
|
||||||
|
|
||||||
export const syncApp = (req: App.AppStoreSync) => {
|
export const syncApp = (req: App.AppStoreSync) => {
|
||||||
return http.post('apps/sync/remote', req);
|
return http.post('apps/sync/remote', req);
|
||||||
@@ -99,8 +100,9 @@ export const getAppDefaultConfig = (key: string, name: string) => {
|
|||||||
return http.post<string>(`apps/installed/conf`, { type: key, name: name });
|
return http.post<string>(`apps/installed/conf`, { type: key, name: name });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAppInstallParams = (id: number) => {
|
export const getAppInstallParams = (id: number, node?: string) => {
|
||||||
return http.get<App.AppConfig>(`apps/installed/params/${id}`);
|
const params = node ? `?operateNode=${encodeURIComponent(node)}` : '';
|
||||||
|
return http.get<App.AppConfig>(`apps/installed/params/${id}${params}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateAppInstallParams = (req: any) => {
|
export const updateAppInstallParams = (req: any) => {
|
||||||
@@ -135,10 +137,9 @@ export const getCurrentNodeCustomAppConfig = () => {
|
|||||||
return http.get<App.CustomAppStoreConfig>(`/custom/app/config`);
|
return http.get<App.CustomAppStoreConfig>(`/custom/app/config`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAppIconUrl(appKey: string, node?: string): string {
|
export function getAppIconUrl(appKey: string, node?: string, icon = ''): string {
|
||||||
const baseURL = import.meta.env.VITE_API_URL as string;
|
const baseURL = import.meta.env.VITE_API_URL as string;
|
||||||
const params = node ? `?operateNode=${node}` : '';
|
return resolveAppIconUrl(appKey, node, icon, baseURL);
|
||||||
return `${baseURL}/apps/icon/${appKey}${params}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const installAppToNodes = (param: App.InstallAppToNodes) => {
|
export const installAppToNodes = (param: App.InstallAppToNodes) => {
|
||||||
|
|||||||
@@ -6,27 +6,14 @@
|
|||||||
:confirm-before-close="true"
|
:confirm-before-close="true"
|
||||||
@before-close="beforeClose"
|
@before-close="beforeClose"
|
||||||
>
|
>
|
||||||
<template v-if="created">
|
<el-form
|
||||||
<el-alert
|
ref="formRef"
|
||||||
:title="$t(secret ? 'apiKeyManagement.saveSecret' : 'apiKeyManagement.alreadyCreated')"
|
:model="form"
|
||||||
:type="secret ? 'warning' : 'info'"
|
:rules="rules"
|
||||||
:closable="false"
|
label-position="top"
|
||||||
show-icon
|
class="text-[var(--el-text-color-regular)]"
|
||||||
class="mb-4"
|
@submit.prevent="submit"
|
||||||
/>
|
>
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item :label="$t('commons.table.name')">{{ created.name }}</el-form-item>
|
|
||||||
<el-form-item v-if="secret" :label="$t('setting.apiKey')">
|
|
||||||
<el-input :model-value="secret" readonly autocomplete="off">
|
|
||||||
<template #append>
|
|
||||||
<CopyButton :content="secret" :is-icon="false" />
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<el-checkbox v-if="secret" v-model="saved">{{ $t('apiKeyManagement.saved') }}</el-checkbox>
|
|
||||||
</template>
|
|
||||||
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
|
|
||||||
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
|
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
|
||||||
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
|
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
|
||||||
<li>
|
<li>
|
||||||
@@ -139,13 +126,10 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button v-if="!created" :disabled="busy" @click="beforeClose(() => (visible = false))">
|
<el-button :disabled="busy" @click="beforeClose(() => (visible = false))">
|
||||||
{{ $t('commons.button.cancel') }}
|
{{ $t('commons.button.cancel') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="created" type="primary" :disabled="!!secret && !saved" @click="finish">
|
<el-button type="primary" :loading="busy" @click="submit">
|
||||||
{{ $t('commons.button.confirm') }}
|
|
||||||
</el-button>
|
|
||||||
<el-button v-else type="primary" :loading="busy" @click="submit">
|
|
||||||
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
|
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -154,7 +138,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||||
import { ElMessageBox, type FormInstance } from 'element-plus';
|
import type { FormInstance } from 'element-plus';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { onBeforeRouteLeave } from 'vue-router';
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
@@ -174,9 +158,6 @@ const visible = ref(false);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const uncertain = ref(false);
|
const uncertain = ref(false);
|
||||||
const item = ref<APIKey.Item>();
|
const item = ref<APIKey.Item>();
|
||||||
const created = ref<APIKey.Item>();
|
|
||||||
const secret = ref('');
|
|
||||||
const saved = ref(false);
|
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const ipMode = ref('restricted');
|
const ipMode = ref('restricted');
|
||||||
const neverExpires = ref(false);
|
const neverExpires = ref(false);
|
||||||
@@ -196,8 +177,6 @@ let editorVersion = 0;
|
|||||||
|
|
||||||
const discardEditor = () => {
|
const discardEditor = () => {
|
||||||
editorVersion++;
|
editorVersion++;
|
||||||
secret.value = '';
|
|
||||||
created.value = undefined;
|
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
};
|
};
|
||||||
@@ -240,9 +219,6 @@ const rules = {
|
|||||||
const open = (existing?: APIKey.Item, fromApp = false) => {
|
const open = (existing?: APIKey.Item, fromApp = false) => {
|
||||||
editorVersion++;
|
editorVersion++;
|
||||||
item.value = existing;
|
item.value = existing;
|
||||||
created.value = undefined;
|
|
||||||
secret.value = '';
|
|
||||||
saved.value = false;
|
|
||||||
uncertain.value = false;
|
uncertain.value = false;
|
||||||
pendingCreate = undefined;
|
pendingCreate = undefined;
|
||||||
requestID = uuidv4();
|
requestID = uuidv4();
|
||||||
@@ -269,19 +245,9 @@ const open = (existing?: APIKey.Item, fromApp = false) => {
|
|||||||
nextTick(() => formRef.value?.clearValidate());
|
nextTick(() => formRef.value?.clearValidate());
|
||||||
};
|
};
|
||||||
|
|
||||||
const finish = () => {
|
const beforeClose = (done: () => void) => {
|
||||||
secret.value = '';
|
|
||||||
visible.value = false;
|
|
||||||
if (created.value) emit('created', created.value);
|
|
||||||
};
|
|
||||||
const beforeClose = async (done: () => void) => {
|
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
if (secret.value && !saved.value) {
|
if (uncertain.value) emit('changed');
|
||||||
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (created.value) finish();
|
|
||||||
else if (uncertain.value) emit('changed');
|
|
||||||
done();
|
done();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -310,9 +276,13 @@ const submit = async () => {
|
|||||||
pendingCreate ??= { ...payload, requestID };
|
pendingCreate ??= { ...payload, requestID };
|
||||||
const response = await createAPIKey(pendingCreate);
|
const response = await createAPIKey(pendingCreate);
|
||||||
if (ticket !== editorVersion) return;
|
if (ticket !== editorVersion) return;
|
||||||
created.value = response.data.item;
|
|
||||||
secret.value = response.data.apiKey;
|
|
||||||
uncertain.value = false;
|
uncertain.value = false;
|
||||||
|
pendingCreate = undefined;
|
||||||
|
MsgSuccess(i18n.global.t('commons.msg.createSuccess'));
|
||||||
|
visible.value = false;
|
||||||
|
emit('changed');
|
||||||
|
emit('created', response.data.item);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
emit('changed');
|
emit('changed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -341,16 +311,12 @@ watch(
|
|||||||
{ flush: 'sync' },
|
{ flush: 'sync' },
|
||||||
);
|
);
|
||||||
onBeforeUnmount(discardEditor);
|
onBeforeUnmount(discardEditor);
|
||||||
onBeforeRouteLeave(async (to) => {
|
onBeforeRouteLeave((to) => {
|
||||||
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
|
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
|
||||||
discardEditor();
|
discardEditor();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (busy.value) return false;
|
if (busy.value) return false;
|
||||||
if (secret.value && !saved.value) {
|
|
||||||
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
discardEditor();
|
discardEditor();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'Show secret',
|
||||||
|
hideSecret: 'Hide secret',
|
||||||
|
revealHelp: 'Keep this secret secure. Disabled or expired keys remain unusable even when displayed.',
|
||||||
|
deleteConfirm:
|
||||||
|
'Delete “{0}”? Its secret will be destroyed and the key removed from the list. New requests will be rejected and terminal connections closed. Audit records are retained; running jobs are not cancelled. This cannot be undone.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Manage API Keys',
|
manage: 'Manage API Keys',
|
||||||
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
|
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
|
||||||
@@ -23,10 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'Allow APP QR binding',
|
allowAppBinding: 'Allow APP QR binding',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.',
|
'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.',
|
||||||
saveSecret: 'Copy and save this secret now. After closing, ordinary key management cannot display it again.',
|
|
||||||
saved: 'I have saved the secret',
|
|
||||||
alreadyCreated:
|
|
||||||
'This key was already created. Its secret will not be shown again. If the original response was lost, revoke and recreate it, or use QR binding if allowed.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
|
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'Mostrar secreto',
|
||||||
|
hideSecret: 'Ocultar secreto',
|
||||||
|
revealHelp: 'Guarde el secreto de forma segura. Mostrarlo no reactiva claves deshabilitadas o caducadas.',
|
||||||
|
deleteConfirm:
|
||||||
|
'¿Eliminar «{0}»? Se destruirá el secreto y se quitará la clave de la lista. Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Se conservará la auditoría; las tareas en ejecución no se cancelarán. Esta acción es irreversible.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Gestionar claves API',
|
manage: 'Gestionar claves API',
|
||||||
personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.',
|
personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.',
|
||||||
@@ -23,10 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'Permitir vinculación de APP por QR',
|
allowAppBinding: 'Permitir vinculación de APP por QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Desactivar la vinculación por QR impide nuevas vinculaciones. Los dispositivos ya vinculados pueden seguir usando la clave hasta que se desactive, se revoque o caduque.',
|
'Desactivar la vinculación por QR impide nuevas vinculaciones. Los dispositivos ya vinculados pueden seguir usando la clave hasta que se desactive, se revoque o caduque.',
|
||||||
saveSecret: 'Copia y guarda la clave ahora. Tras cerrar, la gestión normal no podrá mostrarla de nuevo.',
|
|
||||||
saved: 'He guardado la clave',
|
|
||||||
alreadyCreated:
|
|
||||||
'La clave ya se creó y no se mostrará de nuevo. Si se perdió la respuesta original, revócala y crea otra, o usa QR si está permitido.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
|
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'نمایش کلید محرمانه',
|
||||||
|
hideSecret: 'پنهان کردن کلید محرمانه',
|
||||||
|
revealHelp: 'کلید را در جای امن نگه دارید. نمایش آن، کلید غیرفعال یا منقضیشده را قابل استفاده نمیکند.',
|
||||||
|
deleteConfirm:
|
||||||
|
'«{0}» حذف شود؟ کلید محرمانه نابود و از فهرست حذف میشود. درخواستهای جدید رد و اتصالهای ترمینال مرتبط بسته میشوند. سوابق حسابرسی حفظ و کارهای در حال اجرا لغو نمیشوند. این عملیات برگشتپذیر نیست.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'مدیریت کلیدهای API',
|
manage: 'مدیریت کلیدهای API',
|
||||||
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
|
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
|
||||||
@@ -23,10 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'اجازه اتصال برنامه با QR',
|
allowAppBinding: 'اجازه اتصال برنامه با QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاههای جدید متوقف میشود. دستگاههای متصل میتوانند تا زمان غیرفعالسازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.',
|
'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاههای جدید متوقف میشود. دستگاههای متصل میتوانند تا زمان غیرفعالسازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.',
|
||||||
saveSecret: 'اکنون کلید را کپی و ذخیره کنید. پس از بستن، مدیریت عادی نمیتواند کلید کامل را دوباره نشان دهد.',
|
|
||||||
saved: 'کلید را ذخیره کردم',
|
|
||||||
alreadyCreated:
|
|
||||||
'این کلید قبلاً ایجاد شده و دوباره نمایش داده نمیشود. اگر پاسخ اصلی گم شده است، کلید را لغو و دوباره ایجاد کنید یا در صورت اجازه از QR استفاده کنید.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
|
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'シークレットを表示',
|
||||||
|
hideSecret: 'シークレットを隠す',
|
||||||
|
revealHelp: 'シークレットを安全に保管してください。表示しても無効または期限切れのキーは使用できません。',
|
||||||
|
deleteConfirm:
|
||||||
|
'「{0}」を削除しますか?シークレットは破棄され、キーは一覧から削除されます。新しいリクエストは拒否され、関連ターミナルは切断されます。監査記録は保持され、実行中のタスクは中止されません。この操作は元に戻せません。',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'API Key を管理',
|
manage: 'API Key を管理',
|
||||||
personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。',
|
personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。',
|
||||||
@@ -23,10 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'APP の QR 連携を許可',
|
allowAppBinding: 'APP の QR 連携を許可',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。',
|
'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。',
|
||||||
saveSecret: '今すぐキーをコピーして保存してください。閉じると通常のキー管理から完全なキーを再表示できません。',
|
|
||||||
saved: 'キーを保存しました',
|
|
||||||
alreadyCreated:
|
|
||||||
'このキーは作成済みで、再表示できません。最初の応答を受け取れなかった場合は失効させて再作成するか、許可されていれば QR を使用してください。',
|
|
||||||
uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。',
|
uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
'旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。',
|
'旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。',
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: '비밀 키 표시',
|
||||||
|
hideSecret: '비밀 키 숨기기',
|
||||||
|
revealHelp: '비밀 키를 안전하게 보관하세요. 표시해도 비활성화되거나 만료된 키는 사용할 수 없습니다.',
|
||||||
|
deleteConfirm:
|
||||||
|
'“{0}”을(를) 삭제하시겠습니까? 비밀 키가 파기되고 목록에서 제거됩니다. 새 요청은 거부되고 연결된 터미널은 종료됩니다. 감사 기록은 유지되며 실행 중인 작업은 취소되지 않습니다. 이 작업은 되돌릴 수 없습니다.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'API Key 관리',
|
manage: 'API Key 관리',
|
||||||
personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.',
|
personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.',
|
||||||
@@ -23,10 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'APP QR 연결 허용',
|
allowAppBinding: 'APP QR 연결 허용',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.',
|
'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.',
|
||||||
saveSecret: '지금 키를 복사하여 저장하세요. 닫으면 일반 키 관리에서 전체 키를 다시 표시할 수 없습니다.',
|
|
||||||
saved: '키를 저장했습니다',
|
|
||||||
alreadyCreated:
|
|
||||||
'이 키는 이미 생성되어 다시 표시할 수 없습니다. 원래 응답을 잃었다면 폐기 후 다시 만들거나 허용된 QR 연결을 사용하세요.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
|
'요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'ສະແດງຄີລັບ',
|
||||||
|
hideSecret: 'ເຊື່ອງຄີລັບ',
|
||||||
|
revealHelp: 'ກະລຸນາເກັບຄີລັບໃຫ້ປອດໄພ. ການສະແດງບໍ່ເຮັດໃຫ້ຄີທີ່ປິດໃຊ້ງານ ຫຼືໝົດອາຍຸກັບມາໃຊ້ໄດ້.',
|
||||||
|
deleteConfirm:
|
||||||
|
'ລຶບ “{0}” ຫຼືບໍ່? ຄີລັບຈະຖືກທຳລາຍ ແລະລຶບອອກຈາກລາຍການ. ຄຳຮ້ອງຂໍໃໝ່ຈະຖືກປະຕິເສດ ແລະປິດເທີມິນັລທີ່ກ່ຽວຂ້ອງ. ບັນທຶກການກວດສອບຈະຍັງຄົງຢູ່; ວຽກທີ່ກຳລັງເຮັດຈະບໍ່ຖືກຍົກເລີກ. ບໍ່ສາມາດກູ້ຄືນໄດ້.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'ຈັດການ API Key',
|
manage: 'ຈັດການ API Key',
|
||||||
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
|
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
|
||||||
@@ -23,9 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
|
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.',
|
'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.',
|
||||||
saveSecret: 'ຄັດລອກ ແລະ ບັນທຶກກະແຈດຽວນີ້. ຫຼັງປິດແລ້ວ ການຈັດການປົກກະຕິຈະບໍ່ສະແດງກະແຈເຕັມອີກ.',
|
|
||||||
saved: 'ຂ້ອຍບັນທຶກກະແຈແລ້ວ',
|
|
||||||
alreadyCreated: 'ກະແຈນີ້ສ້າງແລ້ວ ແລະ ບໍ່ສະແດງອີກ. ຖ້າຄຳຕອບເກົ່າຫາຍ ໃຫ້ຖອນແລ້ວສ້າງໃໝ່ ຫຼື ໃຊ້ QR ຖ້າອະນຸຍາດ.',
|
|
||||||
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
|
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.',
|
'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.',
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'Papar rahsia',
|
||||||
|
hideSecret: 'Sembunyikan rahsia',
|
||||||
|
revealHelp:
|
||||||
|
'Simpan rahsia ini dengan selamat. Memaparkannya tidak mengaktifkan semula kunci yang dinyahdayakan atau tamat tempoh.',
|
||||||
|
deleteConfirm:
|
||||||
|
'Padam “{0}”? Rahsianya akan dimusnahkan dan kunci dibuang daripada senarai. Permintaan baharu akan ditolak dan terminal berkaitan ditutup. Rekod audit dikekalkan; tugas yang sedang berjalan tidak dibatalkan. Tindakan ini tidak boleh dibuat asal.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Urus kunci API',
|
manage: 'Urus kunci API',
|
||||||
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
|
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
|
||||||
@@ -23,11 +29,6 @@ const message = {
|
|||||||
allowAppBinding: 'Benarkan pautan APP melalui QR',
|
allowAppBinding: 'Benarkan pautan APP melalui QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Menyahdayakan pautan QR menghalang pautan peranti baharu. Peranti yang telah dipautkan boleh terus digunakan sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.',
|
'Menyahdayakan pautan QR menghalang pautan peranti baharu. Peranti yang telah dipautkan boleh terus digunakan sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.',
|
||||||
saveSecret:
|
|
||||||
'Salin dan simpan kunci sekarang. Selepas ditutup, pengurusan biasa tidak dapat memaparkan kunci penuh lagi.',
|
|
||||||
saved: 'Saya telah menyimpan kunci',
|
|
||||||
alreadyCreated:
|
|
||||||
'Kunci ini telah dicipta dan tidak akan dipaparkan semula. Jika respons asal hilang, batalkan dan cipta semula atau gunakan QR jika dibenarkan.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
|
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'Mostrar segredo',
|
||||||
|
hideSecret: 'Ocultar segredo',
|
||||||
|
revealHelp: 'Guarde o segredo com segurança. Exibi-lo não reativa chaves desabilitadas ou expiradas.',
|
||||||
|
deleteConfirm:
|
||||||
|
'Excluir “{0}”? O segredo será destruído e a chave removida da lista. Novas solicitações serão rejeitadas e os terminais associados serão fechados. Os registros de auditoria serão mantidos; tarefas em execução não serão canceladas. Esta ação é irreversível.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Gerenciar chaves API',
|
manage: 'Gerenciar chaves API',
|
||||||
personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.',
|
personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.',
|
||||||
@@ -23,10 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'Permitir vínculo do APP por QR',
|
allowAppBinding: 'Permitir vínculo do APP por QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Desativar a vinculação por QR impede novas vinculações. Os dispositivos já vinculados podem continuar usando a chave até que ela seja desativada, revogada ou expire.',
|
'Desativar a vinculação por QR impede novas vinculações. Os dispositivos já vinculados podem continuar usando a chave até que ela seja desativada, revogada ou expire.',
|
||||||
saveSecret: 'Copie e salve a chave agora. Após fechar, o gerenciamento normal não poderá exibi-la novamente.',
|
|
||||||
saved: 'Salvei a chave',
|
|
||||||
alreadyCreated:
|
|
||||||
'Esta chave já foi criada e não será exibida novamente. Se a resposta original foi perdida, revogue e recrie a chave ou use o QR, se permitido.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
|
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'Показать секрет',
|
||||||
|
hideSecret: 'Скрыть секрет',
|
||||||
|
revealHelp: 'Храните секрет в безопасности. Просмотр не активирует отключённые или просроченные ключи.',
|
||||||
|
deleteConfirm:
|
||||||
|
'Удалить «{0}»? Секрет будет уничтожен, а ключ удалён из списка. Новые запросы будут отклонены, связанные терминалы закрыты. Журнал аудита сохранится; выполняющиеся задачи не отменяются. Действие необратимо.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Управление API-ключами',
|
manage: 'Управление API-ключами',
|
||||||
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
|
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
|
||||||
@@ -23,11 +28,6 @@ const message = {
|
|||||||
allowAppBinding: 'Разрешить привязку приложения по QR',
|
allowAppBinding: 'Разрешить привязку приложения по QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.',
|
'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.',
|
||||||
saveSecret:
|
|
||||||
'Скопируйте и сохраните ключ сейчас. После закрытия обычное управление не сможет показать полный ключ снова.',
|
|
||||||
saved: 'Ключ сохранён',
|
|
||||||
alreadyCreated:
|
|
||||||
'Ключ уже создан и не будет показан повторно. Если исходный ответ потерян, отзовите и создайте ключ заново либо используйте разрешённую привязку по QR.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
|
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: 'Gizli anahtarı göster',
|
||||||
|
hideSecret: 'Gizli anahtarı gizle',
|
||||||
|
revealHelp:
|
||||||
|
'Gizli anahtarı güvenli tutun. Görüntülemek, devre dışı veya süresi dolmuş anahtarı kullanılabilir yapmaz.',
|
||||||
|
deleteConfirm:
|
||||||
|
'“{0}” silinsin mi? Gizli anahtar yok edilecek ve listeden kaldırılacaktır. Yeni istekler reddedilecek ve ilgili terminaller kapatılacaktır. Denetim kayıtları korunur; çalışan işler iptal edilmez. Bu işlem geri alınamaz.',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'API anahtarlarını yönet',
|
manage: 'API anahtarlarını yönet',
|
||||||
personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.',
|
personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.',
|
||||||
@@ -23,11 +29,6 @@ const message = {
|
|||||||
allowAppBinding: 'Uygulama QR bağlantısına izin ver',
|
allowAppBinding: 'Uygulama QR bağlantısına izin ver',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'QR eşleştirmesini devre dışı bırakmak yeni eşleştirmeleri engeller. Eşleştirilmiş cihazlar, anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar kullanılmaya devam edebilir.',
|
'QR eşleştirmesini devre dışı bırakmak yeni eşleştirmeleri engeller. Eşleştirilmiş cihazlar, anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar kullanılmaya devam edebilir.',
|
||||||
saveSecret:
|
|
||||||
'Anahtarı şimdi kopyalayıp kaydedin. Kapattıktan sonra normal anahtar yönetimi tam anahtarı yeniden gösteremez.',
|
|
||||||
saved: 'Anahtarı kaydettim',
|
|
||||||
alreadyCreated:
|
|
||||||
'Bu anahtar zaten oluşturuldu ve yeniden gösterilmez. İlk yanıt kaybolduysa iptal edip yeniden oluşturun veya izin veriliyorsa QR bağlantısını kullanın.',
|
|
||||||
uncertain:
|
uncertain:
|
||||||
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
|
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: '檢視密鑰',
|
||||||
|
hideSecret: '隱藏密鑰',
|
||||||
|
revealHelp: '請妥善保管密鑰。檢視不會恢復已停用或已過期密鑰的使用權限。',
|
||||||
|
deleteConfirm:
|
||||||
|
'確定刪除「{0}」嗎?密鑰將被銷毀並從列表移除,拒絕後續請求並關閉關聯終端。稽核記錄仍保留,執行中的工作不會取消,此操作無法復原。',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: '管理 API Key',
|
manage: '管理 API Key',
|
||||||
personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。',
|
personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。',
|
||||||
@@ -22,10 +27,6 @@ const message = {
|
|||||||
futureExpiry: '請選擇晚於目前時間的到期時間。',
|
futureExpiry: '請選擇晚於目前時間的到期時間。',
|
||||||
allowAppBinding: '允許 APP 掃碼綁定',
|
allowAppBinding: '允許 APP 掃碼綁定',
|
||||||
bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。',
|
bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。',
|
||||||
saveSecret: '請立即複製並儲存金鑰。關閉後,一般金鑰管理無法再次顯示完整金鑰。',
|
|
||||||
saved: '我已儲存金鑰',
|
|
||||||
alreadyCreated:
|
|
||||||
'這把金鑰已建立,無法再次顯示明文。若原回應遺失,請撤銷後重新建立;允許掃碼綁定時,也可透過掃碼交付。',
|
|
||||||
uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。',
|
uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。',
|
||||||
legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。',
|
legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。',
|
||||||
showLegacy: '檢視舊版金鑰',
|
showLegacy: '檢視舊版金鑰',
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
|
reveal: '查看密钥',
|
||||||
|
hideSecret: '隐藏密钥',
|
||||||
|
revealHelp: '请妥善保管密钥。查看不会恢复已停用或已过期密钥的使用权限。',
|
||||||
|
deleteConfirm:
|
||||||
|
'确定删除“{0}”吗?密钥将被销毁并从列表移除,拒绝后续请求并关闭关联终端。审计记录仍保留,运行中的任务不会取消,此操作不可恢复。',
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: '管理 API Key',
|
manage: '管理 API Key',
|
||||||
personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。',
|
personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。',
|
||||||
@@ -22,10 +27,6 @@ const message = {
|
|||||||
futureExpiry: '请选择晚于当前时间的到期时间。',
|
futureExpiry: '请选择晚于当前时间的到期时间。',
|
||||||
allowAppBinding: '允许 APP 扫码绑定',
|
allowAppBinding: '允许 APP 扫码绑定',
|
||||||
bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。',
|
bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。',
|
||||||
saveSecret: '请立即复制并保存密钥。关闭后,普通密钥管理无法再次显示完整密钥。',
|
|
||||||
saved: '我已保存密钥',
|
|
||||||
alreadyCreated:
|
|
||||||
'这把密钥已创建,无法再次展示明文。若原响应丢失,请撤销后重新创建;允许扫码绑定时,也可通过扫码交付。',
|
|
||||||
uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。',
|
uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。',
|
||||||
legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。',
|
legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。',
|
||||||
showLegacy: '查看旧版密钥',
|
showLegacy: '查看旧版密钥',
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export function isRemoteAppIcon(icon: string): boolean {
|
||||||
|
if (
|
||||||
|
!/^https?:\/\/[^/?#]/i.test(icon) ||
|
||||||
|
/^https?:\/\/[^/?#]*@/i.test(icon) ||
|
||||||
|
Array.from(icon).some(
|
||||||
|
(char) => char <= ' ' || (char >= '\u007f' && char <= '\u009f') || /\s/.test(char) || char === '\\',
|
||||||
|
) ||
|
||||||
|
/%(?![\da-f]{2})/i.test(icon)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(icon);
|
||||||
|
return !!url.hostname && !url.username && !url.password;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAppIconUrl(appKey: string, node: string | undefined, icon: string, baseURL: string): string {
|
||||||
|
if (isRemoteAppIcon(icon)) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
const params = node ? `?operateNode=${encodeURIComponent(node)}` : '';
|
||||||
|
return `${baseURL}/apps/icon/${encodeURIComponent(appKey)}${params}`;
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<div class="app-wrapper" @click="openDetail(app.key)">
|
<div class="app-wrapper" @click="openDetail(app.key)">
|
||||||
<div class="app-image">
|
<div class="app-image">
|
||||||
<el-avatar shape="square" :size="60" :src="getAppIconUrl(app.key, currentNode)" />
|
<el-avatar shape="square" :size="60" :src="getAppIconUrl(app.key, currentNode, app.icon)">
|
||||||
|
<SvgIcon iconName="p-appstore" />
|
||||||
|
</el-avatar>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-content">
|
<div class="app-content">
|
||||||
<div class="content-top">
|
<div class="content-top">
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
<div class="brief" v-loading="loadingApp">
|
<div class="brief" v-loading="loadingApp">
|
||||||
<div class="detail flex">
|
<div class="detail flex">
|
||||||
<div class="w-12 h-12 rounded p-1 shadow-md icon">
|
<div class="w-12 h-12 rounded p-1 shadow-md icon">
|
||||||
<img
|
<el-avatar
|
||||||
:src="getAppIconUrl(app.key, currentNode)"
|
:src="getAppIconUrl(app.key, currentNode, app.icon)"
|
||||||
alt="App Icon"
|
alt="App Icon"
|
||||||
class="w-full h-full rounded"
|
shape="square"
|
||||||
style="object-fit: contain"
|
fit="contain"
|
||||||
/>
|
class="!w-full !h-full rounded"
|
||||||
|
>
|
||||||
|
<SvgIcon iconName="p-appstore" />
|
||||||
|
</el-avatar>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<div class="name mb-2">
|
<div class="name mb-2">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<AppIcon
|
<AppIcon
|
||||||
@open-detail="$emit('openDetail')"
|
@open-detail="$emit('openDetail')"
|
||||||
:appKey="installed.appKey"
|
:appKey="installed.appKey"
|
||||||
|
:icon="installed.icon"
|
||||||
:currentNode="currentNode"
|
:currentNode="currentNode"
|
||||||
></AppIcon>
|
></AppIcon>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<el-avatar @click="$emit('openDetail')" shape="square" :size="77" :src="getAppIconUrl(appKey, currentNode)" />
|
<el-avatar
|
||||||
|
@click="$emit('openDetail')"
|
||||||
|
shape="square"
|
||||||
|
:size="77"
|
||||||
|
:src="getAppIconUrl(appKey, currentNode, icon)"
|
||||||
|
>
|
||||||
|
<SvgIcon iconName="p-appstore" />
|
||||||
|
</el-avatar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -10,6 +17,7 @@ import { getAppIconUrl } from '@/api/modules/app';
|
|||||||
interface Props {
|
interface Props {
|
||||||
currentNode: string;
|
currentNode: string;
|
||||||
appKey: string;
|
appKey: string;
|
||||||
|
icon?: string;
|
||||||
}
|
}
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const acceptParams = (apppInstall: App.AppInstalled) => {
|
|||||||
|
|
||||||
const getVersions = async () => {
|
const getVersions = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getAppUpdateVersions({ appInstallID: appInstallID.value });
|
const res = await getAppUpdateVersions({ appInstallID: appInstallID.value, loadCompose: false });
|
||||||
versions.value = res.data || [];
|
versions.value = res.data || [];
|
||||||
if (versions.value.length > 0) {
|
if (versions.value.length > 0) {
|
||||||
req.appDetailID = versions.value[0].detailId;
|
req.appDetailID = versions.value[0].detailId;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
:resource="resourceName"
|
:resource="resourceName"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<div v-loading="loading">
|
<div v-loading="loading || loadingVersions">
|
||||||
<el-descriptions direction="vertical">
|
<el-descriptions direction="vertical">
|
||||||
<el-descriptions-item>
|
<el-descriptions-item>
|
||||||
<el-link @click="toLink(app.website)">
|
<el-link @click="toLink(app.website)">
|
||||||
@@ -41,6 +41,13 @@
|
|||||||
:label="version.version"
|
:label="version.version"
|
||||||
></el-option>
|
></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-button
|
||||||
|
v-if="operateReq.operate === 'upgrade' && !composeReady && !loadingVersions"
|
||||||
|
class="mt-2"
|
||||||
|
@click="getVersions(operateReq.version)"
|
||||||
|
>
|
||||||
|
{{ $t('commons.button.retry') }}
|
||||||
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="showOpenclawHttpRollbackNotice"
|
v-if="showOpenclawHttpRollbackNotice"
|
||||||
@@ -55,7 +62,7 @@
|
|||||||
<div>2. {{ $t('app.openclawHttpsUpgradeNoticeItem2') }}</div>
|
<div>2. {{ $t('app.openclawHttpsUpgradeNoticeItem2') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<template v-if="feishuLegacy && operateReq.operate === 'upgrade'">
|
<template v-if="feishuLegacy && operateReq.operate === 'upgrade' && composeReady">
|
||||||
<el-alert
|
<el-alert
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -83,7 +90,7 @@
|
|||||||
<el-checkbox v-model="operateReq.deleteImage" :label="$t('app.upgradeDeleteImage')" />
|
<el-checkbox v-model="operateReq.deleteImage" :label="$t('app.upgradeDeleteImage')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div v-if="operateReq.operate === 'upgrade'">
|
<div v-if="operateReq.operate === 'upgrade' && composeReady">
|
||||||
<el-text type="danger" v-if="isEdit">{{ $t('app.isEdirWarn') }}</el-text>
|
<el-text type="danger" v-if="isEdit">{{ $t('app.isEdirWarn') }}</el-text>
|
||||||
<el-text type="warning" v-else>{{ $t('app.upgradeWarn') }}</el-text>
|
<el-text type="warning" v-else>{{ $t('app.upgradeWarn') }}</el-text>
|
||||||
<div>
|
<div>
|
||||||
@@ -100,7 +107,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
|
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="onOperate" :disabled="versions == null || loading">
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="onOperate"
|
||||||
|
:disabled="
|
||||||
|
versions == null ||
|
||||||
|
loading ||
|
||||||
|
loadingVersions ||
|
||||||
|
(operateReq.operate === 'upgrade' && !composeReady)
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ $t('commons.button.confirm') }}
|
{{ $t('commons.button.confirm') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -116,13 +132,13 @@ import CodemirrorPro from '@/components/codemirror-pro/index.vue';
|
|||||||
import { App } from '@/api/interface/app';
|
import { App } from '@/api/interface/app';
|
||||||
import { AI } from '@/api/interface/ai';
|
import { AI } from '@/api/interface/ai';
|
||||||
import FeishuTransition from '@/views/ai/agents/agent/config/tabs/channels/components/feishu-transition.vue';
|
import FeishuTransition from '@/views/ai/agents/agent/config/tabs/channels/components/feishu-transition.vue';
|
||||||
import { getAppUpdateVersions, ignoreUpgrade, installedOp } from '@/api/modules/app';
|
import { getAppInstallParams, getAppUpdateVersions, ignoreUpgrade, installedOp } from '@/api/modules/app';
|
||||||
import { getAppStoreConfig } from '@/api/modules/setting';
|
import { getAppStoreConfig } from '@/api/modules/setting';
|
||||||
import { isOpenclawCurrentHTTPVersion, isOpenclawHTTPSWindowVersion } from '@/utils/agent';
|
import { isOpenclawCurrentHTTPVersion, isOpenclawHTTPSWindowVersion } from '@/utils/agent';
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
import { ElMessageBox, FormInstance } from 'element-plus';
|
import { ElMessageBox, FormInstance } from 'element-plus';
|
||||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||||
import { MsgSuccess } from '@/utils/message';
|
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||||
import { Rules } from '@/global/form-rules';
|
import { Rules } from '@/global/form-rules';
|
||||||
import bus from '@/global/bus';
|
import bus from '@/global/bus';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@@ -133,6 +149,10 @@ const composeDiffRef = ref();
|
|||||||
const updateRef = ref<FormInstance>();
|
const updateRef = ref<FormInstance>();
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadingVersions = ref(false);
|
||||||
|
const composeReady = ref(false);
|
||||||
|
const currentComposeReady = ref(false);
|
||||||
|
let versionRequest = 0;
|
||||||
const versions = ref<App.VersionDetail[]>();
|
const versions = ref<App.VersionDetail[]>();
|
||||||
const operateReq = reactive({
|
const operateReq = reactive({
|
||||||
detailId: 0,
|
detailId: 0,
|
||||||
@@ -163,6 +183,9 @@ const oldContent = ref('');
|
|||||||
const newContent = ref('');
|
const newContent = ref('');
|
||||||
const em = defineEmits(['close']);
|
const em = defineEmits(['close']);
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
versionRequest++;
|
||||||
|
loadingVersions.value = false;
|
||||||
|
composeReady.value = false;
|
||||||
open.value = false;
|
open.value = false;
|
||||||
em('close', open.value);
|
em('close', open.value);
|
||||||
};
|
};
|
||||||
@@ -195,9 +218,10 @@ const toLink = (link: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDiff = async () => {
|
const openDiff = async () => {
|
||||||
if (newContent.value === '') {
|
if (!composeReady.value) {
|
||||||
await getVersions(operateReq.version);
|
await getVersions(operateReq.version);
|
||||||
}
|
}
|
||||||
|
if (!composeReady.value) return;
|
||||||
composeDiffRef.value.acceptParams(oldContent.value, newContent.value);
|
composeDiffRef.value.acceptParams(oldContent.value, newContent.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,16 +236,26 @@ const getNewCompose = (compose: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initData = async () => {
|
const initData = async () => {
|
||||||
|
const request = versionRequest;
|
||||||
const config = await getAppStoreConfig(node.value);
|
const config = await getAppStoreConfig(node.value);
|
||||||
|
if (request !== versionRequest) return false;
|
||||||
newCompose.value = '';
|
newCompose.value = '';
|
||||||
useNewCompose.value = false;
|
useNewCompose.value = false;
|
||||||
operateReq.backup = config.data.upgradeBackup == 'Enable';
|
operateReq.backup = config.data.upgradeBackup == 'Enable';
|
||||||
operateReq.pullImage = true;
|
operateReq.pullImage = true;
|
||||||
operateReq.deleteImage = config.data.upgradeDeleteImage == 'Enable';
|
operateReq.deleteImage = config.data.upgradeDeleteImage == 'Enable';
|
||||||
operateReq.dockerCompose = '';
|
operateReq.dockerCompose = '';
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptParams = async (appInstall: App.AppInstallDto, op: string, opNode?: string) => {
|
const acceptParams = async (appInstall: App.AppInstallDto, op: string, opNode?: string) => {
|
||||||
|
versionRequest++;
|
||||||
|
composeReady.value = false;
|
||||||
|
currentComposeReady.value = false;
|
||||||
|
loadingVersions.value = false;
|
||||||
|
versions.value = undefined;
|
||||||
|
operateReq.detailId = 0;
|
||||||
|
operateReq.version = '';
|
||||||
if (opNode) {
|
if (opNode) {
|
||||||
node.value = opNode;
|
node.value = opNode;
|
||||||
} else {
|
} else {
|
||||||
@@ -236,7 +270,7 @@ const acceptParams = async (appInstall: App.AppInstallDto, op: string, opNode?:
|
|||||||
groupSessionScope: 'group',
|
groupSessionScope: 'group',
|
||||||
requireMention: 'true',
|
requireMention: 'true',
|
||||||
});
|
});
|
||||||
await initData();
|
if (!(await initData())) return;
|
||||||
isEdit.value = appInstall.isEdit;
|
isEdit.value = appInstall.isEdit;
|
||||||
currentVersion.value = appInstall.version;
|
currentVersion.value = appInstall.version;
|
||||||
currentAppKey.value = appInstall.appKey;
|
currentAppKey.value = appInstall.appKey;
|
||||||
@@ -255,20 +289,37 @@ const acceptParams = async (appInstall: App.AppInstallDto, op: string, opNode?:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getVersions = async (version: string) => {
|
const getVersions = async (version: string) => {
|
||||||
const req = {
|
const request = ++versionRequest;
|
||||||
|
const upgrading = operateReq.operate === 'upgrade';
|
||||||
|
composeReady.value = false;
|
||||||
|
loadingVersions.value = true;
|
||||||
|
const req: App.AppUpdateVersionReq = {
|
||||||
appInstallID: appInstallID.value,
|
appInstallID: appInstallID.value,
|
||||||
|
loadCompose: upgrading,
|
||||||
};
|
};
|
||||||
if (version !== '') {
|
if (version !== '') {
|
||||||
req['updateVersion'] = version;
|
req.updateVersion = version;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (upgrading && !currentComposeReady.value) {
|
||||||
|
const config = await getAppInstallParams(appInstallID.value, node.value);
|
||||||
|
if (request !== versionRequest) return;
|
||||||
|
if (typeof config.data.isEdit === 'boolean') isEdit.value = config.data.isEdit;
|
||||||
|
oldContent.value = config.data.dockerCompose;
|
||||||
|
currentComposeReady.value = true;
|
||||||
|
}
|
||||||
const res = await getAppUpdateVersions(req, node.value);
|
const res = await getAppUpdateVersions(req, node.value);
|
||||||
|
if (request !== versionRequest) return;
|
||||||
versions.value = res.data || [];
|
versions.value = res.data || [];
|
||||||
if (res.data != null && res.data.length > 0) {
|
if (versions.value.length > 0) {
|
||||||
let item = res.data[0];
|
let item = res.data[0];
|
||||||
if (version != '') {
|
if (version != '') {
|
||||||
item = res.data.find((v) => v.version === version);
|
item = res.data.find((v) => v.version === version);
|
||||||
}
|
}
|
||||||
|
if (!item || (upgrading && !item.dockerCompose?.trim())) {
|
||||||
|
if (upgrading) MsgError(i18n.global.t('container.contentEmpty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
feishuLegacy.value = Boolean(item.feishuLegacy);
|
feishuLegacy.value = Boolean(item.feishuLegacy);
|
||||||
feishuConfigured.value = Boolean(item.feishuConfigured);
|
feishuConfigured.value = Boolean(item.feishuConfigured);
|
||||||
if (feishuLegacy.value) operateReq.backup = true;
|
if (feishuLegacy.value) operateReq.backup = true;
|
||||||
@@ -277,8 +328,12 @@ const getVersions = async (version: string) => {
|
|||||||
newContent.value = item.dockerCompose;
|
newContent.value = item.dockerCompose;
|
||||||
newCompose.value = item.dockerCompose;
|
newCompose.value = item.dockerCompose;
|
||||||
useNewCompose.value = false;
|
useNewCompose.value = false;
|
||||||
|
composeReady.value = upgrading;
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) {
|
||||||
|
} finally {
|
||||||
|
if (request === versionRequest) loadingVersions.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openTaskLog = (taskID: string) => {
|
const openTaskLog = (taskID: string) => {
|
||||||
@@ -286,6 +341,7 @@ const openTaskLog = (taskID: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const operate = async () => {
|
const operate = async () => {
|
||||||
|
if (operateReq.operate === 'upgrade' && !composeReady.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
if (operateReq.operate === 'upgrade') {
|
if (operateReq.operate === 'upgrade') {
|
||||||
if (useNewCompose.value) {
|
if (useNewCompose.value) {
|
||||||
@@ -316,6 +372,11 @@ const operate = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onOperate = async () => {
|
const onOperate = async () => {
|
||||||
|
if (loadingVersions.value || (operateReq.operate === 'upgrade' && !composeReady.value)) return;
|
||||||
|
if (operateReq.operate === 'upgrade' && useNewCompose.value && !newCompose.value.trim()) {
|
||||||
|
MsgError(i18n.global.t('container.contentEmpty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
await updateRef.value?.validate();
|
await updateRef.value?.validate();
|
||||||
const helper = i18n.global.t('app.operatorHelper', [i18n.global.t('commons.button.' + operateReq.operate)]);
|
const helper = i18n.global.t('app.operatorHelper', [i18n.global.t('commons.button.' + operateReq.operate)]);
|
||||||
const notice =
|
const notice =
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
<el-row :gutter="5">
|
<el-row :gutter="5">
|
||||||
<el-col :span="5" class="h-app-media-col">
|
<el-col :span="5" class="h-app-media-col">
|
||||||
<div class="h-app-media">
|
<div class="h-app-media">
|
||||||
<el-avatar shape="square" :size="55" :src="getAppIconSrc(app)" />
|
<el-avatar shape="square" :size="55" :src="getAppIconSrc(app)">
|
||||||
|
<SvgIcon iconName="p-appstore" />
|
||||||
|
</el-avatar>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="16" class="h-app-main-col">
|
<el-col :span="16" class="h-app-main-col">
|
||||||
@@ -149,6 +151,7 @@ import { ref } from 'vue';
|
|||||||
import { jumpToInstall } from '@/utils/app';
|
import { jumpToInstall } from '@/utils/app';
|
||||||
import { routerToName, routerToNameWithQuery } from '@/utils/router';
|
import { routerToName, routerToNameWithQuery } from '@/utils/router';
|
||||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||||
|
import { isRemoteAppIcon } from '@/utils/app-icon';
|
||||||
|
|
||||||
const { isMobile, isAdminOrNodeAdmin } = useGlobalStore();
|
const { isMobile, isAdminOrNodeAdmin } = useGlobalStore();
|
||||||
let loading = ref(false);
|
let loading = ref(false);
|
||||||
@@ -232,8 +235,8 @@ const loadOption = async () => {
|
|||||||
|
|
||||||
const getAppIconSrc = (app: any) => {
|
const getAppIconSrc = (app: any) => {
|
||||||
const icon = app?.icon || '';
|
const icon = app?.icon || '';
|
||||||
if (icon.startsWith('app_') || icon === '') {
|
if (isRemoteAppIcon(icon) || icon.startsWith('app_') || icon === '') {
|
||||||
return getAppIconUrl(app.key);
|
return getAppIconUrl(app.key, undefined, icon);
|
||||||
}
|
}
|
||||||
return `data:image/png;base64,${icon}`;
|
return `data:image/png;base64,${icon}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 break-all">{{ row.keyHint }}</div>
|
<div class="text-xs text-[var(--el-text-color-secondary)] break-all">{{ row.keyHint }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="status">
|
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="status">
|
||||||
@@ -137,26 +137,50 @@
|
|||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:header="$t('apiKeyManagement.details')"
|
:header="$t('apiKeyManagement.details')"
|
||||||
size="min(640px, 100vw)"
|
size="min(640px, 100vw)"
|
||||||
@close="clearLegacySecret"
|
@close="clearSecret"
|
||||||
>
|
>
|
||||||
<APIKeySummary v-if="detail" :item="detail" />
|
<APIKeySummary v-if="detail" :item="detail" />
|
||||||
<div v-if="detail?.kind === 'legacy' && detail.status !== 'Revoked'" class="mt-5 flex flex-col gap-3">
|
<div v-if="detail && detail.status !== 'Revoked'" class="mt-5 flex flex-col gap-3">
|
||||||
<el-alert :title="$t('apiKeyManagement.legacyHelp')" type="info" :closable="false" show-icon />
|
<el-alert
|
||||||
<el-space wrap>
|
:title="
|
||||||
<el-button type="primary" plain :loading="legacyBusy" @click="revealLegacy">
|
$t(detail.kind === 'legacy' ? 'apiKeyManagement.legacyHelp' : 'apiKeyManagement.revealHelp')
|
||||||
{{ $t('apiKeyManagement.showLegacy') }}
|
"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<el-space v-if="detail.kind === 'legacy'" wrap>
|
||||||
|
<el-button :disabled="secretBusy" @click="resetLegacy">
|
||||||
|
{{ $t('setting.apiKeyReset') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :disabled="legacyBusy" @click="resetLegacy">{{ $t('setting.apiKeyReset') }}</el-button>
|
|
||||||
</el-space>
|
</el-space>
|
||||||
<el-input
|
<el-input
|
||||||
v-if="legacySecret"
|
:model-value="secret || '********'"
|
||||||
:model-value="legacySecret"
|
:type="secret ? 'text' : 'password'"
|
||||||
:aria-label="$t('setting.apiKey')"
|
:aria-label="$t('setting.apiKey')"
|
||||||
readonly
|
readonly
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
:icon="secret ? Hide : View"
|
||||||
|
:loading="secretBusy && secretAction === 'reveal'"
|
||||||
|
:disabled="loading || secretBusy"
|
||||||
|
:aria-label="$t(secret ? 'apiKeyManagement.hideSecret' : 'apiKeyManagement.reveal')"
|
||||||
|
:title="$t(secret ? 'apiKeyManagement.hideSecret' : 'apiKeyManagement.reveal')"
|
||||||
|
:aria-pressed="!!secret"
|
||||||
|
@click="toggleSecret"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template #append>
|
<template #append>
|
||||||
<CopyButton :content="legacySecret" :is-icon="false" />
|
<el-button
|
||||||
|
:loading="secretBusy && secretAction === 'copy'"
|
||||||
|
:disabled="loading || secretBusy"
|
||||||
|
@click="copySecret"
|
||||||
|
>
|
||||||
|
{{ $t('commons.button.copy') }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,10 +189,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue';
|
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { ElMessageBox } from 'element-plus';
|
import { ElMessageBox } from 'element-plus';
|
||||||
|
import { Hide, View } from '@element-plus/icons-vue';
|
||||||
import type { APIKey } from '@/api/interface/api-key';
|
import type { APIKey } from '@/api/interface/api-key';
|
||||||
import { revokeAPIKey, searchAPIKeys, setAPIKeyStatus } from '@/api/modules/api-key';
|
import { deleteAPIKey, revealAPIKey, revokeAPIKey, searchAPIKeys, setAPIKeyStatus } from '@/api/modules/api-key';
|
||||||
import { generateApiKey, getUserInfo } from '@/api/modules/auth';
|
import { generateApiKey, getUserInfo } from '@/api/modules/auth';
|
||||||
import APIKeyEditor from '@/components/api-key-management/editor.vue';
|
import APIKeyEditor from '@/components/api-key-management/editor.vue';
|
||||||
import APIKeySummary from '@/components/api-key-management/summary.vue';
|
import APIKeySummary from '@/components/api-key-management/summary.vue';
|
||||||
@@ -176,35 +201,38 @@ import DrawerPro from '@/components/drawer-pro/index.vue';
|
|||||||
import type { FuTableOperationButton } from '@/components/table/shared';
|
import type { FuTableOperationButton } from '@/components/table/shared';
|
||||||
import { isAnyAPIKeyIP, normalizeAPIKeyIPs } from '@/utils/api-key';
|
import { isAnyAPIKeyIP, normalizeAPIKeyIPs } from '@/utils/api-key';
|
||||||
import { MsgSuccess, MsgWarning } from '@/utils/message';
|
import { MsgSuccess, MsgWarning } from '@/utils/message';
|
||||||
|
import { copyText } from '@/utils/clipboard';
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||||
|
|
||||||
const { isMobile } = useGlobalStore();
|
const { isMobile, globalStore } = useGlobalStore();
|
||||||
|
|
||||||
const items = ref<APIKey.Item[]>([]);
|
const items = ref<APIKey.Item[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const editor = ref<InstanceType<typeof APIKeyEditor>>();
|
const editor = ref<InstanceType<typeof APIKeyEditor>>();
|
||||||
const detail = ref<APIKey.Item>();
|
const detail = ref<APIKey.Item>();
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const legacySecret = ref('');
|
const secret = ref('');
|
||||||
const legacyBusy = ref(false);
|
const secretBusy = ref(false);
|
||||||
|
const secretAction = ref<'reveal' | 'copy'>('reveal');
|
||||||
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 });
|
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 });
|
||||||
let searchVersion = 0;
|
let searchVersion = 0;
|
||||||
let secretRequestVersion = 0;
|
let secretRequestVersion = 0;
|
||||||
let active = false;
|
let active = false;
|
||||||
|
let operationPending = false;
|
||||||
|
|
||||||
const whitelistEntries = (value: string) =>
|
const whitelistEntries = (value: string) =>
|
||||||
normalizeAPIKeyIPs(value || '')
|
normalizeAPIKeyIPs(value || '')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const clearLegacySecret = () => {
|
const clearSecret = () => {
|
||||||
secretRequestVersion++;
|
secretRequestVersion++;
|
||||||
legacySecret.value = '';
|
secret.value = '';
|
||||||
legacyBusy.value = false;
|
secretBusy.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = async (preserveLegacyRequest?: number) => {
|
const search = async (preserveSecretRequest?: number) => {
|
||||||
const ticket = ++searchVersion;
|
const ticket = ++searchVersion;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -213,7 +241,7 @@ const search = async (preserveLegacyRequest?: number) => {
|
|||||||
const lastPage = Math.max(1, Math.ceil(response.data.total / pagination.pageSize));
|
const lastPage = Math.max(1, Math.ceil(response.data.total / pagination.pageSize));
|
||||||
if (pagination.currentPage > lastPage) {
|
if (pagination.currentPage > lastPage) {
|
||||||
pagination.currentPage = lastPage;
|
pagination.currentPage = lastPage;
|
||||||
await search(preserveLegacyRequest);
|
await search(preserveSecretRequest);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
items.value = response.data.items || [];
|
items.value = response.data.items || [];
|
||||||
@@ -221,8 +249,8 @@ const search = async (preserveLegacyRequest?: number) => {
|
|||||||
if (detail.value) {
|
if (detail.value) {
|
||||||
const previousRevision = detail.value.revision;
|
const previousRevision = detail.value.revision;
|
||||||
detail.value = items.value.find((entry) => entry.id === detail.value?.id);
|
detail.value = items.value.find((entry) => entry.id === detail.value?.id);
|
||||||
if (detail.value?.revision !== previousRevision && preserveLegacyRequest !== secretRequestVersion)
|
if (detail.value?.revision !== previousRevision && preserveSecretRequest !== secretRequestVersion)
|
||||||
clearLegacySecret();
|
clearSecret();
|
||||||
if (!detail.value) detailVisible.value = false;
|
if (!detail.value) detailVisible.value = false;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -230,7 +258,7 @@ const search = async (preserveLegacyRequest?: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const showDetail = (entry: APIKey.Item) => {
|
const showDetail = (entry: APIKey.Item) => {
|
||||||
clearLegacySecret();
|
clearSecret();
|
||||||
detail.value = entry;
|
detail.value = entry;
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
};
|
};
|
||||||
@@ -239,15 +267,19 @@ const operate = async (entry: APIKey.Item, action: string) => {
|
|||||||
editor.value?.open(entry);
|
editor.value?.open(entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action !== 'status' && action !== 'revoke') return;
|
if (!active || operationPending || !['status', 'revoke', 'delete'].includes(action)) return;
|
||||||
|
operationPending = true;
|
||||||
|
const requestVersion = searchVersion;
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
i18n.global.t(
|
i18n.global.t(
|
||||||
action === 'revoke'
|
action === 'delete'
|
||||||
? 'apiKeyManagement.revokeConfirm'
|
? 'apiKeyManagement.deleteConfirm'
|
||||||
: entry.status === 'Enable'
|
: action === 'revoke'
|
||||||
? 'apiKeyManagement.disableConfirm'
|
? 'apiKeyManagement.revokeConfirm'
|
||||||
: 'apiKeyManagement.enableConfirm',
|
: entry.status === 'Enable'
|
||||||
|
? 'apiKeyManagement.disableConfirm'
|
||||||
|
: 'apiKeyManagement.enableConfirm',
|
||||||
[entry.name],
|
[entry.name],
|
||||||
),
|
),
|
||||||
i18n.global.t('apiKeyManagement.title'),
|
i18n.global.t('apiKeyManagement.title'),
|
||||||
@@ -258,20 +290,29 @@ const operate = async (entry: APIKey.Item, action: string) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
operationPending = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!active || requestVersion !== searchVersion) {
|
||||||
|
operationPending = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSecret();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const reference = { id: entry.id, revision: entry.revision };
|
const reference = { id: entry.id, revision: entry.revision };
|
||||||
const response =
|
const response =
|
||||||
action === 'revoke'
|
action === 'delete'
|
||||||
? await revokeAPIKey(reference)
|
? await deleteAPIKey(reference)
|
||||||
: await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
|
: action === 'revoke'
|
||||||
clearLegacySecret();
|
? await revokeAPIKey(reference)
|
||||||
|
: await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
|
||||||
|
clearSecret();
|
||||||
if (response.data?.terminalClosePending) MsgWarning(i18n.global.t('apiKeyManagement.closePending'));
|
if (response.data?.terminalClosePending) MsgWarning(i18n.global.t('apiKeyManagement.closePending'));
|
||||||
else MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
else MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||||
} finally {
|
} finally {
|
||||||
await search();
|
operationPending = false;
|
||||||
|
if (active) await search();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
|
const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
|
||||||
@@ -300,21 +341,59 @@ const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
|
|||||||
show: (row) => row.status !== 'Revoked',
|
show: (row) => row.status !== 'Revoked',
|
||||||
click: (row) => operate(row, 'revoke'),
|
click: (row) => operate(row, 'revoke'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: i18n.global.t('commons.button.delete'),
|
||||||
|
show: (row) => row.kind === 'apiKey',
|
||||||
|
click: (row) => operate(row, 'delete'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
const revealLegacy = async () => {
|
const toggleSecret = async () => {
|
||||||
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
|
if (secretBusy.value || loading.value) return;
|
||||||
|
if (secret.value) clearSecret();
|
||||||
|
else await revealSecret();
|
||||||
|
};
|
||||||
|
const accessSecret = async (action: 'reveal' | 'copy') => {
|
||||||
|
if (secretBusy.value || loading.value || !active || !detailVisible.value || !detail.value) return;
|
||||||
|
if (detail.value.status === 'Revoked') return;
|
||||||
|
const reference = { id: detail.value.id, revision: detail.value.revision };
|
||||||
|
const kind = detail.value.kind;
|
||||||
const ticket = ++secretRequestVersion;
|
const ticket = ++secretRequestVersion;
|
||||||
legacyBusy.value = true;
|
secretAction.value = action;
|
||||||
|
secretBusy.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await getUserInfo();
|
if (action === 'copy' && secret.value) {
|
||||||
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
|
await copyText(secret.value);
|
||||||
legacySecret.value = response.data.apiKey;
|
return;
|
||||||
|
}
|
||||||
|
secret.value = '';
|
||||||
|
const data =
|
||||||
|
kind === 'legacy'
|
||||||
|
? { ...reference, apiKey: (await getUserInfo()).data.apiKey }
|
||||||
|
: (await revealAPIKey(reference)).data;
|
||||||
|
if (
|
||||||
|
ticket === secretRequestVersion &&
|
||||||
|
active &&
|
||||||
|
detailVisible.value &&
|
||||||
|
detail.value?.id === reference.id &&
|
||||||
|
detail.value?.kind === kind &&
|
||||||
|
detail.value?.revision === reference.revision &&
|
||||||
|
data.id === reference.id &&
|
||||||
|
data.revision === reference.revision &&
|
||||||
|
data.apiKey
|
||||||
|
) {
|
||||||
|
if (action === 'copy') await copyText(data.apiKey);
|
||||||
|
else secret.value = data.apiKey;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (ticket === secretRequestVersion && active) await search();
|
||||||
} finally {
|
} finally {
|
||||||
if (ticket === secretRequestVersion) legacyBusy.value = false;
|
if (ticket === secretRequestVersion) secretBusy.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const revealSecret = () => accessSecret('reveal');
|
||||||
|
const copySecret = () => accessSecret('copy');
|
||||||
const resetLegacy = async () => {
|
const resetLegacy = async () => {
|
||||||
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
|
if (secretBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
|
||||||
const ticket = ++secretRequestVersion;
|
const ticket = ++secretRequestVersion;
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(i18n.global.t('setting.apiKeyResetHelper'), i18n.global.t('setting.apiKeyReset'));
|
await ElMessageBox.confirm(i18n.global.t('setting.apiKeyResetHelper'), i18n.global.t('setting.apiKeyReset'));
|
||||||
@@ -322,17 +401,18 @@ const resetLegacy = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ticket !== secretRequestVersion || !detailVisible.value || !active) return;
|
if (ticket !== secretRequestVersion || !detailVisible.value || !active) return;
|
||||||
legacyBusy.value = true;
|
secretAction.value = 'reveal';
|
||||||
legacySecret.value = '';
|
secretBusy.value = true;
|
||||||
|
secret.value = '';
|
||||||
try {
|
try {
|
||||||
const response = await generateApiKey();
|
const response = await generateApiKey();
|
||||||
await search(ticket);
|
await search(ticket);
|
||||||
// A reset changes the revision and deliberately clears any older reveal.
|
// A reset changes the revision and deliberately clears any older reveal.
|
||||||
// Never restore a secret after the original details drawer was closed.
|
// Never restore a secret after the original details drawer was closed.
|
||||||
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
|
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
|
||||||
legacySecret.value = response.data;
|
secret.value = response.data;
|
||||||
} finally {
|
} finally {
|
||||||
if (ticket === secretRequestVersion) legacyBusy.value = false;
|
if (ticket === secretRequestVersion) secretBusy.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
@@ -343,9 +423,16 @@ const activate = () => {
|
|||||||
const deactivate = () => {
|
const deactivate = () => {
|
||||||
active = false;
|
active = false;
|
||||||
searchVersion++;
|
searchVersion++;
|
||||||
clearLegacySecret();
|
clearSecret();
|
||||||
detailVisible.value = false;
|
detailVisible.value = false;
|
||||||
};
|
};
|
||||||
|
watch(
|
||||||
|
() => globalStore.isLogin,
|
||||||
|
(loggedIn) => {
|
||||||
|
if (!loggedIn) deactivate();
|
||||||
|
},
|
||||||
|
{ flush: 'sync' },
|
||||||
|
);
|
||||||
onMounted(activate);
|
onMounted(activate);
|
||||||
onActivated(activate);
|
onActivated(activate);
|
||||||
onDeactivated(deactivate);
|
onDeactivated(deactivate);
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { baseParse } from '@vue/compiler-dom';
|
||||||
|
import { parse } from '@vue/compiler-sfc';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { computed, proxyRefs, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
const view = (name) =>
|
||||||
|
parse(readFileSync(new URL(`../src/views/app-store/installed/${name}/index.vue`, import.meta.url), 'utf8'))
|
||||||
|
.descriptor;
|
||||||
|
const upgrade = view('upgrade');
|
||||||
|
const target = (version = '2.0', detailId = 2, dockerCompose = 'services:\n app:\n image: demo:2') => ({
|
||||||
|
version,
|
||||||
|
detailId,
|
||||||
|
dockerCompose,
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawer(overrides = {}) {
|
||||||
|
const calls = { params: [], versions: [], errors: [], operations: [], confirmations: [] };
|
||||||
|
const bindings = {
|
||||||
|
ref,
|
||||||
|
reactive,
|
||||||
|
computed,
|
||||||
|
onBeforeUnmount: () => {},
|
||||||
|
defineEmits: () => () => {},
|
||||||
|
defineExpose: () => {},
|
||||||
|
useGlobalStore: () => ({ currentNode: ref('local') }),
|
||||||
|
Rules: { requiredSelect: {} },
|
||||||
|
i18n: { global: { t: (key) => key } },
|
||||||
|
bus: { on() {}, off() {}, emit() {} },
|
||||||
|
isOpenclawCurrentHTTPVersion: () => false,
|
||||||
|
isOpenclawHTTPSWindowVersion: () => false,
|
||||||
|
uuidv4: () => 'task-id',
|
||||||
|
MsgError: (message) => calls.errors.push(message),
|
||||||
|
MsgSuccess() {},
|
||||||
|
ElMessageBox: {
|
||||||
|
confirm: (...args) => {
|
||||||
|
calls.confirmations.push(args);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAppStoreConfig: async () => ({ data: { upgradeBackup: 'Disable', upgradeDeleteImage: 'Disable' } }),
|
||||||
|
getAppInstallParams: async (...args) => {
|
||||||
|
calls.params.push(args);
|
||||||
|
return { data: { isEdit: true, dockerCompose: 'original custom instance compose' } };
|
||||||
|
},
|
||||||
|
getAppUpdateVersions: async (...args) => {
|
||||||
|
calls.versions.push(args);
|
||||||
|
return { data: [target()] };
|
||||||
|
},
|
||||||
|
installedOp: async (...args) => calls.operations.push(args),
|
||||||
|
ignoreUpgrade: async () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const ast = ts.createSourceFile('upgrade.ts', upgrade.scriptSetup.content, ts.ScriptTarget.Latest, true);
|
||||||
|
const script = ast.statements
|
||||||
|
.filter((statement) => !ts.isImportDeclaration(statement))
|
||||||
|
.map((statement) => statement.getText(ast))
|
||||||
|
.join('\n');
|
||||||
|
const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ESNext } }).outputText;
|
||||||
|
const state = new Function(
|
||||||
|
...Object.keys(bindings),
|
||||||
|
`${compiled}\nreturn { acceptParams, getVersions, handleClose, openDiff, onOperate, operate, versions, loading, loadingVersions, composeReady, currentComposeReady, operateReq, oldContent, newContent, newCompose, useNewCompose, isEdit, node, appInstallID, feishuLegacy, feishuConfigured, open };`,
|
||||||
|
)(...Object.values(bindings));
|
||||||
|
state.node.value = 'child & node';
|
||||||
|
state.appInstallID.value = 17;
|
||||||
|
state.oldContent.value = 'saved instance compose';
|
||||||
|
return { ...state, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(node, predicate) {
|
||||||
|
if (predicate(node)) return node;
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
const found = findNode(child, predicate);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = baseParse(upgrade.template.content);
|
||||||
|
const button = findNode(template, (node) =>
|
||||||
|
node.props?.some((prop) => prop.name === 'on' && prop.exp?.content === 'onOperate'),
|
||||||
|
);
|
||||||
|
const disabled = button.props.find((prop) => prop.name === 'bind' && prop.arg.content === 'disabled').exp.content;
|
||||||
|
const isDisabled = (state) => new Function('state', `with (state) { return (${disabled}); }`)(proxyRefs(state));
|
||||||
|
|
||||||
|
test('upgrade validates the current template and loads only the default target on the selected node', async () => {
|
||||||
|
const state = drawer();
|
||||||
|
assert.equal(isDisabled(state), true);
|
||||||
|
await state.getVersions('');
|
||||||
|
assert.deepEqual(state.calls.params, [[17, 'child & node']]);
|
||||||
|
assert.deepEqual(state.calls.versions, [[{ appInstallID: 17, loadCompose: true }, 'child & node']]);
|
||||||
|
assert.equal(state.isEdit.value, true, 'authoritative configuration overrides the stale list flag');
|
||||||
|
assert.equal(state.oldContent.value, 'original custom instance compose');
|
||||||
|
assert.equal(state.operateReq.detailId, 2);
|
||||||
|
assert.equal(state.newContent.value, target().dockerCompose);
|
||||||
|
assert.equal(state.composeReady.value, true);
|
||||||
|
assert.equal(isDisabled(state), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('older agents without isEdit retain the existing custom Compose warning', async () => {
|
||||||
|
const state = drawer({ getAppInstallParams: async () => ({ data: { dockerCompose: 'old agent compose' } }) });
|
||||||
|
state.isEdit.value = true;
|
||||||
|
await state.getVersions('');
|
||||||
|
assert.equal(state.isEdit.value, true);
|
||||||
|
assert.equal(state.oldContent.value, 'old agent compose');
|
||||||
|
assert.equal(state.composeReady.value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current-template failure blocks preview and submission without changing the saved Compose', async () => {
|
||||||
|
const state = drawer({
|
||||||
|
getAppInstallParams: async () => {
|
||||||
|
throw new Error('template unavailable');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await state.getVersions('');
|
||||||
|
assert.equal(state.calls.versions.length, 0);
|
||||||
|
assert.equal(state.oldContent.value, 'saved instance compose');
|
||||||
|
assert.equal(state.currentComposeReady.value, false);
|
||||||
|
assert.equal(state.loadingVersions.value, false);
|
||||||
|
assert.equal(isDisabled(state), true);
|
||||||
|
await state.onOperate();
|
||||||
|
await state.operate();
|
||||||
|
assert.equal(state.calls.confirmations.length, 0);
|
||||||
|
assert.equal(state.calls.operations.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed target switch cannot submit the previous target and can be retried', async () => {
|
||||||
|
let fail = true;
|
||||||
|
const state = drawer({
|
||||||
|
getAppUpdateVersions: async (req) => {
|
||||||
|
if (req.updateVersion === '3.0' && fail) throw new Error('download failed');
|
||||||
|
return { data: [target(), target('3.0', 3, 'services:\n app:\n image: demo:3')] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await state.getVersions('');
|
||||||
|
state.operateReq.version = '3.0';
|
||||||
|
await state.getVersions('3.0');
|
||||||
|
assert.equal(state.operateReq.detailId, 2);
|
||||||
|
assert.equal(state.oldContent.value, 'original custom instance compose');
|
||||||
|
assert.equal(isDisabled(state), true);
|
||||||
|
await state.onOperate();
|
||||||
|
assert.equal(state.calls.confirmations.length, 0);
|
||||||
|
fail = false;
|
||||||
|
await state.getVersions('3.0');
|
||||||
|
assert.equal(state.operateReq.detailId, 3);
|
||||||
|
assert.equal(state.composeReady.value, true);
|
||||||
|
assert.equal(state.calls.params.length, 1, 'switching targets reuses the verified current configuration');
|
||||||
|
assert.equal(isDisabled(state), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty and missing target Compose cannot become valid previews', async () => {
|
||||||
|
for (const data of [[target('2.0', 2, '')], [target('2.0', 2, ' ')], [target('different', 3)]]) {
|
||||||
|
const state = drawer({ getAppUpdateVersions: async () => ({ data }) });
|
||||||
|
await state.getVersions('2.0');
|
||||||
|
assert.equal(state.composeReady.value, false);
|
||||||
|
assert.equal(state.operateReq.detailId, 0);
|
||||||
|
assert.equal(state.oldContent.value, 'original custom instance compose');
|
||||||
|
assert.deepEqual(state.calls.errors, ['container.contentEmpty']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('out-of-order responses cannot overwrite the latest selection or enable it too soon', async () => {
|
||||||
|
const pending = new Map();
|
||||||
|
const state = drawer({
|
||||||
|
getAppUpdateVersions: (req) => new Promise((resolve) => pending.set(req.updateVersion, resolve)),
|
||||||
|
});
|
||||||
|
state.currentComposeReady.value = true;
|
||||||
|
const first = state.getVersions('2.0');
|
||||||
|
const latest = state.getVersions('3.0');
|
||||||
|
pending.get('2.0')({ data: [target()] });
|
||||||
|
await first;
|
||||||
|
assert.equal(state.loadingVersions.value, true);
|
||||||
|
assert.equal(state.composeReady.value, false);
|
||||||
|
pending.get('3.0')({ data: [target('3.0', 3)] });
|
||||||
|
await latest;
|
||||||
|
assert.equal(state.operateReq.version, '3.0');
|
||||||
|
assert.equal(state.operateReq.detailId, 3);
|
||||||
|
assert.equal(state.composeReady.value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing a drawer invalidates in-flight template responses', async () => {
|
||||||
|
let resolve;
|
||||||
|
const state = drawer({ getAppUpdateVersions: () => new Promise((done) => (resolve = done)) });
|
||||||
|
state.currentComposeReady.value = true;
|
||||||
|
const request = state.getVersions('2.0');
|
||||||
|
state.handleClose();
|
||||||
|
resolve({ data: [target()] });
|
||||||
|
await request;
|
||||||
|
assert.equal(state.open.value, false);
|
||||||
|
assert.equal(state.composeReady.value, false);
|
||||||
|
assert.equal(state.operateReq.detailId, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late settings response cannot reopen a closed upgrade drawer', async () => {
|
||||||
|
let resolve;
|
||||||
|
const state = drawer({ getAppStoreConfig: () => new Promise((done) => (resolve = done)) });
|
||||||
|
const request = state.acceptParams({ id: 17 }, 'upgrade');
|
||||||
|
state.handleClose();
|
||||||
|
resolve({ data: {} });
|
||||||
|
await request;
|
||||||
|
assert.equal(state.open.value, false);
|
||||||
|
assert.equal(state.calls.params.length, 0);
|
||||||
|
assert.equal(state.calls.versions.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignore version requests skip both current and target Compose loading', async () => {
|
||||||
|
const state = drawer();
|
||||||
|
state.operateReq.operate = 'ignore';
|
||||||
|
await state.getVersions('');
|
||||||
|
assert.equal(state.calls.params.length, 0);
|
||||||
|
assert.equal(state.calls.versions[0][0].loadCompose, false);
|
||||||
|
|
||||||
|
const source = view('ignore/create').scriptSetup.content;
|
||||||
|
const ast = ts.createSourceFile('ignore.ts', source, ts.ScriptTarget.Latest, true);
|
||||||
|
const declaration = ast.statements
|
||||||
|
.filter(ts.isVariableStatement)
|
||||||
|
.flatMap((statement) => statement.declarationList.declarations)
|
||||||
|
.find((item) => item.name.getText(ast) === 'getVersions');
|
||||||
|
const compiled = ts.transpileModule(`const getVersions = ${declaration.initializer.getText(ast)};`, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ESNext },
|
||||||
|
}).outputText;
|
||||||
|
let request;
|
||||||
|
const getVersions = new Function(
|
||||||
|
'getAppUpdateVersions',
|
||||||
|
'appInstallID',
|
||||||
|
'versions',
|
||||||
|
'req',
|
||||||
|
`${compiled}\nreturn getVersions;`,
|
||||||
|
)(
|
||||||
|
async (req) => {
|
||||||
|
request = req;
|
||||||
|
return { data: [target()] };
|
||||||
|
},
|
||||||
|
ref(17),
|
||||||
|
ref([]),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
await getVersions();
|
||||||
|
assert.deepEqual(request, { appInstallID: 17, loadCompose: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blank custom Compose is rejected before the upgrade confirmation', async () => {
|
||||||
|
const state = drawer();
|
||||||
|
await state.getVersions('');
|
||||||
|
state.useNewCompose.value = true;
|
||||||
|
state.newCompose.value = ' ';
|
||||||
|
await state.onOperate();
|
||||||
|
assert.deepEqual(state.calls.errors, ['container.contentEmpty']);
|
||||||
|
assert.equal(state.calls.confirmations.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Feishu legacy transition still enforces the required backup', async () => {
|
||||||
|
const state = drawer({
|
||||||
|
getAppUpdateVersions: async () => ({ data: [{ ...target(), feishuLegacy: true, feishuConfigured: true }] }),
|
||||||
|
});
|
||||||
|
state.operateReq.backup = false;
|
||||||
|
await state.getVersions('');
|
||||||
|
assert.equal(state.feishuLegacy.value, true);
|
||||||
|
assert.equal(state.feishuConfigured.value, true);
|
||||||
|
assert.equal(state.operateReq.backup, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit current-configuration loading preserves child-node routing', () => {
|
||||||
|
const source = readFileSync(new URL('../src/api/modules/app.ts', import.meta.url), 'utf8');
|
||||||
|
const ast = ts.createSourceFile('app-api.ts', source, ts.ScriptTarget.Latest, true);
|
||||||
|
const declaration = ast.statements
|
||||||
|
.filter(ts.isVariableStatement)
|
||||||
|
.flatMap((statement) => statement.declarationList.declarations)
|
||||||
|
.find((item) => item.name.getText(ast) === 'getAppInstallParams');
|
||||||
|
const compiled = ts.transpileModule(`const getAppInstallParams = ${declaration.initializer.getText(ast)};`, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ESNext },
|
||||||
|
}).outputText;
|
||||||
|
const request = new Function('http', `${compiled}\nreturn getAppInstallParams;`)({ get: (url) => url });
|
||||||
|
assert.equal(request(17), 'apps/installed/params/17');
|
||||||
|
assert.equal(request(17, 'child & node'), 'apps/installed/params/17?operateNode=child%20%26%20node');
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { baseParse, compile } from '@vue/compiler-dom';
|
||||||
|
import { parse } from '@vue/compiler-sfc';
|
||||||
|
import { ElAvatar } from 'element-plus/es/components/avatar/index.mjs';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import * as Vue from 'vue';
|
||||||
|
import { isRemoteAppIcon, resolveAppIconUrl } from '../src/utils/app-icon.ts';
|
||||||
|
|
||||||
|
const cdnURL = 'https://cdn.example/icons/php.png?v=abc123&token=a%2Bb#icon';
|
||||||
|
const apiURL = '/api/v2';
|
||||||
|
const getAppIconUrl = (key, node, icon = '') => resolveAppIconUrl(key, node, icon, apiURL);
|
||||||
|
|
||||||
|
test('accepts absolute HTTP(S) image URLs without changing their query or fragment', () => {
|
||||||
|
for (const icon of [
|
||||||
|
cdnURL,
|
||||||
|
'http://cdn.example/icon.png',
|
||||||
|
'HTTPS://cdn.example:443/icon.png',
|
||||||
|
'https://[::1]/icon',
|
||||||
|
]) {
|
||||||
|
assert.equal(isRemoteAppIcon(icon), true, icon);
|
||||||
|
assert.equal(getAppIconUrl('php', 'child-node', icon), icon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unsafe schemes, credentials, normalization ambiguities and malformed URLs', () => {
|
||||||
|
for (const icon of [
|
||||||
|
'//cdn.example/icon.png',
|
||||||
|
'https:cdn.example/icon.png',
|
||||||
|
'https:///icon.png',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'data:image/png;base64,eA==',
|
||||||
|
'file:///tmp/icon.png',
|
||||||
|
'https://user:password@cdn.example/icon.png',
|
||||||
|
'https://@cdn.example/icon.png',
|
||||||
|
'https://cdn.example:bad/icon.png',
|
||||||
|
'https://cdn.example:65536/icon.png',
|
||||||
|
'https://cdn.example/%zz',
|
||||||
|
'https://cdn.example/icon.png?token=%zz',
|
||||||
|
' https://cdn.example/icon.png',
|
||||||
|
'https://cdn.example/icon.png\n',
|
||||||
|
'https://cdn.example/\u00a0icon.png',
|
||||||
|
'https://cdn.example/\u0085icon.png',
|
||||||
|
'https://cdn.example\\other/icon.png',
|
||||||
|
]) {
|
||||||
|
assert.equal(isRemoteAppIcon(icon), false, icon);
|
||||||
|
assert.equal(getAppIconUrl('php', 'child-node', icon), '/api/v2/apps/icon/php?operateNode=child-node');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps historical icons and missing fields on the original node-aware API', () => {
|
||||||
|
for (const icon of ['', undefined, 'app_php.png?etag=%22old%22', 'aGlzdG9yaWNhbC1pbWFnZQ==']) {
|
||||||
|
assert.equal(
|
||||||
|
getAppIconUrl('local-php', 'child & node', icon),
|
||||||
|
'/api/v2/apps/icon/local-php?operateNode=child%20%26%20node',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(getAppIconUrl('php'), '/api/v2/apps/icon/php');
|
||||||
|
});
|
||||||
|
|
||||||
|
const readView = (name) => parse(readFileSync(new URL(`../src/views/${name}`, import.meta.url), 'utf8')).descriptor;
|
||||||
|
|
||||||
|
function launcherResolver() {
|
||||||
|
const source = readView('home/app/index.vue').scriptSetup.content;
|
||||||
|
const ast = ts.createSourceFile('launcher.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||||
|
const declaration = ast.statements
|
||||||
|
.filter(ts.isVariableStatement)
|
||||||
|
.flatMap((statement) => statement.declarationList.declarations)
|
||||||
|
.find((item) => item.name.getText(ast) === 'getAppIconSrc');
|
||||||
|
const expression = ts.transpileModule(`const getAppIconSrc = ${declaration.initializer.getText(ast)};`, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ESNext },
|
||||||
|
}).outputText;
|
||||||
|
return new Function('isRemoteAppIcon', 'getAppIconUrl', `${expression}\nreturn getAppIconSrc;`)(
|
||||||
|
isRemoteAppIcon,
|
||||||
|
getAppIconUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('home launcher uses CDN URLs and retains inline historical Base64 icons', () => {
|
||||||
|
const getAppIconSrc = launcherResolver();
|
||||||
|
assert.equal(getAppIconSrc({ key: 'php', icon: cdnURL }), cdnURL);
|
||||||
|
assert.equal(
|
||||||
|
getAppIconSrc({ key: 'php', icon: 'aGlzdG9yaWNhbC1pbWFnZQ==' }),
|
||||||
|
'data:image/png;base64,aGlzdG9yaWNhbC1pbWFnZQ==',
|
||||||
|
);
|
||||||
|
assert.equal(getAppIconSrc({ key: 'php', icon: 'app_php.png?etag=old' }), '/api/v2/apps/icon/php');
|
||||||
|
assert.equal(getAppIconSrc({ key: 'php' }), '/api/v2/apps/icon/php');
|
||||||
|
});
|
||||||
|
|
||||||
|
function findComponent(node, tag) {
|
||||||
|
if (node.tag === tag) return node;
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
const component = findComponent(child, tag);
|
||||||
|
if (component) return component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountAvatar(template, context) {
|
||||||
|
const node = (type, text) => ({ type, text, children: [], props: {} });
|
||||||
|
const renderer = Vue.createRenderer({
|
||||||
|
createElement: (type) => node(type),
|
||||||
|
createText: (text) => node('#text', text),
|
||||||
|
createComment: (text) => node('#comment', text),
|
||||||
|
setText: (element, text) => {
|
||||||
|
element.text = text;
|
||||||
|
},
|
||||||
|
setElementText: (element, text) => {
|
||||||
|
element.children = [node('#text', text)];
|
||||||
|
},
|
||||||
|
parentNode: (element) => element.parent,
|
||||||
|
nextSibling: () => null,
|
||||||
|
patchProp: (element, key, _, value) => {
|
||||||
|
element.props[key] = value;
|
||||||
|
},
|
||||||
|
insert(child, parent) {
|
||||||
|
parent.children.push(child);
|
||||||
|
child.parent = parent;
|
||||||
|
},
|
||||||
|
remove(child) {
|
||||||
|
child.parent.children = child.parent.children.filter((item) => item !== child);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const render = new Function('Vue', compile(template, { mode: 'function', prefixIdentifiers: true }).code)(Vue);
|
||||||
|
const app = renderer.createApp({ setup: () => context, render });
|
||||||
|
app.component('ElAvatar', ElAvatar);
|
||||||
|
app.component('AppIcon', {
|
||||||
|
props: ['appKey', 'icon', 'currentNode'],
|
||||||
|
setup: () => ({ getAppIconUrl }),
|
||||||
|
render: new Function(
|
||||||
|
'Vue',
|
||||||
|
compile(readView('app-store/installed/app/icon.vue').template.content, {
|
||||||
|
mode: 'function',
|
||||||
|
prefixIdentifiers: true,
|
||||||
|
}).code,
|
||||||
|
)(Vue),
|
||||||
|
});
|
||||||
|
app.component('SvgIcon', {
|
||||||
|
props: ['iconName'],
|
||||||
|
render() {
|
||||||
|
return Vue.h('svg', { 'data-icon': this.iconName });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = node('root');
|
||||||
|
app.mount(root);
|
||||||
|
return { root, app };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const view of [
|
||||||
|
'app-store/apps/app/index.vue',
|
||||||
|
'app-store/detail/index.vue',
|
||||||
|
'app-store/installed/app/icon.vue',
|
||||||
|
'home/app/index.vue',
|
||||||
|
]) {
|
||||||
|
test(`${view}: actual avatar binding loads CDN once and displays the local fallback on error`, async () => {
|
||||||
|
const avatar = findComponent(baseParse(readView(view).template.content), 'el-avatar');
|
||||||
|
assert.ok(avatar, 'image entry must use an avatar with a local fallback');
|
||||||
|
const { root, app } = mountAvatar(avatar.loc.source, {
|
||||||
|
app: { key: 'php', icon: cdnURL },
|
||||||
|
appKey: 'php',
|
||||||
|
icon: cdnURL,
|
||||||
|
currentNode: 'child-node',
|
||||||
|
getAppIconUrl,
|
||||||
|
getAppIconSrc: launcherResolver(),
|
||||||
|
});
|
||||||
|
const element = root.children[0];
|
||||||
|
const image = element.children.find((child) => child.type === 'img');
|
||||||
|
assert.equal(image.props.src, cdnURL);
|
||||||
|
image.props.onError(new Event('error'));
|
||||||
|
await Vue.nextTick();
|
||||||
|
assert.equal(
|
||||||
|
element.children.some((child) => child.type === 'img'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(element.children.find((child) => child.type === 'svg').props['data-icon'], 'p-appstore');
|
||||||
|
await Vue.nextTick();
|
||||||
|
assert.equal(
|
||||||
|
element.children.some((child) => child.type === 'img'),
|
||||||
|
false,
|
||||||
|
'failed URL is not retried',
|
||||||
|
);
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('installed card passes the API icon URL through to the actual icon child component', () => {
|
||||||
|
const icon = findComponent(baseParse(readView('app-store/installed/app/card.vue').template.content), 'AppIcon');
|
||||||
|
const { root, app } = mountAvatar(icon.loc.source, {
|
||||||
|
installed: { appKey: 'php', icon: cdnURL },
|
||||||
|
currentNode: 'child-node',
|
||||||
|
});
|
||||||
|
const image = root.children[0].children[0].children.find((child) => child.type === 'img');
|
||||||
|
assert.equal(image.props.src, cdnURL);
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user