chromium/ios/chrome/browser/web/model/choose_file/resources/choose_file.ts

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Obsers taps on Choose file inputs.
 */

import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js'

const CHOOSE_FILE_INPUT_HANDLER_NAME = 'ChooseFileHandler';

// These lists are not exhaustive, but should give a good clue of what type
// are filtered.
const AUDIO_FILES = ['aac', 'flac', 'm4a', 'mp3', 'ogg', 'pcm', 'wav', 'wma'];
const IMAGE_FILES = [
  'apng', 'avif', 'gif', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'pjp', 'png', 'svg',
  'webp'
];
const VIDEO_FILES = ['avi', 'flv', 'mkv', 'mov', 'mp4', 'mpeg', 'webm', 'wmv'];
const COMPRESSED_FILES = ['7z', 'cab', 'gz', 'rar', 'tar', 'zip'];
const PDF_FILES = ['pdf'];
const DOC_FILES = [
  'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'key', 'numbers', 'pages',
  'epub', 'txt'
];
const APPLE_FILES = ['pkpass', 'mobileprovision'];

// The accept type sent to the browser.
enum AcceptType {
  NoAccept = 0,
  MixedAccept = 1,
  UnknownAccept = 2,
  ImageAccept = 3,
  VideoAccept = 4,
  AudioAccept = 5,
  ArchiveAccept = 6,
  PDFAccept = 7,
  DocAccept = 8,
  AppleAccept = 9,
}

// Converts a single accept string to an AcceptType
function stringToAcceptType(acceptString: String): AcceptType {
  var accept = acceptString.trim().toLowerCase();
  if (accept === '') {
    return AcceptType.NoAccept;
  }
  if (accept.startsWith('image/')) {
    return AcceptType.ImageAccept;
  }
  if (accept.startsWith('video/')) {
    return AcceptType.VideoAccept;
  }
  if (accept.startsWith('audio/')) {
    return AcceptType.AudioAccept;
  }
  if (accept.startsWith('.')) {
    accept = accept.substring(1);
  }
  if (IMAGE_FILES.includes(accept)) {
    return AcceptType.ImageAccept;
  }
  if (VIDEO_FILES.includes(accept)) {
    return AcceptType.VideoAccept;
  }
  if (AUDIO_FILES.includes(accept)) {
    return AcceptType.AudioAccept;
  }
  if (COMPRESSED_FILES.includes(accept)) {
    return AcceptType.ArchiveAccept;
  }
  if (PDF_FILES.includes(accept)) {
    return AcceptType.PDFAccept;
  }
  if (DOC_FILES.includes(accept)) {
    return AcceptType.DocAccept;
  }
  if (APPLE_FILES.includes(accept)) {
    return AcceptType.AppleAccept;
  }
  return AcceptType.UnknownAccept;
}

// Converts a multiple accept string to an AcceptType
function MultipleStringToAcceptType(acceptString: String): AcceptType {
  const accepts = acceptString.split(',');
  var acceptType = AcceptType.NoAccept;
  for (const accept of accepts) {
    const current = stringToAcceptType(accept);
    if (acceptType == AcceptType.NoAccept ||
        acceptType == AcceptType.UnknownAccept) {
      acceptType = current;
      continue;
    }
    if (acceptType == current || current == AcceptType.UnknownAccept ||
        current == AcceptType.NoAccept) {
      continue;
    }
    // Types are different and neither is Unknown, so it is Mixed.
    return AcceptType.MixedAccept;
  }
  return acceptType;
}

// Returns whether `ch` is a string with a single UTF-16 code unit which is a
// valid RFC2616 token character. This is mimicking verifications done by Blink.
function isRFC2616TokenCharacter(ch: String): boolean {
  const code = ch.charCodeAt(0);
  if (code < 32 || code > 127) {
    return false;
  }
  switch (ch) {
    case '(':
    case ')':
    case '<':
    case '>':
    case '@':
    case ',':
    case ';':
    case ':':
    case '\\':
    case '"':
    case '/':
    case '[':
    case ']':
    case '?':
    case '=':
    case '{':
    case '}':
    case String.fromCharCode(0x20):  // SP
    case String.fromCharCode(0x09):  // HT
    case String.fromCharCode(0x7f):  // DEL
      return false;
    default:
      return true;
  }
}

// Returns whether `mimeType` is a string which represents a valid MIME type.
function isValidMIMEType(mimeType: String): boolean {
  let slashPosition = mimeType.indexOf('/');
  if (slashPosition == -1 || slashPosition == mimeType.length - 1 ||
      slashPosition == 0) {
    return false;
  }
  for (let i = 0; i < mimeType.length; i++) {
    if (!isRFC2616TokenCharacter(mimeType[i]!) && i != slashPosition) {
      return false;
    }
  }
  return true;
}

// Returns whether `fileExtension` is a string which represents a valid file
// extension.
function isValidFileExtension(fileExtension: String): boolean {
  if (fileExtension.length < 2) {
    return false;
  }
  for (let i = 0; i < fileExtension.length; i++) {
    if (!isRFC2616TokenCharacter(fileExtension[i]!)) {
      return false;
    }
  }
  return fileExtension[0] == '.';
}

// Filters `acceptString` so it only contains valid MIME types.
function parseAcceptAttributeMimeTypes(acceptString: String): String {
  const acceptTypes = acceptString.split(',');
  var mimeTypes: String[] = [];
  for (const acceptType of acceptTypes) {
    const trimmedType = acceptType.trim();
    if (isValidMIMEType(trimmedType)) {
      mimeTypes.push(trimmedType);
    }
  }
  return mimeTypes.join(',');
}

// Filters `acceptString` so it only contains valid file extensions.
function parseAcceptAttributeFileExtensions(acceptString: String): String {
  const acceptTypes = acceptString.split(',');
  var fileExtensions: String[] = [];
  for (const acceptType of acceptTypes) {
    const trimmedType = acceptType.trim();
    if (isValidFileExtension(trimmedType)) {
      fileExtensions.push(trimmedType);
    }
  }
  return fileExtensions.join(',');
}

// Sends data about the event.
function processChooseFileClick(inputEvent: MouseEvent): void {
  if (!inputEvent.target || !(inputEvent.target instanceof HTMLInputElement)) {
    return;
  }
  const target = inputEvent.target as HTMLInputElement;
  if (target.type.toLowerCase() !== "file") {
    return;
  }
  var accept = AcceptType.NoAccept;
  var acceptString = target.getAttribute('accept')
  if (acceptString) {
    accept = MultipleStringToAcceptType(acceptString!);
  }

  acceptString = acceptString ? acceptString : '';
  const response = {
    'hasMultiple': target.hasAttribute('multiple'),
    'acceptType': accept,
    'mimeTypes': parseAcceptAttributeMimeTypes(acceptString),
    'fileExtensions': parseAcceptAttributeFileExtensions(acceptString),
  };

  sendWebKitMessage(CHOOSE_FILE_INPUT_HANDLER_NAME, response);
}

// Registers passive event listeners for the click on choose file inputs.
function registerChooseFileClick(): void {
  window.addEventListener(
      'click', processChooseFileClick, {capture: true, passive: true});
}

registerChooseFileClick();