@stelfield/cli 0.0.0-stage → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ // Local image/video references inside a Markdown intro (v0.19.0 M3 / CLI 0.10.0).
2
+ //
3
+ // An agent may write ![说明](./img/a.jpg) or ![封面](a.jpg#list) in a .md file. Before the intro
4
+ // is written, `set-profile-intro --file` uploads those local files and swaps the path for the
5
+ // platform address (linger://media/<id>, linger://video/<id>), keeping the #list (cover) suffix.
6
+ //
7
+ // SECURITY (the point of this file): the agent may have been prompt-injected into pointing at
8
+ // "~/.ssh/id_rsa" or "../../secret.png". So a path from Markdown is only accepted when
9
+ // - it is a plain relative path (no absolute path, "~", file://, Windows drive / UNC, backslash),
10
+ // - it carries an allowed image/video extension,
11
+ // - after resolving "..", it stays inside the sub-tree of the Markdown file's directory,
12
+ // - after resolving symlinks (realpath), it STILL stays inside that sub-tree,
13
+ // - it is a regular file.
14
+ // Error messages say what is wrong and how to fix it, and never print the real (resolved)
15
+ // location of a path that escaped the sub-tree.
16
+ //
17
+ // http(s) image addresses are left untouched on purpose: the server drops them by its existing rule
18
+ // and nothing is ever fetched from a web address.
19
+
20
+ import { realpathSync } from 'node:fs';
21
+ import { dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
22
+ import { ALLOWED_EXT_TEXT, MAX_FILES_PER_COMMAND, MEDIA_EXTENSIONS, prepareFile } from './media-upload.js';
23
+
24
+ // ![alt](target "title") - the target is either <...> or a run without spaces / ")".
25
+ const IMAGE_RE = /!\[([^\]\n]*)\]\(\s*(<[^>\n]*>|[^)\s]*)((?:\s+(?:"[^"\n]*"|'[^'\n]*'))?)\s*\)/gd;
26
+
27
+ const HOW_TO_FIX = '图片请放在这个 .md 文件所在目录或其子目录里,并用相对路径(如 ./img/a.jpg)引用。';
28
+
29
+ /**
30
+ * Same-length copy of the text where fenced code blocks and inline code spans are blanked out,
31
+ * so an image syntax shown as an example inside code is never treated as a real reference.
32
+ */
33
+ function maskCode(text) {
34
+ const out = [];
35
+ let fence = null;
36
+ for (const line of text.split('\n')) {
37
+ const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
38
+ if (fence) {
39
+ out.push(' '.repeat(line.length));
40
+ if (m && m[1][0] === fence[0] && m[1].length >= fence.length && /^\s{0,3}[`~]+\s*$/.test(line)) fence = null;
41
+ continue;
42
+ }
43
+ if (m) {
44
+ fence = m[1];
45
+ out.push(' '.repeat(line.length));
46
+ continue;
47
+ }
48
+ out.push(line.replace(/(`+)[^\n]*?\1/g, (s) => ' '.repeat(s.length)));
49
+ }
50
+ return out.join('\n');
51
+ }
52
+
53
+ function lineOf(text, index) {
54
+ let n = 1;
55
+ for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) n++;
56
+ return n;
57
+ }
58
+
59
+ /**
60
+ * Decide what a Markdown image target is.
61
+ * @returns {{kind:'keep'}|{kind:'local', rel:string, fragment:string}|{kind:'reject', reason:string}}
62
+ * keep - linger:// reference, http(s) address or another scheme: not ours to touch
63
+ * local - a candidate local path (still to be checked against the directory rule)
64
+ * reject - a path form that is never allowed, with the reason
65
+ */
66
+ export function classifyTarget(rawTarget) {
67
+ let t = String(rawTarget ?? '').trim();
68
+ if (t.startsWith('<') && t.endsWith('>')) t = t.slice(1, -1).trim();
69
+ if (t === '') return { kind: 'keep' };
70
+ if (/^stelfield:\/\//i.test(t)) return { kind: 'keep' };
71
+ if (/^https?:\/\//i.test(t)) return { kind: 'keep' };
72
+ if (/^file:/i.test(t)) return { kind: 'reject', reason: `不允许 file:// 地址。${HOW_TO_FIX}` };
73
+ if (/^[A-Za-z]:/.test(t)) return { kind: 'reject', reason: `不允许 Windows 盘符路径。${HOW_TO_FIX}` };
74
+ if (/^[/\\]{2}/.test(t)) return { kind: 'reject', reason: `不允许网络共享(UNC)或以 // 开头的地址。${HOW_TO_FIX}` };
75
+ if (/^[a-z][a-z0-9+.-]*:/i.test(t)) return { kind: 'keep' }; // data:, ftp: ... never read from disk
76
+ if (t.startsWith('~')) return { kind: 'reject', reason: `不允许 ~ 开头的路径(主目录)。${HOW_TO_FIX}` };
77
+ if (t.startsWith('/') || t.startsWith('\\')) return { kind: 'reject', reason: `不允许绝对路径。${HOW_TO_FIX}` };
78
+
79
+ // Only the "#list" fragment means something (cover flag); any other fragment is a mistake.
80
+ let fragment = '';
81
+ const hash = t.indexOf('#');
82
+ if (hash >= 0) {
83
+ fragment = t.slice(hash + 1);
84
+ t = t.slice(0, hash);
85
+ if (fragment !== 'list') return { kind: 'reject', reason: '路径后面的 # 只支持 #list(设为列表封面)。' };
86
+ fragment = '#list';
87
+ }
88
+ if (t === '') return { kind: 'reject', reason: '路径为空。' };
89
+
90
+ let decoded = t;
91
+ try {
92
+ decoded = decodeURIComponent(t);
93
+ } catch {
94
+ return { kind: 'reject', reason: '路径里的 % 转义不合法。' };
95
+ }
96
+ // Decoding may reveal what the raw form hid (%2e%2e, %2F ...): run the hard rules again.
97
+ if (decoded.includes('\0')) return { kind: 'reject', reason: '路径里有不合法的字符。' };
98
+ if (decoded.includes('\\')) return { kind: 'reject', reason: `路径里不要用反斜杠 \\,请用 / 分隔。${HOW_TO_FIX}` };
99
+ if (decoded.startsWith('~') || decoded.startsWith('/') || /^[A-Za-z]:/.test(decoded) || /^file:/i.test(decoded)) {
100
+ return { kind: 'reject', reason: `不允许绝对路径、~ 或盘符。${HOW_TO_FIX}` };
101
+ }
102
+ return { kind: 'local', rel: decoded, fragment };
103
+ }
104
+
105
+ /** true when `child` (already resolved) lies strictly inside `base` (resolved). */
106
+ function isInside(base, child) {
107
+ const r = relative(base, child);
108
+ if (r === '' || isAbsolute(r)) return false;
109
+ return !(r === '..' || r.startsWith('..' + sep));
110
+ }
111
+
112
+ /**
113
+ * Resolve one relative path against the Markdown directory and check it stays inside.
114
+ * @returns {{ok:true, real:string}|{ok:false, error:string}}
115
+ */
116
+ export function resolveInsideBase(baseReal, rel) {
117
+ const ext = extname(rel).toLowerCase();
118
+ if (!MEDIA_EXTENSIONS[ext]) {
119
+ return { ok: false, error: `格式不支持(${ext || '没有扩展名'})。只支持:${ALLOWED_EXT_TEXT}。` };
120
+ }
121
+ const lexical = resolve(baseReal, rel);
122
+ if (!isInside(baseReal, lexical)) {
123
+ return { ok: false, error: `路径跑出了 .md 文件所在的目录。${HOW_TO_FIX}` };
124
+ }
125
+ let real;
126
+ try {
127
+ real = realpathSync(lexical);
128
+ } catch {
129
+ return { ok: false, error: `找不到这个文件。请检查路径是否写对(相对 .md 文件所在目录)。` };
130
+ }
131
+ if (!isInside(baseReal, real)) {
132
+ // Do not print where the link points: that is exactly what must not leak.
133
+ return { ok: false, error: `这个路径经符号链接指向了目录之外,已拒绝。${HOW_TO_FIX}` };
134
+ }
135
+ return { ok: true, real };
136
+ }
137
+
138
+ /**
139
+ * Scan Markdown for local image/video references and check every one of them.
140
+ * Nothing is uploaded or written here.
141
+ *
142
+ * @param {string} contentMd the intro text
143
+ * @param {string} mdFilePath path of the .md file (its directory is the allowed sub-tree)
144
+ * @param {{keepLocation?:boolean}} [opts] keepLocation: allow JPEGs that carry GPS data
145
+ * @returns {{refs:object[], files:object[], errors:string[]}}
146
+ * refs - each local reference: { start, end, fragment, realPath, label } (start/end = target span)
147
+ * files - unique prepared files (output of prepareFile().file), in first-seen order
148
+ * errors- ready-to-print Chinese lines, one per bad reference (plus a count-limit line)
149
+ */
150
+ export function collectLocalMediaRefs(contentMd, mdFilePath, opts = {}) {
151
+ const refs = [];
152
+ const files = [];
153
+ const errors = [];
154
+ const byReal = new Map();
155
+
156
+ let baseReal;
157
+ try {
158
+ baseReal = realpathSync(dirname(resolve(mdFilePath)));
159
+ } catch {
160
+ return { refs, files, errors: ['读不到正文文件所在的目录。'] };
161
+ }
162
+
163
+ const masked = maskCode(contentMd);
164
+ IMAGE_RE.lastIndex = 0;
165
+ let m;
166
+ while ((m = IMAGE_RE.exec(masked)) !== null) {
167
+ const [tStart, tEnd] = m.indices[2];
168
+ const rawTarget = contentMd.slice(tStart, tEnd);
169
+ const cls = classifyTarget(rawTarget);
170
+ if (cls.kind === 'keep') continue;
171
+ const where = `第 ${lineOf(contentMd, m.index)} 行 ${m[0].length > 60 ? m[0].slice(0, 57) + '...' : m[0]}`;
172
+ if (cls.kind === 'reject') { errors.push(`${where}:${cls.reason}`); continue; }
173
+
174
+ const label = cls.rel;
175
+ const inside = resolveInsideBase(baseReal, cls.rel);
176
+ if (!inside.ok) { errors.push(`${where}:${inside.error}`); continue; }
177
+
178
+ let entry = byReal.get(inside.real);
179
+ if (!entry) {
180
+ const prep = prepareFile(inside.real, label, { keepLocation: !!opts.keepLocation });
181
+ if (!prep.ok) { errors.push(`${where}:${prep.error}`); continue; }
182
+ entry = prep.file;
183
+ byReal.set(inside.real, entry);
184
+ files.push(entry);
185
+ }
186
+ refs.push({ start: tStart, end: tEnd, fragment: cls.fragment, realPath: inside.real, label });
187
+ }
188
+
189
+ if (files.length > MAX_FILES_PER_COMMAND) {
190
+ errors.push(`正文里引用了 ${files.length} 个不同的本地文件,一次最多 ${MAX_FILES_PER_COMMAND} 个。请分批:先 stelfield media upload 传一部分,把正文里的路径换成 linger:// 地址。`);
191
+ }
192
+ return { refs, files, errors };
193
+ }
194
+
195
+ /**
196
+ * Replace the target of each local reference with its platform address.
197
+ * @param {string} contentMd
198
+ * @param {object[]} refs from collectLocalMediaRefs
199
+ * @param {Map<string,string>} refByRealPath realPath -> "linger://media/<id>" | "linger://video/<id>"
200
+ */
201
+ export function applyUploadedRefs(contentMd, refs, refByRealPath) {
202
+ let out = contentMd;
203
+ // Back to front so earlier offsets stay valid.
204
+ for (const r of [...refs].sort((a, b) => b.start - a.start)) {
205
+ const ref = refByRealPath.get(r.realPath);
206
+ if (!ref) continue;
207
+ out = out.slice(0, r.start) + ref + r.fragment + out.slice(r.end);
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * For --stdin and inline text: list image targets that look like local paths. Those inputs have no
214
+ * directory to anchor a relative path to, so the command refuses instead of guessing.
215
+ * @returns {string[]} the targets as written (empty = nothing local)
216
+ */
217
+ export function findLocalLikeTargets(contentMd) {
218
+ const found = [];
219
+ const masked = maskCode(contentMd);
220
+ IMAGE_RE.lastIndex = 0;
221
+ let m;
222
+ while ((m = IMAGE_RE.exec(masked)) !== null) {
223
+ const [s, e] = m.indices[2];
224
+ const raw = contentMd.slice(s, e);
225
+ if (classifyTarget(raw).kind !== 'keep') found.push(raw);
226
+ }
227
+ return found;
228
+ }
@@ -0,0 +1,121 @@
1
+ // Command-level glue for the two upload entry points (v0.19.0 M3 / CLI 0.10.0):
2
+ // 1. `stelfield media upload <files...>` - upload the files the user named
3
+ // 2. `stelfield set-profile-intro --file` - upload the local images/videos referenced in the Markdown
4
+ //
5
+ // Both share one rule: check EVERYTHING locally first, only then start uploading, and stop at the
6
+ // first failure. The intro text is written only when every upload succeeded.
7
+
8
+ import { realpathSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+ import { formatMediaUpload } from './format.js';
11
+ import { MAX_FILES_PER_COMMAND, MediaUploadError, prepareFile, uploadFiles } from './media-upload.js';
12
+ import { applyUploadedRefs, collectLocalMediaRefs } from './local-media-refs.js';
13
+ import { isInsideOrSame, isSensitivePath } from './media-safety.js';
14
+
15
+ /** Print "what already got uploaded" so a failed run does not leave the agent guessing. */
16
+ function describeUploaded(uploaded) {
17
+ if (!uploaded || uploaded.length === 0) return ['还没有任何文件上传成功。'];
18
+ return [
19
+ '已上传成功的文件(会保留在媒体库,可用 stelfield get-profile-intro --media 查看):',
20
+ ...uploaded.map((r) => ` ${r.file} -> ${r.ref}`),
21
+ ];
22
+ }
23
+
24
+ function reportUploadFailure(e, err, { introUntouched }) {
25
+ if (e instanceof MediaUploadError) {
26
+ err(`上传失败${e.file ? `(${e.file})` : ''}:${e.message}`);
27
+ for (const l of describeUploaded(e.uploaded)) err(l);
28
+ } else {
29
+ err(`上传失败:${e && e.message ? e.message : e}`);
30
+ }
31
+ if (introUntouched) err('主页介绍没有被改动。处理好上面的问题后重新运行同一条命令即可(已上传的文件会再传一遍,多占媒体库名额;也可以先把已成功的换成上面的 linger:// 地址)。');
32
+ }
33
+
34
+ /**
35
+ * `stelfield media upload <files...> [--json]`
36
+ * @returns {Promise<number>} exit code (0 ok / 2 bad input, nothing sent / 1 upload failed)
37
+ */
38
+ export async function runMediaUpload({ paths, json, baseUrl, credOpts, fetchImpl, log, err, allowOutside = false, keepLocation = false }) {
39
+ if (!paths || paths.length === 0) {
40
+ err('缺少文件。用法:stelfield media upload <图片或视频文件…> [--json]');
41
+ return 2;
42
+ }
43
+ if (paths.length > MAX_FILES_PER_COMMAND) {
44
+ err(`一次最多上传 ${MAX_FILES_PER_COMMAND} 个文件(你给了 ${paths.length} 个)。请分批运行。`);
45
+ return 2;
46
+ }
47
+
48
+ // Files named directly: the real location (after symlinks) must not be in a sensitive folder
49
+ // (always), and must be inside the current folder unless --allow-outside is given. Then the
50
+ // usual checks: regular file, extension, size, file header, JPEG location data.
51
+ let cwdReal = process.cwd();
52
+ try { cwdReal = realpathSync(cwdReal); } catch { /* keep the plain path */ }
53
+ const files = [];
54
+ const problems = [];
55
+ for (const p of paths) {
56
+ const abs = resolve(p);
57
+ let real = null;
58
+ try { real = realpathSync(abs); } catch { /* prepareFile reports "not found" */ }
59
+ if (real && isSensitivePath(real)) {
60
+ problems.push(`${p} 位于敏感目录(密钥、配置、系统目录等),任何情况下都不允许上传。`);
61
+ continue;
62
+ }
63
+ if (real && !allowOutside && !isInsideOrSame(cwdReal, real)) {
64
+ problems.push(`${p} 在当前目录之外。为防止 agent 被诱导上传电脑里的私人图片,默认只允许当前目录里的文件;确认这是商户本人指定的文件后,再加 --allow-outside。`);
65
+ continue;
66
+ }
67
+ const prep = prepareFile(abs, p, { keepLocation });
68
+ if (prep.ok) files.push(prep.file); else problems.push(prep.error);
69
+ }
70
+ if (problems.length) {
71
+ err('有文件不能上传,所有文件都没有发送:');
72
+ for (const p of problems) err(` - ${p}`);
73
+ return 2;
74
+ }
75
+
76
+ try {
77
+ const results = await uploadFiles(files, { baseUrl, credOpts, fetchImpl, onProgress: (m) => err(m) });
78
+ log(formatMediaUpload(results, { json }));
79
+ return 0;
80
+ } catch (e) {
81
+ reportUploadFailure(e, err, { introUntouched: false });
82
+ if (json && e instanceof MediaUploadError) {
83
+ log(JSON.stringify({
84
+ error: e.message,
85
+ failed_file: e.file,
86
+ uploaded: (e.uploaded || []).map((r) => ({ file: r.file, ref: r.ref, media_id: r.mediaId })),
87
+ }, null, 2));
88
+ }
89
+ return 1;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * For `set-profile-intro --file`: find, check and upload the local media referenced in the text,
95
+ * then swap the paths for platform addresses.
96
+ *
97
+ * @returns {Promise<{contentMd:string, uploaded:object[]}|{exit:number}>}
98
+ * `exit` means "stop with this code, nothing was written".
99
+ */
100
+ export async function prepareIntroMedia({ contentMd, mdFile, baseUrl, credOpts, fetchImpl, err, keepLocation = false }) {
101
+ const scan = collectLocalMediaRefs(contentMd, mdFile, { keepLocation });
102
+ if (scan.errors.length) {
103
+ err('正文里的本地图片 / 视频有问题,什么都没有上传,主页介绍也没有改:');
104
+ for (const e of scan.errors) err(` - ${e}`);
105
+ return { exit: 2 };
106
+ }
107
+ if (scan.refs.length === 0) return { contentMd, uploaded: [] };
108
+
109
+ let results;
110
+ try {
111
+ results = await uploadFiles(scan.files, { baseUrl, credOpts, fetchImpl, onProgress: (m) => err(m) });
112
+ } catch (e) {
113
+ reportUploadFailure(e, err, { introUntouched: true });
114
+ return { exit: 1 };
115
+ }
116
+
117
+ // results[i] belongs to scan.files[i] (same order, duplicates included).
118
+ const refByRealPath = new Map();
119
+ scan.files.forEach((f, i) => refByRealPath.set(f.absPath, results[i].ref));
120
+ return { contentMd: applyUploadedRefs(contentMd, scan.refs, refByRealPath), uploaded: results };
121
+ }
@@ -0,0 +1,160 @@
1
+ // Local safety checks for media upload (v0.19.0 M6 fixes L-2 / L-3).
2
+ //
3
+ // What this does for the user: before any byte of a file leaves the computer, the command checks
4
+ // 1. the file really is a picture / video (by its first bytes, not by its name),
5
+ // 2. a path typed directly (`media upload <file>`) is inside the current folder, or the user
6
+ // explicitly allowed otherwise (--allow-outside), and is not in a sensitive folder,
7
+ // 3. a JPEG does not carry GPS (shooting location) data, unless the user confirmed (--keep-location).
8
+ // Nothing here sends anything to the network.
9
+
10
+ import { BRAND } from './brand.js';
11
+ import { realpathSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { isAbsolute, join, relative, sep } from 'node:path';
14
+
15
+ // ============================================================
16
+ // magic numbers (same table as backend profile_media_service.sniff_magic)
17
+ // ============================================================
18
+
19
+ /**
20
+ * @param {'image'|'video'} mediaType
21
+ * @param {Buffer} head first bytes of the file
22
+ * @returns {string|null} the MIME type the bytes look like, or null
23
+ */
24
+ export function sniffMagic(mediaType, head) {
25
+ if (!head || head.length === 0) return null;
26
+ if (mediaType === 'image') {
27
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
28
+ if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
29
+ if (head.length >= 6) {
30
+ const g = head.toString('latin1', 0, 6);
31
+ if (g === 'GIF87a' || g === 'GIF89a') return 'image/gif';
32
+ }
33
+ if (head.length >= 12 && head.toString('latin1', 0, 4) === 'RIFF' && head.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
34
+ return null;
35
+ }
36
+ // Video: mp4/mov carry "ftyp" at bytes 4-7, webm starts with the EBML header.
37
+ if (head.length >= 8 && head.toString('latin1', 4, 8) === 'ftyp') return 'video/mp4';
38
+ if (head.length >= 4 && head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) return 'video/webm';
39
+ return null;
40
+ }
41
+
42
+ // ============================================================
43
+ // JPEG GPS detection (EXIF APP1 -> IFD0 -> GPSInfo pointer 0x8825)
44
+ // ============================================================
45
+
46
+ const TAG_GPS_POINTER = 0x8825;
47
+
48
+ /**
49
+ * true when the JPEG carries GPS position data in its EXIF block.
50
+ * Conservative: a GPS pointer whose block cannot be read counts as "has GPS".
51
+ * @param {Buffer} buf the first bytes of the JPEG (the EXIF block sits right after the SOI marker)
52
+ */
53
+ export function jpegHasGps(buf) {
54
+ if (!buf || buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return false;
55
+ let pos = 2;
56
+ while (pos + 4 <= buf.length) {
57
+ if (buf[pos] !== 0xff) return false;
58
+ const marker = buf[pos + 1];
59
+ if (marker === 0xff) { pos++; continue; } // fill byte
60
+ if (marker === 0xda || marker === 0xd9) return false; // start of scan / end: no more headers
61
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { pos += 2; continue; } // no length
62
+ const len = buf.readUInt16BE(pos + 2);
63
+ if (len < 2) return false;
64
+ const segStart = pos + 4;
65
+ const segEnd = pos + 2 + len;
66
+ if (marker === 0xe1 && segStart + 6 <= buf.length && buf.toString('latin1', segStart, segStart + 6) === 'Exif\0\0') {
67
+ const tiffStart = segStart + 6;
68
+ const tiff = buf.subarray(tiffStart, Math.min(segEnd, buf.length));
69
+ const truncated = segEnd > buf.length;
70
+ const r = exifHasGps(tiff);
71
+ if (r === true) return true;
72
+ if (r === 'unreadable' && truncated) return true;
73
+ }
74
+ pos = segEnd;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ /** @returns {boolean|'unreadable'} */
80
+ function exifHasGps(tiff) {
81
+ if (tiff.length < 8) return false;
82
+ const bo = tiff.toString('latin1', 0, 2);
83
+ if (bo !== 'II' && bo !== 'MM') return false;
84
+ const le = bo === 'II';
85
+ const u16 = (o) => (le ? tiff.readUInt16LE(o) : tiff.readUInt16BE(o));
86
+ const u32 = (o) => (le ? tiff.readUInt32LE(o) : tiff.readUInt32BE(o));
87
+ if (u16(2) !== 0x2a) return false;
88
+ const ifd0 = u32(4);
89
+ if (ifd0 + 2 > tiff.length) return 'unreadable';
90
+ const n0 = u16(ifd0);
91
+ let gpsOffset = null;
92
+ for (let i = 0; i < n0; i++) {
93
+ const e = ifd0 + 2 + i * 12;
94
+ if (e + 12 > tiff.length) return 'unreadable';
95
+ if (u16(e) === TAG_GPS_POINTER) { gpsOffset = u32(e + 8); break; }
96
+ }
97
+ if (gpsOffset == null) return false;
98
+ if (gpsOffset + 2 > tiff.length) return true; // pointer exists but block unreadable: be careful
99
+ const ng = u16(gpsOffset);
100
+ if (ng === 0) return false;
101
+ // GPSVersionID alone (tag 0) carries no position; latitude (2) / longitude (4) do.
102
+ let readable = true;
103
+ for (let i = 0; i < ng; i++) {
104
+ const e = gpsOffset + 2 + i * 12;
105
+ if (e + 12 > tiff.length) { readable = false; break; }
106
+ const tag = u16(e);
107
+ if (tag === 2 || tag === 4) return true;
108
+ }
109
+ return readable ? false : true;
110
+ }
111
+
112
+ // ============================================================
113
+ // path rules for directly typed paths
114
+ // ============================================================
115
+
116
+ /** true when `child` lies inside (or is) `base`; both already resolved. */
117
+ export function isInsideOrSame(base, child) {
118
+ const r = relative(base, child);
119
+ if (r === '') return true;
120
+ if (isAbsolute(r)) return false;
121
+ return !(r === '..' || r.startsWith('..' + sep));
122
+ }
123
+
124
+ function realOrSelf(p) {
125
+ try { return realpathSync(p); } catch { return p; }
126
+ }
127
+
128
+ /**
129
+ * Sensitive folders that are never uploaded from, even with --allow-outside.
130
+ * @returns {string[]} absolute paths (both the plain and the symlink-resolved spelling)
131
+ */
132
+ export function sensitiveDirs() {
133
+ const home = homedir();
134
+ const rel = [
135
+ '.ssh', '.aws', '.gnupg', '.config', '.claude', '.kube', '.docker',
136
+ 'Library/Keychains', 'Library/Application Support',
137
+ ];
138
+ const out = new Set();
139
+ for (const r of rel) { const p = join(home, r); out.add(p); out.add(realOrSelf(p)); }
140
+ for (const p of ['/etc', '/var/root']) { out.add(p); out.add(realOrSelf(p)); }
141
+ return [...out];
142
+ }
143
+
144
+ /** true when the real path is inside a sensitive folder (or any config / credential folder). */
145
+ export function isSensitivePath(realPath) {
146
+ for (const d of sensitiveDirs()) if (isInsideOrSame(d, realPath)) return true;
147
+ for (const h of new Set([homedir(), realOrSelf(homedir())])) {
148
+ const r = relative(h, realPath);
149
+ // Both the current config folder and the pre-rename one: a machine that has not re-bound yet
150
+ // still keeps real credentials in ~/.linger, so uploading from there must stay refused.
151
+ const top = r && !isAbsolute(r) && !r.startsWith('..') ? r.split(sep)[0] : '';
152
+ if (top && (top.startsWith(BRAND.configDirName) || top.startsWith(LEGACY_CONFIG_DIR))) return true;
153
+ }
154
+ return false;
155
+ }
156
+
157
+ /** Config folder used before the brand rename; its credentials are still real. */
158
+ const LEGACY_CONFIG_DIR = '.linger';
159
+
160
+ export const GPS_REJECT_TEXT = '这张照片带有拍摄位置信息,上传会让位置随主页公开。请先去掉位置信息,或加 --keep-location 确认公开';