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:
86
server.js
86
server.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user