// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// This script handles the caching of the UI resources, allowing it to work
// offline (as long as the UI site has been visited at least once).
// Design doc: http://go/perfetto-offline.

// When a new version of the UI is released (e.g. v1 -> v2), the following
// happens on the next visit:
// 1. The v1 (old) service worker is activated. At this point we don't know yet
//    that v2 is released.
// 2. /index.html is requested. The SW intercepts the request and serves it from
//    the network.
// 3a If the request fails (offline / server unreachable) or times out, the old
//    v1 is served.
// 3b If the request succeeds, the browser receives the index.html for v2. That
//    will try to fetch resources from /v2/frontend_bundle.ts.
// 4. When the SW sees the /v2/ request, will have a cache miss and will issue
//    a network fetch(), returning the fresh /v2/ content.
// 4. The v2 site will call serviceWorker.register('service_worker.js?v=v2').
//    This (i.e. the different querystring) will cause a re-installation of the
//    service worker (even if the service_worker.js script itself is unchanged).
// 5. In the "install" step, the service_worker.js script will fetch the newer
//    version (v2).
//    Note: the v2 will be fetched twice, once upon the first request that
//    causes causes a cache-miss, the second time while re-installing the SW.
//    The  latter though will hit a HTTP 304 (Not Changed) and will be served
//    from the browser cache after the revalidation request.
// 6. The 'activate' handler is triggered. The old v1 cache is deleted at this
//    point.

declare let self: ServiceWorkerGlobalScope;
export {};

const LOG_TAG = `ServiceWorker: `;
const CACHE_NAME = 'ui-perfetto-dev';
const OPEN_TRACE_PREFIX = '/_open_trace'

// If the fetch() for the / doesn't respond within 3s, return a cached version.
// This is to avoid that a user waits too much if on a flaky network.
const INDEX_TIMEOUT_MS = 3000;

// Use more relaxed timeouts when caching the subresources for the new version
// in the background.
const INSTALL_TIMEOUT_MS = 30000;

// Files passed to POST /_open_trace/NNNN.
let postedFiles = new Map<string, File>();

// Allowlisted external domains that bypass firewall restrictions.
const ALLOWLISTED_DOMAINS = [
  /\.googleapis\.com$/, // For Google Cloud Storage fetches.
  /\.google-analytics\.com$/, // For analytics.
  /\.googletagmanager\.com$/, // For analytics.
];

function isAllowlistedDomain(hostname: string): boolean {
  return ALLOWLISTED_DOMAINS.some((pattern) => pattern.test(hostname));
}

function isLocalhost(hostname: string): boolean {
  return hostname === 'localhost' ||
    hostname === '127.0.0.1' ||
    hostname === '::1';
}

// Firewall check for outbound requests.
// - Localhost: Allow everything (needed for trace_processor_shell, ADB, etc.)
// - Same-origin: Allow everything (includes /_open_trace/ POST)
// - Allowlisted domains: Allow everything
// - All other external requests: GET only, no query string
function checkFirewall(req: Request): {allowed: boolean; reason?: string} {
  const url = new URL(req.url);

  // Exempt: localhost
  if (isLocalhost(url.hostname)) {
    return {allowed: true};
  }

  // Exempt: same-origin requests
  if (url.origin === self.location.origin) {
    return {allowed: true};
  }

  // Allowlisted external domains: allow everything
  if (isAllowlistedDomain(url.hostname)) {
    return {allowed: true};
  }

  // All other external requests: GET only, no query string
  if (req.method !== 'GET') {
    return {allowed: false, reason: `Method ${req.method} not allowed`};
  }

  // Allow GitHub API content requests with only a ?ref= parameter.
  // These are used by extension servers for authenticated (PAT) access
  // to private repos.
  if (url.hostname === 'api.github.com' &&
      url.pathname.includes('/contents/') &&
      url.searchParams.has('ref') &&
      url.search.indexOf('&') === -1) {
    return {allowed: true};
  }

  if (url.search !== '') {
    return {allowed: false, reason: 'Query strings not allowed'};
  }

  return {allowed: true};
}

