mattintosh note

どこかのエンジニアモドキの備忘録

フィギュア関連の写真はすべて削除されました。しばらく CDN のキャッシュが表示されることがあります
ホビー関連の情報は hobby.mattintohs-note.jp に移行しています

Apacheで画像が呼び出されたときにPHPで加工して返却する

新しいサーバーを使い始めてそろそろ一ヶ月くらい経ちました。

いまのところ画像の無断転載はタイ人による Facebook への投稿くらいで済んでいます(この投稿は削除されましたがタイからのアクセスはブロックされました)。

毎日ログを見ているとボットに偽装したものが来ていたり Tor や VPN を使用したアクセスも見られます。Cloudflare が優秀なのでボットに偽装したものはブロックしてくれていますし、自身でもホスティングサービスや VPN からのアクセスはブロック対象に登録するようにしています。WAF 入れてないとほんと WordPress とか怖くて運用できないですね。

さて、画像を保存できないように色々対策はしていますがブラウザで表示できてしまってる以上保存はできてしまうので追加の対策を考えることにしました。

Apache では mod_ext_filter の ExtFilterDefine を使うことでレスポンスにフィルタをかけることができます。効率は悪いですが ImageMagick や cwebp といったコマンドを挟んでサムネイルをリアルタイムで生成したり JPEG を WebP に変換したりすることができます。

しかしこの ExtFilterDefine.htaccess では使えないんですね(対象がサーバー設定ファイルとなっています)。Xserver(シンレンタルサーバー)ではそこまで触れないのでこれが使えません。

仕方ないので画像を PHP に渡してフィルタをかけることにしました。

まず Imagick で画像を加工するスクリプトを書きます。今回は画像の右上に Cloudflare の拡張機能でヘッダーに追加している国名、リージョン名、市名を埋め込みます。ASN は本来ヘッダーにはありませんが Cloudflare の変換ルールで X-ASNUM として追加しています。そして WebP に対応していれば WebP 返し、対応していなければ JPEG で渡します。

convert.php

<?php

$image = new \Imagick(__DIR__ . urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)));
$draw  = new \ImagickDraw();

$text = implode(PHP_EOL, [
    'COUNTRY: ' . $_SERVER['HTTP_CF_IPCOUNTRY'],
    'REGION: '  . $_SERVER['HTTP_CF_REGION'],
    'CITY: '    . $_SERVER['HTTP_CF_IPCITY'],
    'ASN: '     . $_SERVER['HTTP_X_ASNUM'],
    'RAY: '     . $_SERVER['HTTP_CF_RAY'],
    strftime('%Y-%m-%dT%H:%M:%S%z', $_SERVER['REQUEST_TIME']),
]);

$draw->setFont('Courier');
$draw->setFontSize(16);
$draw->setGravity(Imagick::GRAVITY_NORTHEAST);
$draw->setFillColor('gray');
$image->annotateImage($draw, 15, 17, 0, $text);
$draw->setFillColor('white');
$image->annotateImage($draw, 16, 16, 0, $text);

$accept = isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : '';
if (strpos($accept, 'image/webp') !== false) {
    $image->setImageFormat('webp');
    header('Content-type: image/webp');
} else {
    $image->setImageFormat('jpeg');
    header('Content-type: image/jpeg');
}
$image->setImageCompressionQuality(95);

echo $image;

.htaccess でアップロードした画像(/wp-content/uploads/ 以下)へのリクエストがあった場合にスクリプトを通るようにしておきます。ここで %{REQUEST_FILENAME} -f しているので PHP 側ではファイルの存在有無のチェックはしていません。

.htaccess

RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^wp-content/uploads/.*\.(?i:jpe?g) /convert.php [L]

ここで生成された画像がキャッシュされてしまうとすべてのユーザーに同じ画像が配信されてしまうので Xserver(シンレンタルサーバー)のキャッシュは無効にしておき、Cloudflare の方はキャッシュルールでバイパスしておきます。

