ラベル PHP の投稿を表示しています。 すべての投稿を表示
ラベル PHP の投稿を表示しています。 すべての投稿を表示

2026-03-08

今日こそ終止符!iPhoneの標準メーラーで添付ファイル名が文字化けする原因とその対応

OutlookからiPhoneにメールを送ると、添付ファイル名が =?UTF-8?Q?...?= と化ける問題。長年「相性の問題」で片付けられてきたこの怪奇現象に、休日の半日を潰してトドメを刺してきました。

1. 境界線の発見:「5Nの法則」

実験の結果、ファイル名に日本語(非ASCII)が混じっている場合、ある一定の条件でエンコード方式が切り替わることが分かりました。それが以下の「5Nの法則」です。

5Nの法則:
非ASCII文字を N 個含むとき、ASCII文字(英数字)が 5N 個に達した瞬間にiPhoneで文字化けが発生する。
非ASCII数 (N) Base64限界 (ASCII) 文字化け発動 (ASCII)
1文字 (あ) 4文字 5文字以上
2文字 (あい) 9文字 10文字以上
3文字 (あいう) 14文字 15文字以上

この法則はUTF-8だけでなく、欧州圏の iso-8859-1 (Latin-1) でも全く同様に発動しました。

2. なぜ「5」なのか?数学的背景

Outlookはメールヘッダーを1バイトでも節約しようと、以下の2つのエンコードを動的に使い分けています。

  • MIME-B (Base64): 全体を1.33倍にする。
  • MIME-Q (Quoted-Printable): 英数字はそのまま、非ASCIIを =XX 形式にする。

Latin-1の1バイト文字 Á を例にとると、Qエンコードでは =C1 (3バイト) に膨らみます。計算すると、英数字が5文字を超えたあたりで、全体をBase64で包むよりも、英数字をそのまま送れるQエンコードの方がヘッダーサイズが短くなるのです。Outlookの「ケチな最適化」がこの「5」という数字を生んでいます。

3. 犯人は誰だ?PHPによる「パケット偽装」実験

「Outlookが生成するQエンコードが悪いのか、それともiPhoneが悪いのか」を特定するため、以下の検証スクリプトを作成しました。意図的に添付ファイル名をQエンコードで送りつける「嫌がらせ」コードです。

// --- 検証用添付ファイルパート抜粋 ---
switch ($attachment_type) {
    case "B": // RFC 2047 Base64
        $filename_encoded = mb_encode_mimeheader($filename, "UTF-8", "B");
        $head = "Content-Type: application/octet-stream; name=\"{$filename_encoded}\"\r\n";
        $head .= "Content-Disposition: attachment; filename=\"{$filename_encoded}\"\r\n";
        break;
    case "Q": // RFC 2047 Quoted-Printable
        $filename_encoded = mb_encode_mimeheader($filename, "UTF-8", "Q");
        $head = "Content-Type: application/octet-stream; name=\"{$filename_encoded}\"\r\n";
        $head .= "Content-Disposition: attachment; filename=\"{$filename_encoded}\"\r\n";
        break;
    default: // RFC 2231 (モダンな形式)
        $head = "Content-Type: application/octet-stream;\r\n";
        $head .= "Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename) . "\r\n";
}

検証結果

  • To/FromヘッダーのQエンコード: iPhoneでも正常表示。
  • 添付ファイル名のQエンコード: iPhoneで文字化け確定。

驚くべきことに、iPhoneはQエンコードそのものが解釈できないわけではありません。「ヘッダー(宛先など)なら読めるが、添付ファイル名の場所にあるQエンコードだけは無視する」という、極めて偏った実装をしていたのです。

4. 結論:我々が取るべき自衛策

Outlook先輩が「1バイトでも削って効率化しよう(Qを使おう)」とした善意が、iPhoneの「そこはBase64かRFC2231しか読まないよ」という偏食によって仇となる。これがこの問題の正体です。

現状、現場のエンジニアができる唯一の対策は、「英数字が5の倍数に達する前に、全角文字を1文字追加して、OutlookにBase64を強制させる」という、泥臭いファイル名ハックのみです。

もし、あなたの周りでファイル名が化けて困っている人がいたら、そっと「全角文字をもう一文字増やしてみて」と伝えてあげてください。

