Plugins: Allow loading panel plugins from a CDN (#59096)

* POC: Plugins CDN reverse proxy

* CDN proxy POC: changed env var names

* Add authorization: false for /public path in frontend plugin loader

* Moved CDN settings to Cfg, add some comments

* Fix error 500 in asset fetch if plugin is not using CDN

* Fix EnterpriseLicensePath declared twice

* Fix linter complaining about whitespaces

* Plugins CDN: Skip signature verification for CDN plugins

* Plugins CDN: Skip manifest and signature check for cdn plugins

* Plugins: use IsValid() and IsInternal() rather than equality checks

* Plugins CDN: remove comment

* Plugins CDN: Fix seeker can't seek when serving plugins from local fs

* Plugins CDN: add back error codes in getLocalPluginAssets

* Plugins CDN: call asset.Close() rather than asset.readSeekCloser.Close()

* Plugins CDN: Fix panic in JsonApiErr when errorMessageCoder wraps a nil error

* Plugins CDN: Add error handling to proxyCDNPluginAsset

* Plugins CDN: replace errorMessageCoder with errutil

* Plugins CDN POC: expose cdn plugin paths to frontend for system.js

* Plugins CDN: Fix cdn plugins showing as unsigned in frontend

* WIP: Add support for formatted URL

* Fix missing cdnPluginsBaseURLs in GrafanaConfig

* Plugins CDN: Remove reverse proxy mode and reverse proxy references

* Plugins CDN: Simplify asset serving logic

* Plugins CDN: sanitize redirect path

* Plugins CDN: Removed unused pluginAsset type

* Plugins CDN: Removed system.js changes

* Plugins CDN: Return different system.js baseURL and module for cdn plugins

* Plugins CDN: Ensure CDN is disabled for non-external plugins

* lint

* Plugins CDN: serve images and screenshots from CDN, refactoring

* Lint

* Plugins CDN: Fix URLs for system.js (baseUrl and module)

* Plugins CDN: Add more tests for RelativeURLForSystemJS

* Plugins CDN: Iterate only on apps when preloading

* Plugins CDN: Refactoring

* Plugins CDN: Add comments to url_constructor.go

* Plugins CDN: Update defaultHGPluginsCDNBaseURL

* Plugins CDN: undo extract meta from system js config

* refactor(plugins): migrate systemjs css plugin to typescript

* feat(plugins): introduce systemjs cdn loader plugin

* feat(plugins): add systemjs load type

* Plugins CDN: Removed RelativeURLForSystemJS

* Plugins CDN: Log backend redirect hits along with plugin info

* Plugins CDN: Add pluginsCDNBasePath to getFrontendSettingsMap

* feat(plugins): introduce cdn loading for angular plugins

* refactor(plugins): move systemjs cache buster into systemjsplugins directory

* Plugins CDN: Rename pluginsCDNBasePath to pluginsCDNBaseURL

* refactor(plugins): introduce pluginsCDNBaseURL to the frontend

* Plugins CDN: Renamed "cdn base path" to "cdn url template" in backend

* Plugins CDN: lint

* merge with main

* Instrumentation: Add prometheus counter for backend hits, log from Info to Warn

* Config: Changed key from plugins_cdn.url to plugins.plugins_cdn_base_url

* CDN: Add backend tests

* Lint: goimports

* Default CDN URL to empty string,

* Do not use CDN in setImages and module if the url template is empty

* CDN: Backend: Add test for frontend settings

* CDN: Do not log missing module.js warn if plugin is being loaded from CDN

* CDN: Add backend test for CDN plugin loader

* Removed 'cdn' signature level, switch to 'valid'

* Fix pfs.TestParseTreeTestdata for cdn plugin testdata dir

* Fix TestLoader_Load

* Fix gocyclo complexity of loadPlugins

* Plugins CDN: Moved prometheus metric to api package, removed asset_path label

* Fix missing  in config

* Changes after review

* Add pluginscdn.Service

* Fix tests

* Refactoring

* Moved all remaining CDN checks inside pluginscdn.Service

* CDN url constructor: Renamed stringURLFor to stringPath

* CDN: Moved asset URL functionality to assetpath service

* CDN: Renamed HasCDN() to IsEnabled()

* CDN: Replace assert with require

* CDN: Changes after review

* Assetpath: Handle url.Parse error

* Fix plugin_resource_test

* CDN: Change fallback redirect from 302 to 307

* goimports

* Fix tests

* Switch to contextmodel.ReqContext in plugins.go

Co-authored-by: Will Browne <will.browne@grafana.com>
Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
Giuseppe Guerra 2023-01-27 15:08:17 +01:00 committed by GitHub
parent c931b8031e
commit af1e2d68da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1139 additions and 188 deletions

View File

@ -111,6 +111,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
pluginAdminEnabled = true;
pluginAdminExternalManageEnabled = false;
pluginCatalogHiddenPlugins: string[] = [];
pluginsCDNBaseURL = '';
expressionsEnabled = false;
customTheme?: undefined;
awsAllowedAuthProviders: string[] = [];

View File

@ -2,6 +2,7 @@ package api
import (
"context"
"fmt"
"net/http"
"strconv"
@ -212,6 +213,14 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *contextmodel.ReqContext) (map[st
"snapshotEnabled": hs.Cfg.SnapshotEnabled,
}
if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() {
cdnBaseURL, err := hs.pluginsCDNService.BaseURL()
if err != nil {
return nil, fmt.Errorf("plugins cdn base url: %w", err)
}
jsonObj["pluginsCDNBaseURL"] = cdnBaseURL
}
if hs.ThumbService != nil {
jsonObj["dashboardPreviews"] = hs.ThumbService.GetDashboardPreviewsSetupSettings(c)
}

View File

@ -8,6 +8,8 @@ import (
"testing"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -58,7 +60,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.
grafanaUpdateChecker: &updatechecker.GrafanaService{},
AccessControl: accesscontrolmock.New().WithDisabled(),
PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService),
SocialService: social.ProvideService(cfg, features),
pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
PluginSettings: cfg.PluginSettings,
}),
SocialService: social.ProvideService(cfg, features),
}
m := web.New()
@ -138,3 +144,53 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) {
})
}
}
func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) {
type settings struct {
PluginsCDNBaseURL string `json:"pluginsCDNBaseURL"`
}
tests := []struct {
desc string
mutateCfg func(*setting.Cfg)
expected settings
}{
{
desc: "With CDN",
mutateCfg: func(cfg *setting.Cfg) {
cfg.PluginsCDNURLTemplate = "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}"
},
expected: settings{PluginsCDNBaseURL: "https://cdn.example.com"},
},
{
desc: "Without CDN",
mutateCfg: func(cfg *setting.Cfg) {
cfg.PluginsCDNURLTemplate = ""
},
expected: settings{PluginsCDNBaseURL: ""},
},
{
desc: "CDN is disabled by default",
expected: settings{PluginsCDNBaseURL: ""},
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
cfg := setting.NewCfg()
if test.mutateCfg != nil {
test.mutateCfg(cfg)
}
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures())
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
recorder := httptest.NewRecorder()
m.ServeHTTP(recorder, req)
var got settings
err := json.Unmarshal(recorder.Body.Bytes(), &got)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.EqualValues(t, test.expected, got)
})
}
}

View File