これでリクエストがあった際に画像にクライアント情報のテキストを埋め込んでから返却してくれるようになるので無断転載されてもある程度どこのユーザーが転載したものだかわかるようになります(多分)。

IP や User-Agent ももちろんわかっているので埋め込みできるのですがとりあえず様子見です。

あとは Xserver(シンレンタルサーバー)の CPU がどれくらいもつかですかね。

どんな状態かは新しいブログの方で見れます。

hobby.mattintosh-note.jp


Imagick の WebP が汚すぎるので cwebp に置き換えて、追加で PNG にも同様の処理をすることにしました。cwebp は自前で用意したものを Xserver(シンレンタルサーバー)上に置いています。Imagick だけで処理するより 100〜200 ms くらい遅い気がしますが体感ではあまり気にならないのでしばらくこれで運用してみようと思います。

convert.php

<?php

$image = new \Imagick(__DIR__ . urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)));
$image_format = $image->getImageFormat();

$text = implode(PHP_EOL, [
    'COUNTRY: ' . (isset($_SERVER['HTTP_CF_IPCOUNTRY']) ? $_SERVER['HTTP_CF_IPCOUNTRY'] : ''),
    'REGION: '  . (isset($_SERVER['HTTP_CF_REGION'])    ? $_SERVER['HTTP_CF_REGION']    : ''),
    'CITY: '    . (isset($_SERVER['HTTP_CF_IPCITY'])    ? $_SERVER['HTTP_CF_IPCITY']    : ''),
#   'UA: '      . $_SERVER['HTTP_X_REAL_UA'],
#   'IP: '      . $_SERVER['HTTP_CF_CONNECTING_IP'],
    $_SERVER['HTTP_X_ASNUM'],
    $_SERVER['HTTP_CF_RAY'],
    strftime('%Y-%m-%dT%H:%M:%S%z', $_SERVER['REQUEST_TIME']),
]);

$draw  = new \ImagickDraw();
$draw->setFont('Courier');
$draw->setFontSize(16);
$draw->setGravity(Imagick::GRAVITY_NORTHEAST);
$draw->setFillColor('gray');
$image->annotateImage($draw, 15, 17, 0, $text);
$draw->setFillColor('white');
$image->annotateImage($draw, 16, 16, 0, $text);

if (strpos(isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : '', 'image/webp') !== false) {
    $image->setImageFormat('tiff');
    $data = $image->getImageBlob();
    $image->clear();
    $image->destroy();
    $descriptor_spec = [
        0 => ['pipe', 'r'],
        1 => ['pipe', 'w'],
        2 => ['pipe', 'w'],
    ];
    switch ($image_format) {
        case 'JPEG':
            $cmd = '/path/to/cwebp -quiet -q 95     -mt -metadata all -sharp_yuv -o - -- -';
            break;
        case 'PNG':
            $cmd = '/path/to/cwebp -quiet -lossless -mt -metadata all -sharp_yuv -o - -- -';
            break;
    }
    $process = proc_open($cmd, $descriptor_spec, $pipes);
    fwrite($pipes[0], $data);
    fclose($pipes[0]);
    $stdout = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    $stderr = stream_get_contents($pipes[2]);
    fclose($pipes[2]);
    $return_value = proc_close($process);
    header('Content-type: image/webp');
    header('Vary: Accept');
    header('Content-Length: ' . strlen($stdout));
    echo $stdout;
} else {
    switch ($image_format) {
        case 'JPEG':
            $image->setImageFormat('jpeg');
            $image->setImageCompressionQuality(95);
            header('Content-type: image/jpeg');
            break;
        case 'PNG':
            $image->setImageFormat('png');
            header('Content-type: image/png');
    }
    $data = $image->getImageBlob();
    $image->clear();
    $image->destroy();
    header('Content-Length: ' . strlen($data));
    echo $data;
}

.htaccess

RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^wp-content/uploads/.*\.(?i:jpe?g|png) /convert.php [L]