go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/result_entry/link_artifact.ts (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import '@material/mwc-icon'; 16 import { MobxLitElement } from '@adobe/lit-mobx'; 17 import { css, html } from 'lit'; 18 import { customElement } from 'lit/decorators.js'; 19 import { computed, makeObservable, observable } from 'mobx'; 20 import { fromPromise, IPromiseBasedObservable } from 'mobx-utils'; 21 22 import '@/generic_libs/components/expandable_entry'; 23 import { ARTIFACT_LENGTH_LIMIT } from '@/common/constants/test'; 24 import { Artifact } from '@/common/services/resultdb'; 25 import { commonStyles } from '@/common/styles/stylesheets'; 26 import { logging } from '@/common/tools/logging'; 27 import { reportRenderError } from '@/generic_libs/tools/error_handler'; 28 import { unwrapObservable } from '@/generic_libs/tools/mobx_utils'; 29 import { urlSetSearchQueryParam } from '@/generic_libs/tools/utils'; 30 31 // Allowlist of hosts, used to validate URLs specified in the contents of link 32 // artifacts. If the URL specified in a link artifact is not in this allowlist, 33 // the original fetch URL for the artifact will be returned instead. 34 const LINK_ARTIFACT_HOST_ALLOWLIST = [ 35 'cros-test-analytics.appspot.com', // Testhaus logs 36 'stainless.corp.google.com', // Stainless logs 37 'tests.chromeos.goog', // Preferred. A hostname alias for Testhaus logs. 38 ]; 39 40 /** 41 * Renders a link artifact. 42 */ 43 @customElement('milo-link-artifact') 44 export class LinkArtifactElement extends MobxLitElement { 45 @observable.ref artifact!: Artifact; 46 @observable.ref label?: string; 47 48 @observable.ref private loadError = false; 49 50 @computed 51 private get content$(): IPromiseBasedObservable<string> { 52 return fromPromise( 53 // TODO(crbug/1206109): use permanent raw artifact URL. 54 fetch( 55 urlSetSearchQueryParam( 56 this.artifact.fetchUrl, 57 'n', 58 ARTIFACT_LENGTH_LIMIT, 59 ), 60 ).then((res) => { 61 if (!res.ok) { 62 this.loadError = true; 63 return ''; 64 } 65 return res.text(); 66 }), 67 ); 68 } 69 70 @computed 71 private get content() { 72 const content = unwrapObservable(this.content$, null); 73 if (content) { 74 const url = new URL(content); 75 const allowedProtocol = ['http:', 'https:'].includes(url.protocol); 76 const allowedHost = LINK_ARTIFACT_HOST_ALLOWLIST.includes(url.host); 77 if (!allowedProtocol || !allowedHost) { 78 logging.warn( 79 `Invalid target URL for link artifact ${this.artifact.name} - ` + 80 'returning the original fetch URL for the artifact instead', 81 ); 82 return this.artifact.fetchUrl; 83 } 84 } 85 return content; 86 } 87 88 constructor() { 89 super(); 90 makeObservable(this); 91 } 92 93 protected render = reportRenderError(this, () => { 94 if (this.loadError) { 95 return html` <span class="load-error"> 96 Error loading ${this.artifact.artifactId} link 97 </span>`; 98 } 99 100 if (this.content) { 101 const linkText = this.label || this.artifact.artifactId; 102 return html`<a href=${this.content} target="_blank">${linkText}</a>`; 103 } 104 105 return html`<span class="greyed-out">Loading...</span>`; 106 }); 107 108 static styles = [ 109 commonStyles, 110 css` 111 .greyed-out { 112 color: var(--greyed-out-text-color); 113 } 114 115 .load-error { 116 color: var(--failure-color); 117 } 118 `, 119 ]; 120 }