@ -33,6 +33,7 @@ import (
"github.com/grafana/grafana/pkg/middleware/csrf"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/plugincontext"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/registry/corekind"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/alerting"
@ -201,6 +202,7 @@ type HTTPServer struct {
playlistService playlist.Service
apiKeyService apikey.Service
kvStore kvstore.KVStore
pluginsCDNService *pluginscdn.Service
userService user.Service
tempUserService tempUser.Service
@ -258,6 +260,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService,
queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService,
statsService stats.Service, authnService authn.Service,
pluginsCDNService *pluginscdn.Service,
k8saccess k8saccess.K8SAccess, // required so that the router is registered
starApi *starApi.API,
) (*HTTPServer, error) {
@ -366,6 +369,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
oauthTokenService: oauthTokenService,
statsService: statsService,
authnService: authnService,
pluginsCDNService: pluginsCDNService,
starApi: starApi,
}
if hs.Listener != nil {

View File

@ -23,10 +23,12 @@ import (
pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client"
"github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/store"
"github.com/grafana/grafana/pkg/plugins/plugincontext"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/accesscontrol"
datasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -56,8 +58,9 @@ func TestCallResource(t *testing.T) {
nil, nil, nil, nil, testdatasource.ProvideService(cfg, featuremgmt.WithFeatures()), nil, nil, nil, nil, nil, nil)
pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg)
reg := registry.ProvideService()
cdn := pluginscdn.ProvideService(pCfg)
l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg),
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry())
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry(), cdn, assetpath.ProvideService(cdn))
ps, err := store.ProvideService(cfg, pCfg, reg, l)
require.NoError(t, err)

View File

@ -15,6 +15,8 @@ import (
"strings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
@ -33,6 +35,14 @@ import (
"github.com/grafana/grafana/pkg/web"
)
// pluginsCDNFallbackRedirectRequests is a metric counter keeping track of how many
// requests are received on the plugins CDN backend redirect fallback handler.
var pluginsCDNFallbackRedirectRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "grafana",
Name: "plugins_cdn_fallback_redirect_requests_total",
Help: "Number of requests to the plugins CDN backend redirect fallback handler.",
}, []string{"plugin_id", "plugin_version"})
func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Response {
typeFilter := c.Query("type")
enabledFilter := c.Query("enabled")
@ -301,6 +311,13 @@ func (hs *HTTPServer) CollectPluginMetrics(c *contextmodel.ReqContext) response.
// getPluginAssets returns public plugin assets (images, JS, etc.)
//
// If the plugin has cdn = false in its config (default), it will always attempt to return the asset
// from the local filesystem.
//
// If the plugin has cdn = true and hs.Cfg.PluginsCDNURLTemplate is empty, it will get the file
// from the local filesystem. If hs.Cfg.PluginsCDNURLTemplate is not empty,
// this handler returns a redirect to the plugin asset file on the specified CDN.
//
// /public/plugins/:pluginId/*
func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
pluginID := web.Params(c.Req)[":pluginId"]
@ -318,7 +335,19 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
return
}
f, err := plugin.File(requestedFile)
if hs.pluginsCDNService.PluginSupported(pluginID) {
// Send a redirect to the client
hs.redirectCDNPluginAsset(c, plugin, requestedFile)
return
}
// Send the actual file to the client from local filesystem
hs.serveLocalPluginAsset(c, plugin, requestedFile)
}
// serveLocalPluginAsset returns the content of a plugin asset file from the local filesystem to the http client.
func (hs *HTTPServer) serveLocalPluginAsset(c *contextmodel.ReqContext, plugin plugins.PluginDTO, assetPath string) {
f, err := plugin.File(assetPath)
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
c.JsonApiErr(404, "Plugin file not found", nil)
@ -346,15 +375,37 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
}
if rs, ok := f.(io.ReadSeeker); ok {
http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), rs)
} else {
b, err := io.ReadAll(f)
if err != nil {
c.JsonApiErr(500, "Plugin file exists but could not read", err)
return
}
http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), bytes.NewReader(b))
http.ServeContent(c.Resp, c.Req, assetPath, fi.ModTime(), rs)
return
}
b, err := io.ReadAll(f)
if err != nil {
c.JsonApiErr(500, "Plugin file exists but could not read", err)
return
}
http.ServeContent(c.Resp, c.Req, assetPath, fi.ModTime(), bytes.NewReader(b))
}
// redirectCDNPluginAsset redirects the http request to specified asset path on the configured plugins CDN.
func (hs *HTTPServer) redirectCDNPluginAsset(c *contextmodel.ReqContext, plugin plugins.PluginDTO, assetPath string) {
remoteURL, err := hs.pluginsCDNService.AssetURL(plugin.ID, plugin.Info.Version, assetPath)
if err != nil {
c.JsonApiErr(500, "Failed to get CDN plugin asset remote URL", err)
return
}
hs.log.Warn(
"plugin cdn redirect hit",
"pluginID", plugin.ID,
"pluginVersion", plugin.Info.Version,
"assetPath", assetPath,
"remoteURL", remoteURL,
)
pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{
"plugin_id": plugin.ID,
"plugin_version": plugin.Info.Version,
}).Inc()
http.Redirect(c.Resp, c.Req, remoteURL, http.StatusTemporaryRedirect)
}
// CheckHealth returns the health of a plugin.

View File