そして、欧州のお友達には、逆に「非ASCII文字を添付ファイル名に使わないで!」と教えてあげてください。

2017-07-08

Aレコードさえあればメールは届く

俺:80%?冗談じゃない、この設定で100%メールは届きます。

無能:MXレコードは定義されていない?

俺:あんなの飾りです。MXレコードなんてなくても、www.example.comでAレコードさえ定義してあれば、 apache@www.example.com っていう Return-Path にも、メールは返ってくるんです。

2015-12-05

やっぱりおかしい

愚痴。

これの意味わかるひと~

define('DATA_REGIST', 0);
define('DATA_NOTREGIST', -1);

define('DATA_VISIBLE', 0);
define('DATA_UNVISIBLE', -1);


なんだろう、このもやもや感。
自称スーパーなPGが書いたPHPのソースに書いてあった。


>> 2024.11.
そいつはもういないらしい。
辞める数年前には、俺はそいつを嫌いになっていた。

2015-11-15

php で include/require しているファイルの一覧を find と grep で作成する

include (require) で読み込んでいるファイルの一覧がほしかったので、find & grep で作ってみた。

例:common/html というディレクトリに共通ファイル(ヘッダやフッタ)がある場合
# cd /var/www/html
# find . -type f -name "*.php" | xargs grep -E "common/html"

2015-06-05

CentOS + Apache + PHP + PDO で SQL Server へ接続

前提:httpd、phpのインストールが終わっていること
# yum install httpd php

作業:
# yum install epel-release
# vi /etc/yum.repos.d/epel.repo
[epel]

enabled=0


# yum --enablerepo=epel install php-mssql
# vi /etc/freetds.conf

[MSSQLSVR2012]
        host = 1.2.3.4
        port = 1433
        tds version = 8.0
        charset = CP932
        client charset = UTF-8

# vi /etc/selinux/config

SELINUX=disabled


# restart

2015-01-10

PHP のメール送信フォームなどで iCloud 宛のメールが届かない場合

# vi /etc/php.ini
mail.add_x_header = off

これで届くようになる。

ただ、spamフィルタリングされているようで、他の原因で届かない(アカウントのメールボックスに配信される前にフィルタされ、引っかかると届けないようなので、迷惑メールフォルダにすら振り分けられない)こともあるので、その時は、これ以外の要因も探る。

X-Powered-By ヘッダを消す

httpd.conf で消す方法

# vi /etc/httpd/conf/httpd.conf

Header always unset X-Powered-By



php.ini で消す方法

# vi /etc/php.ini

expose_php = off



httpf.conf で消しておいた方が無難かと。

2014-10-22

ついでに調べたLAMPのバージョン(CentOS 5 と CentOS 6)

2014/10/22 時点で、標準リポジトリからインストール可能なバージョンを調べた。


■ CentOS 5.11
パッケージ名          バージョン
httpd                 2.2.3-91
php                   5.1.6-44
php53                 5.3.3-23
mysql-server          5.0.95-5
mysql51-mysql-server  5.1.70-1
mysql55-mysql-server  5.5.37-1


■ CentOS 6.5
パッケージ名          バージョン
httpd                 2.2.15-31
php                   5.3.3-27
mysql-server          5.1.73-3



# yum install パッケージ名

MySQL5.5は、CentOS 6.5の標準リポジトリからはインストールできないようだ。

2014-10-21

CentOS 7 で LAMP (Linux + Apache + MySQL + PHP)

CentOS 7 から MySQL が MariaDB になったので、こんな感じでインストールする。

# yum install httpd mariadb-server php php-mysql


後は、必要なphpモジュールを追加でインストールして終わり。


ちなみに、インストールされたバージョンは、2014/10/21現在でこんな感じ

# yum list installed
httpd.x86_64                          2.4.6-18.el7.centos
mariadb-server.x86_64                 1:5.5.37-1.el7_0
php.x86_64                            5.4.16-23.el7_0.1


MySQL が MariaDB になった理由は、Wikipedia 参照で。

■ MariaDB - Wikipedia
http://ja.wikipedia.org/wiki/MariaDB

2014-05-30

