mirror of
https://github.com/grafana/grafana.git
synced 2025-02-20 11:48:34 -06:00
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:
parent
c931b8031e
commit
af1e2d68da
@ -111,6 +111,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
pluginAdminEnabled = true;
|
||||
pluginAdminExternalManageEnabled = false;
|
||||
pluginCatalogHiddenPlugins: string[] = [];
|
||||
pluginsCDNBaseURL = '';
|
||||
expressionsEnabled = false;
|
||||
customTheme?: undefined;
|
||||
awsAllowedAuthProviders: string[] = [];
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
70
pkg/plugins/manager/loader/assetpath/assetpath.go
Normal file
70
pkg/plugins/manager/loader/assetpath/assetpath.go
Normal 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
|
||||
}
|
88
pkg/plugins/manager/loader/assetpath/assetpath_test.go
Normal file
88
pkg/plugins/manager/loader/assetpath/assetpath_test.go
Normal 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)
|
||||
})
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
40
pkg/plugins/manager/testdata/cdn/plugin/plugin.json
vendored
Normal file
40
pkg/plugins/manager/testdata/cdn/plugin/plugin.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
80
pkg/plugins/pluginscdn/pluginscdn.go
Normal file
80
pkg/plugins/pluginscdn/pluginscdn.go
Normal 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)
|
||||
}
|
49
pkg/plugins/pluginscdn/pluginscdn_test.go
Normal file
49
pkg/plugins/pluginscdn/pluginscdn_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
61
pkg/plugins/pluginscdn/url_constructor.go
Normal file
61
pkg/plugins/pluginscdn/url_constructor.go
Normal 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
|
||||
}
|
34
pkg/plugins/pluginscdn/url_constructor_test.go
Normal file
34
pkg/plugins/pluginscdn/url_constructor_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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)),
|
||||
|
@ -276,6 +276,8 @@ type Cfg struct {
|
||||
PluginAdminEnabled bool
|
||||
PluginAdminExternalManageEnabled bool
|
||||
|
||||
PluginsCDNURLTemplate string
|
||||
|
||||
// Panels
|
||||
DisableSanitizeHtml bool
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
|
18
public/app/angular/components/plugin_component.test.ts
Normal file
18
public/app/angular/components/plugin_component.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
105
public/app/features/plugins/systemjsPlugins/pluginCDN.test.ts
Normal file
105
public/app/features/plugins/systemjsPlugins/pluginCDN.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
29
public/app/features/plugins/systemjsPlugins/pluginCDN.ts
Normal file
29
public/app/features/plugins/systemjsPlugins/pluginCDN.ts
Normal 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;
|
||||
}
|
75
public/app/features/plugins/systemjsPlugins/pluginCSS.ts
Normal file
75
public/app/features/plugins/systemjsPlugins/pluginCSS.ts
Normal 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);
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { clearPluginSettingsCache } from './pluginSettings';
|
||||
import { clearPluginSettingsCache } from '../pluginSettings';
|
||||
|
||||
const cache: Record<string, string> = {};
|
||||
const initializedAt: number = Date.now();
|
16
public/app/features/plugins/systemjsPlugins/types.ts
Normal file
16
public/app/features/plugins/systemjsPlugins/types.ts
Normal 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;
|
||||
};
|
73
public/vendor/plugin-css/css.js
vendored
73
public/vendor/plugin-css/css.js
vendored
@ -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);
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user