/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
extensions/amp-script/0.1/amp-script.js
954 строки
28 KB
Shihua Zheng
Do not call preventDefault if not available (#40323)
20 июн 2025, 20:20
Не верифицирован
20 июн 2025, 20:20
abe5c9e
Код
Авторство
О чём код?
import {upgrade} from '@ampproject/worker-dom/dist/amp-production/main.mjs'; import {Deferred} from '#core/data-structures/promise'; import { Layout_Enum, applyFillContent, isLayoutSizeDefined, } from '#core/dom/layout'; import {realChildElements} from '#core/dom/query'; import * as mode from '#core/mode'; import {map} from '#core/types/object'; import {tryParseJson} from '#core/types/object/json'; import {utf8Encode} from '#core/types/string/bytes'; import {Purifier} from '#purifier'; import {Services} from '#service'; import {calculateExtensionScriptUrl} from '#service/extension-script'; import {dev, user, userAssert} from '#utils/log'; import {UserActivationTracker} from './user-activation-tracker'; import {CSS} from '../../../build/amp-script-0.1.css'; import * as urls from '../../../src/config/urls'; import {getElementServiceForDoc} from '../../../src/element-service'; import {cancellation} from '../../../src/error-reporting'; import {getMode} from '../../../src/mode'; import {getService, registerServiceBuilder} from '../../../src/service-helpers'; import {rewriteAttributeValue} from '../../../src/url-rewrite'; /** @const {string} */ const TAG = 'amp-script'; /** * @typedef {{ * terminate: function():void, * callFunction: function(string, ...*):Promise<*>, * onerror: ?function(!ErrorEvent):void * }} */ let WorkerDOMWorkerDef; /** * Max cumulative size of author scripts from all amp-script elements on page that are not using sandboxed mode. * @const {number} */ const MAX_TOTAL_NONSANDBOXED_SCRIPT_SIZE = 150000; /** * Max cumulative size of author scripts from all amp-script elements on page that are using sandboxed mode. * @const {number} */ const MAX_TOTAL_SANDBOXED_SCRIPT_SIZE = 300000; /** * See src/transfer/Phase.ts in worker-dom. * @enum {number} */ const Phase = { INITIALIZING: 0, HYDRATING: 1, MUTATING: 2, }; /** * See src/transfer/TransferrableStorage.ts in worker-dom. * @enum {number} * @visibleForTesting */ export const StorageLocation = { LOCAL: 0, SESSION: 1, AMP_STATE: 2, }; /** @private {*} */ let upgradeForTest = null; /** * Set the WorkerDOM.upgrade function for tests. * @param {*} fn the function to use instead of WorkerDOM.upgrade. * @visibleForTesting */ export function setUpgradeForTest(fn) { upgradeForTest = fn; } export class AmpScript extends AMP.BaseElement { /** * @param {!Element} element */ constructor(element) { super(element); /** @private @const {!../../../src/service/vsync-impl.Vsync} */ this.vsync_ = Services.vsyncFor(this.win); /** @private {?WorkerDOMWorkerDef} */ this.workerDom_ = null; /** @private {?UserActivationTracker} */ this.userActivation_ = null; /** @private {?AmpScriptService} */ this.service_ = null; /** @private {string} */ this.debugId_ = 'amp-script[unknown].js'; /** @private {boolean} */ this.layoutCompleted_ = false; /** @private {boolean} */ this.reportedZeroSize_ = false; /** @private {Deferred} */ this.initialize_ = new Deferred(); /** * If true, most production constraints are disabled including script size, * script hash sum for local scripts, etc. Default is false. * * Enabled by the "data-ampdevmode" attribute which is intentionally invalid. * * @private {boolean} */ this.development_ = false; /** * If true, signals that the `nodom` variant of worker-dom should be used. * The worker js will have a much smaller bundle size, but no access to dom * functions. * * @private {boolean} */ this.nodom_ = false; /** * If true, signals that worker-dom should activate sandboxed mode. * In this mode the Worker lives in its own crossorigin iframe, creating * a strong security boundary. It also forces nodom mode. * * @private {boolean} */ this.sandboxed_ = false; } /** @override */ isLayoutSupported(layout) { return layout == Layout_Enum.CONTAINER || isLayoutSizeDefined(layout); } /** @override */ buildCallback() { this.sandboxed_ = this.element.hasAttribute('sandboxed'); this.nodom_ = this.sandboxed_ || this.element.hasAttribute('nodom'); this.development_ = this.element.hasAttribute('data-ampdevmode') || this.element.ownerDocument.documentElement.hasAttribute( 'data-ampdevmode' ); if (this.development_) { user().warn( TAG, 'JavaScript size and script hash requirements are disabled in development mode.', this.element ); } if ( this.nodom_ && (this.element.hasAttribute('width') || this.element.hasAttribute('height') || this.element.hasAttribute('layout')) ) { user().warn( TAG, 'Cannot set width, height, or layout of a nodom <amp-script>', this.element ); } return getElementServiceForDoc(this.element, TAG, TAG).then((service) => { this.setService(/** @type {!AmpScriptService} */ (service)); }); } /** * @override */ onLayoutMeasure() { if (this.layoutCompleted_ || this.reportedZeroSize_) { return; } const {height, width} = this.getLayoutSize(); if (width * height === 0 && !this.nodom_) { this.reportedZeroSize_ = true; user().warn( TAG, 'Skipped initializing amp-script due to zero width or height.', this.element ); } } /** * @param {!AmpScriptService} service * @visibleForTesting */ setService(service) { this.service_ = service; } /** * @return {?UserActivationTracker} * @visibleForTesting */ getUserActivation() { return this.userActivation_; } /** * Calls the specified function on this amp-script's worker-dom instance. * Accepts variable number of args to pass along to the underlying worker. * * @param {string} unusedFnId - function identifier * @param {...*} unusedFnArgs * @return {!Promise<*>} */ callFunction(unusedFnId, unusedFnArgs) { return this.initialize_.promise.then(() => { if (!this.workerDom_) { return Promise.reject( new Error( 'Attempted to call a function on an amp-script which failed initialization.' ) ); } return this.workerDom_.callFunction.apply(this.workerDom_, arguments); }); } /** @override */ layoutCallback() { this.layoutCompleted_ = true; // Layouts that use sizers (responsive, fluid) require the worker-dom // subtree to be wrapped in a "fill content" container. This is because // these layouts do _not_ constrain the size of the amp-script element // via inline styles (they use sizerElement). However, this breaks // "container" layout so only do it selectively. let container; if (this.element.sizerElement) { container = this.win.document.createElement('div'); applyFillContent(container, /* replacedContent */ true); // Reparent all real children to the container. const realChildren = realChildElements(this.element); for (let i = 0; i < realChildren.length; i++) { container.appendChild(realChildren[i]); } this.element.appendChild(container); } this.userActivation_ = new UserActivationTracker(this.element); // The displayed name of the combined script in dev tools. this.debugId_ = this.element.hasAttribute('src') ? `amp-script[src="${this.element.getAttribute('src')}"].js` : `amp-script[script="${this.element.getAttribute('script')}"].js`; const authorScriptPromise = this.getAuthorScript_(this.debugId_); if (!authorScriptPromise) { user().error(TAG, '"src" or "script" attribute is required.'); return Promise.reject(cancellation()); } const workerAndAuthorScripts = Promise.all([ this.getWorkerScript_(), authorScriptPromise, ]).then((results) => { const workerScript = results[0]; const authorScript = results[1]; if ( !this.development_ && this.service_.sizeLimitExceeded(authorScript.length, this.sandboxed_) ) { user().error( TAG, 'Maximum total script size exceeded (%s). %s is disabled. ' + 'See https://amp.dev/documentation/components/amp-script/#size-of-javascript-code.', this.sandboxed_ ? MAX_TOTAL_SANDBOXED_SCRIPT_SIZE : MAX_TOTAL_NONSANDBOXED_SCRIPT_SIZE, this.debugId_ ); this.element.classList.add('i-amphtml-broken'); return []; } return [workerScript, authorScript]; }); const sandbox = this.element.getAttribute('sandbox') || ''; const sandboxTokens = sandbox.split(' ').map((s) => s.trim()); let iframeUrl; if (getMode().localDev) { const folder = mode.isMinified() ? 'current-min' : 'current'; iframeUrl = `/dist.3p/${folder}/amp-script-proxy-iframe.html`; } else { iframeUrl = `${ urls.thirdParty }/${mode.version()}/amp-script-proxy-iframe.html`; } // @see src/main-thread/configuration.WorkerDOMConfiguration in worker-dom. const config = { authorURL: this.debugId_, mutationPump: this.mutationPump_.bind(this), longTask: (promise) => { this.userActivation_.expandLongTask(promise); // TODO(dvoytenko): consider additional "progress" UI. }, sanitizer: new SanitizerImpl(this, sandboxTokens), // Callbacks. onCreateWorker: (data) => { dev().info(TAG, 'Create worker:', data); }, onSendMessage: (data) => { dev().info(TAG, 'To worker:', data); }, onReceiveMessage: (data) => { dev().info(TAG, 'From worker:', data); }, sandbox: this.sandboxed_ && {iframeUrl}, }; // Create worker and hydrate. return (upgradeForTest || upgrade)( container || this.element, workerAndAuthorScripts, config ).then((workerDom) => { this.workerDom_ = workerDom; this.initialize_.resolve(); // workerDom will be null if it failed to init. if (this.workerDom_) { this.workerDom_.onerror = (errorEvent) => { if (errorEvent.preventDefault) { errorEvent.preventDefault(); } user().error( TAG, `${errorEvent.message}\n at (${errorEvent.filename}:${errorEvent.lineno})` ); }; } }); } /** * @return {!Promise<string>} * @private */ getWorkerScript_() { // Use `testLocation` for testing with iframes. @see testing/iframe.js. const location = getMode().test && this.win.testLocation ? this.win.testLocation : this.win.location; const useLocal = getMode().localDev || getMode().test; const workerUrl = calculateExtensionScriptUrl( location, this.nodom_ ? 'amp-script-worker-nodom' : 'amp-script-worker', '0.1', useLocal ); const xhr = Services.xhrFor(this.win); return xhr.fetchText(workerUrl, {ampCors: false}).then((r) => r.text()); } /** * Query local or fetch remote author script. * * Returns promise that resolves with the script contents or rejected if the * fetch fails or if the script fails CORS checks. * * Returns null if script reference is missing. * * @param {string} debugId An element identifier for error messages. * @return {?Promise<string>} * @private */ getAuthorScript_(debugId) { const authorUrl = this.element.getAttribute('src'); if (authorUrl) { return this.fetchAuthorScript_(authorUrl, debugId); } else { const id = this.element.getAttribute('script'); if (id) { const local = this.getAmpDoc().getElementById(id); userAssert( local, '[%s] %s could not find element with #%s.', TAG, debugId, id ); const target = local.getAttribute('target'); userAssert( target === 'amp-script', '[%s] script#%s must have target="amp-script".', TAG, id ); const text = local.textContent; if (this.development_ || this.sandboxed_) { return Promise.resolve(text); } else { return this.service_.checkSha384(text, debugId).then(() => text); } } } // No [src] or [script]. return null; } /** * @param {string} authorUrl * @param {string} debugId An element identifier for error messages. * @return {!Promise<string>} */ fetchAuthorScript_(authorUrl, debugId) { return Services.xhrFor(this.win) .fetchText(authorUrl, {ampCors: false}) .then((response) => { if (response.url && this.sameOrigin_(response.url)) { // Disallow non-JS content type for same-origin scripts. const contentType = response.headers.get('Content-Type'); if ( !contentType || !( contentType.startsWith('application/javascript') || contentType.startsWith('text/javascript') ) ) { // TODO(#24266): Refactor to %s interpolation when error string // extraction is ready. throw user().createError( TAG, 'Same-origin "src" requires "Content-Type: text/javascript" or "Content-Type: application/javascript". ' + `Fetched source for ${debugId} has "Content-Type: ${contentType}". ` + 'See https://amp.dev/documentation/components/amp-script/#security-features.' ); } return response.text(); } else { // For cross-origin, verify hash of script itself (skip in // development and sandboxed mode). if (this.development_ || this.sandboxed_) { return response.text(); } else { return response.text().then((text) => { return this.service_.checkSha384(text, debugId).then(() => text); }); } } }); } /** * Returns true iff `url` has the same origin as the AMP document. * @param {string} url * @return {boolean} */ sameOrigin_(url) { const urlService = Services.urlForDoc(this.element); const docOrigin = urlService.getSourceOrigin(this.getAmpDoc().getUrl()); const scriptOrigin = urlService.parse(url).origin; return docOrigin === scriptOrigin; } /** * Returns true if mutations should be allowed based on container attributes. * @return {boolean} */ isMutationAllowedByFixedSize() { return isLayoutSizeDefined(this.getLayout()); } /** * Returns true if mutations should be allowed based on recent user gesture. * @return {boolean} */ isMutationAllowedByUserGesture() { return this.userActivation_.isActive(); } /** * @param {function(boolean)} flush If `flush(false)` is invoked, mutations * that cause user-visible changes (e.g. DOM changes) will be dropped * (changes like event listener registration will be kept). * @param {number} phase * @private */ mutationPump_(flush, phase) { if (phase == Phase.HYDRATING) { this.vsync_.mutate(() => this.element.classList.add('i-amphtml-hydrated') ); } const allowMutation = this.isMutationAllowedByFixedSize() || this.isMutationAllowedByUserGesture(); this.vsync_.mutate(() => { const disallowedTypes = flush(allowMutation); // Count the number of mutations dropped by type. const errors = map(); /** @type {!Array} */ (disallowedTypes).forEach((type) => { errors[type] = errors[type] + 1 || 1; }); // Emit an error message for each mutation type, including count. Object.keys(errors).forEach((type) => { const count = errors[type]; user().error(TAG, this.mutationTypeToErrorMessage_(type, count)); }); if (disallowedTypes.length > 0 && phase === Phase.MUTATING) { this.workerDom_.terminate(); this.element.classList.remove('i-amphtml-hydrated'); this.element.classList.add('i-amphtml-broken'); user().error( TAG, '%s was terminated due to illegal mutation.', this.debugId_ ); } }); } /** * @param {string} type * @param {number} count * @return {string} */ mutationTypeToErrorMessage_(type, count) { let target; // Matches TransferrableMutationType in worker-dom#src/transfer/TransferrableMutation.ts. switch (type) { // Attributes and Properties case '0': case '3': target = 'DOM element attributes or styles'; break; // Character data case '1': target = 'textContent or the like'; break; // Child list case '2': target = 'DOM element children, innerHTML, or the like'; break; // Other default: target = 'the DOM'; break; } return ( `Blocked ${count} attempts to modify ${target}.` + ' For variable-sized <amp-script> containers, a user action has to happen first.' ); } } /** * Service for sharing data across <amp-script> elements. * * @visibleForTesting */ export class AmpScriptService { /** * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { /** @private {!../../../src/service/ampdoc-impl.AmpDoc} */ this.ampdoc_ = ampdoc; /** @private {number} */ this.cumulativeNonSandboxedSize_ = 0; /** @private {number} */ this.cumulativeSandboxedSize_ = 0; /** @private {!Array<string>} */ this.sources_ = []; // Query the meta tag once per document. const allowedHashes = ampdoc.getMetaByName('amp-script-src'); if (allowedHashes) { // Allow newlines between hashes for readability/diffs this.sources_ = allowedHashes.split(/\s+/).filter(Boolean); } /** @private @const {!../../../src/service/crypto-impl.Crypto} */ this.crypto_ = Services.cryptoFor(ampdoc.win); } /** * Checks if `sha384(script)` exists in `meta[name="amp-script-src"]` element * in document head. * * @param {string} script The script contents. * @param {string} debugId An element identifier for error messages. * @return {!Promise} */ checkSha384(script, debugId) { const bytes = utf8Encode(script); return this.crypto_.sha384Base64(bytes).then((hash) => { if (!hash || !this.sources_.includes('sha384-' + hash)) { // TODO(#24266): Refactor to %s interpolation when error string // extraction is ready. throw user().createError( TAG, `Script hash not found or incorrect for ${debugId}. You must include <meta name="amp-script-src" content="sha384-${hash}">. ` + `During development, you can disable this check by adding the "data-ampdevmode" attribute to ${debugId}, or the root html node` + 'See https://amp.dev/documentation/components/amp-script/#script-hash.' ); } }); } /** * Adds `size` to current total. Returns true if new total is <= size cap. * * @param {number} size * @param {boolean} isSandboxed * @return {boolean} */ sizeLimitExceeded(size, isSandboxed) { isSandboxed ? (this.cumulativeSandboxedSize_ += size) : (this.cumulativeNonSandboxedSize_ += size); return isSandboxed ? this.cumulativeSandboxedSize_ > MAX_TOTAL_SANDBOXED_SCRIPT_SIZE : this.cumulativeNonSandboxedSize_ > MAX_TOTAL_NONSANDBOXED_SCRIPT_SIZE; } /** * Fetches an amp-script URI by finding the associated amp-script and * calling the specified function. * * Note: the amp-script URI does not yet support function args, * even though worker-dom's callFunction does. Therefore we allow it * via extra args. * * @param {string} uri * @param {...*} unusedArgs * @return {Promise<*>} */ fetch(uri, unusedArgs) { const uriParts = uri.slice('amp-script:'.length).split('.'); userAssert( uriParts.length === 2 && uriParts[0].length > 0 && uriParts[1].length > 0, `[${TAG}]: "amp-script" URIs must be of the format "scriptId.functionIdentifier".` ); const ampScriptId = uriParts[0]; const fnIdentifier = uriParts[1]; const ampScriptEl = this.ampdoc_.getElementById(ampScriptId); userAssert( ampScriptEl && ampScriptEl.tagName === 'AMP-SCRIPT', `[${TAG}]: could not find <amp-script> with script set to ${ampScriptId}` ); const args = Array.prototype.slice.call(arguments, 1); return ampScriptEl .getImpl() .then((impl) => impl.callFunction.apply(impl, [fnIdentifier].concat(args)) ); } } /** * sandbox="allow-forms" enables tags in HTMLFormElement.elements. * @const {!Array<string>} */ const FORM_ELEMENTS = [ 'form', 'button', 'fieldset', 'input', 'object', 'output', 'select', 'textarea', ]; /** * A DOMPurify wrapper that implements the worker-dom.Sanitizer interface. * @visibleForTesting */ export class SanitizerImpl { /** * @param {!AmpScript} ampScript * @param {!Array<string>} sandboxTokens */ constructor(ampScript, sandboxTokens) { /** @private @const {!Window} */ this.win_ = ampScript.win; /** @private @const {!Element} */ this.element_ = ampScript.element; registerServiceBuilder(this.win_, 'purifier-inplace', function () { return new Purifier( ampScript.win.document, {'IN_PLACE': true}, rewriteAttributeValue ); }); /** @private @const {!Purifier} */ this.purifier_ = getService(this.win_, 'purifier-inplace'); /** @private @const {!{[key: string]: boolean}} */ this.allowedTags_ = this.purifier_.getAllowedTags(); /** * @private * @return {boolean} */ this.allowFullEval_ = () => ampScript.isMutationAllowedByUserGesture(); /** * @private * @return {boolean} */ this.shouldConstrainEval_ = () => !this.allowFullEval_() && ampScript.isMutationAllowedByFixedSize(); // TODO(choumx): Support opt-in for variable substitutions. // For now, only allow built-in AMP components except amp-pixel. this.allowedTags_['amp-img'] = true; this.allowedTags_['amp-layout'] = true; this.allowedTags_['amp-pixel'] = false; /** @private @const {boolean} */ this.allowForms_ = sandboxTokens.includes('allow-forms'); FORM_ELEMENTS.forEach((fe) => { this.allowedTags_[fe] = this.allowForms_; }); } /** * This is called by worker-dom on node creation, so all invocations are on * super-simple nodes like <p></p>. * * @param {!Node} node * @return {boolean} */ sanitize(node) { // TODO(choumx): allowedTags_[] is more strict than purifier.sanitize() // because the latter has attribute-specific allowances. const tag = node.nodeName.toLowerCase(); const clean = this.allowedTags_[tag]; if (!clean) { if (!this.warnIfFormsAreDisallowed_(tag)) { user().warn(TAG, 'Sanitized node:', node); } } return clean; } /** * @param {!Node} node * @param {string} attribute * @param {?string} value * @return {boolean} */ setAttribute(node, attribute, value) { // TODO(choumx): Call mutatedAttributesCallback() on AMP elements e.g. // so an amp-img can update its child img when [src] is changed. const tag = node.nodeName.toLowerCase(); // DOMPurify's attribute validation is tag-agnostic, so we need to check // that the tag itself is valid. E.g. a[href] is ok, but base[href] is not. if (this.allowedTags_[tag]) { const attr = attribute.toLowerCase(); if (this.purifier_.validateAttributeChange(node, attr, value)) { if (value == null) { node.removeAttribute(attr); } else { const newValue = rewriteAttributeValue(tag, attr, value); node.setAttribute(attr, newValue); } // a[href] requires [target], which defaults to _top. if (tag === 'a') { if (node.hasAttribute('href') && !node.hasAttribute('target')) { node.setAttribute('target', '_top'); } } return true; } } if (!this.warnIfFormsAreDisallowed_(tag)) { user().warn(TAG, 'Sanitized [%s]="%s":', attribute, value, node); } return false; } /** * @param {string} tag * @return {boolean} */ warnIfFormsAreDisallowed_(tag) { if (!this.allowForms_ && FORM_ELEMENTS.includes(tag)) { user().warn( TAG, 'Form elements (%s) cannot be mutated unless your <amp-script> includes the attribute sandbox="allow-forms".', tag ); return true; } return false; } /** * @param {!Node} node * @param {string} property * @param {string} value * @return {boolean} */ setProperty(node, property, value) { const prop = property.toLowerCase(); // worker-dom's supported properties and corresponding attribute name // differences are minor, e.g. acceptCharset vs. accept-charset. if (this.purifier_.validateAttributeChange(node, prop, value)) { node[property] = value; return true; } return false; } /** * TODO(choumx): Make this method always return a Promise. * @param {!StorageLocation} location * @param {string} opt_key * @return {!Promise<Object>|?Object} */ getStorage(location, opt_key) { if (location === StorageLocation.AMP_STATE) { return Services.bindForDocOrNull(this.element_).then((bind) => { if (bind) { return bind.getStateValue(opt_key || '.'); } }); } // Note that filtering out amp-* keys will affect the predictability of // Storage.key(). We could preserve indices by adding empty entries but // that might be even more confusing. const storage = this.storageFor_(location); const output = {}; for (let i = 0; i < storage.length; i++) { const key = storage.key(i); if (key && !key.startsWith('amp-')) { output[key] = storage.getItem(key); } } return output; } /** * @param {!StorageLocation} location * @param {?string} key * @param {?string} value * @return {!Promise} */ setStorage(location, key, value) { if (location === StorageLocation.AMP_STATE) { return Services.bindForDocOrNull(this.element_).then((bind) => { if (bind) { const state = tryParseJson(value, () => { dev().error(TAG, 'Invalid AMP.setState() argument: %s', value); }); if (state) { // Only evaluate updates in case of recent user interaction or a small fixed layout. const fullEval = this.allowFullEval_(); const constrain = this.shouldConstrainEval_() ? [this.element_] : undefined; if (!fullEval && !constrain) { user().warn( TAG, 'AMP.setState only updated page state and did not reevaluate bindings due to lack of recent user interaction.' ); } bind.setState(state, { skipEval: !fullEval && !constrain, skipAmpState: false, constrain, }); } } }); } const storage = this.storageFor_(location); if (key === null) { if (value === null) { user().error(TAG, 'Storage.clear() is not supported in amp-script.'); } } else { if (key.startsWith('amp-')) { user().error(TAG, 'Invalid "amp-" prefix for storage key: %s', key); } else { if (value === null) { storage.removeItem(key); } else { storage.setItem(key, value); } } } return Promise.resolve(); } /** * @param {!StorageLocation} location * @return {?Storage} * @private */ storageFor_(location) { if (location === StorageLocation.LOCAL) { return this.win_.localStorage; } else if (location === StorageLocation.SESSION) { return this.win_.sessionStorage; } return null; } } AMP.extension(TAG, '0.1', function (AMP) { AMP.registerServiceForDoc(TAG, AmpScriptService); AMP.registerElement(TAG, AmpScript, CSS); });