PHPでheader()を使ってHTTPヘッダを送信する

忘れるのでメモっておく。

header("Content-type: text/html; charset=UTF-8");
header("Content-type: text/html; charset=Shift_JIS");
header("Content-type: text/html; charset=EUC-JP");

2014-01-12

mb_send_mail()するならReturn-Pathを第5引数に

mb_send_mail()を使ってメールを送信する場合は、第5引数に Return-Path を指定する。

さもなければ、Return-Path は apache@hoge.dom (コマンドラインから実行したときは、実行アカウント名@hoge.dom)になる。

第5引数に Return-Path を指定する方法はこんな感じ

<?php
//  日本語だよって宣言して
mb_language('ja');

//  UTF-8で組んでるなら、文字エンコードをUTF-8に合わせて
mb_internal_encoding("UTF-8");

//  ヘッダーとReturn-Pathを指定して、mb_send_mail()
mb_send_mail("dare@doko.koko", "メールの送信テスト", "これは、メールの送信テストです。", "From:ore@example.dom", "-f ore@example.dom");


問い合わせフォームなんかでメールアドレスを入力させるインターフェイスがある。
問い合わせ者へ受付メールを送信するのが一般的だが、もし、問い合わせ者がメールアドレスを入力ミスした場合、550 unknown user の送信先は Return-Path になるため、正しく指定しないと誰も間違いに気づかない。

まぁ、問い合わせ先の担当者が、問い合わせ者が入力したメールアドレスへメールを送れば気づくけどね。
どうせなら、最初から気づいた方がいいと思うけど。

2013-08-25

MySQLiクラスをラップする

<?php
/**
 *  MySQLiクラスの拡張
 */
class MySQLiDB extends mysqli {

    private $_host;
    private $_user;
    private $_pass;
    private $_db;
    private $_char;
    private $_timeout;

    public function __construct($host, $user, $pass, $db, $char, $timeout) {

        parent::init();

        //  AUTOCOMMIT オフ
        if (!parent::options(MYSQLI_INIT_COMMAND, "SET AUTOCOMMIT = 0")) {
            die("Setting MYSQLI_INIT_COMMAND failed");
        }

        //  接続のタイムアウト 設定
        if (!parent::options(MYSQLI_OPT_CONNECT_TIMEOUT, $timeout)) {
            die("Setting MYSQLI_OPT_CONNECT_TIMEOUT failed");
        }

        //  接続
        if (!parent::real_connect($host, $user, $pass, $db)) {
            die("Connect Error (" . mysqli_connect_errno() . ") " . mysqli_connect_error());
        }

        //  デフォルトのクライアント文字エンコーディング 設定
        if (!parent::set_charset($char)) {
            die("Error loading character set {$char}: %s\n" . $mysqli->error);
        }

        //  ローカル変数に保存
        $this->_host = $host;
        $this->_user = $user;
        $this->_pass = $pass;
        $this->_db   = $db;
        $this->_char = $char;
        $this->_timeout = $timeout;

    }
}

2013-08-21

mb_send_mail() するなら mb_convert_encoding() は不要

おれも勘違いしていたが、mb_send_mail()を使ってメールを送るとき、mb_convert_encoding()使って、ISO-2022-JPへ変換しなくていい。

具体的には、たったこれだけ。

<?php
//  日本語だよって宣言して
mb_language('ja');

//  UTF-8で組んでるなら、文字エンコードをUTF-8に合わせて
mb_internal_encoding("UTF-8");

//  mb_send_mail()するだけ
mb_send_mail("dare@doko.koko", "メールの送信テスト", "これは、メールの送信テストです。");

これだけで、
件名は、mb_encode_mimeheader(mb_convert_encoding("件名", "ISO-2022-JP", "UTF-8"), "ISO-2022-JP") してくれるし
本文は、mb_convert_encoding("本文", "ISO-2022-JP", "UTF-8") してくれる。

ただ、Fromに日本語使いたいときだけは、自前で

$from = mb_encode_mimeheader(mb_convert_encoding("おれから", "ISO-2022-JP", "UTF-8"), "ISO-2022-JP")."<ore@doko.soko>";

ってやってから

mb_send_mail("dare@doko.koko", "メールの送信テスト", "これは、メールの送信テストです。", "From:".$from);

