【PHP】S3に置いているファイルを署名付きURLを発行してダウンロードする

S3にあるオブジェクトURLを直接叩くと「Access Denied」と表示される場合があります。
それはそのオブジェクトのアクセス権限の全員(パブリックアクセス)の部分に適切な許可がないためになります。
誰がいつ見てもいいファイルとかならここの権限をちょちょいと変更してパブリックアクセスに変更することもある思うのですが、そうしたくない場合も多いと思います。

そういうときのために、「署名付きURLを発行してそのURLが発行から有効な時間範囲内であれば見れるようにする」ということを行います。
URLの有効期限があって「なんとか日以降は見れなくなります。」みたいな文言ってよくありますよね。
あれです。

PHP SDK を利用しています。

まずは公式を参考に

公式ドキュメント

ほぼコピペでいけました。
署名だの有効期限だの難しそうなことが簡単で嬉しいです。

$s3Client = new Aws\S3\S3Client([
    'credentials' => [
        'key' => [アクセスキー],
        'secret' => [シークレットアクセスキー]
    ],
    'region' => 'ap-northeast-1',
    'version' => 'latest'
]);

$cmd = $s3Client->getCommand('GetObject', [
    'Bucket' => [バケット名],
    'Key' => [バケット名以降のパス],
]);

$request = $s3Client->createPresignedRequest($cmd, '+20 minutes'); // 20分間有効

こちらでリクエストにアクセスしたら確認が出来ました。
ただし、今回はアクセス時にPCにダウンロードしてほしかったので、コマンドにメタデータをセットします。

メタデータのセット

新しいファイル名を指定してセットします。

$new_filename = urlencode('ほげほげ.pdf'); // ほげほげ.pdfとしてダウンロードされます

$cmd = $s3client->getCommand('GetObject', [
    'Bucket' => [バケット名],
    'Key' => [バケット名以降のパス],
    'ResponseContentDisposition' => "attachment; filename=\"{$new_filename}\""
]);

無事にダウンロードできました。
ブラウザは Chrome でしかやってないので他のブラウザはどうかはちょっとわからないです…。

他の方がやっているのを見たので発見できましたが、いつも見ているような日本語のページでは見つけられませんでした。

ここならありました。

PHP SDK ドキュメント

【参考サイト】

Amazon S3の署名付きURL発行の際に別名でファイルをダウンロードさせるようにする

プログラミング学習として使うChat GPT

プログラマ歴約6年になりますが、関数型プログラミングが出来る人に憧れております。
理由はなんかかっこいいからです。

しかし、残念ながら私は非関数型プログラミングしか書けません。
どう学ぶの? Haskell やってみるか?などの選択肢を頭に浮かべてはやらないという不毛な日々を過ごしていました。

そこでふと思いつきました。

ChatGPTに問題出してもらおう

これは結構、良い考えなのでは…自分天才では…
(似たようなことをやっている人調べたら、たくさん居ました。みんな天才でした)

実際にやってみました。

いいですね…。一発目で初心者にちょうど良さそうな問題を出してくれました。
自分なりに書いてみました。

// numbers が与えられた配列
const result = numbers.map(num => {if (num % 2 === 0) return num * 2; else return num}); 

書いたコードを載せると…

そして、もっといいなと思うのが他の書き方を聞いた時に答えてくれるんですよね

書籍とかだと、「こう書いているけどこの書き方はダメなのかなあ」と感じることがよくあります。
そういうことも確認できるので凄いですね。

こうやって新たな使い方を発見すると、また楽しくなります。

PHPでS3にあるファイルをSplFileObjectを使ってあれやこれやしようとすると Fatal Error

下記のようなエラーが発生しました。

① Fatal error: Uncaught RuntimeException: SplFileObject::__construct(): Unable to find the wrapper

② Fatal error: Uncaught RuntimeException: Cannot rewind file

なんぞこれ…って感じでしたが、対処法は公式ドキュメントに載ってましたね、有難い…。
AWSはドキュメントがしっかりしてて好きです。

公式ドキュメント

① の 対処法ですが、ドキュメントの上の方にあるように

Amazon S3 ストリームラッパーにより、組み込み PHP 関数 file_get_contents、fopen、copy、rename、unlink、mkdir、rmdir などを使用して Amazon S3 に対してデータの保存および取得ができます。使用するために Amazon S3 ストリームラッパーを登録する必要があります。


AWS SDK for PHPバージョン 3 の Amazon S3 ストリームラッパー

とあります。

なので、 $s3Client に対してこちらを追加します。

 $s3Client->registerStreamWrapper(); 

①は出なくなりました。
そしてファイル操作をしようとすると②のエラーが出ました。

こちらも同じページ書いてありました。有難い…。

「r」モードで開いたストリームは、ストリームからデータを読み取ることのみ可能です。さらに、デフォルトではシーク可能ではありません。これは、真にストリーミング方式でデータを Amazon S3 からダウンロードできるようにするためです。ここで、事前読み取りバイトをメモリにバッファする必要はありません。ストリームをシーク可能にする必要がある場合は、seekable を関数のストリームコンテキストオプションに渡すことができます。


シーク可能なストリームを開く

