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  }