関わっているプロジェクトにアップロードされたイメージの表示されない問題が起こりました。原因は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つしかないでしょう。
- HEIFをクライアントでjpegなどへ変換して表示
- HEIFファイルをサーバへアップロードし、jpegへ変換して表示
- HEIFをアップロードできないように制限
個人的に方法1がベストと考えます。
今スマホで撮った写真のサイズがデカくて、軽く数メガバイトを超えます。クライアント側でアップロードされたイメージのサイズをFHDへリサイズし、画質もちょっと調整してあげてから、サーバへの通信量が減ります。UI/UXのレスポンスもよく、通信量の減少でお客さんのお財布にも優しいでしょう。
だが、公式(Apple社)のサポートライブラリーがなく、見つけた関連ライブラリーはheic2any、heic-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
タグで表示できるものとなり、とても簡単しかも有効な方法でしょう。
- まずは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;
}
- そして、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と言われる)を使って、正しいファイルタイプのみ通させる方法は有効な方法の1つとも言えるでしょう。イメージファイルのMagic Number
はこちら。
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],
},
];
- そして、ファイルのヘッダーから取った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';
}
- 最後に、ファイルのヘッダーの数バイトを取って判断すれば完成
アップロードされたファイルから取得すべきなバイト数を計算。
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