手機拍的照片裡通常帶著 GPS 座標,分享一張照片給朋友,等於順便告訴了他拍攝地點,如果那是家裡的窗景,就是順便告訴了他住址。
所以一個注重安全隱私的圖片分享服務,都應該要清掉 EXIF。
簡單科普 EXIF
EXIF 是手機或相機拍照時,會自動儲存在照片中的拍攝相關資訊,例如:時間、裝置、GPS 位置等中繼資料。
最簡單且受信任的作法
資料流過任何地方都有可能受到紀錄,透過 AP 端上傳,也意味著上傳的圖片或影像能夠被後端伺服器讀取,惡意的經營者可能就會將其備份原檔案,留下一份具有 EXIF 資訊的原檔,團隊的處理方式如同上一篇文章中提到的,上傳流量並不會通過 AP 伺服器端,所以能做的只有在使用者瀏覽器上進行處理,最終在伺服器儲存的檔案,從頭到尾沒有見過那些座標,也避免不必要的爭議點。
大部分格式不用特別處理
Mork 的圖片本來就會在瀏覽器裡經過一次重新編碼,會員的浮水印也是在那一次蓋上去的,所以 JPEG、PNG、WebP 這幾種格式的 EXIF、GPS、XMP 是順便消失的,不需要為它寫任何一行程式碼。
順帶一提,這也是為什麼浮水印跟清中繼資料要放在同一次編碼裡,分成兩件事做的話,圖片會被壓兩次,每一次都掉一點品質。
動圖走不了這條路
GIF 沒辦法比照辦理,原因有兩層。
第一層是 <canvas> 只畫得下第一格,把動圖畫進去,動畫就沒了,第二層是就算畫得進去也編不回來,HTML 規格明訂 toBlob() 遇到不支援的格式要靜默退回 PNG——不會報錯,你會拿到一張看起來正常的靜態圖,然後在使用者回報「我的動圖不會動」的時候才發現。
那能不能直接送原檔?可以,但那樣同樣是上傳圖片,隱私待遇卻不一樣,JPEG 清得乾乾淨淨,GIF 原封不動,而使用者不會知道自己傳的是哪一種。
所以 GIF 得自己剝。
一塊一塊剝掉
GIF 的結構是一連串的區塊,每一塊前面有個標記說明它是什麼,要留的是畫面相關的部分,包括影像資料、色盤、每格延遲、循環次數;要丟的是註解,以及應用程式擴充裡跟畫面無關的那些。
從檔頭開始走:
// 檔頭 6 bytes + 邏輯螢幕描述 7 bytes
let headerEnd = 13;
const packed = b[10];
if (packed & 0x80) { // 有全域色盤
headerEnd += 3 * (1 << ((packed & 0x07) + 1));
}
然後照著標記一路往下,遇到 0x3B(Trailer)就是檔案結束。
真正的陷阱在截斷的檔案。走 sub-block 串列的時候,每一段前面有一個長度位元組,0 表示結束:
const skipSubBlocks = (q) => {
while (q < b.length) {
const n = b[q];
if (n === 0) { return q + 1; }
q += 1 + n;
}
return -1; // 走到尾端還沒遇到結束標記
};
檔案如果在中間被截斷,那個 0 就永遠不會出現,少了 return -1 這條路,迴圈會一直加下去讀到陣列外面,在 JavaScript 裡讀到的是 undefined,接著 q += 1 + undefined 變成 NaN,而 NaN < b.length 是 false,迴圈就靜靜地結束了,最後吐出一個壞掉的 GIF。這種錯不會拋例外,只會在使用者那邊變成一張打不開的圖。
剝不動的時候送原檔
最後一個要決定的是剝除失敗怎麼辦,我們的選擇是回 null,然後送原檔。
這聽起來像放棄,但另外兩個選項更糟,擋下來不給傳,等於因為自己寫的解析器看不懂一個合法檔案,就讓使用者傳不了圖;硬送剝完的位元組,則是送出一個連自己都不確定正確的檔案,使用者拿到一張壞掉的圖,還不知道為什麼。
送原檔至少是「跟你選的那個檔案一模一樣」,這件事寫在常見問題裡,不會假裝沒發生。
影片沒有這個待遇
瀏覽器裡的影片編輯器會重新編碼,那一次同樣會順手清掉位置與器材資訊,但直接上傳、沒有編輯過的影片不會被處理——重新編碼一支大檔案要好幾分鐘,不能為了清中繼資料就強迫每一個人等。
這件事寫在影片頁上,不是藏在條款裡。如果拍攝地點重要而你又不打算編輯,先用別的工具清掉,或是錄影的時候就把定位關掉。