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がベストと考えます。

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

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

HEIF

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

A: Wikipedia

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

STEP1: 拡張子を制御

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

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を取る
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してみる
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と言われる)を使って、正しいファイルタイプのみ通させる方法は有効な方法の一つとも言えるでしょう。イメージファイルのMagic Numberこちら

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

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

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でファイルタイプの判別関数を作成
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. 最後に、ファイルのヘッダーの数バイトを取って判断すれば完成

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

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

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

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


関連投稿

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

2020年12月21日

ThunderMiracle

Blog part of ThunderMiracle.com