Ctrl K
ComunidadGuías…

app/utils/files.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import invariant from "invariant";
import type { Accept } from "react-dropzone";
import { AttachmentPreset } from "@shared/types";
import { dataUrlToBlob } from "@shared/utils/files";
import { client } from "./ApiClient";
import Logger from "./Logger";
import { NetworkError } from "./errors";

type UploadOptions = {
  /** The user generated ID of the file */
  id?: string;
  /** The user facing name of the file */
  name?: string;
  /** The document that this file was uploaded in, if any */
  documentId?: string;
  /** The preset to use for attachment configuration */
  preset: AttachmentPreset;
  /** Callback will be passed a number between 0-1 as upload progresses */
  onProgress?: (fractionComplete: number) => void;
};

/**
 * Upload a file from a URL
 *
 * @param url The remote URL to download the file from
 * @param options The upload options
 * @returns The attachment object
 */
export const uploadFileFromUrl = async (
  url: string,
  options: UploadOptions
) => {
  const response = await client.post("/attachments.createFromUrl", {
    documentId: options.documentId,
    url,
    id: options.id,
  });
  return response.data;
};

/**
 * Upload a file
 *
 * @param file The file to upload
 * @param options The upload options
 * @returns The attachment object
 */
export const uploadFile = async (
  file: File | Blob,
  options: UploadOptions = {
    name: "",
    preset: AttachmentPreset.DocumentAttachment,
  }
) => {
  const name = file instanceof File ? file.name : options.name;
  const response = await client.post("/attachments.create", {
    preset: options.preset,
    documentId: options.documentId,
    contentType: file.type,
    size: file.size,
    name,
    id: options.id,
  });
  invariant(response, "Response should be available");
  const data = response.data;
  const attachment = data.attachment;
  const usePut = data.mode === "put";

  // Using XMLHttpRequest instead of fetch because fetch doesn't support progress
  const xhr = new XMLHttpRequest();
  const success = await new Promise((resolve) => {
    xhr.upload.addEventListener("progress", (event) => {
      if (event.lengthComputable && options.onProgress) {
        options.onProgress(event.loaded / event.total);
      }
    });
    xhr.addEventListener("error", () => {
      // The request received no response, which is thrown as a network error.
      if (xhr.status === 0) {
        return;
      }

      Logger.error(
        "File upload failed",
        new Error(`${xhr.status} ${xhr.statusText}`),
        {
          status: xhr.status,
          statusText: xhr.statusText,
          contentType: file.type,
          size: file.size,
        }
      );
    });
    xhr.addEventListener("loadend", () => {
      resolve(xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 400);
    });

    if (usePut) {
      xhr.open("PUT", data.url, true);
      if (data.headers) {
        for (const [key, value] of Object.entries(
          data.headers as Record<string, string>
        )) {
          if (isForbiddenRequestHeader(key)) {
            continue;
          }
          xhr.setRequestHeader(key, value);
        }
      }
      xhr.send(file);
    } else {
      const formData = new FormData();
      for (const key in data.form) {
        formData.append(key, data.form[key]);
      }
      formData.append("file", file);

      // Do not send credentials if uploading to a different origin, as the
      // combination of CORS and cookies will cause preflight request failure.
      // However S3-like storage on the same host can work with credentials.
      if (data.uploadUrl.startsWith("/")) {
        xhr.withCredentials = true;
      } else {
        const parsed = new URL(data.uploadUrl);
        const requiresPreflightRequest =
          parsed.origin !== window.location.origin;
        xhr.withCredentials = !requiresPreflightRequest;
      }

      xhr.open("POST", data.uploadUrl, true);
      xhr.send(formData);
    }
  });

  if (!success) {
    throw xhr.status === 0
      ? new NetworkError("Upload failed")
      : new Error("Upload failed");
  }

  return attachment;
};

export { dataUrlToBlob };

/**
 * Converts a list of mime types and file extensions to the accept map used by
 * react-dropzone. The map is flattened into a single accept attribute, so the
 * extensions are grouped under the first mime type, or a placeholder if there
 * is none.
 *
 * @param types the mime types and file extensions, such as "image/png" or ".png".
 * @returns the accept map.
 */
