Try the app

写真の位置情報(Exif)消しゴム(PrivacyPhoto)
Enjoyed this article?
Support zero-dev. jp
Sat, Nov 7, 20:00 JST Tsukutta-chan live stream
Watch on YouTube(opens YouTube in a new tab)Translation pending
写真のExif(位置情報)を消去するツール『PrivacyPhoto』の開発記録。消えたことを“証明”する仕組みと、壊れた写真に『安全』と表示してしまった問題の話
Try the app

写真の位置情報(Exif)消しゴム(PrivacyPhoto)
Enjoyed this article?
Support zero-dev. jp
写真の位置情報(Exif)消しゴム「PrivacyPhoto」は、写真に含まれるGPS位置情報や撮影日時・機種名などのExifメタデータを、ブラウザの中だけで検出して消去するWebツールです。写真データを外部サーバーへ一切送信しません。SNSやフリマアプリに投稿する前に「この写真、自宅の場所が入っていないか」を確認し、その場で消すための道具です。
この記事では機能紹介ではなく、開発と公開後の見直しで実際に詰まった点――特に「消すこと」より「消えたと証明すること」と、「確認できていないのに安全と表示しないこと」――を記録します。
GPS座標は、自宅や職場、子どもの生活圏そのものです。これをサーバー側で処理する設計にすると、アップロード経路の盗聴や、サーバー上の一時保持・ログへの残留といったリスクが常につきまといます。
そこで「写真は端末の外に出ない」を仕様の中核に置きました。この一文が決まると、実装の形はほぼ自動的に決まります。静的ホスティングで足りること、Exifパーサーは外部CDNに頼らず同梱すること、処理はCanvasで完結させること。プライバシーの方針が、そのままアーキテクチャになっています。
メタデータの除去自体は単純です。画像をCanvasに描画し直して新しいJPEGを生成すれば、元のExifセグメント(APP1領域)は引き継がれません。ただし、そこに2つの落とし穴がありました。
1つ目は、透過PNGをJPEGにすると透明部分が真っ黒になる問題です。JPEGには透明の概念がないため、描画前に白背景(#FFFFFF)を敷いてから合成する処理が必須でした。
2つ目は、写真の向き(Exif Orientation)です。スマホ写真の向きはExifに記録されていますが、現在のブラウザは画像の読み込み時に自動で補正して正立表示します。ここでCanvas側でも回転処理を書くと、二重に回転して横倒しの画像ができあがります。正解は「回転処理を書かないこと」で、ブラウザの自動補正済みの寸法のまま描画します。直感に反するので、仕様書には理由ごと残し、Orientation 1・3・6・8の実測テストで裏づけを取りました。
「消去しました」という表示だけでは、利用者は本当に消えたのか分かりません。そこで、生成した画像データに対して同じExif解析をもう一度実行し、タグが残っていないことを確認してから結果を表示する仕組みにしました。除去と検証を同じパーサーで対にするのがポイントです。
「外部通信ゼロ」も公約で終わらせていません。ページ読み込みから処理・ダウンロードまでの全リクエストを監査するテストで、外部ドメインへのリクエストが0件であることを確認しています。さらにCSP(default-src 'none' を基調とする設定)で、ブラウザのエンジン側でも通信を遮断しています。約束ではなく、破れない構造と検査で担保する形です。
公開後の深層レビューで見つかったのが、読み込めない壊れたファイルを入れたときの表示です。デコードできないファイルに対して「Exifメタデータは検出されませんでした」と、問題がないかのような表示を出していました。
位置情報を消す道具が、確認できていない写真に「安全」と言う。これは最悪のパターンです。解析結果へは進まず、エラーとして初期画面に戻すよう修正し、「壊れた画像で安全と表示しない」ことを確認する回帰テストを追加しました。
同じ観点で、パーサーの境界条件も見直しています。GPSの度・分・秒の配列が欠けたデータで、足りない分を0で補うと、もっともらしい誤った座標が計算できてしまいます。要素が揃わない場合は座標を出さない。分母が0の値は「0」ではなく「なし(null)」として扱う。ファズテスト400件で境界を潰しました。
深層レビューでは、操作の競合も見つかりました。写真を連続で投入すると、先に始めた解析が後から終わり、新しい写真の解析結果を古い結果で上書きしてしまう可能性があります。解析の世代IDを持たせて、古い処理の結果は破棄する方式に変えました。あわせて、除去処理中の二重実行を防ぐロックと、50MB上限のような同期チェックを先に済ませて即座に反応を返す工夫も入れています。単機能のツールでも、非同期処理があるだけで状態管理は油断できないという例です。
もう1つ、見直しで効いたのがテスト側の不具合です。ブラウザが1つも起動できなかった場合でも、テストが終了コード0(成功)で終わるものがありました。落ちても緑色に見える検査は、無いより悪い。起動できなければ失敗として終わるよう、テスト5本の終了コードを直しました。検査する側の正しさも、検査対象と同じくらい大事だという話です。
CSPには 'unsafe-inline' が残っています。これは甘さではなく、意図して維持した決定です。過去にWebKitでCSP違反が発生した経緯があり、この開発環境ではWebKitの再検証が通常権限で実行できません。未検証のまま締めて、特定のブラウザで壊すほうがリスクが高いと判断しました。
大事なのは、この決定の理由と、外すための代替案(表示切り替えの11箇所をクラス切り替えへ移す)と、未確認事項(WebKitでどの操作が違反したか未特定)まで、コード内コメントと品質記録に残したことです。「なぜ緩いままなのか」が後から追える状態にしておくのも、品質の一部だと思っています。
公開までに行った検証は、Chromium・Firefox・WebKitの3ブラウザでのE2E(全て合格、CSP違反・JSエラー0件)、「選択→消去→リセット」を50回繰り返すメモリリーク計測(未解放のObject URL 0件)、公開URLに対するE2Eの再実行などです。修正は承認ポイントを挟み、公開用リポジトリへ同期したあと、本番URLで同じ検査をもう一度回しています。
正直な制限も書いておきます。iPhoneのHEIC形式には対応していません(JPEGに変換してから使ってください)。出力はすべてJPEGになります。そして、写真に写り込んだ看板や風景そのものは消えません。消えるのはあくまでメタデータです。
「消す」のはCanvasで数行の処理ですが、「消えたことを証明する」「安全でないときに安全と言わない」「検査自体が正しいか」まで含めて、やっと道具として信用できる形になったと感じています。