公式の通りにやってみましょう。
公式は fopen() 関数を例にしてサンプルを置いてくれていますが、SplFileObjectも同様です。

$context = stream_context_create(array(
        's3' => array(
            'seekable' => true
        )
    ));

$file = new SplFileObject($s3_path, 'r', false, $context);

他にもこの辺の関数も同様に使えるので file_exists() なんかを使って存在チェックなどもした方が良いかと思います。

PHPで大量の行数のCSVファイルを分割する際にはまったポイント

表題の通りなのですが、数MBもの CSVファイルをインポートすることになり、その過程の分割スクリプトではまりました。

そもそも分割せずにデフォルトの nginx 環境に まず 1MB 以上のものファイルをアップロードすると、 以下の 413 エラーが出てしまいます。

413 Request Entity Too Large

単純に、サーバー全体でアップロードする容量を大きくしたい場合は、nginx の conf をいじるなどを検討する必要があると思うのですが、常にそうしたいわけでなく今回だけと言うことだったので、数MBのCSVファイルを1MB 未満になるようにいくつかのCSVファイルに分割したいと考えました。

安易な気持ちでスクリプトを書いていたんですが、実際に分割されたデータを見ると変なところで次の行に入ってしまうことがありました。

要は1万行のファイルを3000行ごとに分割する場合

分割ファイル1… 1 ~ 3000 行
分割ファイル2… 3001 ~ 6000 行
分割ファイル3… 6001 ~ 9000 行
分割ファイル4… 9001 ~ 10000 行

が入ると想定してたんですが、変なところで次の行に入っているので、分割ファイル4が 1037行になるみたいな形で1000行を超えてました。

以下のケースがあると発生しました。

  • データの中に改行やカンマなどの記号があった場合
  • 分割時に文字コードを変換した場合


文字コードの方はあまり条件がわかってないですが…変換の際に記号と相性が悪かったりするのかなと思っています。

初めは SplFileObject の fgets() メソッドを使っていたのですが、使わなければ「データの中にカンマがあった場合」以外は解決しました。
setFlags() で うまいこと回避できたかもしれませんが…。

カンマがあった場合については、別の文字(今回は半角スペース)に置き換えました。これでいいかはユースケースに依りますが、今回はこれでよかったのでそうしています。

ということで長々しゃべってしまいましたが、コードです

$filePath = '分割前のファイルのパス';
$rowsPerFile = 3000; // 3000行ごとに分割する
$outputDir = '分割したファイルを置く場所のパス';
$hasHeader = true; // 分割前のファイルがヘッダーを持っているか

$splitter = new FileSplitter();
$splitter->split($filePath, $rowsPerFile, $outputDir, $hasHeader);

class FileSplitter {
    private ?array $rows;
    private int $fileCount;
    private ?string $header;

    public function split($filePath, $rowsPerFile, $outputDir, bool $hasHeader) {
        $this->fileCount = 0;
        $this->rows = null;

        $file = new \SplFileObject($filePath);
        $file->setFlags(\SplFileObject::READ_CSV);
        $rowCount = 0;

        // fgets ではなく $fileを foreach で回している
        foreach ($file as $key => $data) {
            if (!empty($data)) {
                if ($hasHeader && $key === 0) {
                    $this->header  = implode(',', $data);
                    continue;
                }

                if($rowCount % $rowsPerFile === 0) {
                    $this->writeToFile($this->generateOutputFilePath($outputDir, $file));
                }

                // カンマがあったら半角スペースに変換しているので注意
                foreach ($data as $key2 => $datum) {
                    $data[$key2] = str_replace(',', ' ', $datum);
                }

                $this->rows[] = implode(',', $data);
                $rowCount++;
            }
        }

        $this->writeToFile($this->generateOutputFilePath($outputDir, $file));
        $file = null;
    }

    private function generateOutputFilePath($outputDir, \SplFileObject $file) {
        $baseName = $file->getBasename('.'.$file->getExtension());
        $ext = $file->getExtension();
        $num = $this->fileCount + 1;
        return $outputDir.$baseName.'_'.$num.'.'.$ext;
    }

    private function writeToFile($filePath) {
        if($this->rows) {
            $file = new SplFileObject($filePath, 'w');
            
            if (!empty($this->header)) {
                $header_array = explode(',', $this->header);
                $file->fputcsv($header_array);
            }

            foreach ($this->rows as $line) {
                $contents = explode(',', $line);
                $file->fputcsv($contents);
            }
            $this->fileCount++;
            $this->rows = null;
        }
    }
}

大量データを扱うと想定外のことが起きますね。
今回たまたま見つからなかっただけのものも全然ありそうです。

AndroidManifest にそんな権限を記載していないのにしていることになって Google Play Console からその権限を利用するなら 申告してと言われた

表題のまんまです。
結論を言うとアプリのメインと言うかデフォルトのマニフェストファイル内では宣言していなくても利用しているライブラリのマニフェストで使っている可能性があります。

今回 AD_ID が引っかかってしまい実際に宣言されていたのが以下の通りでした。
Google Play Console の App Bundle の詳細より確認が出来ます。

似たような人は結構いて、今回の場合は firebase ががっつり使っていましたね…。

[GA4] データ収集

android studio で ライブラリが必要とする権限とかすぐにわかったりしないのかしら…。