って送ってあげる。

2013-07-12

HTTP_HOST と SERVER_NAME の違い

あざーっす。

http://phpspot.org/blog/archives/2006/05/server_namehttp.html


--2013/9/17 追記--
上の説明を読んで色々試してみたんだけど、やっぱり違いがわからなかった。
そんな時は、やっぱりドキュメント読まなきゃね。

■Apache HTTP サーバ バージョン 2.2 ドキュメント
UseCanonicalName ディレクティブ

2013-06-25

header()で飛ばされてもリファラは

例えば、

http://www.example.dom/a.html



<a href="./b.php">リンク</a>

のリンクを踏んで、b.php へ飛んだとする。

b.php が

<?php
header("Location: ./c.php");
?>

で、c.php が

<?php
echo $_SERVER['HTTP_REFERER'];
?>

だった場合、c.php で echo されるリファラは

http://www.example.dom/a.html

となる。

2013-04-03

PHPで非同期処理

こんな感じで開始時刻をミリ秒ではいて、5秒間待つだけの処理を

test_mulitproc.php
<?php
list($microSec, $timeStamp) = explode(" ", microtime());
error_log(date('Y-m-d H:i:', $timeStamp) . (date('s', $timeStamp) + $microSec));
sleep(5);



同じくこんな感じで実行してみると

test_multiexec.php
<?php
error_log('同期処理開始');
exec('php /var/www/test_multiproc.php 1> /dev/null 2>&1');
exec('php /var/www/test_multiproc.php 1> /dev/null 2>&1');
exec('php /var/www/test_multiproc.php 1> /dev/null 2>&1');
exec('php /var/www/test_multiproc.php 1> /dev/null 2>&1');
exec('php /var/www/test_multiproc.php 1> /dev/null 2>&1');
error_log('非同期処理開始');
exec('nohup php /var/www/test_multiproc.php > /dev/null &');
exec('nohup php /var/www/test_multiproc.php > /dev/null &');
exec('nohup php /var/www/test_multiproc.php > /dev/null &');
exec('nohup php /var/www/test_multiproc.php > /dev/null &');
exec('nohup php /var/www/test_multiproc.php > /dev/null &');



実行結果はこんな感じ。

[01-Apr-2013 20:28:30] 同期処理開始
[01-Apr-2013 20:28:30] 2013-04-01 20:28:30.742573
[01-Apr-2013 20:28:35] 2013-04-01 20:28:36.031054
[01-Apr-2013 20:28:40] 2013-04-01 20:28:41.291464
[01-Apr-2013 20:28:46] 2013-04-01 20:28:46.56506
[01-Apr-2013 20:28:51] 2013-04-01 20:28:51.827265
[01-Apr-2013 20:28:56] 非同期処理開始
[01-Apr-2013 20:28:57] 2013-04-01 20:28:57.468525
[01-Apr-2013 20:28:57] 2013-04-01 20:28:57.616613
[01-Apr-2013 20:28:57] 2013-04-01 20:28:57.644256
[01-Apr-2013 20:28:57] 2013-04-01 20:28:57.69278
[01-Apr-2013 20:28:57] 2013-04-01 20:28:57.70006

2013-02-28

PHPで2重ポスト対策 その2 同一ページ内で処理

流れのサンプルなので、XSSとかSQLインジェクションとか考慮してません。

なので、使うときは考慮してね。

<?php
$act    = isset($_POST['act'])  ? $_POST['act']  : 'init';
$data   = isset($_POST['data']) ? $_POST['data'] : '';
$ticket = '';

session_start();