export const toDropzoneAccept = (types: readonly string[]): Accept => {
  const mimeTypes = types.filter((type) => !type.startsWith("."));
  const extensions = types.filter((type) => type.startsWith("."));

  if (mimeTypes.length === 0 && extensions.length > 0) {
    return { [extensionOnlyMimeType]: extensions };
  }

  return Object.fromEntries(
    mimeTypes.map((mimeType, index) => [
      mimeType,
      index === 0 ? extensions : [],
    ])
  );
};

/** A key for extensions with no mime type, no file reports this type. */
const extensionOnlyMimeType = "application/x-outline-extension-only";

/**
 * Header names the browser controls itself - it silently ignores an attempt to
 * set one and logs an error to the console.
 *
 * @see https://fetch.spec.whatwg.org/#forbidden-request-header
 */
const forbiddenRequestHeaders = [
  "accept-charset",
  "accept-encoding",
  "access-control-request-headers",
  "access-control-request-method",
  "connection",
  "content-length",
  "cookie",
  "cookie2",
  "date",
  "dnt",
  "expect",
  "host",
  "keep-alive",
  "origin",
  "referer",
  "set-cookie",
  "te",
  "trailer",
  "transfer-encoding",
  "upgrade",
  "via",
];

const forbiddenRequestHeaderPrefixes = ["proxy-", "sec-"];

/**
 * Checks whether the browser refuses to let a header be set on a request.
 *
 * @param name the header name to check.
 * @returns true if the header cannot be set.
 */
const isForbiddenRequestHeader = (name: string): boolean => {
  const lowercased = name.toLowerCase();
  return (
    forbiddenRequestHeaders.includes(lowercased) ||
    forbiddenRequestHeaderPrefixes.some((prefix) =>
      lowercased.startsWith(prefix)
    )
  );
};

const CHAR_FORWARD_SLASH = 47; /* / */
const CHAR_DOT = 46; /* . */

// Based on the NodeJS Library https://github.com/nodejs/node/blob/896b75a4da58a7283d551c4595e0aa454baca3e0/lib/path.js
// Copyright Joyent, Inc. and other Node contributors.
/**
 * Returns the extension of the path, from the last occurrence of the "."
 * character to the end of the string in the last portion of the path.
 *
 * @param path the path to evaluate.
 * @returns the extension including the leading ".", or an empty string.
 * @throws TypeError if path is not a string.
 */
export const extname = (path: string) => {
  if (typeof path !== "string") {
    throw new TypeError(
      `The "path" argument must be of type string. Received type ${typeof path}`
    );
  }

  let startDot = -1;
  let startPart = 0;
  let end = -1;
  let matchedSlash = true;
  // Track the state of characters (if any) we see before our first dot and
  // after any path separator we find
  let preDotState = 0;
  for (let i = path.length - 1; i >= 0; --i) {
    const code = path.charCodeAt(i);
    if (code === CHAR_FORWARD_SLASH) {
      // If we reached a path separator that was not part of a set of path
      // separators at the end of the string, stop now
      if (!matchedSlash) {
        startPart = i + 1;
        break;
      }
      continue;
    }
    if (end === -1) {
      // We saw the first non-path separator, mark this as the end of our
      // extension
      matchedSlash = false;
      end = i + 1;
    }
    if (code === CHAR_DOT) {
      // If this is our first dot, mark it as the start of our extension
      if (startDot === -1) {
        startDot = i;
      } else if (preDotState !== 1) {
        preDotState = 1;
      }
    } else if (startDot !== -1) {
      // We saw a non-dot and non-path separator before our dot, so we should
      // have a good chance at having a non-empty extension
      preDotState = -1;
    }
  }

  if (
    startDot === -1 ||
    end === -1 ||
    // We saw a non-dot character immediately before the dot
    preDotState === 0 ||
    // The (right-most) trimmed path component is exactly '..'
    (preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)
  ) {
    return "";
  }
  return path.slice(startDot, end);
};