mirror of https://github.com/grafana/grafana
Frontend Sandbox: Create a plugin sandbox enable registry. Use enable list instead of disable list (#94809)
* Use a enable configuration to enable frontend sandbox * Modify settings to load enableFrontendSandbox * Check for signature type * Update commment * Fix e2e tests for the frontend sandbox * Modify logic so a custom check function is used instead of a list of checks * Fixes flaky test * fix comment * Update comment * Empty commit * Empty commitpull/94894/head^2
parent
beac7de4df
commit
f248a55576
@ -1,128 +0,0 @@ |
||||
import panelSandboxDashboard from '../dashboards/PanelSandboxDashboard.json'; |
||||
import { e2e } from '../utils'; |
||||
|
||||
const DASHBOARD_ID = 'c46b2460-16b7-42a5-82d1-b07fbf431950'; |
||||
|
||||
describe('Panel sandbox', () => { |
||||
beforeEach(() => { |
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); |
||||
return e2e.flows.importDashboard(panelSandboxDashboard, 1000, true); |
||||
}); |
||||
|
||||
describe('Sandbox disabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); |
||||
}); |
||||
cy.reload(); |
||||
}); |
||||
|
||||
it('Add iframes to body', () => { |
||||
// this button adds iframes to the body
|
||||
cy.get('[data-testid="button-create-iframes"]').click(); |
||||
|
||||
const iframeIds = [ |
||||
'createElementIframe', |
||||
'innerHTMLIframe', |
||||
'appendIframe', |
||||
'prependIframe', |
||||
'afterIframe', |
||||
'beforeIframe', |
||||
'outerHTMLIframe', |
||||
'parseFromStringIframe', |
||||
'insertBeforeIframe', |
||||
'replaceChildIframe', |
||||
]; |
||||
iframeIds.forEach((id) => { |
||||
cy.get(`#${id}`).should('exist'); |
||||
}); |
||||
}); |
||||
|
||||
it('Reaches out of panel div', () => { |
||||
// this button reaches out of the panel div and modifies the element dataset
|
||||
cy.get('[data-testid="button-reach-out"]').click(); |
||||
|
||||
cy.get('[data-sandbox-test="true"]').should('exist'); |
||||
}); |
||||
|
||||
it('Reaches out of the panel editor', () => { |
||||
e2e.flows.openDashboard({ |
||||
uid: DASHBOARD_ID, |
||||
queryParams: { |
||||
editPanel: 1, |
||||
}, |
||||
}); |
||||
|
||||
cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); |
||||
cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true }); |
||||
cy.get('[data-sandbox-test="panel-editor"]').should('exist'); |
||||
}); |
||||
}); |
||||
|
||||
describe('Sandbox enabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); |
||||
}); |
||||
cy.reload(); |
||||
}); |
||||
|
||||
it('Does not add iframes to body', () => { |
||||
// this button adds 3 iframes to the body
|
||||
cy.get('[data-testid="button-create-iframes"]').click(); |
||||
cy.wait(100); // small delay to prevent false positives from too fast tests
|
||||
|
||||
const iframeIds = [ |
||||
'createElementIframe', |
||||
'innerHTMLIframe', |
||||
'appendIframe', |
||||
'prependIframe', |
||||
'afterIframe', |
||||
'beforeIframe', |
||||
'outerHTMLIframe', |
||||
'parseFromStringIframe', |
||||
'insertBeforeIframe', |
||||
'replaceChildIframe', |
||||
]; |
||||
iframeIds.forEach((id) => { |
||||
cy.get(`#${id}`).should('not.exist'); |
||||
}); |
||||
}); |
||||
|
||||
it('Does not reaches out of panel div', () => { |
||||
// this button reaches out of the panel div and modifies the element dataset
|
||||
cy.get('[data-testid="button-reach-out"]').click(); |
||||
cy.wait(100); // small delay to prevent false positives from too fast tests
|
||||
cy.get('[data-sandbox-test="true"]').should('not.exist'); |
||||
}); |
||||
|
||||
it('Does not Reaches out of the panel editor', () => { |
||||
e2e.flows.openDashboard({ |
||||
uid: DASHBOARD_ID, |
||||
queryParams: { |
||||
editPanel: 1, |
||||
}, |
||||
}); |
||||
|
||||
cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); |
||||
cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true }); |
||||
cy.wait(100); // small delay to prevent false positives from too fast tests
|
||||
cy.get('[data-sandbox-test="panel-editor"]').should('not.exist'); |
||||
}); |
||||
|
||||
it('Can access specific window global variables', () => { |
||||
cy.get('[data-testid="button-test-globals"]').click(); |
||||
cy.get('[data-sandbox-global="Prism"]').should('be.visible'); |
||||
cy.get('[data-sandbox-global="jQuery"]').should('be.visible'); |
||||
cy.get('[data-sandbox-global="location"]').should('be.visible'); |
||||
}); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
e2e.flows.revertAllChanges(); |
||||
}); |
||||
|
||||
after(() => { |
||||
return cy.clearCookies(); |
||||
}); |
||||
}); |
@ -1,71 +0,0 @@ |
||||
import { e2e } from '../utils'; |
||||
|
||||
const APP_ID = 'sandbox-app-test'; |
||||
|
||||
describe('Datasource sandbox', () => { |
||||
before(() => { |
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); |
||||
cy.request({ |
||||
url: `${Cypress.env('BASE_URL')}/api/plugins/${APP_ID}/settings`, |
||||
method: 'POST', |
||||
body: { |
||||
enabled: true, |
||||
}, |
||||
}); |
||||
}); |
||||
beforeEach(() => { |
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); |
||||
}); |
||||
|
||||
describe('App Page', () => { |
||||
describe('Sandbox disabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); |
||||
}); |
||||
}); |
||||
|
||||
it('Loads the app page without the sandbox div wrapper', () => { |
||||
cy.visit(`/a/${APP_ID}`); |
||||
cy.wait(200); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); |
||||
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); |
||||
}); |
||||
|
||||
it('Loads the app configuration without the sandbox div wrapper', () => { |
||||
cy.visit(`/plugins/${APP_ID}`); |
||||
cy.wait(200); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); |
||||
cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); |
||||
}); |
||||
}); |
||||
|
||||
describe('Sandbox enabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); |
||||
}); |
||||
}); |
||||
|
||||
it('Loads the app page with the sandbox div wrapper', () => { |
||||
cy.visit(`/a/${APP_ID}`); |
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); |
||||
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); |
||||
}); |
||||
|
||||
it('Loads the app configuration with the sandbox div wrapper', () => { |
||||
cy.visit(`/plugins/${APP_ID}`); |
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); |
||||
cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
e2e.flows.revertAllChanges(); |
||||
}); |
||||
|
||||
after(() => { |
||||
cy.clearCookies(); |
||||
}); |
||||
}); |
@ -1,155 +0,0 @@ |
||||
import { random } from 'lodash'; |
||||
|
||||
import { e2e } from '../utils'; |
||||
|
||||
const DATASOURCE_ID = 'sandbox-test-datasource'; |
||||
let DATASOURCE_CONNECTION_ID = ''; |
||||
const DATASOURCE_TYPED_NAME = 'SandboxDatasourceInstance'; |
||||
|
||||
describe('Datasource sandbox', () => { |
||||
before(() => { |
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); |
||||
|
||||
e2e.pages.AddDataSource.visit(); |
||||
e2e.pages.AddDataSource.dataSourcePluginsV2('Sandbox datasource test plugin') |
||||
.scrollIntoView() |
||||
.should('be.visible') // prevents flakiness
|
||||
.click(); |
||||
e2e.pages.DataSource.name().clear(); |
||||
e2e.pages.DataSource.name().type(DATASOURCE_TYPED_NAME); |
||||
e2e.pages.DataSource.saveAndTest().click(); |
||||
cy.url().then((url) => { |
||||
const split = url.split('/'); |
||||
DATASOURCE_CONNECTION_ID = split[split.length - 1]; |
||||
}); |
||||
}); |
||||
beforeEach(() => { |
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); |
||||
}); |
||||
|
||||
describe('Config Editor', () => { |
||||
describe('Sandbox disabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); |
||||
}); |
||||
}); |
||||
it('Should not render a sandbox wrapper around the datasource config editor', () => { |
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); |
||||
cy.wait(300); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('not.exist'); |
||||
}); |
||||
}); |
||||
|
||||
describe('Sandbox enabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); |
||||
}); |
||||
}); |
||||
|
||||
it('Should render a sandbox wrapper around the datasource config editor', () => { |
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); |
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('exist'); |
||||
}); |
||||
|
||||
it('Should store values in jsonData and secureJsonData correctly', () => { |
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); |
||||
|
||||
const valueToStore = 'test' + random(100); |
||||
|
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('not.be.disabled'); |
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').type(valueToStore); |
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('have.value', valueToStore); |
||||
|
||||
e2e.pages.DataSource.saveAndTest().click(); |
||||
e2e.pages.DataSource.alert().should('exist').contains('Sandbox Success', {}); |
||||
|
||||
// validate the value was stored
|
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); |
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('not.be.disabled'); |
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('have.value', valueToStore); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('Explore Page', () => { |
||||
describe('Sandbox disabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); |
||||
}); |
||||
}); |
||||
|
||||
it('Should not wrap the query editor in a sandbox wrapper', () => { |
||||
e2e.pages.Explore.visit(); |
||||
e2e.components.DataSourcePicker.container().should('be.visible').click(); |
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); |
||||
// make sure the datasource was correctly selected and rendered
|
||||
e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); |
||||
|
||||
cy.wait(300); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('not.exist'); |
||||
}); |
||||
|
||||
it('Should accept values when typed', () => { |
||||
e2e.pages.Explore.visit(); |
||||
e2e.components.DataSourcePicker.container().should('be.visible').click(); |
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); |
||||
|
||||
// make sure the datasource was correctly selected and rendered
|
||||
e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); |
||||
|
||||
const valueToType = 'test' + random(100); |
||||
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('not.be.disabled'); |
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').type(valueToType); |
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('have.value', valueToType); |
||||
}); |
||||
}); |
||||
|
||||
describe('Sandbox enabled', () => { |
||||
beforeEach(() => { |
||||
cy.window().then((win) => { |
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); |
||||
}); |
||||
}); |
||||
|
||||
it('Should wrap the query editor in a sandbox wrapper', () => { |
||||
e2e.pages.Explore.visit(); |
||||
e2e.components.DataSourcePicker.container().should('be.visible').click(); |
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); |
||||
// make sure the datasource was correctly selected and rendered
|
||||
e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); |
||||
|
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('exist'); |
||||
}); |
||||
|
||||
it('Should accept values when typed', () => { |
||||
e2e.pages.Explore.visit(); |
||||
e2e.components.DataSourcePicker.container().should('be.visible').click(); |
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); |
||||
|
||||
// make sure the datasource was correctly selected and rendered
|
||||
e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); |
||||
|
||||
const valueToType = 'test' + random(100); |
||||
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('not.be.disabled'); |
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').type(valueToType); |
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('have.value', valueToType); |
||||
|
||||
// typing the query editor should reflect in the url
|
||||
cy.url().should('include', valueToType); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
e2e.flows.revertAllChanges(); |
||||
}); |
||||
|
||||
after(() => { |
||||
cy.clearCookies(); |
||||
}); |
||||
}); |
@ -0,0 +1,92 @@ |
||||
import { PluginMeta, PluginSignatureType } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { getPluginSettings } from '../pluginSettings'; |
||||
|
||||
import { |
||||
shouldLoadPluginInFrontendSandbox, |
||||
setSandboxEnabledCheck, |
||||
isPluginFrontendSandboxEnabled, |
||||
} from './sandbox_plugin_loader_registry'; |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
config: { |
||||
featureToggles: { pluginsFrontendSandbox: true }, |
||||
buildInfo: { env: 'production' }, |
||||
enableFrontendSandboxForPlugins: [], |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('../pluginSettings', () => ({ |
||||
getPluginSettings: jest.fn(), |
||||
})); |
||||
|
||||
const getPluginSettingsMock = getPluginSettings as jest.MockedFunction<typeof getPluginSettings>; |
||||
|
||||
const fakePlugin: PluginMeta = { |
||||
id: 'test-plugin', |
||||
name: 'Test Plugin', |
||||
} as PluginMeta; |
||||
|
||||
describe('Sandbox eligibility checks', () => { |
||||
const originalNodeEnv = process.env.NODE_ENV; |
||||
|
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
config.enableFrontendSandboxForPlugins = []; |
||||
config.featureToggles.pluginsFrontendSandbox = true; |
||||
process.env.NODE_ENV = 'development'; |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
process.env.NODE_ENV = originalNodeEnv; |
||||
}); |
||||
|
||||
test('shouldLoadPluginInFrontendSandbox returns false for Angular plugins', async () => { |
||||
const result = await shouldLoadPluginInFrontendSandbox({ isAngular: true, pluginId: 'test-plugin' }); |
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
test('shouldLoadPluginInFrontendSandbox returns false when feature toggle is off', async () => { |
||||
config.featureToggles.pluginsFrontendSandbox = false; |
||||
const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); |
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
test('shouldLoadPluginInFrontendSandbox returns false for Grafana-signed plugins', async () => { |
||||
getPluginSettingsMock.mockResolvedValue({ ...fakePlugin, signatureType: PluginSignatureType.grafana }); |
||||
const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); |
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
test('shouldLoadPluginInFrontendSandbox returns true for eligible plugins in the list', async () => { |
||||
getPluginSettingsMock.mockResolvedValue({ ...fakePlugin, signatureType: PluginSignatureType.community }); |
||||
config.enableFrontendSandboxForPlugins = ['test-plugin']; |
||||
const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); |
||||
expect(result).toBe(true); |
||||
}); |
||||
|
||||
test('isPluginFrontendSandboxEnabled returns false when plugin is not in the enabled list', async () => { |
||||
config.enableFrontendSandboxForPlugins = ['other-plugin']; |
||||
const result = await isPluginFrontendSandboxEnabled({ pluginId: 'test-plugin' }); |
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
test('setSandboxEnabledCheck sets custom check function', async () => { |
||||
const customCheck = jest.fn().mockResolvedValue(true); |
||||
setSandboxEnabledCheck(customCheck); |
||||
const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); |
||||
expect(customCheck).toHaveBeenCalledWith({ pluginId: 'test-plugin' }); |
||||
expect(result).toBe(true); |
||||
}); |
||||
|
||||
test('setSandboxEnabledCheck has precedence over default', async () => { |
||||
const customCheck = jest.fn().mockResolvedValue(false); |
||||
setSandboxEnabledCheck(customCheck); |
||||
// this should be ignored by the custom check
|
||||
config.enableFrontendSandboxForPlugins = ['test-plugin']; |
||||
const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); |
||||
expect(customCheck).toHaveBeenCalledWith({ pluginId: 'test-plugin' }); |
||||
expect(result).toBe(false); |
||||
}); |
||||
}); |
@ -0,0 +1,78 @@ |
||||
import { PluginSignatureType } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { getPluginSettings } from '../pluginSettings'; |
||||
|
||||
type SandboxEligibilityCheckParams = { |
||||
isAngular?: boolean; |
||||
pluginId: string; |
||||
}; |
||||
|
||||
type SandboxEnabledCheck = (params: SandboxEligibilityCheckParams) => Promise<boolean>; |
||||
|
||||
/** |
||||
* We allow core extensions to register their own |
||||
* sandbox enabled checks. |
||||
*/ |
||||
let sandboxEnabledCheck: SandboxEnabledCheck = isPluginFrontendSandboxEnabled; |
||||
|
||||
export function setSandboxEnabledCheck(checker: SandboxEnabledCheck) { |
||||
sandboxEnabledCheck = checker; |
||||
} |
||||
|
||||
export async function shouldLoadPluginInFrontendSandbox({ |
||||
isAngular, |
||||
pluginId, |
||||
}: SandboxEligibilityCheckParams): Promise<boolean> { |
||||
// basic check if the plugin is eligible for the sandbox
|
||||
if (!(await isPluginFrontendSandboxElegible({ isAngular, pluginId }))) { |
||||
return false; |
||||
} |
||||
|
||||
return sandboxEnabledCheck({ isAngular, pluginId }); |
||||
} |
||||
|
||||
/** |
||||
* This is a basic check that checks if the plugin is eligible to run in the sandbox. |
||||
* It does not check if the plugin is actually enabled for the sandbox. |
||||
*/ |
||||
async function isPluginFrontendSandboxElegible({ |
||||
isAngular, |
||||
pluginId, |
||||
}: SandboxEligibilityCheckParams): Promise<boolean> { |
||||
// Only if the feature is not enabled no support for sandbox
|
||||
if (!Boolean(config.featureToggles.pluginsFrontendSandbox)) { |
||||
return false; |
||||
} |
||||
|
||||
// no support for angular plugins
|
||||
if (isAngular) { |
||||
return false; |
||||
} |
||||
|
||||
// To fast-test and debug the sandbox in the browser (dev mode only).
|
||||
const sandboxDisableQueryParam = location.search.includes('nosandbox') && config.buildInfo.env === 'development'; |
||||
if (sandboxDisableQueryParam) { |
||||
return false; |
||||
} |
||||
|
||||
// no sandbox in test mode. it often breaks e2e tests
|
||||
if (process.env.NODE_ENV === 'test') { |
||||
return false; |
||||
} |
||||
|
||||
// don't run grafana-signed plugins in sandbox
|
||||
const pluginMeta = await getPluginSettings(pluginId); |
||||
if (pluginMeta.signatureType === PluginSignatureType.grafana) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* Check if the plugin is enabled for the sandbox via configuration. |
||||
*/ |
||||
export async function isPluginFrontendSandboxEnabled({ pluginId }: SandboxEligibilityCheckParams): Promise<boolean> { |
||||
return Boolean(config.enableFrontendSandboxForPlugins?.includes(pluginId)); |
||||
} |
Loading…
Reference in new issue