@ -12,7 +12,9 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@ -21,6 +23,8 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/log/logtest"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/org"
@ -139,7 +143,7 @@ func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) {
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(1, tc.permissions))
res, err := server.SendJSON(req)
require.NoError(t, err)
assert.Equal(t, tc.expectedCode, res.StatusCode)
require.Equal(t, tc.expectedCode, res.StatusCode)
require.NoError(t, res.Body.Close())
})
@ -148,12 +152,105 @@ func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) {
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(1, tc.permissions))
res, err := server.SendJSON(req)
require.NoError(t, err)
assert.Equal(t, tc.expectedCode, res.StatusCode)
require.Equal(t, tc.expectedCode, res.StatusCode)
require.NoError(t, res.Body.Close())
})
}
}
func Test_GetPluginAssetCDNRedirect(t *testing.T) {
const cdnPluginID = "cdn-plugin"
const nonCDNPluginID = "non-cdn-plugin"
t.Run("Plugin CDN asset redirect", func(t *testing.T) {
cdnPlugin := &plugins.Plugin{
JSONData: plugins.JSONData{ID: cdnPluginID, Info: plugins.Info{Version: "1.0.0"}},
}
nonCdnPlugin := &plugins.Plugin{
JSONData: plugins.JSONData{ID: nonCDNPluginID, Info: plugins.Info{Version: "2.0.0"}},
}
service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{
cdnPlugin.ToDTO(),
nonCdnPlugin.ToDTO(),
},
}
cfg := setting.NewCfg()
cfg.PluginsCDNURLTemplate = "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}"
cfg.PluginSettings = map[string]map[string]string{
cdnPluginID: {"cdn": "true"},
}
const cdnFolderBaseURL = "https://cdn.example.com/cdn-plugin/1.0.0/public/plugins/cdn-plugin"
type tc struct {
assetURL string
expRelativeURL string
}
for _, cas := range []tc{
{"module.js", "module.js"},
{"other/folder/file.js", "other/folder/file.js"},
{"double////slashes/file.js", "double/slashes/file.js"},
} {
pluginAssetScenario(
t,
"When calling GET for a CDN plugin on",
fmt.Sprintf("/public/plugins/%s/%s", cdnPluginID, cas.assetURL),
"/public/plugins/:pluginId/*",
cfg, service, func(sc *scenarioContext) {
// Get the prometheus metric (to test that the handler is instrumented correctly)
counter := pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{
"plugin_id": cdnPluginID,
"plugin_version": "1.0.0",
})
// Encode the prometheus metric and get its value
var m dto.Metric
require.NoError(t, counter.Write(&m))
before := m.Counter.GetValue()
// Call handler
callGetPluginAsset(sc)
// Check redirect code + location
require.Equal(t, http.StatusTemporaryRedirect, sc.resp.Code, "wrong status code")
require.Equal(t, cdnFolderBaseURL+"/"+cas.expRelativeURL, sc.resp.Header().Get("Location"), "wrong location header")
// Check metric
require.NoError(t, counter.Write(&m))
require.Equal(t, before+1, m.Counter.GetValue(), "prometheus metric not incremented")
},
)
}
pluginAssetScenario(
t,
"When calling GET for a non-CDN plugin on",
fmt.Sprintf("/public/plugins/%s/%s", nonCDNPluginID, "module.js"),
"/public/plugins/:pluginId/*",
cfg, service, func(sc *scenarioContext) {
// Here the metric should not increment
var m dto.Metric
counter := pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{
"plugin_id": nonCDNPluginID,
"plugin_version": "2.0.0",
})
require.NoError(t, counter.Write(&m))
require.Zero(t, m.Counter.GetValue())
// Call handler
callGetPluginAsset(sc)
// 404 implies access to fs
require.Equal(t, http.StatusNotFound, sc.resp.Code)
require.Empty(t, sc.resp.Header().Get("Location"))
// Ensure the metric did not change
require.NoError(t, counter.Write(&m))
require.Zero(t, m.Counter.GetValue())
},
)
})
}
func Test_GetPluginAssets(t *testing.T) {
pluginID := "test-plugin"
pluginDir := "."
@ -185,8 +282,8 @@ func Test_GetPluginAssets(t *testing.T) {
}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) {
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
setting.NewCfg(), service, func(sc *scenarioContext) {
callGetPluginAsset(sc)
require.Equal(t, 200, sc.resp.Code)
@ -201,8 +298,8 @@ func Test_GetPluginAssets(t *testing.T) {
}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, tmpFileInParentDir.Name())
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) {
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
setting.NewCfg(), service, func(sc *scenarioContext) {
callGetPluginAsset(sc)
require.Equal(t, 404, sc.resp.Code)
@ -217,8 +314,8 @@ func Test_GetPluginAssets(t *testing.T) {
requestedFile := "nonExistent"
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) {
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
setting.NewCfg(), service, func(sc *scenarioContext) {
callGetPluginAsset(sc)
var respJson map[string]interface{}
@ -237,8 +334,8 @@ func Test_GetPluginAssets(t *testing.T) {
requestedFile := "nonExistent"
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) {
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
setting.NewCfg(), service, func(sc *scenarioContext) {
callGetPluginAsset(sc)
var respJson map[string]interface{}
@ -262,8 +359,8 @@ func Test_GetPluginAssets(t *testing.T) {
l := &logtest.Fake{}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) {
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
setting.NewCfg(), service, func(sc *scenarioContext) {
callGetPluginAsset(sc)
require.Equal(t, 200, sc.resp.Code)
@ -383,12 +480,18 @@ func callGetPluginAsset(sc *scenarioContext) {
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
}
func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store,
fn scenarioFunc) {
func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string,
cfg *setting.Cfg, pluginStore plugins.Store, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
cfg.IsFeatureToggleEnabled = func(_ string) bool { return false }
hs := HTTPServer{
Cfg: setting.NewCfg(),
Cfg: cfg,
pluginStore: pluginStore,
log: log.NewNopLogger(),
pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
PluginSettings: cfg.PluginSettings,
}),
}
sc := setupScenarioContext(t, url)

View File

@ -30,6 +30,8 @@ type Cfg struct {
BuildVersion string // TODO Remove
LogDatasourceRequests bool
PluginsCDNURLTemplate string
}
func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg) *Cfg {
@ -63,6 +65,7 @@ func NewCfg(settingProvider setting.Provider, grafanaCfg *setting.Cfg) *Cfg {
AWSAssumeRoleEnabled: aws.KeyValue("assume_role_enabled").MustBool(grafanaCfg.AWSAssumeRoleEnabled),
Azure: grafanaCfg.Azure,
LogDatasourceRequests: grafanaCfg.IsFeatureToggleEnabled(featuremgmt.FlagDatasourceLogger),
PluginsCDNURLTemplate: grafanaCfg.PluginsCDNURLTemplate,
}
}

View File

@ -0,0 +1,70 @@
package assetpath
import (
"fmt"
"net/url"
"path"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// Service provides methods for constructing asset paths for plugins.
// It supports core plugins, external plugins stored on the local filesystem, and external plugins stored
// on the plugins CDN, and it will switch to the correct implementation depending on the plugin and the config.
type Service struct {
cdn *pluginscdn.Service
}
func ProvideService(cdn *pluginscdn.Service) *Service {
return &Service{cdn: cdn}
}
// Base returns the base path for the specified plugin.
func (s *Service) Base(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (string, error) {
if class == plugins.Core {
return path.Join("public/app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir)), nil
}
if s.cdn.PluginSupported(pluginJSON.ID) {
return s.cdn.SystemJSAssetPath(pluginJSON.ID, pluginJSON.Info.Version, "")
}
return path.Join("public/plugins", pluginJSON.ID), nil
}
// Module returns the module.js path for the specified plugin.
func (s *Service) Module(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (string, error) {
if class == plugins.Core {
return path.Join("app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir), "module"), nil
}
if s.cdn.PluginSupported(pluginJSON.ID) {
return s.cdn.SystemJSAssetPath(pluginJSON.ID, pluginJSON.Info.Version, "module")
}
return path.Join("plugins", pluginJSON.ID, "module"), nil
}
// RelativeURL returns the relative URL for an arbitrary plugin asset.
// If pathStr is an empty string, defaultStr is returned.
func (s *Service) RelativeURL(p *plugins.Plugin, pathStr, defaultStr string) (string, error) {
if pathStr == "" {
return defaultStr, nil
}
if s.cdn.PluginSupported(p.ID) {
// CDN
return s.cdn.NewCDNURLConstructor(p.ID, p.Info.Version).StringPath(pathStr)
}
// Local
u, err := url.Parse(pathStr)
if err != nil {
return "", fmt.Errorf("url parse: %w", err)
}
if u.IsAbs() {
return pathStr, nil
}
// is set as default or has already been prefixed with base path
if pathStr == defaultStr || strings.HasPrefix(pathStr, p.BaseURL) {
return pathStr, nil
}
return path.Join(p.BaseURL, pathStr), nil
}

View File

@ -0,0 +1,88 @@
package assetpath
import (
"testing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/stretchr/testify/require"
)
func extPath(pluginID string) string {
return "/grafana/data/plugins/" + pluginID
}
func TestService(t *testing.T) {
svc := ProvideService(pluginscdn.ProvideService(&config.Cfg{
PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}",
PluginSettings: map[string]map[string]string{
"one": {"cdn": "true"},
"two": {},
},
}))
const tableOldPath = "/grafana/public/app/plugins/panel/table-old"
jsonData := map[string]plugins.JSONData{
"table-old": {ID: "table-old", Info: plugins.Info{Version: "1.0.0"}},
"one": {ID: "one", Info: plugins.Info{Version: "1.0.0"}},
"two": {ID: "two", Info: plugins.Info{Version: "2.0.0"}},
}
t.Run("Base", func(t *testing.T) {
base, err := svc.Base(jsonData["one"], plugins.External, extPath("one"))
require.NoError(t, err)
require.Equal(t, "plugin-cdn/one/1.0.0/public/plugins/one", base)
base, err = svc.Base(jsonData["two"], plugins.External, extPath("two"))
require.NoError(t, err)
require.Equal(t, "public/plugins/two", base)
base, err = svc.Base(jsonData["table-old"], plugins.Core, tableOldPath)
require.NoError(t, err)
require.Equal(t, "public/app/plugins/table-old", base)
})
t.Run("Module", func(t *testing.T) {
module, err := svc.Module(jsonData["one"], plugins.External, extPath("one"))
require.NoError(t, err)
require.Equal(t, "plugin-cdn/one/1.0.0/public/plugins/one/module", module)
module, err = svc.Module(jsonData["two"], plugins.External, extPath("two"))
require.NoError(t, err)
require.Equal(t, "plugins/two/module", module)
module, err = svc.Module(jsonData["table-old"], plugins.Core, tableOldPath)
require.NoError(t, err)
require.Equal(t, "app/plugins/table-old/module", module)
})
t.Run("RelativeURL", func(t *testing.T) {
pluginsMap := map[string]*plugins.Plugin{
"one": {
JSONData: plugins.JSONData{ID: "one", Info: plugins.Info{Version: "1.0.0"}},
BaseURL: "plugin-cdn/one/1.0.0/public/pluginsMap/one",
},
"two": {
JSONData: plugins.JSONData{ID: "two", Info: plugins.Info{Version: "2.0.0"}},
BaseURL: "public/pluginsMap/two",
},
}
u, err := svc.RelativeURL(pluginsMap["one"], "", "default")
require.NoError(t, err)
require.Equal(t, "default", u)
u, err = svc.RelativeURL(pluginsMap["one"], "path/to/file.txt", "default")
require.NoError(t, err)
require.Equal(t, "https://cdn.example.com/one/1.0.0/public/plugins/one/path/to/file.txt", u)
u, err = svc.RelativeURL(pluginsMap["two"], "path/to/file.txt", "default")
require.NoError(t, err)
require.Equal(t, "public/pluginsMap/two/path/to/file.txt", u)
u, err = svc.RelativeURL(pluginsMap["two"], "default", "default")
require.NoError(t, err)
require.Equal(t, "default", u)
})
}

View File

@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
@ -19,11 +18,13 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/logger"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
"github.com/grafana/grafana/pkg/plugins/manager/loader/initializer"
"github.com/grafana/grafana/pkg/plugins/manager/process"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/plugins/storage"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
@ -44,21 +45,25 @@ type Loader struct {
pluginInitializer initializer.Initializer
signatureValidator signature.Validator
pluginStorage storage.Manager
pluginsCDN *pluginscdn.Service
assetPath *assetpath.Service
log log.Logger
cfg *config.Cfg
errs map[string]*plugins.SignatureError
}
func ProvideService(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer,
pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider,
roleRegistry plugins.RoleRegistry) *Loader {
roleRegistry plugins.RoleRegistry, pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader {
return New(cfg, license, authorizer, pluginRegistry, backendProvider, process.NewManager(pluginRegistry),
storage.FileSystem(logger.NewLogger("loader.fs"), cfg.PluginsPath), roleRegistry)
storage.FileSystem(logger.NewLogger("loader.fs"), cfg.PluginsPath), roleRegistry, pluginsCDNService, assetPath)
}
func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer,
pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider,
processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry) *Loader {
processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry,
pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader {
return &Loader{
pluginFinder: finder.New(),
pluginRegistry: pluginRegistry,
@ -69,6 +74,9 @@ func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLo
errs: make(map[string]*plugins.SignatureError),
log: log.New("plugin.loader"),
roleRegistry: roleRegistry,
cfg: cfg,
pluginsCDN: pluginsCDNService,
assetPath: assetPath,
}
}
@ -81,6 +89,36 @@ func (l *Loader) Load(ctx context.Context, class plugins.Class, paths []string)
return l.loadPlugins(ctx, class, pluginJSONPaths)
}
func (l *Loader) createPluginsForLoading(class plugins.Class, foundPlugins foundPlugins) map[string]*plugins.Plugin {
loadedPlugins := make(map[string]*plugins.Plugin)
for pluginDir, pluginJSON := range foundPlugins {
plugin, err := l.createPluginBase(pluginJSON, class, pluginDir)
if err != nil {
l.log.Warn("Could not create plugin base", "pluginID", pluginJSON.ID, "err", err)
continue
}
// calculate initial signature state
var sig plugins.Signature
if l.pluginsCDN.PluginSupported(plugin.ID) {
// CDN plugins have no signature checks for now.
sig = plugins.Signature{Status: plugins.SignatureValid}
} else {
sig, err = signature.Calculate(l.log, plugin)
if err != nil {
l.log.Warn("Could not calculate plugin signature state", "pluginID", plugin.ID, "err", err)
continue
}
}
plugin.Signature = sig.Status
plugin.SignatureType = sig.Type
plugin.SignatureOrg = sig.SigningOrg
loadedPlugins[plugin.PluginDir] = plugin
}
return loadedPlugins
}
func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSONPaths []string) ([]*plugins.Plugin, error) {
var foundPlugins = foundPlugins{}
@ -113,22 +151,8 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
foundPlugins.stripDuplicates(registeredPlugins, l.log)
// calculate initial signature state
loadedPlugins := make(map[string]*plugins.Plugin)
for pluginDir, pluginJSON := range foundPlugins {
plugin := createPluginBase(pluginJSON, class, pluginDir)
sig, err := signature.Calculate(l.log, plugin)
if err != nil {
l.log.Warn("Could not calculate plugin signature state", "pluginID", plugin.ID, "err", err)
continue
}
plugin.Signature = sig.Status
plugin.SignatureType = sig.Type
plugin.SignatureOrg = sig.SigningOrg
loadedPlugins[plugin.PluginDir] = plugin
}
// create plugins structs and calculate signatures
loadedPlugins := l.createPluginsForLoading(class, foundPlugins)
// wire up plugin dependencies
for _, plugin := range loadedPlugins {
@ -165,12 +189,13 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
// clear plugin error if a pre-existing error has since been resolved
delete(l.errs, plugin.ID)
// verify module.js exists for SystemJS to load
// verify module.js exists for SystemJS to load.
// CDN plugins can be loaded with plugin.json only, so do not warn for those.
if !plugin.IsRenderer() && !plugin.IsCorePlugin() {
module := filepath.Join(plugin.PluginDir, "module.js")
if exists, err := fs.Exists(module); err != nil {
return nil, err
} else if !exists {
} else if !exists && !l.pluginsCDN.PluginSupported(plugin.ID) {
l.log.Warn("Plugin missing module.js",
"pluginID", plugin.ID,
"warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.",
@ -312,28 +337,47 @@ func (l *Loader) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error)
return plugin, nil
}
func createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) *plugins.Plugin {
func (l *Loader) createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (*plugins.Plugin, error) {
baseURL, err := l.assetPath.Base(pluginJSON, class, pluginDir)
if err != nil {
return nil, fmt.Errorf("base url: %w", err)
}
moduleURL, err := l.assetPath.Module(pluginJSON, class, pluginDir)
if err != nil {
return nil, fmt.Errorf("module url: %w", err)
}
plugin := &plugins.Plugin{
JSONData: pluginJSON,
PluginDir: pluginDir,
BaseURL: baseURL(pluginJSON, class, pluginDir),
Module: module(pluginJSON, class, pluginDir),
BaseURL: baseURL,
Module: moduleURL,
Class: class,
}
plugin.SetLogger(log.New(fmt.Sprintf("plugin.%s", plugin.ID)))
setImages(plugin)
if err := l.setImages(plugin); err != nil {
return nil, err
}
return plugin
return plugin, nil
}
func setImages(p *plugins.Plugin) {
p.Info.Logos.Small = pluginLogoURL(p.Type, p.Info.Logos.Small, p.BaseURL)
p.Info.Logos.Large = pluginLogoURL(p.Type, p.Info.Logos.Large, p.BaseURL)
for i := 0; i < len(p.Info.Screenshots); i++ {
p.Info.Screenshots[i].Path = evalRelativePluginURLPath(p.Info.Screenshots[i].Path, p.BaseURL, p.Type)
func (l *Loader) setImages(p *plugins.Plugin) error {
var err error
for _, dst := range []*string{&p.Info.Logos.Small, &p.Info.Logos.Large} {
*dst, err = l.assetPath.RelativeURL(p, *dst, defaultLogoPath(p.Type))
if err != nil {
return fmt.Errorf("logo: %w", err)
}
}
for i := 0; i < len(p.Info.Screenshots); i++ {
screenshot := &p.Info.Screenshots[i]
screenshot.Path, err = l.assetPath.RelativeURL(p, screenshot.Path, "")
if err != nil {
return fmt.Errorf("screenshot %d relative url: %w", i, err)
}
}
return nil
}
func setDefaultNavURL(p *plugins.Plugin) {
@ -377,36 +421,10 @@ func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) {
}
}
func pluginLogoURL(pluginType plugins.Type, path, baseURL string) string {
if path == "" {
return defaultLogoPath(pluginType)
}
return evalRelativePluginURLPath(path, baseURL, pluginType)
}
func defaultLogoPath(pluginType plugins.Type) string {
return "public/img/icn-" + string(pluginType) + ".svg"
}
func evalRelativePluginURLPath(pathStr, baseURL string, pluginType plugins.Type) string {
if pathStr == "" {
return ""
}
u, _ := url.Parse(pathStr)
if u.IsAbs() {
return pathStr
}
// is set as default or has already been prefixed with base path
if pathStr == defaultLogoPath(pluginType) || strings.HasPrefix(pathStr, baseURL) {
return pathStr
}
return path.Join(baseURL, pathStr)
}
func (l *Loader) PluginErrors() []*plugins.Error {
errs := make([]*plugins.Error, 0)
for _, err := range l.errs {
@ -419,20 +437,6 @@ func (l *Loader) PluginErrors() []*plugins.Error {
return errs
}
func baseURL(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) string {
if class == plugins.Core {
return path.Join("public/app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir))
}
return path.Join("public/plugins", pluginJSON.ID)
}
func module(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) string {
if class == plugins.Core {
return path.Join("app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir), "module")
}
return path.Join("plugins", pluginJSON.ID, "module")
}
func validatePluginJSON(data plugins.JSONData) error {
if data.ID == "" || !data.Type.IsValid() {
return ErrInvalidPluginJSON

View File

@ -7,6 +7,9 @@ import (
"sort"
"testing"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require"
@ -404,6 +407,61 @@ func TestLoader_Load(t *testing.T) {
},
},
},
{
name: "Load CDN plugin",
class: plugins.External,
cfg: &config.Cfg{
PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}",
PluginSettings: setting.PluginSettings{
"grafana-worldmap-panel": {"cdn": "true"},
},
},
pluginPaths: []string{"../testdata/cdn"},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "grafana-worldmap-panel",
Type: "panel",
Name: "Worldmap Panel",
Info: plugins.Info{
Version: "0.3.3",
Links: []plugins.InfoLink{
{Name: "Project site", URL: "https://github.com/grafana/worldmap-panel"},
{Name: "MIT License", URL: "https://github.com/grafana/worldmap-panel/blob/master/LICENSE"},
},
Logos: plugins.Logos{
// Path substitution
Small: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg",
Large: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg",
},
Screenshots: []plugins.Screenshots{
{
Name: "World",
Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-world.png",
},
{
Name: "USA",
Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-usa.png",
},
{
Name: "Light Theme",
Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-light-theme.png",
},
},
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "3.x.x",
Plugins: []plugins.Dependency{},
},
},
PluginDir: filepath.Join(parentDir, "testdata/cdn/plugin"),
Class: plugins.External,
Signature: plugins.SignatureValid,
BaseURL: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel",
Module: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module",
},
},
},
}
for _, tt := range tests {
reg := fakes.NewFakePluginRegistry()
@ -1320,9 +1378,10 @@ func Test_setPathsBasedOnApp(t *testing.T) {
}
func newLoader(cfg *config.Cfg, cbs ...func(loader *Loader)) *Loader {
cdn := pluginscdn.ProvideService(cfg)
l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(),
fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakePluginStorage(),
fakes.NewFakeRoleRegistry())
fakes.NewFakeRoleRegistry(), cdn, assetpath.ProvideService(cdn))
for _, cb := range cbs {
cb(l)

View File

@ -8,6 +8,9 @@ import (
"testing"
"time"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana-azure-sdk-go/azsettings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
@ -110,10 +113,12 @@ func TestIntegrationPluginManager(t *testing.T) {
pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg)
reg := registry.ProvideService()
cdn := pluginscdn.ProvideService(pCfg)
lic := plicensing.ProvideLicensing(cfg, &licensing.OSSLicensingService{Cfg: cfg})
l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg),
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry())
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry(),
cdn, assetpath.ProvideService(cdn))
ps, err := store.ProvideService(cfg, pCfg, reg, l)
require.NoError(t, err)

View File

@ -18,14 +18,14 @@ func NewValidator(authorizer plugins.PluginLoaderAuthorizer) Validator {
}
func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
if plugin.Signature == plugins.SignatureValid {
if plugin.Signature.IsValid() {
s.log.Debug("Plugin has valid signature", "id", plugin.ID)
return nil
}
// If a plugin is nested within another, create links to each other to inherit signature details
if plugin.Parent != nil {
if plugin.IsCorePlugin() || plugin.Signature == plugins.SignatureInternal {
if plugin.IsCorePlugin() || plugin.Signature.IsInternal() {
s.log.Debug("Not setting descendant plugin's signature to that of root since it's core or internal",
"plugin", plugin.ID, "signature", plugin.Signature, "isCore", plugin.IsCorePlugin())
} else {
@ -34,7 +34,7 @@ func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
plugin.Signature = plugin.Parent.Signature
plugin.SignatureType = plugin.Parent.SignatureType
plugin.SignatureOrg = plugin.Parent.SignatureOrg
if plugin.Signature == plugins.SignatureValid {
if plugin.Signature.IsValid() {
s.log.Debug("Plugin has valid signature (inherited from root)", "id", plugin.ID)
return nil
}

View File

@ -0,0 +1,40 @@
{
"type": "panel",
"name": "Worldmap Panel",
"id": "grafana-worldmap-panel",
"info": {
"logos": {
"small": "images/worldmap_logo.svg",
"large": "images/worldmap_logo.svg"
},
"links": [
{
"name": "Project site",
"url": "https://github.com/grafana/worldmap-panel"
},
{
"name": "MIT License",
"url": "https://github.com/grafana/worldmap-panel/blob/master/LICENSE"
}
],
"screenshots": [
{
"name": "World",
"path": "images/worldmap-world.png"
},
{
"name": "USA",
"path": "images/worldmap-usa.png"
},
{
"name": "Light Theme",
"path": "images/worldmap-light-theme.png"
}
],
"version": "0.3.3"
},
"dependencies": {
"grafanaVersion": "3.x.x",
"plugins": []
}
}

View File

@ -127,6 +127,10 @@ func TestParsePluginTestdata(t *testing.T) {
"disallowed-cue-import": {
err: ErrDisallowedCUEImport,
},
"cdn": {
rootid: "grafana-worldmap-panel",
subpath: "plugin",
},
}
staticRootPath, err := filepath.Abs("../manager/testdata")

View File

@ -0,0 +1,80 @@
package pluginscdn
import (
"errors"
"fmt"
"net/url"
"path"
"github.com/grafana/grafana/pkg/plugins/config"
)
const (
// systemJSCDNKeyword is the path prefix used by system.js to identify the plugins CDN.
systemJSCDNKeyword = "plugin-cdn"
)
var ErrPluginNotCDN = errors.New("plugin is not a cdn plugin")
// Service provides methods for the plugins CDN.
type Service struct {
cfg *config.Cfg
}
func ProvideService(cfg *config.Cfg) *Service {
return &Service{cfg: cfg}
}
// NewCDNURLConstructor returns a new URLConstructor for the provided plugin id and version.
// The CDN should be enabled for the plugin, otherwise the returned URLConstructor will have
// and invalid base url.
func (s *Service) NewCDNURLConstructor(pluginID, pluginVersion string) URLConstructor {
return URLConstructor{
cdnURLTemplate: s.cfg.PluginsCDNURLTemplate,
pluginID: pluginID,
pluginVersion: pluginVersion,
}
}
// IsEnabled returns true if the plugins cdn is enabled.
func (s *Service) IsEnabled() bool {
return s.cfg.PluginsCDNURLTemplate != ""
}
// PluginSupported returns true if the CDN is enabled in the config and if the specified plugin ID has CDN enabled.
func (s *Service) PluginSupported(pluginID string) bool {
return s.IsEnabled() && s.cfg.PluginSettings[pluginID]["cdn"] != ""
}
// BaseURL returns the absolute base URL of the plugins CDN.
// If the plugins CDN is disabled, it returns an empty string.
func (s *Service) BaseURL() (string, error) {
if !s.IsEnabled() {
return "", nil
}
u, err := url.Parse(s.cfg.PluginsCDNURLTemplate)
if err != nil {
return "", fmt.Errorf("url parse: %w", err)
}
return u.Scheme + "://" + u.Host, nil
}
// SystemJSAssetPath returns a system-js path for the specified asset on the plugins CDN.
// It replaces the base path of the CDN with systemJSCDNKeyword.
// If assetPath is an empty string, the base path for the plugin is returned.
func (s *Service) SystemJSAssetPath(pluginID, pluginVersion, assetPath string) (string, error) {
u, err := s.NewCDNURLConstructor(pluginID, pluginVersion).Path(assetPath)
if err != nil {
return "", err
}
return path.Join(systemJSCDNKeyword, u.Path), nil
}
// AssetURL returns the URL of a CDN asset for a CDN plugin. If the specified plugin is not a CDN plugin,
// it returns ErrPluginNotCDN.
func (s *Service) AssetURL(pluginID, pluginVersion, assetPath string) (string, error) {
if !s.PluginSupported(pluginID) {
return "", ErrPluginNotCDN
}
return s.NewCDNURLConstructor(pluginID, pluginVersion).StringPath(assetPath)
}

View File

@ -0,0 +1,49 @@
package pluginscdn
import (
"testing"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/stretchr/testify/require"
)
func TestService(t *testing.T) {
svc := ProvideService(&config.Cfg{
PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}",
PluginSettings: map[string]map[string]string{
"one": {"cdn": "true"},
"two": {},
},
})
t.Run("IsCDNPlugin", func(t *testing.T) {
require.True(t, svc.PluginSupported("one"))
require.False(t, svc.PluginSupported("two"))
require.False(t, svc.PluginSupported("unknown"))
})
t.Run("CDNBaseURL", func(t *testing.T) {
for _, c := range []struct {
name string
cfgURL string
expBaseURL string
}{
{
name: "valid",
cfgURL: "https://grafana-assets.grafana.net/plugin-cdn-test/plugin-cdn/{id}/{version}/public/plugins/{id}/{assetPath}",
expBaseURL: "https://grafana-assets.grafana.net",
},
{
name: "empty",
cfgURL: "",
expBaseURL: "",
},
} {
t.Run(c.name, func(t *testing.T) {
u, err := ProvideService(&config.Cfg{PluginsCDNURLTemplate: c.cfgURL}).BaseURL()
require.NoError(t, err)
require.Equal(t, c.expBaseURL, u)
})
}
})
}

View File

@ -0,0 +1,61 @@
package pluginscdn
import (
"fmt"
"net/url"
"path"
"strings"
)
// URLConstructor is a struct that can build CDN URLs for plugins on a remote CDN.
type URLConstructor struct {
// cdnURLTemplate is absolute base url of the CDN. This string will be formatted
// according to the rules specified in the Path method.
cdnURLTemplate string
// pluginID is the ID of the plugin.
pluginID string
// pluginVersion is the version of the plugin.
pluginVersion string
}
// Path returns a new *url.URL that points to an asset file for the CDN, plugin and plugin version
// specified by the current URLConstructor.
//
// c.cdnURLTemplate is used to build the string, the following substitutions are performed in it:
//
// - {id} -> plugin id
//
// - {version} -> plugin version
//
// - {assetPath} -> assetPath
//
// The asset Path is sanitized via path.Clean (double slashes are removed, "../" is resolved, etc).
//
// The returned URL will be for a file, so it won't have a trailing slash.
func (c URLConstructor) Path(assetPath string) (*url.URL, error) {
u, err := url.Parse(
strings.TrimRight(
strings.NewReplacer(
"{id}", c.pluginID,
"{version}", c.pluginVersion,
"{assetPath}", strings.Trim(path.Clean("/"+assetPath+"/"), "/"),
).Replace(c.cdnURLTemplate),
"/",
),
)
if err != nil {
return nil, fmt.Errorf("url parse: %w", err)
}
return u, nil
}
// StringPath is like Path, but it returns the absolute URL as a string rather than *url.URL.
func (c URLConstructor) StringPath(assetPath string) (string, error) {
u, err := c.Path(assetPath)
if err != nil {
return "", err
}
return u.String(), nil
}

View File

@ -0,0 +1,34 @@
package pluginscdn
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestURLConstructor_StringURLFor(t *testing.T) {
uc := URLConstructor{
cdnURLTemplate: "https://the.cdn/{id}/{version}/{assetPath}",
pluginID: "the-plugin",
pluginVersion: "0.1",
}
type tc struct {
name string
path string
exp string
}
for _, c := range []tc{
{"simple", "file.txt", "https://the.cdn/the-plugin/0.1/file.txt"},
{"multiple", "some/path/to/file.txt", "https://the.cdn/the-plugin/0.1/some/path/to/file.txt"},
{"path traversal", "some/../to/file.txt", "https://the.cdn/the-plugin/0.1/to/file.txt"},
{"above root", "../../../../../file.txt", "https://the.cdn/the-plugin/0.1/file.txt"},
{"multiple slashes", "some/////file.txt", "https://the.cdn/the-plugin/0.1/some/file.txt"},
{"dots", "some/././././file.txt", "https://the.cdn/the-plugin/0.1/some/file.txt"},
} {
t.Run(c.name, func(t *testing.T) {
u, err := uc.StringPath(c.path)
require.NoError(t, err)
require.Equal(t, c.exp, u)
})
}
}

View File

@ -10,11 +10,13 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager"
"github.com/grafana/grafana/pkg/plugins/manager/client"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/manager/process"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/store"
"github.com/grafana/grafana/pkg/plugins/plugincontext"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware"
@ -34,6 +36,8 @@ var WireSet = wire.NewSet(
process.ProvideService,
wire.Bind(new(process.Service), new(*process.Manager)),
coreplugin.ProvideCoreRegistry,
pluginscdn.ProvideService,
assetpath.ProvideService,
loader.ProvideService,
wire.Bind(new(loader.Service), new(*loader.Loader)),
wire.Bind(new(plugins.ErrorResolver), new(*loader.Loader)),

View File

@ -276,6 +276,8 @@ type Cfg struct {
PluginAdminEnabled bool
PluginAdminExternalManageEnabled bool
PluginsCDNURLTemplate string
// Panels
DisableSanitizeHtml bool

View File

@ -26,6 +26,7 @@ func extractPluginSettings(sections []*ini.Section) PluginSettings {
func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
pluginsSection := iniFile.Section("plugins")
cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false)
cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false)
cfg.PluginSettings = extractPluginSettings(iniFile.Sections())
@ -47,5 +48,8 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
cfg.PluginCatalogHiddenPlugins = append(cfg.PluginCatalogHiddenPlugins, plug)
}
// Plugins CDN settings
cfg.PluginsCDNURLTemplate = strings.TrimRight(pluginsSection.Key("cdn_base_url").MustString(""), "/")
return nil
}

View File

@ -43,12 +43,14 @@ export class AngularApp {
'$filterProvider',
'$httpProvider',
'$provide',
'$sceDelegateProvider',
(
$controllerProvider: angular.IControllerProvider,
$compileProvider: angular.ICompileProvider,
$filterProvider: angular.IFilterProvider,
$httpProvider: angular.IHttpProvider,
$provide: angular.auto.IProvideService
$provide: angular.auto.IProvideService,
$sceDelegateProvider: angular.ISCEDelegateProvider
) => {
if (config.buildInfo.env !== 'development') {
$compileProvider.debugInfoEnabled(false);
@ -56,6 +58,10 @@ export class AngularApp {
$httpProvider.useApplyAsync(true);
if (Boolean(config.pluginsCDNBaseURL)) {
$sceDelegateProvider.trustedResourceUrlList(['self', `${config.pluginsCDNBaseURL}/**`]);
}
this.registerFunctions.controller = $controllerProvider.register;
this.registerFunctions.directive = $compileProvider.directive;
this.registerFunctions.factory = $provide.factory;

View File

@ -0,0 +1,18 @@
import { config } from '@grafana/runtime';
import { relativeTemplateUrlToCDN } from './plugin_component';
describe('Plugin Component', () => {
describe('relativeTemplateUrlToCDN()', () => {
it('should create a proper path', () => {
config.pluginsCDNBaseURL = 'http://my-host.com';
const templateUrl = 'partials/module.html';
const baseUrl = 'plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel';
const expectedUrl =
'http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/partials/module.html';
expect(relativeTemplateUrlToCDN(templateUrl, baseUrl)).toBe(expectedUrl);
});
});
});

View File

@ -8,6 +8,20 @@ import config from 'app/core/config';
import { importPanelPlugin } from '../../features/plugins/importPanelPlugin';
import { importDataSourcePlugin, importAppPlugin } from '../../features/plugins/plugin_loader';
export function relativeTemplateUrlToCDN(templateUrl: string, baseUrl: string) {
if (!templateUrl) {
return undefined;
}
// the templateUrl may have already been updated with the hostname
if (templateUrl.startsWith(config.pluginsCDNBaseURL)) {
return templateUrl;
}
// use the 'plugin-cdn' key to load via cdn
return `${baseUrl.replace('plugin-cdn/', `${config.pluginsCDNBaseURL}/`)}/${templateUrl}`;
}
coreModule.directive('pluginComponent', ['$compile', '$http', '$templateCache', '$location', pluginDirectiveLoader]);
function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $location: ILocationService) {
@ -31,12 +45,17 @@ function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $
if (templateUrl.indexOf('public') === 0) {
return templateUrl;
}
return baseUrl + '/' + templateUrl;
}
function getPluginComponentDirective(options: any) {
// handle relative template urls for plugin templates
options.Component.templateUrl = relativeTemplateUrlToAbs(options.Component.templateUrl, options.baseUrl);
if (options.baseUrl.includes('plugin-cdn')) {
options.Component.templateUrl = relativeTemplateUrlToCDN(options.Component.templateUrl, options.baseUrl);
} else {
// handle relative template urls for plugin templates
options.Component.templateUrl = relativeTemplateUrlToAbs(options.Component.templateUrl, options.baseUrl);
}
return () => {
return {
@ -86,13 +105,17 @@ function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $
}
if (panelInfo) {
PanelCtrl.templateUrl = relativeTemplateUrlToAbs(PanelCtrl.templateUrl, panelInfo.baseUrl);
if (panelInfo.baseUrl.includes('plugin-cdn')) {
PanelCtrl.templateUrl = relativeTemplateUrlToCDN(PanelCtrl.templateUrl, panelInfo.baseUrl);
} else {
PanelCtrl.templateUrl = relativeTemplateUrlToAbs(PanelCtrl.templateUrl, panelInfo.baseUrl);
}
}
PanelCtrl.templatePromise = getTemplate(PanelCtrl).then((template: any) => {
PanelCtrl.templateUrl = null;
PanelCtrl.template = `<grafana-panel ctrl="ctrl" class="panel-height-helper">${template}</grafana-panel>`;
return componentInfo;
return { ...componentInfo, baseUrl: panelInfo.baseUrl };
});
return PanelCtrl.templatePromise;

View File

@ -5,7 +5,7 @@ import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { StoreState, ThunkResult } from 'app/types';
import { invalidatePluginInCache } from '../../pluginCacheBuster';
import { invalidatePluginInCache } from '../../systemjsPlugins/pluginCacheBuster';
import {
getRemotePlugins,
getPluginErrors,

View File

@ -32,7 +32,9 @@ import * as ticks from 'app/core/utils/ticks';
import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins';
import { locateWithCache, registerPluginInCache } from './pluginCacheBuster';
import { locateFromCDN, translateForCDN } from './systemjsPlugins/pluginCDN';
import { fetchCSS, locateCSS } from './systemjsPlugins/pluginCSS';
import { locateWithCache, registerPluginInCache } from './systemjsPlugins/pluginCacheBuster';
// Help the 6.4 to 6.5 migration
// The base classes were moved from @grafana/ui to @grafana/data
@ -43,7 +45,12 @@ grafanaUI.DataSourcePlugin = grafanaData.DataSourcePlugin;
grafanaUI.AppPlugin = grafanaData.AppPlugin;
grafanaUI.DataSourceApi = grafanaData.DataSourceApi;
grafanaRuntime.SystemJS.registry.set('css', grafanaRuntime.SystemJS.newModule({ locate: locateCSS, fetch: fetchCSS }));
grafanaRuntime.SystemJS.registry.set('plugin-loader', grafanaRuntime.SystemJS.newModule({ locate: locateWithCache }));
grafanaRuntime.SystemJS.registry.set(
'cdn-loader',
grafanaRuntime.SystemJS.newModule({ locate: locateFromCDN, translate: translateForCDN })
);
grafanaRuntime.SystemJS.config({
baseURL: 'public',
@ -52,10 +59,12 @@ grafanaRuntime.SystemJS.config({
plugins: {
defaultExtension: 'js',
},
'plugin-cdn': {
defaultExtension: 'js',
},
},
map: {
text: 'vendor/plugin-text/text.js',
css: 'vendor/plugin-css/css.js',
},
meta: {
'/*': {
@ -63,6 +72,14 @@ grafanaRuntime.SystemJS.config({
authorization: true,
loader: 'plugin-loader',
},
'*.css': {
loader: 'css',
},
'plugin-cdn/*': {
esModule: true,
authorization: false,
loader: 'cdn-loader',
},
},
});

View File

@ -0,0 +1,105 @@
import { config } from '@grafana/runtime';
import { translateForCDN, extractPluginNameVersionFromUrl } from './pluginCDN';
describe('Plugin CDN', () => {
describe('translateForCDN', () => {
const load = {
name: 'http://localhost:3000/public/plugin-cdn/grafana-worldmap-panel/0.3.3/grafana-worldmap-panel/module.js',
address: 'http://my-host.com/grafana-worldmap-panel/0.3.3/grafana-worldmap-panel/module.js',
source: 'public/plugins/grafana-worldmap-panel/template.html',
metadata: {
extension: '',
deps: [],
format: 'amd',
loader: 'cdn-loader',
encapsulateGlobal: false,
cjsRequireDetection: true,
cjsDeferDepsExecute: false,
esModule: true,
authorization: false,
},
};
config.pluginsCDNBaseURL = 'http://my-host.com';
it('should update the default local path to use the CDN path', () => {
const translatedLoad = translateForCDN({
...load,
source: 'public/plugins/grafana-worldmap-panel/template.html',
});
expect(translatedLoad).toBe(
'http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html'
);
});
it('should replace the default path in a multi-line source code', () => {
const source = `
const a = "public/plugins/grafana-worldmap-panel/template.html";
const img = "<img src='public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
`;
const expectedSource = `
const a = "http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html";
const img = "<img src='http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
`;
const translatedLoad = translateForCDN({ ...load, source });
expect(translatedLoad).toBe(expectedSource);
});
it('should cater for local paths starting with a slash', () => {
const source = `
const a = "/public/plugins/grafana-worldmap-panel/template.html";
const img = "<img src='public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
`;
const expectedSource = `
const a = "http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html";
const img = "<img src='http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
`;
const translatedLoad = translateForCDN({ ...load, source });
expect(translatedLoad).toBe(expectedSource);
});
it('should cater for a particular path', () => {
const source = `
.getJSON(
"public/plugins/grafana-worldmap-panel/data/" +
this.panel.locationData +
".json"
)
`;
const expectedSource = `
.getJSON(
"http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/data/" +
this.panel.locationData +
".json"
)
`;
const translatedLoad = translateForCDN({ ...load, source });
expect(translatedLoad).toBe(expectedSource);
});
it('should replace sourcemap locations', () => {
const source = `
Zn(t,e)},t.Rectangle=ui,t.rectangle=function(t,e){return new ui(t,e)},t.Map=He,t.map=function(t,e){return new He(t,e)}}(e)}])});
//# sourceMappingURL=module.js.map
`;
const expectedSource = `
Zn(t,e)},t.Rectangle=ui,t.rectangle=function(t,e){return new ui(t,e)},t.Map=He,t.map=function(t,e){return new He(t,e)}}(e)}])});
//# sourceMappingURL=http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js.map
`;
const translatedLoad = translateForCDN({ ...load, source });
expect(translatedLoad).toBe(expectedSource);
});
});
describe('extractPluginNameVersionFromUrl', () => {
it('should extract the plugin name and version from a path', () => {
const source =
'http://localhost:3000/public/plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js';
const expected = {
name: 'grafana-worldmap-panel',
version: '0.3.3',
};
const expectedExtractedPluginDeets = extractPluginNameVersionFromUrl(source);
expect(expectedExtractedPluginDeets).toEqual(expected);
});
});
});

View File

@ -0,0 +1,29 @@
import { config } from '@grafana/runtime';
import type { SystemJSLoad } from './types';
export function extractPluginNameVersionFromUrl(address: string) {
const path = new URL(address).pathname;
const match = path.split('/');
return { name: match[3], version: match[4] };
}
export function locateFromCDN(load: SystemJSLoad) {
const { address } = load;
const pluginPath = address.split('/public/plugin-cdn/');
return `${config.pluginsCDNBaseURL}/${pluginPath[1]}`;
}
export function translateForCDN(load: SystemJSLoad) {
const { name, version } = extractPluginNameVersionFromUrl(load.name);
const baseAddress = `${config.pluginsCDNBaseURL}/${name}/${version}`;
load.source = load.source.replace(/(\/?)(public\/plugins)/g, `${baseAddress}/$2`);
load.source = load.source.replace(/(["|'])(plugins\/.+.css)(["|'])/g, `$1${baseAddress}/public/$2$3`);
load.source = load.source.replace(
/(\/\/#\ssourceMappingURL=)(.+)\.map/g,
`$1${baseAddress}/public/plugins/${name}/$2.map`
);
return load.source;
}

View File

@ -0,0 +1,75 @@
import { noop } from 'lodash';
import { config } from '@grafana/runtime';
import type { SystemJSLoad } from './types';
/*
Locate: Overrides the location of the plugin resource
Plugins that import css use relative paths in Systemjs.register dependency list.
Rather than attempt to resolve it in the pluginCDN systemjs plugin let SystemJS resolve it to origin
then we can replace the "baseUrl" with the "cdnHost".
*/
export function locateCSS(load: SystemJSLoad) {
if (load.metadata.loader === 'cdn-loader' && load.address.startsWith(`${location.origin}/public/plugin-cdn`)) {
load.address = load.address.replace(`${location.origin}/public/plugin-cdn`, config.pluginsCDNBaseURL);
}
return load.address;
}
/*
Fetch: Called with second argument representing default fetch function, has full control of fetch output.
Plugins that have external CSS will use this plugin to load their custom styles
*/
export function fetchCSS(load: SystemJSLoad) {
const links = document.getElementsByTagName('link');
const linkHrefs: string[] = Array.from(links).map((link) => link.href);
// dont reload styles loaded in the head
if (linkHrefs.includes(load.address)) {
return '';
}
return loadCSS(load.address);
}
const bust = '?_cache=' + Date.now();
const waitSeconds = 100;
function loadCSS(url: string) {
return new Promise(function (resolve, reject) {
const timeout = setTimeout(function () {
reject('Unable to load CSS');
}, waitSeconds * 1000);
const _callback = function (error?: string | Error) {
clearTimeout(timeout);
link.onload = link.onerror = noop;
setTimeout(function () {
if (error) {
reject(error);
} else {
resolve('');
}
}, 7);
};
const link = document.createElement('link');
link.type = 'text/css';
link.rel = 'stylesheet';
link.href = url;
// Don't cache bust plugins loaded from cdn.
if (!link.href.startsWith(config.pluginsCDNBaseURL)) {
link.href = link.href + bust;
}
link.onload = function () {
_callback();
};
link.onerror = function (event) {
_callback(event instanceof ErrorEvent ? event.message : new Error('Error loading CSS file.'));
};
document.head.appendChild(link);
});
}

View File

@ -1,6 +1,7 @@
import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from '../pluginCacheBuster';
import * as pluginSettings from '../pluginSettings';
import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from './pluginCacheBuster';
describe('PluginCacheBuster', () => {
const now = 12345;

View File

@ -1,4 +1,4 @@
import { clearPluginSettingsCache } from './pluginSettings';
import { clearPluginSettingsCache } from '../pluginSettings';
const cache: Record<string, string> = {};
const initializedAt: number = Date.now();

View File

@ -0,0 +1,16 @@
export type SystemJSLoad = {
address: string;
metadata: {
authorization: boolean;
cjsDeferDepsExecute: boolean;
cjsRequireDetection: boolean;
crossOrigin?: boolean;
encapsulateGlobal: boolean;
esModule: boolean;
integrity?: string;
loader: string;
scriptLoad?: boolean;
};
name: string;
source: string;
};

View File

@ -1,73 +0,0 @@
"use strict";
if (typeof window !== 'undefined') {
var bust = '?_cache=' + Date.now();
var waitSeconds = 100;
var head = document.getElementsByTagName('head')[0];
// get all link tags in the page
var links = document.getElementsByTagName('link');
var linkHrefs = [];
for (var i = 0; i < links.length; i++) {
linkHrefs.push(links[i].href);
}
var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/);
var webkitLoadCheck = function (link, callback) {
setTimeout(function () {
for (var i = 0; i < document.styleSheets.length; i++) {
var sheet = document.styleSheets[i];
if (sheet.href === link.href) {
return callback();
}
}
webkitLoadCheck(link, callback);
}, 10);
};
var noop = function () { };
var loadCSS = function (url) {
return new Promise(function (resolve, reject) {
var timeout = setTimeout(function () {
reject('Unable to load CSS');
}, waitSeconds * 1000);
var _callback = function (error) {
clearTimeout(timeout);
link.onload = link.onerror = noop;
setTimeout(function () {
if (error) {
reject(error);
}
else {
resolve('');
}
}, 7);
};
var link = document.createElement('link');
link.type = 'text/css';
link.rel = 'stylesheet';
link.href = url + bust;
if (!isWebkit) {
link.onload = function () {
_callback();
}
} else {
webkitLoadCheck(link, _callback);
}
link.onerror = function (event) {
_callback(event.error || new Error('Error loading CSS file.'));
};
head.appendChild(link);
});
};
exports.fetch = function (load) {
// dont reload styles loaded in the head
for (var i = 0; i < linkHrefs.length; i++)
if (load.address == linkHrefs[i])
return '';
return loadCSS(load.address);
};
}