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