// The install() event is fired:
// 1. On the first visit, when there is no SW installed.
// 2. Every time the user opens the site and the version has been updated (they
//    will get the newer version regardless, unless we hit INDEX_TIMEOUT_MS).
// The latter happens because:
// - / (index.html) is always served from the network (% timeout) and it pulls
//   /v1.2-sha/frontend_bundle.js.
// - /v1.2-sha/frontend_bundle.js will register /service_worker.js?v=v1.2-sha.
// The service_worker.js script itself never changes, but the browser
// re-installs it because the version in the V? query-string argument changes.
// The reinstallation will cache the new files from the v.1.2-sha/manifest.json.
self.addEventListener('install', (event) => {
  const doInstall = async () => {
    // If we can not access the cache we must give up on the service
    // worker:
    let bypass = true;
    try {
      bypass = await caches.has('BYPASS_SERVICE_WORKER');
    } catch (_) {
      // TODO(288483453)
    }
    if (bypass) {
      // Throw will prevent the installation.
      throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
    }

    // Delete old cache entries from the pre-feb-2021 service worker.
    try {
      for (const key of await caches.keys()) {
        if (key.startsWith('dist-')) {
          await caches.delete(key);
        }
      }
    } catch (_) {
      // TODO(288483453)
      // It's desirable to delete the old entries but it's not actually
      // damaging to keep them around so don't give up on the
      // installation if this fails.
    }

    // The UI should register this as service_worker.js?v=v1.2-sha. Extract the
    // version number and pre-fetch all the contents for the version.
    const match = /\bv=([\w.-]*)/.exec(location.search);
    if (!match) {
      throw new Error(
          'Failed to install. Was epecting a query string like ' +
          `?v=v1.2-sha query string, got "${location.search}" instead`);
    }
    await installAppVersionIntoCache(match[1]);

    // skipWaiting() still waits for the install to be complete. Without this
    // call, the new version would be activated only when all tabs are closed.
    // Instead, we ask to activate it immediately. This is safe because the
    // subresources are versioned (e.g. /v1.2-sha/frontend_bundle.js). Even if
    // there is an old UI tab opened while we activate() a newer version, the
    // activate() would just cause cache-misses, hence fetch from the network,
    // for the old tab.
    self.skipWaiting();
  };
  event.waitUntil(doInstall());
});

self.addEventListener('activate', (event) => {
  console.info(LOG_TAG + 'activated');
  const doActivate = async () => {
    // This makes a difference only for the very first load, when no service
    // worker is present. In all the other cases the skipWaiting() will hot-swap
    // the active service worker anyways.
    await self.clients.claim();
  };
  event.waitUntil(doActivate());
});

self.addEventListener('fetch', (event) => {
  // Firewall check - runs for ALL requests including cross-origin.
  const firewall = checkFirewall(event.request);
  if (!firewall.allowed) {
    console.warn(
      LOG_TAG + `Blocked: ${event.request.url} - ${firewall.reason}`,
    );
    event.respondWith(
      new Response(`Blocked by firewall: ${firewall.reason}`, {
        status: 403,
        statusText: 'Forbidden',
      }),
    );
    return;
  }

  // The early return here will cause the browser to fall back on standard
  // network-based fetch.
  if (!shouldHandleHttpRequest(event.request)) {
    console.debug(LOG_TAG + `serving ${event.request.url} from network`);
    return;
  }

  event.respondWith(handleHttpRequest(event.request));
});


function shouldHandleHttpRequest(req: Request): boolean {
  // Suppress warning: 'only-if-cached' can be set only with 'same-origin' mode.
  // This seems to be a chromium bug. An internal code search suggests this is a
  // socially acceptable workaround.
  if (req.cache === 'only-if-cached' && req.mode !== 'same-origin') {
    return false;
  }

  const url = new URL(req.url);
  if (url.pathname === '/live_reload') return false;
  if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) return true;

  return req.method === 'GET' && url.origin === self.location.origin;
}

async function handleHttpRequest(req: Request): Promise<Response> {
  if (!shouldHandleHttpRequest(req)) {
    throw new Error(LOG_TAG + `${req.url} shouldn't have been handled`);
  }

  // We serve from the cache even if req.cache == 'no-cache'. It's a bit
  // contra-intuitive but it's the most consistent option. If the user hits the
  // reload button*, the browser requests the "/" index with a 'no-cache' fetch.
  // However all the other resources (css, js, ...) are requested with a
  // 'default' fetch (this is just how Chrome works, it's not us). If we bypass
  // the service worker cache when we get a 'no-cache' request, we can end up in
  // an inconsistent state where the index.html is more recent than the other
  // resources, which is undesirable.
  // * Only Ctrl+R. Ctrl+Shift+R will always bypass service-worker for all the
  // requests (index.html and the rest) made in that tab.

  const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
  const url = new URL(req.url);
  if (url.pathname === '/') {
    try {
      console.debug(LOG_TAG + `Fetching live ${req.url}`);
      // The await bleow is needed to fall through in case of an exception.
      return await fetchWithTimeout(req, INDEX_TIMEOUT_MS);
    } catch (err) {
      console.warn(LOG_TAG + `Failed to fetch ${req.url}, using cache.`, err);
      // Fall through the code below.
    }
  } else if (url.pathname === '/offline') {
    // Escape hatch to force serving the offline version without attempting the
    // network fetch.
    const cachedRes = await caches.match(new Request('/'), cacheOps);
    if (cachedRes) return cachedRes;
  } else if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) {
    return await handleOpenTraceRequest(req);
  }

  const cachedRes = await caches.match(req, cacheOps);
  if (cachedRes) {
    console.debug(LOG_TAG + `serving ${req.url} from cache`);
    return cachedRes;
  }

  // In any other case, just propagate the fetch on the network, which is the
  // safe behavior.
  console.warn(LOG_TAG + `cache miss on ${req.url}, using live network`);
  return fetch(req);
}

