2021年7月11日 • ☕️☕️ 8 min read

関わっているプロジェクトにアップロードされたイメージの表示されない問題が起こりました。原因はiPhoneで撮った証明写真をMacへ転送してアップロードしたからなのです。

iOS11からiPhoneで写真を撮ると、jpegより圧縮率の高いHEIF(High Efficiency Image File Format)が採用されましたが、4年近く立てた現在でも各ブラウザーにサポートされていなくて(そもそもSafariでもアウト?!WebPに負けた?)、imgタグで表示されない問題が続いています。なので、HEIFファイルがアップロードされたら、'対策'が必要となります。

TL;DR

HEIFの普及率を考えると、現時点アップロードさせない方が一番いいと思います。まずinputにfile typeで制御しましょう。しかし、これだけでは拡張子が間違ったパターンを防げません。万全の対策として、アップロードされたファイルが本物のイメージファイルのことをチェックする必要があります。チェックする方法が2つあります。クライアント側でイメージファイルをリサイズしようとしたら、Image objectに読み込めるかどうかでチェックすればいいでしょう。それ以外の場合は、まめにファイルヘッダーの数バイトを読み込んで、Image Type Patternでチェックしたほうが早いでしょう。

対策

簡単に考えると、対策といえば、3つしかないでしょう。

  1. HEIFをクライアントでjpegなどへ変換して表示
  2. HEIFファイルをサーバへアップロードし、jpegへ変換して表示
  3. HEIFをアップロードできないように制限

個人的に方法1がベストと考えます。

今スマホで撮った写真のサイズがデカくて、軽く数メガバイトを超えます。クライアント側でアップロードされたイメージのサイズをFHDへリサイズし、画質もちょっと調整してあげてから、サーバへの通信量が減ります。UI/UXのレスポンスもよく、通信量の減少でお客さんのお財布にも優しいでしょう。

だが、公式(Apple社)のサポートライブラリーがなく、見つけた関連ライブラリーはheic2anyheic-decodeなど、保存ではなくあくまでプレビュー用、安定しているわけではありません。GoogleLabのsquooshでも編集できないのは、みなさんあまりHEIFを相手にしないことと認識して、現時点では方法3:HEIFのアップロードをブロックがいいかなと思います。

HEIF

Q: HEIFはどんなファイル?

A: Wikipediaへ。

HEIFのアップロードを禁止にする

STEP1: 拡張子を制御

これは最も簡単、しかも有効な方法となります。

Copy
const imageFileTypes = [
  'image/apng',
  'image/bmp',
  'image/gif',
  'image/jpeg',
  'image/pjpeg',
  'image/png',
  'image/svg+xml',
  'image/tiff',
  'image/webp',
  'image/x-icon',
];

<input type="file" accept={imageFileTypes.join(',')} />

これだけでは不十分となります。たまにアップロードできるように、拡張子を変えてアップロードすればいいと思うバイナリーファイルに知識の少ない人がいます。なので、完全に防ぐために、アップロードされたファイルをクライアント側で読み込んで判別してみる必要があります。

STEP2: アップロードファイルのバイナリーデータでHEIFファイルを制御

ダメだった方法。

  • file.typeで判別

    file.typeでファイルのmimeタイプを取れます。だが、ブラウザーが拡張子でmimeタイプを決めているのでアウト

  • base64のDataURLで判別

    file.typeと同じ、アップロードされるファイルの拡張子を使っているため、HEIFファイルの拡張子を*.jpegへ変えたら、data:image/jpeg;base64, xxxxxxとなり、アウト

方法1: Image objectに読み込めることで

Image objectに読み込めるファイルはブラウザーにimgタグで表示できるものとなり、とても簡単しかも有効な方法でしょう。

  1. まずはbase64のDataURLを取る
Copy
async function getFileDataURL(file: File): Promise<string> {
  let fileDataURL = '';
  await new Promise<void>((resolve) => {
    const reader = new FileReader();
    reader.onload = function (e) {
      if (typeof e.target.result === 'string') fileDataURL = e.target.result;
      resolve();
    };
    reader.readAsDataURL(file);
  });

  return fileDataURL;
}
  1. そして、Imageにdecodeしてみる
