Show removed metadata in strip UI after download

Extracts EXIF, ICC, IPTC, XMP metadata before stripping and displays
it in a card below the drop zone — camera make/model, GPS, date taken,
lens, exposure settings, etc. GPS location gets a warning highlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:01:42 -07:00
parent f836948e6d
commit d263bec6bb
3 changed files with 94 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import fs from "fs/promises";
import { randomUUID } from "crypto";
import libheifModule from "libheif-js";
import exifReader from "exif-reader";
const libheif = libheifModule?.default ?? libheifModule;
const app = express();
@@ -299,6 +300,15 @@ app.get("/", (_req, res) => {
.drop input { display: none; }
.status { margin-top: 1rem; text-align: center; font-size: .9rem; color: #888; }
.status.error { color: #f66; }
.removed { margin-top: 1.2rem; background: #1a1a1a; border: 1px solid #333; border-radius: 10px;
padding: 1rem 1.2rem; font-size: .85rem; }
.removed h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #666;
margin-bottom: .6rem; }
.removed table { width: 100%; border-collapse: collapse; }
.removed td { padding: .25rem 0; vertical-align: top; }
.removed td:first-child { color: #888; padding-right: .8rem; white-space: nowrap; }
.removed td:last-child { color: #eee; word-break: break-word; }
.removed .warn { color: #f90; }
.overlay { position: fixed; inset: 0; background: #111; display: flex;
align-items: center; justify-content: center; z-index: 10; }
.auth { max-width: 340px; width: 100%; padding: 2rem; text-align: center; }
@@ -328,6 +338,10 @@ app.get("/", (_req, res) => {
<input type="file" id="file" accept="image/*">
</div>
<div class="status" id="status"></div>
<div class="removed" id="removed" style="display:none">
<h3>Metadata Removed</h3>
<table><tbody id="removed-body"></tbody></table>
</div>
</div>
<script>
(function() {
@@ -340,6 +354,8 @@ app.get("/", (_req, res) => {
const drop = document.getElementById("drop");
const fileInput = document.getElementById("file");
const status = document.getElementById("status");
const removedDiv = document.getElementById("removed");
const removedBody = document.getElementById("removed-body");
function showApp() { overlay.style.display = "none"; main.style.display = ""; }
function showAuth() { overlay.style.display = ""; main.style.display = "none"; tokenErr.textContent = ""; }
@@ -369,6 +385,8 @@ app.get("/", (_req, res) => {
status.className = "status";
status.textContent = "Stripping metadata…";
removedDiv.style.display = "none";
removedBody.innerHTML = "";
try {
const res = await fetch("/strip", {
@@ -392,6 +410,29 @@ app.get("/", (_req, res) => {
throw new Error(err.message || "Strip failed");
}
// Show removed metadata
const metaHeader = res.headers.get("X-Removed-Metadata");
if (metaHeader) {
try {
const meta = JSON.parse(metaHeader);
const keys = Object.keys(meta);
if (keys.length > 0) {
keys.forEach(function(k) {
const tr = document.createElement("tr");
const td1 = document.createElement("td");
td1.textContent = k;
const td2 = document.createElement("td");
td2.textContent = meta[k];
if (meta[k].includes("⚠")) td2.className = "warn";
tr.appendChild(td1);
tr.appendChild(td2);
removedBody.appendChild(tr);
});
removedDiv.style.display = "";
}
} catch {}
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -425,6 +466,45 @@ app.get("/", (_req, res) => {
</html>`);
});
async function extractMetadata(buf) {
const removed = {};
try {
const meta = await sharp(buf, { failOnError: false }).metadata();
if (meta.width && meta.height) removed["Dimensions"] = `${meta.width} × ${meta.height}`;
if (meta.format) removed["Format"] = meta.format.toUpperCase();
if (meta.space) removed["Color Space"] = meta.space;
if (meta.density) removed["DPI"] = String(meta.density);
if (meta.hasProfile) removed["ICC Profile"] = meta.icc ? `${(meta.icc.length / 1024).toFixed(1)} KB` : "Yes";
if (meta.orientation && meta.orientation !== 1) removed["Orientation"] = String(meta.orientation);
if (meta.exif) {
removed["EXIF Data"] = `${(meta.exif.length / 1024).toFixed(1)} KB`;
try {
const exif = exifReader(meta.exif);
if (exif.Image?.Make) removed["Camera Make"] = String(exif.Image.Make);
if (exif.Image?.Model) removed["Camera Model"] = String(exif.Image.Model);
if (exif.Image?.Software) removed["Software"] = String(exif.Image.Software);
if (exif.Photo?.DateTimeOriginal) {
const d = exif.Photo.DateTimeOriginal;
removed["Date Taken"] = d instanceof Date ? d.toISOString().slice(0, 19).replace("T", " ") : String(d);
}
if (exif.Photo?.LensModel) removed["Lens"] = String(exif.Photo.LensModel);
if (exif.Photo?.ExposureTime) removed["Exposure"] = `1/${Math.round(1 / exif.Photo.ExposureTime)}s`;
if (exif.Photo?.FNumber) removed["Aperture"] = `f/${exif.Photo.FNumber}`;
if (exif.Photo?.ISOSpeedRatings) removed["ISO"] = String(exif.Photo.ISOSpeedRatings);
if (exif.Photo?.FocalLength) removed["Focal Length"] = `${exif.Photo.FocalLength}mm`;
if (exif.GPSInfo?.GPSLatitude) removed["GPS Location"] = "Removed ⚠️";
if (exif.Photo?.ImageUniqueID) removed["Unique ID"] = "Removed";
if (exif.Image?.HostComputer) removed["Device"] = String(exif.Image.HostComputer);
} catch {}
}
if (meta.iptc) removed["IPTC Data"] = `${(meta.iptc.length / 1024).toFixed(1)} KB`;
if (meta.xmp) removed["XMP Data"] = `${(meta.xmp.length / 1024).toFixed(1)} KB`;
} catch {}
return removed;
}
app.post("/strip", async (req, res) => {
res.setHeader("Connection", "close");
@@ -442,6 +522,9 @@ app.post("/strip", async (req, res) => {
withoutEnlargement: true,
};
// Extract metadata before stripping
const removed = await extractMetadata(req.body);
let jpeg;
if (looksLikeHeic(req.body)) {
if (isAborted(req, res)) return;
@@ -454,6 +537,9 @@ app.post("/strip", async (req, res) => {
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "image/jpeg");
try {
res.setHeader("X-Removed-Metadata", JSON.stringify(removed));
} catch {}
return res.send(jpeg);
} catch (e) {
const status = e?.statusCode || 500;