// Handles GET and POST requests to /_open_trace/NNNN, where NNNN is typically a
// random token generated by the client.
// This works as follows:
// - The client does a POST request to /_open_trace/NNNN passing the trace blob
//   as multipart-data, alongside other options like hideSidebar & co that we
//   support in the usual querystring (see router.ts)
// - The SW takes the file and puts it in the global variable `postedFiles`.
// - The SW responds to the POST request with a redirect to
//   ui.perfetto.dev/#!/?url=https://ui.perfetto.dev/_open_trace/NNNN&other_args
// - When the new ui.perfetto.dev is reloaded, it will naturally try to fetch
//   the trace from /_open_trace/NNNN, this time via a GET request.
// - The SW intercepts the GET request and returns the file previosly stored in
//   `postedFiles`.
// We use postedFiles here to handle the case of progammatically POST-ing to >1
// instances of ui.perfetto.dev simultaneously, to avoid races.
// Note that we should not use a global variable for `postedFiles` but we should
// use the CacheAPI because, technically speaking, the SW could be disposed
// and respawned in between the POST and the GET request. In practice, however,
// SWs are disposed only after 30s seconds of idleness. The POST->GET requests
// happen back-to-back..
async function handleOpenTraceRequest(req: Request): Promise<Response> {
  const url = new URL(req.url);
  console.assert(url.pathname.startsWith(OPEN_TRACE_PREFIX));
  const fileKey = url.pathname.substring(OPEN_TRACE_PREFIX.length);
  if (req.method === 'POST') {
    const formData = await req.formData();
    const qsParams = new URLSearchParams();
    // Iterate over the POST fields and copy them over the querystring in
    // the hash, with the exception of the trace file. The trace file is
    // kept in the serviceworker and passed as a url= argument.
    formData.forEach((value, key) => {
      if (key === 'trace') {
        if (value instanceof File) {
          postedFiles.set(fileKey, value);
          qsParams.set('url', req.url);
        }
        return;
      }
      qsParams.set(key, `${value}`);
    });  // formData.forEach()
    return Response.redirect(`${url.protocol}//${url.host}/#!/?${qsParams}`);
  }

  // else... method == 'GET'
  const file = postedFiles.get(fileKey);
  if (file !== undefined) {
    postedFiles.delete(fileKey);
    return new Response(file);
  }

  // The file /_open_trace/NNNN does not exist.
  return Response.error();
}

async function installAppVersionIntoCache(version: string) {
  const manifestUrl = `${version}/manifest.json`;
  try {
    console.log(LOG_TAG + `Starting installation of ${manifestUrl}`);
    await caches.delete(CACHE_NAME);
    const resp = await fetchWithTimeout(manifestUrl, INSTALL_TIMEOUT_MS);
    const manifest = await resp.json();
    const manifestResources = manifest['resources'];
    if (!manifestResources || !(manifestResources instanceof Object)) {
      throw new Error(`Invalid manifest ${manifestUrl} : ${manifest}`);
    }

    const cache = await caches.open(CACHE_NAME);
    const urlsToCache: RequestInfo[] = [];

    // We use cache:reload to make sure that the index is always current and we
    // don't end up in some cycle where we keep re-caching the index coming from
    // the service worker itself.
    urlsToCache.push(new Request('/', {cache: 'reload', mode: 'same-origin'}));

    for (const [resource, integrity] of Object.entries(manifestResources)) {
      // We use cache: no-cache rather then reload here because the versioned
      // sub-resources are expected to be immutable and should never be
      // ambiguous. A revalidation request is enough.
      const reqOpts: RequestInit = {
        cache: 'no-cache',
        mode: 'same-origin',
        integrity: `${integrity}`,
      };
      urlsToCache.push(new Request(`${version}/${resource}`, reqOpts));
    }
    await cache.addAll(urlsToCache);
    console.log(LOG_TAG + 'installation completed for ' + version);
  } catch (err) {
    console.error(LOG_TAG + `Installation failed for ${manifestUrl}`, err);
    await caches.delete(CACHE_NAME);
    throw err;
  }
}

class TimeoutError extends Error {
  constructor(url: string) {
    super(`Timed out while fetching ${url}`);
  }
}

class NetworkError extends Error {
  constructor(url: string, cause: unknown) {
    super(`Network error while fetching ${url}: ${cause}`);
  }
}

function fetchWithTimeout(req: Request|string, timeoutMs: number) {
  const url = (req as {url?: string}).url || `${req}`;
  return new Promise<Response>((resolve, reject) => {
    const timerId = setTimeout(() => {
      reject(new TimeoutError(url));
    }, timeoutMs);
    fetch(req).then((resp) => {
      clearTimeout(timerId);
      if (resp.ok) {
        resolve(resp);
      } else {
        reject(new Error(
            `Fetch failed for ${url}: ${resp.status} ${resp.statusText}`));
      }
    }, (e) => { clearTimeout(timerId); reject(new NetworkError(url, e)); });
  });
}
