1// gps/location metadata removal, ported from the old file-scan.ts. runs
2// before hashing so the stored hash always reflects the scrubbed file.
3// requires the file to be fully written (the scanner's stability gate runs
4// first); modifies the file in place while preserving timestamps.
5const exiftoolBin = testProgram("exiftool");
6
7// `-ee` on an iphone video dumps per-frame embedded metadata, which easily
8// exceeds execFile's default 1MB stdout buffer
9const execOptions = { maxBuffer: 64 * 1024 * 1024 };
10
11export async function scrubLocationMetadata(
12 path: Path,
13 stats: fs.Stats,
14 progress: progress.Ref,
15): Promise<boolean> {
16 using _ = progress.start("scrub exif metadata");
17 const ext = path.ext.toLowerCase();
18 if (!rules.extsScrubExif.has(ext)) return false;
19 if (!exiftoolBin) {
20 warnMissingExiftool();
21 return false;
22 }
23
24 let hasLocation = false;
25 let args: string[] = [];
26
27 // Check for location metadata based on file type
28 const tempOutput = UNWRAP(path.parent).join(`.tmp.${path.base}`);
29 switch (ext) {
30 case ".jpg":
31 case ".jpeg":
32 case ".png":
33 const { stdout: gpsCheck } = await subprocess.exec("exiftool", [
34 "-gps:all",
35 path.toString(),
36 ], execOptions);
37 hasLocation = gpsCheck.trim().length > 0;
38 args = ["-gps:all=", path.toString(), "-o", tempOutput.toString()];
39 break;
40 case ".mov":
41 case ".mp4":
42 const { stdout: videoCheck } = await subprocess.exec("exiftool", [
43 "-ee",
44 "-G3",
45 "-s",
46 path.toString(),
47 ], execOptions);
48 hasLocation = videoCheck.includes("GPS")
49 || videoCheck.includes("Location");
50 args = [
51 "-gps:all=",
52 "-xmp:all=",
53 path.toString(),
54 "-o",
55 tempOutput.toString(),
56 ];
57 break;
58 case ".m4a":
59 const { stdout: m4aCheck } = await subprocess.exec("exiftool", [
60 "-ee",
61 "-G3",
62 "-s",
63 path.toString(),
64 ], execOptions);
65 hasLocation = m4aCheck.includes("GPS")
66 || m4aCheck.includes("Location")
67 || m4aCheck.includes("Filename")
68 || m4aCheck.includes("Title");
69
70 if (hasLocation) {
71 args = [
72 "-gps:all=",
73 "-location:all=",
74 "-filename:all=",
75 "-title=",
76 "-m4a:all=",
77 path.toString(),
78 "-o",
79 tempOutput.toString(),
80 ];
81 }
82 break;
83 }
84
85 const accessTime = stats.atime;
86 const modTime = stats.mtime;
87
88 let backup: Path | null = null;
89 try {
90 if (hasLocation) {
91 // Prepare a backup. content-only copy: fs.copyFile's metadata
92 // preservation gets EPERM'd on the NAS datasets, plain writes do not.
93 const tmp = UNWRAP(path.parent).join(`.tmp.backup.${path.base}`);
94 await stream.promises.pipeline(
95 fs.createReadStream(path.toString()),
96 fs.createWriteStream(tmp.toString()),
97 );
98 await fsp.utimes(tmp.toString(), accessTime, modTime);
99 backup = tmp;
100
101 // a leftover temp from a crashed run makes exiftool refuse to write
102 await tempOutput.delete({ force: true });
103
104 // Remove metadata
105 await subprocess.exec("exiftool", args, execOptions);
106 if (!tempOutput.ifExistsSync()) {
107 throw new Error(`Failed to create output file: ${tempOutput}`);
108 }
109
110 // Restore original timestamps
111 await fsp.rename(tempOutput.toString(), path.toString());
112 await fsp.utimes(path.toString(), accessTime, modTime);
113
114 // Backup is no longer needed
115 await fsp.unlink(backup.toString());
116
117 console.info(`Scrubbed location metadata in ${path}`);
118 return true;
119 }
120 } catch (error) {
121 // restore is best-effort: a concurrent scrub of the same physical file
122 // (case-insensitive store) may have already consumed the backup
123 if (backup && fs.existsSync(backup.toString())) {
124 await fsp.rename(backup.toString(), path.toString());
125 }
126 if (fs.existsSync(tempOutput.toString())) {
127 await fsp.unlink(tempOutput.toString());
128 }
129 throw error;
130 }
131
132 return false;
133}
134
135let warnedMissingExiftool = false;
136function warnMissingExiftool() {
137 if (warnedMissingExiftool) return;
138 warnedMissingExiftool = true;
139 console.warn(
140 "exiftool is not installed; skipping gps metadata scrubbing entirely",
141 );
142}
143
144function testProgram(name: string) {
145 // spawnSync does not throw on a missing binary; it reports `error`
146 const result = child_process.spawnSync(name, ["-ver"]);
147 return result.error ? null : name;
148}
149
150import * as child_process from "node:child_process";
151import * as fs from "node:fs";
152import * as fsp from "node:fs/promises";
153import * as stream from "node:stream";
154
155import { Path } from "#sitegen/path";
156import { UNWRAP } from "@clo/lib/assert";
157import * as progress from "@clo/lib/progress";
158import * as subprocess from "@clo/lib/subprocess";
159
160import * as rules from "#src/file-viewer/rules.ts";