@buildinternet/uploads 0.26.0 → 0.27.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,85 @@
1
+ /**
2
+ * Keep Better Auth session.cliVersion in sync with the installed package.
3
+ *
4
+ * Device login stores UPLOADS_SESSION_TOKEN; later commands POST
5
+ * /api/auth/update-session when the local version changes (fire-and-forget).
6
+ */
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { authUrlFromApi } from "./config.js";
11
+ import { loadConfigFile, removeConfigKeys, resolveConfigPath } from "./config-file.js";
12
+ import { packageVersion } from "./package-version.js";
13
+ const POST_TIMEOUT_MS = 1500;
14
+ function defaultCachePath() {
15
+ const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
16
+ return join(base, "uploads", "cli-version-sync");
17
+ }
18
+ function readLastSyncedVersion(path) {
19
+ try {
20
+ const v = readFileSync(path, "utf8").trim();
21
+ return v || undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ function writeLastSyncedVersion(path, version) {
28
+ try {
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ writeFileSync(path, version + "\n", { mode: 0o600 });
31
+ }
32
+ catch {
33
+ // best-effort
34
+ }
35
+ }
36
+ /** Best-effort POST; never throws. */
37
+ export async function syncSessionCliVersion(opts = {}) {
38
+ const version = opts.version ?? packageVersion();
39
+ const configPath = resolveConfigPath({ envFile: opts.envFile });
40
+ const fromFile = loadConfigFile(configPath);
41
+ const sessionToken = opts.sessionToken ?? fromFile.UPLOADS_SESSION_TOKEN;
42
+ if (!sessionToken)
43
+ return false;
44
+ const cachePath = opts.cachePath ?? defaultCachePath();
45
+ if (!opts.force && readLastSyncedVersion(cachePath) === version)
46
+ return true;
47
+ const apiUrl = opts.apiUrl ?? fromFile.UPLOADS_API_URL ?? process.env.UPLOADS_API_URL;
48
+ const authUrl = (opts.authUrl ??
49
+ process.env.UPLOADS_AUTH_URL ??
50
+ (apiUrl ? authUrlFromApi(apiUrl) : "https://auth.uploads.sh")).replace(/\/$/, "");
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
53
+ try {
54
+ const fetchImpl = opts.fetchImpl ?? fetch;
55
+ const res = await fetchImpl(`${authUrl}/api/auth/update-session`, {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ Authorization: `Bearer ${sessionToken}`,
60
+ "User-Agent": `@buildinternet/uploads/${version} (session-version)`,
61
+ },
62
+ body: JSON.stringify({ cliVersion: version }),
63
+ signal: controller.signal,
64
+ });
65
+ if (res.ok) {
66
+ writeLastSyncedVersion(cachePath, version);
67
+ return true;
68
+ }
69
+ // Stale session: drop it so we stop retrying every command.
70
+ if (res.status === 401 || res.status === 403) {
71
+ removeConfigKeys(configPath, ["UPLOADS_SESSION_TOKEN"]);
72
+ }
73
+ return false;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+ /** Fire-and-forget for CLI command starts. */
83
+ export function maybeSyncSessionCliVersion(opts = {}) {
84
+ void syncSessionCliVersion(opts);
85
+ }
@@ -0,0 +1,31 @@
1
+ /** The sidecar path for a given local file — `<file>.uploads.json`. */
2
+ export declare function sidecarPath(filePath: string): string;
3
+ /** Hex-encoded SHA-256 of `bytes`. */
4
+ export declare function sha256Hex(bytes: Uint8Array): string;
5
+ /** Keep only entries whose key is in the closed canonical metadata vocabulary. */
6
+ export declare function restrictToCanonicalMeta(meta: Record<string, string>): Record<string, string>;
7
+ /**
8
+ * Write a sidecar manifest next to `filePath`, recording `meta` (restricted
9
+ * to the canonical vocabulary) and the SHA-256 of `bytes` (the exact bytes
10
+ * being written to `filePath`). No-ops when `meta` is empty — an image with
11
+ * no derived metadata gets no sidecar. Best-effort: a write failure (e.g. a
12
+ * read-only directory) is swallowed, matching the rest of the derived-
13
+ * metadata pipeline's "never fail the primary operation" contract.
14
+ */
15
+ export declare function writeSidecarMeta(filePath: string, bytes: Uint8Array, meta: Record<string, string>): void;
16
+ /**
17
+ * Read back a sidecar manifest for `filePath`, only when it is present,
18
+ * well-formed, and its recorded hash matches `bytes` (the file's current
19
+ * content, as read for the upload in progress). Returns `undefined` on any
20
+ * absence, parse failure, malformed shape, or hash mismatch — a sidecar is a
21
+ * best-effort convenience and must never fail or noise an upload. Returned
22
+ * keys are always a subset of `CANONICAL_META_KEYS`, so a hand-edited
23
+ * manifest can never inject arbitrary metadata.
24
+ */
25
+ export declare function readSidecarMeta(filePath: string, bytes: Uint8Array): Record<string, string> | undefined;
26
+ /**
27
+ * Merge `filePath`'s sidecar metadata (if any, per {@link readSidecarMeta})
28
+ * under `baseMeta` — explicit metadata always wins. Shared by the `put` and
29
+ * `attach` upload loops (issue #469 lever 2).
30
+ */
31
+ export declare function mergeSidecarMeta(filePath: string, bytes: Uint8Array, baseMeta: Record<string, string> | undefined): Record<string, string> | undefined;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Sidecar manifest for `uploads screenshot --out`: derived metadata written
3
+ * next to a local screenshot file so a later `put`/`attach` of that exact
4
+ * file can recover the metadata the hosted copy would have gotten at capture
5
+ * time. See issue #469 lever 2 (the "sidecar manifest" variant — the
6
+ * alternative, content-hash-keyed server-side inheritance, is out of scope
7
+ * here).
8
+ *
9
+ * File: `<file>.uploads.json` next to `<file>`, e.g. `shot.png.uploads.json`.
10
+ * Shape: `{ version, sha256, meta }`. `sha256` is the SHA-256 of the exact
11
+ * bytes written to `<file>` at capture time — read-back compares it against
12
+ * the file's *current* bytes, so a file that was regenerated or hand-edited
13
+ * since capture silently loses its sidecar instead of attaching stale
14
+ * metadata to a different image.
15
+ *
16
+ * `meta` is filtered to the closed `CANONICAL_META_KEYS` vocabulary
17
+ * (metadata-vocab.ts) on both write and read: the sidecar is a plain JSON
18
+ * file sitting next to the image, so it must never be a channel for
19
+ * arbitrary metadata even if hand-edited.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { CANONICAL_META_KEYS, mergeDerivedMeta } from "./metadata-vocab.js";
24
+ const SIDECAR_VERSION = 1;
25
+ /** The sidecar path for a given local file — `<file>.uploads.json`. */
26
+ export function sidecarPath(filePath) {
27
+ return `${filePath}.uploads.json`;
28
+ }
29
+ /** Hex-encoded SHA-256 of `bytes`. */
30
+ export function sha256Hex(bytes) {
31
+ return createHash("sha256").update(bytes).digest("hex");
32
+ }
33
+ /** Keep only entries whose key is in the closed canonical metadata vocabulary. */
34
+ export function restrictToCanonicalMeta(meta) {
35
+ const out = {};
36
+ for (const key of CANONICAL_META_KEYS) {
37
+ if (Object.prototype.hasOwnProperty.call(meta, key))
38
+ out[key] = meta[key];
39
+ }
40
+ return out;
41
+ }
42
+ function isPlainStringRecord(value) {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value))
44
+ return false;
45
+ return Object.values(value).every((v) => typeof v === "string");
46
+ }
47
+ /**
48
+ * Write a sidecar manifest next to `filePath`, recording `meta` (restricted
49
+ * to the canonical vocabulary) and the SHA-256 of `bytes` (the exact bytes
50
+ * being written to `filePath`). No-ops when `meta` is empty — an image with
51
+ * no derived metadata gets no sidecar. Best-effort: a write failure (e.g. a
52
+ * read-only directory) is swallowed, matching the rest of the derived-
53
+ * metadata pipeline's "never fail the primary operation" contract.
54
+ */
55
+ export function writeSidecarMeta(filePath, bytes, meta) {
56
+ const restricted = restrictToCanonicalMeta(meta);
57
+ if (Object.keys(restricted).length === 0)
58
+ return;
59
+ try {
60
+ const manifest = {
61
+ version: SIDECAR_VERSION,
62
+ sha256: sha256Hex(bytes),
63
+ meta: restricted,
64
+ };
65
+ writeFileSync(sidecarPath(filePath), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
66
+ }
67
+ catch {
68
+ // best-effort — never fail the screenshot over a sidecar write
69
+ }
70
+ }
71
+ /**
72
+ * Read back a sidecar manifest for `filePath`, only when it is present,
73
+ * well-formed, and its recorded hash matches `bytes` (the file's current
74
+ * content, as read for the upload in progress). Returns `undefined` on any
75
+ * absence, parse failure, malformed shape, or hash mismatch — a sidecar is a
76
+ * best-effort convenience and must never fail or noise an upload. Returned
77
+ * keys are always a subset of `CANONICAL_META_KEYS`, so a hand-edited
78
+ * manifest can never inject arbitrary metadata.
79
+ */
80
+ export function readSidecarMeta(filePath, bytes) {
81
+ const path = sidecarPath(filePath);
82
+ try {
83
+ if (!existsSync(path))
84
+ return undefined;
85
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
86
+ if (typeof parsed !== "object" || parsed === null)
87
+ return undefined;
88
+ const candidate = parsed;
89
+ if (candidate.version !== SIDECAR_VERSION ||
90
+ typeof candidate.sha256 !== "string" ||
91
+ !isPlainStringRecord(candidate.meta)) {
92
+ return undefined;
93
+ }
94
+ if (candidate.sha256 !== sha256Hex(bytes))
95
+ return undefined; // stale/regenerated file
96
+ const restricted = restrictToCanonicalMeta(candidate.meta);
97
+ return Object.keys(restricted).length > 0 ? restricted : undefined;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ /**
104
+ * Merge `filePath`'s sidecar metadata (if any, per {@link readSidecarMeta})
105
+ * under `baseMeta` — explicit metadata always wins. Shared by the `put` and
106
+ * `attach` upload loops (issue #469 lever 2).
107
+ */
108
+ export function mergeSidecarMeta(filePath, bytes, baseMeta) {
109
+ const sidecarMeta = readSidecarMeta(filePath, bytes);
110
+ return sidecarMeta ? mergeDerivedMeta(baseMeta ?? {}, sidecarMeta) : baseMeta;
111
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
- "license": "MIT",
7
+ "license": "Apache-2.0",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/buildinternet/uploads.git",