Copy
export async function validateByDecode(file: File): Promise<boolean> {
  const url = await getFileDataURL(file);

  const img = new Image();
  img.decoding = 'async';
  img.src = url;
  const tryDecodeImg = new Promise<boolean>((resolve) => {    img.onload = () => resolve(true);    img.onerror = () => resolve(false);  });
  if (img.decode) {
    await img.decode().catch(() => null);
  }

  const isValid = await tryDecodeImg;

  return isValid;
}

ただし、この方法に致命的な問題があります。それは、判断するたびに、イメージファイルを丸ごとでメモリに読み込むこととなります。とはいえ、イメージファイルをリサイズしてサーバへ送る仕組みであれば、問題にはならないでしょう。

方法2: Image Type Patternで判別

ファイルヘッダーの数バイトにファイルのタイプが決められます。この数バイトのMagic Number(正式はFile Signaturesと言われる)を使って、正しいファイルタイプのみ通させる方法は有効な方法の1つとも言えるでしょう。イメージファイルのMagic Numberこちら

  1. Magic Number通りにパターンの定義

※ Pattern Maskが0x00の部分は何でもいいなので、その場所のpatternをnullに定義し、チェック対象外にすればいい。

Copy
type ImageMimeTypePattern = {
  mime: string;
  pattern: number[];
};
const imageMimeTypePatterns: ImageMimeTypePattern[] = [
  {
    mime: 'image/x-icon',
    pattern: [0x00, 0x00, 0x01, 0x00],
  },
  {
    mime: 'image/x-icon',
    pattern: [0x00, 0x00, 0x02, 0x00],
  },
  {
    mime: 'image/bmp',
    pattern: [0x42, 0x4d],
  },
  {
    mime: 'image/gif',
    pattern: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
  },
  {
    mime: 'image/gif',
    pattern: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
  },
  {
    mime: 'image/webp',
    // we'll skip null pattern from check because Pattern Mask is 0x00    pattern: [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50],
  },
  {
    mime: 'image/png',
    pattern: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
  },
  {
    mime: 'image/jpeg',
    pattern: [0xff, 0xd8, 0xff],
  },
];
  1. そして、ファイルのヘッダーから取ったbytesでファイルタイプの判別関数を作成
Copy
function getMimeTypeByBytes(bytes: Uint8Array): string {
  const matched = imageMimeTypePatterns.find(({ pattern }) =>
    // skip if pattern is null as Pattern Mask is 0x00
    pattern.every((p, ind) => p == null || bytes[ind] === p),
  );

  return matched?.mime || 'unknown type';
}
  1. 最後に、ファイルのヘッダーの数バイトを取って判断すれば完成

アップロードされたファイルから取得すべきなバイト数を計算。

Copy
const minFileHeadBytesCount = Math.max(...imageMimeTypePatterns.map(({pattern}) => pattern.length));

ファイルヘッダーから数バイトを取得。

Copy
export async function validateByPattern(file: File): Promise<boolean> {
  let fileHeadBytes: Uint8Array;

  await new Promise<void>((resolve) => {
    const reader = new FileReader();
    reader.onload = function (e) {
      if (typeof e.target.result !== 'string')
        fileHeadBytes = new Uint8Array(e.target.result);
      resolve();
    };
    // only read necessary header
    reader.readAsArrayBuffer(file.slice(0, minFileHeadBytesCount));
  });

  const mimeType = getMimeTypeByBytes(fileHeadBytes);

  return mimeType !== 'unknown type';
}

方法1 or 方法2??

クライアントにイメージファイルをリサイズする?

  • Yes? 方法1
  • No? 方法2

完了

ソースコードはこちらへ。

https://github.com/thundermiracle/ios-resize-image/blob/master/src/lib/isImageValid.tsx


関連投稿

sourcemapを利用して、stacktraceでエラーの発生源を特定する

2021年7月14日

iOSのSafariブラウザにアップロードされたファイルを正しくリサイズする方法の検証

2020年12月21日

ThunderMiracle

Blog part of ThunderMiracle.com