//  初期化処理
if ($act == 'init') {
    $act = 'check';
}
//  チェック処理
else if (($act == 'check') || ($act == 'regist')) {
    if (チェック処理正常) {
        $act = 'regist';
        $ticket = time();
        $_SESSION['ticket'] = $ticket;
    }
    //  登録処理
    else {
        //  2重ポスト時
        if (empty($_SESSION['ticket'])) {
            //  2重ポストされたときの処理
        }
        //  不正なポスト時
        else if ($ticket != $_SESSION['ticket']) {
            //  不正な処理
        }
        //  正常なポスト時
        else {
            //  先にチケットを空にする
            unset($_SESSION['ticket']);

            //  登録処理
            if (登録処理正常) {
                header("Location: 登録完了ページのURL", true, 303);
                exit();
            }
            else {
            }
        }
    }
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>sample</title>
<meta name="keywords" content="">
<meta name="description" content="">
</head>
<body>
<form method="post">
<input type="text"   name="data"   value="<?php echo $data ?>">
<input type="submit" value="<?php echo $caption ?>">
<input type="hidden" name="ticket" value="<?php echo $ticket ?>">
<input type="hidden" name="act"    value="<?php echo $act ?>">
</form>
</body>
</html>

2013-02-23

リクエストはキャンセルできない

基本的なことを知らなかった。


<?php
$fp = fopen('/var/tmp/hoge.txt', 'wb');
for ($i = 0; $i < 1000; $i++) {
    //  カウンタをファイルへ出力
    fwrite($fp, "{$i}\n");
    //  1秒待機
    sleep(1);
}



千秒カウントし、カウンタをファイルに出力するPGを作ってみた。

このPGへブラウザからアクセスして、途中で [ Esc ] キーでキャンセルした。



つもりだった。



で、サーバー上のファイルを less コマンドで開いて [Shift] + G で何気に表示を更新していたら、ファイルにカウンタが出力され続けていた。



・・・一度受け付けたリクエストは、サーバー上で処理が終わるか、サービスが再起動されるまで処理され続けることがわかった。





知らなかったおれ、恥ずかしい・・・

2013-01-29

PDOのプリペアドステートメントで同じ名前のパラメータマーカは使えない

PDOでプリペアドステートメントを使うとき、クエリ中にパラメータマーカを指定するが、このとき、同じ名前のパラメータマーカは使えない。
でも、使ってもエラーにならない。

クエリの結果を精査すれば、クエリで想定した結果にならないためわかるのだが、テストが嫌いな俺は、クエリそのものを検査することにした。

//  すべてのパラメータマーカを取得する。
preg_match_all('/(:[\w]+)/', $sql, $matches);

//  パラメータマーカの出現回数を得る。
$param = array_count_values($matches[1]);

//  2回以上出現したパラメータマーカを調べる。
foreach($param as $key => $val) {
  if($val > 1) {
    echo "パラメータマーカ {$key} を {$val} 個検出しました。";
    //  ここでエラーを発生させるか、強制終了する。
  }
}

こんな感じのロジックをPDOを継承したクラスに仕込んでおけばOK。

class dao extends PDO
{
    const DAO_ERROR_PARAMETER_MARKER_DUPLICATED = 777;

    //  今回必要なところだけ記述
    public function prepare($statement, $options = array())
    {
        preg_match_all('/(:[\w]+)/', $sql, $matches);
        $param = array_count_values($matches[1]);
        foreach($param as $key => $val) {
            if($val > 1) {
                //  エラーモードに応じて、例外を発生させるか、falseを返す。
                $err_mode = $this->getAttribute(PDO::ATTR_ERRMODE);
                if($err_mode === PDO::ERRMODE_EXCEPTION) {
                    throw new Exception("パラメータマーカ {$key} を {$val} 個検出しました。"
                                      , self::DAO_ERROR_PARAMETER_MARKER_DUPLICATED);
                }
                else {
                    return false;
                }
            }
        }
        return parent::prepare($statement, $options);
    }
}

2013-01-25

phpでstdoutとstderrを使い分ける

こんな感じ。

fwrite(STDOUT, '標準出力です');
fwrite(STDERR, '標準エラー出力です');

■入出力ストリーム
http://php.net/manual/ja/features.commandline.io-streams.php

綺麗に見せることに全振りした操法大会の訓練を50日間するよりも、月1回でいいから基本的な操作方法の訓練を実施すべきだと思うのは俺だけか

 7月に県の消防操法大会がある。 市内分団の持ち回り制で、今年はうちの分団が出場する。 俺も前回回ってきたときに、選手として出た。 訓練が5月から始まっている。 週5日。 訓練時間は2時間だけど、その前に準備する時間もあり、訓練後の撤収時間も必要だから、拘束時間は約3時間。 俺が...