astro-dev-edit 0.11.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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname } from 'node:path';
|
|
3
|
+
import { launchInEditor } from './editor.ts';
|
|
4
|
+
import type { OptionsResolver, ResolvedOptions } from './options.ts';
|
|
5
|
+
import { atomicWrite, insideRoot } from './paths.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The injected write seam.
|
|
9
|
+
*
|
|
10
|
+
* `original`: null means a new file; undefined snapshots the current contents.
|
|
11
|
+
* `mode`: file mode for the write, applied to the temp file so a secret is
|
|
12
|
+
* never briefly world-readable — pass `SECRET_MODE` for anything holding the
|
|
13
|
+
* access key, and omit it otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export type TextWriter = (
|
|
16
|
+
target: string,
|
|
17
|
+
content: string,
|
|
18
|
+
original?: string | null,
|
|
19
|
+
mode?: number,
|
|
20
|
+
) => Promise<void>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The default when no write seam is injected.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately not `atomicWrite` itself: its third parameter is the file mode
|
|
26
|
+
* and {@link TextWriter}'s is the verified original, so a bare assignment
|
|
27
|
+
* typechecks in some positions while silently passing one as the other.
|
|
28
|
+
*/
|
|
29
|
+
export const directWrite: TextWriter = (target, content, _original, mode) =>
|
|
30
|
+
atomicWrite(target, content, mode);
|
|
31
|
+
|
|
32
|
+
async function contents(target: string): Promise<string | null> {
|
|
33
|
+
try {
|
|
34
|
+
return await readFile(target, 'utf8');
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One instance per middleware. Entire requests queue, including a settings save
|
|
42
|
+
* that writes both `.env.local` and the settings file. */
|
|
43
|
+
export function createTextWrites(deps: {
|
|
44
|
+
root: string;
|
|
45
|
+
optionsResolver: OptionsResolver;
|
|
46
|
+
logger: { warn(message: string): void };
|
|
47
|
+
launch?: (spec: string, onError?: () => void) => Promise<void>;
|
|
48
|
+
wait?: (ms: number) => Promise<void>;
|
|
49
|
+
}) {
|
|
50
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
51
|
+
let active: ResolvedOptions | undefined;
|
|
52
|
+
const launch = deps.launch ?? launchInEditor;
|
|
53
|
+
const wait = deps.wait ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
54
|
+
|
|
55
|
+
async function reveal(target: string, line: number) {
|
|
56
|
+
let warned = false;
|
|
57
|
+
const warn = () => {
|
|
58
|
+
if (!warned) deps.logger.warn(`Could not reveal ${basename(target)} in the editor; saving continues.`);
|
|
59
|
+
warned = true;
|
|
60
|
+
};
|
|
61
|
+
try { await launch(`${target}:${line}:1`, warn); } catch { warn(); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const write: TextWriter = async (target, content, original, mode) => {
|
|
65
|
+
const options = active ?? (await deps.optionsResolver.resolve()).options;
|
|
66
|
+
const before = original === undefined ? await contents(target) : original;
|
|
67
|
+
if (before === content) return;
|
|
68
|
+
// Callers retain their content / config / fixed-path gates (the settings
|
|
69
|
+
// file and `.env.local` are both fixed targets). Pin the
|
|
70
|
+
// resolved target and parent as well, so a symlink swap during the pause fails.
|
|
71
|
+
const root = await realpath(deps.root);
|
|
72
|
+
const parent = await realpath(dirname(target));
|
|
73
|
+
const identity = before === null ? null : await realpath(target);
|
|
74
|
+
if (!insideRoot(root, parent) || (identity !== null && !insideRoot(root, identity))) {
|
|
75
|
+
throw new Error('write path escapes the project root');
|
|
76
|
+
}
|
|
77
|
+
if (options.revealWrites && before !== null) {
|
|
78
|
+
const oldLines = before.split('\n');
|
|
79
|
+
const newLines = content.split('\n');
|
|
80
|
+
let index = 0;
|
|
81
|
+
while (index < Math.min(oldLines.length, newLines.length) && oldLines[index] === newLines[index]) index++;
|
|
82
|
+
await reveal(target, Math.min(index + 1, oldLines.length));
|
|
83
|
+
await wait(options.revealWriteDelayMs);
|
|
84
|
+
}
|
|
85
|
+
if (await realpath(dirname(target)) !== parent ||
|
|
86
|
+
(identity !== null && await realpath(target) !== identity) ||
|
|
87
|
+
await contents(target) !== before) {
|
|
88
|
+
throw new Error('file changed on disk before saving; reopen it and try again');
|
|
89
|
+
}
|
|
90
|
+
await atomicWrite(target, content, mode);
|
|
91
|
+
if (options.revealWrites && before === null) await reveal(target, 1);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
write,
|
|
96
|
+
run<T>(operation: () => Promise<T>): Promise<T> {
|
|
97
|
+
const next = tail.then(async () => {
|
|
98
|
+
active = (await deps.optionsResolver.resolve()).options;
|
|
99
|
+
try { return await operation(); } finally { active = undefined; }
|
|
100
|
+
});
|
|
101
|
+
tail = next.catch(() => {});
|
|
102
|
+
return next;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import type { AstroIntegrationLogger } from 'astro';
|
|
2
|
+
import type {
|
|
3
|
+
UnsplashErrorCode,
|
|
4
|
+
UnsplashImportRequest,
|
|
5
|
+
UnsplashImportWidth,
|
|
6
|
+
UnsplashPhoto,
|
|
7
|
+
UnsplashSearchRequest,
|
|
8
|
+
UnsplashSearchResponse,
|
|
9
|
+
} from '../shared/protocol.ts';
|
|
10
|
+
import { slugify } from '../shared/slug.ts';
|
|
11
|
+
import { UNSPLASH_IMPORT_WIDTHS, coerceImportWidth } from '../shared/unsplash.ts';
|
|
12
|
+
import { saveBuffer } from './assets.ts';
|
|
13
|
+
import { resolveAssetTarget } from './paths.ts';
|
|
14
|
+
import type { Route, RouteResult } from './router.ts';
|
|
15
|
+
import type { ResolvedKey } from './settings.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `/unsplash*` route group — search a third-party photo library and
|
|
19
|
+
* download a chosen photo into the project like any other upload.
|
|
20
|
+
*
|
|
21
|
+
* **These routes return anticipated failures explicitly rather than throwing.**
|
|
22
|
+
* `router.ts::dispatch` maps a thrown error to a 400, which would read as "your
|
|
23
|
+
* query was malformed" when the real cause is Unsplash being down, rate-limited
|
|
24
|
+
* or unreachable. Every foreseeable failure here therefore becomes a
|
|
25
|
+
* `{ status, body: { error, code } }` result with an accurate status, and
|
|
26
|
+
* `onError` exists only to turn a genuinely unanticipated throw into a 500.
|
|
27
|
+
* This is an intentional divergence from every other route group.
|
|
28
|
+
*
|
|
29
|
+
* The Settings panel's own `/settings` endpoints used to live here, because the
|
|
30
|
+
* only setting was this feature's access key. They now have their own group in
|
|
31
|
+
* `settings-routes.ts` — the split this file's comment always called for.
|
|
32
|
+
*
|
|
33
|
+
* Security notes:
|
|
34
|
+
* - The access key is resolved lazily per request (a thunk, not a value
|
|
35
|
+
* captured at config time), so a key entered through the Settings panel works
|
|
36
|
+
* without a dev-server restart. It never enters a response body or a log line.
|
|
37
|
+
* - `/import` never fetches a URL the browser supplied. Search results are
|
|
38
|
+
* reshaped to drop the download URLs, which the server keeps in a bounded map
|
|
39
|
+
* keyed by photo id — so the browser can name a photo but cannot point the
|
|
40
|
+
* dev server at an arbitrary host.
|
|
41
|
+
*
|
|
42
|
+
* Attribution, per the Unsplash API guidelines: every credit link carries
|
|
43
|
+
* `utm_source`/`utm_medium` (attached server-side, so the client cannot forget
|
|
44
|
+
* them), and `/import` pings the photo's `download_location` when a user
|
|
45
|
+
* actually chooses it.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const API_BASE = 'https://api.unsplash.com';
|
|
49
|
+
const API_VERSION = 'v1';
|
|
50
|
+
|
|
51
|
+
/** JSON calls get 8s; the byte download gets 30s — the same order as a large
|
|
52
|
+
* upload, and the reader below is what actually bounds it. */
|
|
53
|
+
const API_TIMEOUT_MS = 8_000;
|
|
54
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
55
|
+
|
|
56
|
+
/** Same cap `/upload` enforces, so the two write paths agree. */
|
|
57
|
+
const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
/** How long an identical search is served from memory. The demo tier allows
|
|
61
|
+
* only 50 API requests/hour, and iterating on one query must not burn them. */
|
|
62
|
+
const SEARCH_TTL_MS = 5 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
/** Ids the server will still honour at import time. Bounded because this map
|
|
65
|
+
* outlives HMR: it is dev-server memory, and only ever holds values the server
|
|
66
|
+
* itself minted from an Unsplash response. */
|
|
67
|
+
const PHOTO_CACHE_LIMIT = 500;
|
|
68
|
+
|
|
69
|
+
/** Resolved Unsplash configuration. `null` in `UnsplashRouteDeps` means the
|
|
70
|
+
* feature was never enabled, which is a different answer from "enabled but no
|
|
71
|
+
* key" (`disabled` vs `unconfigured`). */
|
|
72
|
+
export interface UnsplashConfig {
|
|
73
|
+
/** Resolve the access key and where it came from. Async and per-request by
|
|
74
|
+
* design — a key entered through the Settings panel must work without a
|
|
75
|
+
* dev-server restart. `key` is `''` when nothing is configured anywhere. */
|
|
76
|
+
resolve: () => Promise<ResolvedKey>;
|
|
77
|
+
/** Whether the feature is switched on at all. A thunk for the same reason
|
|
78
|
+
* `resolve` is one: the Settings panel can turn the source on without a
|
|
79
|
+
* dev-server restart, so a value captured at setup time would be stale. */
|
|
80
|
+
enabled: () => Promise<boolean>;
|
|
81
|
+
/** Sent as `utm_source` on credit links, per the API guidelines. */
|
|
82
|
+
appName: () => Promise<string>;
|
|
83
|
+
/** Default results per page; already clamped to Unsplash's maximum. */
|
|
84
|
+
perPage: () => Promise<number>;
|
|
85
|
+
/** Width to fetch when a request does not name one. A thunk like the rest:
|
|
86
|
+
* the Settings drawer can change it without a dev-server restart. */
|
|
87
|
+
importWidth: () => Promise<UnsplashImportWidth>;
|
|
88
|
+
/** Injected so tests can stub Unsplash without touching globals — unlike a
|
|
89
|
+
* global stub this cannot leak across suites. Defaults to `globalThis.fetch`. */
|
|
90
|
+
fetchImpl?: typeof fetch;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface UnsplashRouteDeps {
|
|
94
|
+
logger: AstroIntegrationLogger;
|
|
95
|
+
/** Project root (fsPath). Downloads are confined to it. */
|
|
96
|
+
root: string;
|
|
97
|
+
/** Astro's `publicDir`, root-relative — what an imported photo's returned web
|
|
98
|
+
* path is measured against. Defaults to `public`. */
|
|
99
|
+
publicDir?: string;
|
|
100
|
+
/** Where an imported photo may land — the same rule `/upload` uses. A thunk,
|
|
101
|
+
* because the directories come from options the Settings panel can change
|
|
102
|
+
* without a dev-server restart. */
|
|
103
|
+
dirs: () => Promise<{ uploadDir: string; imageUploadDir: string; allowedDirs: string[] }>;
|
|
104
|
+
/** null when the feature is disabled in config. */
|
|
105
|
+
unsplash: UnsplashConfig | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- the shape of what Unsplash actually sends -------------------------------
|
|
109
|
+
// Only the fields we read. Everything else is dropped by `reshape`.
|
|
110
|
+
interface RawPhoto {
|
|
111
|
+
id?: string;
|
|
112
|
+
color?: string;
|
|
113
|
+
width?: number;
|
|
114
|
+
height?: number;
|
|
115
|
+
description?: string | null;
|
|
116
|
+
alt_description?: string | null;
|
|
117
|
+
urls?: { small?: string; raw?: string };
|
|
118
|
+
links?: { html?: string; download_location?: string };
|
|
119
|
+
user?: { name?: string; username?: string; links?: { html?: string } };
|
|
120
|
+
}
|
|
121
|
+
interface RawSearch {
|
|
122
|
+
results?: RawPhoto[];
|
|
123
|
+
total?: number;
|
|
124
|
+
total_pages?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What `/import` needs and the client never sees. */
|
|
128
|
+
interface CachedPhoto {
|
|
129
|
+
rawUrl: string;
|
|
130
|
+
downloadLocation: string;
|
|
131
|
+
description: string;
|
|
132
|
+
photographer: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** An explicit failure result. Never carries anything key-derived. */
|
|
136
|
+
function fail(status: number, code: UnsplashErrorCode, error: string): RouteResult {
|
|
137
|
+
return { status, body: { error, code } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const DISABLED = fail(
|
|
141
|
+
403,
|
|
142
|
+
'disabled',
|
|
143
|
+
'The Unsplash photo source is not enabled. Add `unsplash: {}` to the ' +
|
|
144
|
+
'astro-dev-edit integration options.',
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const UNCONFIGURED = fail(
|
|
148
|
+
403,
|
|
149
|
+
'unconfigured',
|
|
150
|
+
'No Unsplash access key is configured. Add one from the admin bar’s ' +
|
|
151
|
+
'Settings panel, or set UNSPLASH_ACCESS_KEY.',
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The two guards every route starts with. Returns the resolved key, or the
|
|
156
|
+
* result to send back. The unconfigured case returns **before any fetch
|
|
157
|
+
* happens** — a missing key must never produce an outbound request.
|
|
158
|
+
*/
|
|
159
|
+
async function requireKey(
|
|
160
|
+
cfg: UnsplashConfig | null,
|
|
161
|
+
): Promise<{ ok: true; key: string; appName: string } | { ok: false; result: RouteResult }> {
|
|
162
|
+
if (!cfg || !(await cfg.enabled())) return { ok: false, result: DISABLED };
|
|
163
|
+
const { key } = await cfg.resolve();
|
|
164
|
+
if (!key.trim()) return { ok: false, result: UNCONFIGURED };
|
|
165
|
+
return { ok: true, key: key.trim(), appName: await cfg.appName() };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Map a thrown fetch failure to a result. `AbortSignal.timeout` raises a
|
|
170
|
+
* DOMException *named* TimeoutError under undici; `instanceof DOMException` is
|
|
171
|
+
* not reliable across Node and vitest environments, so match on the name.
|
|
172
|
+
*
|
|
173
|
+
* The browser gets a generic "could not reach" — it can't act on undici's
|
|
174
|
+
* wording — but the real cause goes to the dev-server log, including the
|
|
175
|
+
* `cause` undici hides the interesting part in. Without it, an environment
|
|
176
|
+
* fault (a TLS-intercepting proxy whose root CA Node doesn't trust is the
|
|
177
|
+
* common one) is indistinguishable from Unsplash being down.
|
|
178
|
+
*/
|
|
179
|
+
function fromThrown(err: unknown, what: string, logger: AstroIntegrationLogger): RouteResult {
|
|
180
|
+
if (err && typeof err === 'object' && (err as { name?: string }).name === 'TimeoutError') {
|
|
181
|
+
return fail(504, 'timeout', `Unsplash took too long to respond while ${what}.`);
|
|
182
|
+
}
|
|
183
|
+
const cause = (err as { cause?: { message?: string } } | undefined)?.cause?.message;
|
|
184
|
+
logger.warn(`Unsplash request failed while ${what}: ${String(err)}${cause ? ` (${cause})` : ''}`);
|
|
185
|
+
return fail(502, 'upstream', `Could not reach Unsplash while ${what}.`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Map a non-2xx upstream status to a result. 403 is Unsplash's rate-limit
|
|
189
|
+
* signal, not a permission error, and deserves a 429 the client can act on. */
|
|
190
|
+
function fromStatus(status: number): RouteResult {
|
|
191
|
+
if (status === 401) {
|
|
192
|
+
return fail(
|
|
193
|
+
502,
|
|
194
|
+
'unauthorized',
|
|
195
|
+
'Unsplash rejected the access key. Check the key in the Settings panel.',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (status === 403) {
|
|
199
|
+
return fail(
|
|
200
|
+
429,
|
|
201
|
+
'rate-limited',
|
|
202
|
+
'Unsplash’s hourly request limit is used up. A demo-tier key allows 50 ' +
|
|
203
|
+
'requests per hour; it resets within the hour.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return fail(502, 'upstream', `Unsplash returned ${status}.`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Append the attribution params the API guidelines require. Preserves any
|
|
210
|
+
* query string the URL already carries. */
|
|
211
|
+
function withUtm(url: string, appName: string): string {
|
|
212
|
+
if (!url) return '';
|
|
213
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
214
|
+
return `${url}${sep}utm_source=${encodeURIComponent(appName)}&utm_medium=referral`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Drop everything the client has no business seeing — exif, tags, topics,
|
|
218
|
+
* sponsorship, the full user record, every URL variant — so none of it can
|
|
219
|
+
* become an accidental API surface. */
|
|
220
|
+
function reshape(raw: RawPhoto, appName: string): UnsplashPhoto {
|
|
221
|
+
const photographer = raw.user?.name || raw.user?.username || 'Unknown';
|
|
222
|
+
return {
|
|
223
|
+
id: raw.id ?? '',
|
|
224
|
+
thumbUrl: raw.urls?.small ?? '',
|
|
225
|
+
color: raw.color || '#333333',
|
|
226
|
+
width: raw.width ?? 0,
|
|
227
|
+
height: raw.height ?? 0,
|
|
228
|
+
description: (raw.description || raw.alt_description || '').trim(),
|
|
229
|
+
photographer,
|
|
230
|
+
photographerUrl: withUtm(raw.user?.links?.html ?? '', appName),
|
|
231
|
+
pageUrl: withUtm(raw.links?.html ?? '', appName),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createUnsplashRoutes(deps: UnsplashRouteDeps): Route[] {
|
|
236
|
+
const { logger, root, dirs, unsplash } = deps;
|
|
237
|
+
const publicDir = deps.publicDir ?? 'public';
|
|
238
|
+
const doFetch: typeof fetch = (...args) => (unsplash?.fetchImpl ?? globalThis.fetch)(...args);
|
|
239
|
+
|
|
240
|
+
// Both caches are per-middleware, so a test's tree never sees another's.
|
|
241
|
+
const photos = new Map<string, CachedPhoto>();
|
|
242
|
+
const searches = new Map<string, { at: number; raw: RawSearch; remaining?: number }>();
|
|
243
|
+
|
|
244
|
+
/** Remember what `/import` will need, evicting the oldest id first. */
|
|
245
|
+
const rememberPhoto = (raw: RawPhoto): void => {
|
|
246
|
+
if (!raw.id) return;
|
|
247
|
+
photos.set(raw.id, {
|
|
248
|
+
rawUrl: raw.urls?.raw ?? '',
|
|
249
|
+
downloadLocation: raw.links?.download_location ?? '',
|
|
250
|
+
description: (raw.description || raw.alt_description || '').trim(),
|
|
251
|
+
photographer: raw.user?.name || raw.user?.username || '',
|
|
252
|
+
});
|
|
253
|
+
while (photos.size > PHOTO_CACHE_LIMIT) {
|
|
254
|
+
const oldest = photos.keys().next();
|
|
255
|
+
if (oldest.done) break;
|
|
256
|
+
photos.delete(oldest.value);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const authHeaders = (key: string): Record<string, string> => ({
|
|
261
|
+
Authorization: `Client-ID ${key}`,
|
|
262
|
+
'Accept-Version': API_VERSION,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return [
|
|
266
|
+
// Proxies a search, reshaping every photo. Read-only: nothing touches disk.
|
|
267
|
+
{
|
|
268
|
+
method: 'POST',
|
|
269
|
+
path: '/unsplash/search',
|
|
270
|
+
maxBytes: 4 * 1024,
|
|
271
|
+
label: 'unsplash search',
|
|
272
|
+
handler: async (body) => {
|
|
273
|
+
const guard = await requireKey(unsplash);
|
|
274
|
+
if (!guard.ok) return guard.result;
|
|
275
|
+
const cfg = unsplash!;
|
|
276
|
+
|
|
277
|
+
const req = (body ?? {}) as UnsplashSearchRequest;
|
|
278
|
+
const query = (req.query ?? '').trim();
|
|
279
|
+
if (!query) return { status: 400, body: { error: 'query is required' } };
|
|
280
|
+
|
|
281
|
+
const page = Math.max(1, Math.trunc(Number(req.page) || 1));
|
|
282
|
+
const perPage = Math.min(
|
|
283
|
+
30,
|
|
284
|
+
Math.max(1, Math.trunc(Number(req.perPage) || (await cfg.perPage()))),
|
|
285
|
+
);
|
|
286
|
+
const orientation = req.orientation && req.orientation !== 'any' ? req.orientation : '';
|
|
287
|
+
|
|
288
|
+
const cacheKey = `${query}\0${page}\0${perPage}\0${orientation}`;
|
|
289
|
+
const hit = searches.get(cacheKey);
|
|
290
|
+
let raw: RawSearch;
|
|
291
|
+
let remaining: number | undefined;
|
|
292
|
+
|
|
293
|
+
if (hit && Date.now() - hit.at < SEARCH_TTL_MS) {
|
|
294
|
+
// Re-reshaped rather than replayed, so the photo cache is repopulated
|
|
295
|
+
// even if these ids had been evicted since.
|
|
296
|
+
raw = hit.raw;
|
|
297
|
+
remaining = hit.remaining;
|
|
298
|
+
} else {
|
|
299
|
+
const url = new URL('/search/photos', API_BASE);
|
|
300
|
+
url.searchParams.set('query', query);
|
|
301
|
+
url.searchParams.set('page', String(page));
|
|
302
|
+
url.searchParams.set('per_page', String(perPage));
|
|
303
|
+
if (orientation) url.searchParams.set('orientation', orientation);
|
|
304
|
+
|
|
305
|
+
let res: Response;
|
|
306
|
+
try {
|
|
307
|
+
res = await doFetch(url, {
|
|
308
|
+
headers: authHeaders(guard.key),
|
|
309
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
310
|
+
});
|
|
311
|
+
} catch (err) {
|
|
312
|
+
return fromThrown(err, 'searching', logger);
|
|
313
|
+
}
|
|
314
|
+
if (!res.ok) return fromStatus(res.status);
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
raw = (await res.json()) as RawSearch;
|
|
318
|
+
} catch {
|
|
319
|
+
return fail(502, 'upstream', 'Unsplash returned a malformed search response.');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const headerRemaining = Number(res.headers.get('x-ratelimit-remaining'));
|
|
323
|
+
remaining = Number.isFinite(headerRemaining) ? headerRemaining : undefined;
|
|
324
|
+
if (remaining !== undefined && remaining <= 5) {
|
|
325
|
+
logger.warn(`Unsplash rate limit is nearly used up (${remaining} requests left)`);
|
|
326
|
+
}
|
|
327
|
+
searches.set(cacheKey, { at: Date.now(), raw, remaining });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const results = Array.isArray(raw.results) ? raw.results : [];
|
|
331
|
+
for (const photo of results) rememberPhoto(photo);
|
|
332
|
+
|
|
333
|
+
const response: UnsplashSearchResponse = {
|
|
334
|
+
photos: results.map((photo) => reshape(photo, guard.appName)),
|
|
335
|
+
total: Number(raw.total) || 0,
|
|
336
|
+
totalPages: Number(raw.total_pages) || 0,
|
|
337
|
+
page,
|
|
338
|
+
...(remaining === undefined ? {} : { remaining }),
|
|
339
|
+
};
|
|
340
|
+
return { status: 200, body: response };
|
|
341
|
+
},
|
|
342
|
+
onError: () =>
|
|
343
|
+
fail(500, 'upstream', 'The Unsplash search failed unexpectedly. See the dev-server log.'),
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
// Downloads a chosen photo into the project — a NEW asset file, never a
|
|
347
|
+
// source patch, through the same confinement rule as /upload.
|
|
348
|
+
{
|
|
349
|
+
method: 'POST',
|
|
350
|
+
path: '/unsplash/import',
|
|
351
|
+
maxBytes: 4 * 1024,
|
|
352
|
+
label: 'unsplash import',
|
|
353
|
+
handler: async (body) => {
|
|
354
|
+
const guard = await requireKey(unsplash);
|
|
355
|
+
if (!guard.ok) return guard.result;
|
|
356
|
+
|
|
357
|
+
const req = (body ?? {}) as UnsplashImportRequest;
|
|
358
|
+
const id = (req.id ?? '').trim();
|
|
359
|
+
if (!id) return { status: 400, body: { error: 'id is required' } };
|
|
360
|
+
|
|
361
|
+
// A client-supplied width reaches the URL the dev server fetches, so it
|
|
362
|
+
// is checked against the safelist and **refused** when it misses —
|
|
363
|
+
// clamping would hide the bug and quietly import the wrong size. An
|
|
364
|
+
// absent width falls back to the resolved project-wide option.
|
|
365
|
+
let width: UnsplashImportWidth;
|
|
366
|
+
if (req.width === undefined) {
|
|
367
|
+
width = await unsplash!.importWidth(); // non-null past requireKey, as in /search
|
|
368
|
+
} else {
|
|
369
|
+
const asked = coerceImportWidth(req.width);
|
|
370
|
+
if (asked === null) {
|
|
371
|
+
return {
|
|
372
|
+
status: 400,
|
|
373
|
+
body: {
|
|
374
|
+
error:
|
|
375
|
+
`width must be one of ${UNSPLASH_IMPORT_WIDTHS.join(', ')} ` +
|
|
376
|
+
`— got ${JSON.stringify(req.width)}`,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
width = asked;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const photo = photos.get(id);
|
|
384
|
+
if (!photo || !photo.rawUrl) {
|
|
385
|
+
return fail(
|
|
386
|
+
409,
|
|
387
|
+
'expired',
|
|
388
|
+
'That photo is no longer available to import — the dev server has ' +
|
|
389
|
+
'restarted since the search. Search again and re-pick it.',
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Built from the URL the *server* stored, never one the client sent.
|
|
394
|
+
// fm=jpg makes the content type deterministic, which keeps the
|
|
395
|
+
// extension lookup honest. (There is no animated-GIF refusal to mirror
|
|
396
|
+
// from /upload here: every import is a JPEG by construction.)
|
|
397
|
+
//
|
|
398
|
+
// `fit=max` only ever shrinks, so a smaller `w` is pure saving; a width
|
|
399
|
+
// of 'original' omits the parameter altogether and MAX_DOWNLOAD_BYTES
|
|
400
|
+
// is then the only bound.
|
|
401
|
+
const byteUrl = new URL(photo.rawUrl);
|
|
402
|
+
// `delete`, not just "don't set": Unsplash's raw URL can already carry
|
|
403
|
+
// sizing parameters of its own, and 'original' must mean the full file.
|
|
404
|
+
if (width === 'original') byteUrl.searchParams.delete('w');
|
|
405
|
+
else byteUrl.searchParams.set('w', String(width));
|
|
406
|
+
byteUrl.searchParams.set('fit', 'max');
|
|
407
|
+
byteUrl.searchParams.set('q', '80');
|
|
408
|
+
byteUrl.searchParams.set('fm', 'jpg');
|
|
409
|
+
|
|
410
|
+
// The guideline is to ping when the user *chooses* to download, which is
|
|
411
|
+
// now — so it goes out concurrently with the bytes rather than after a
|
|
412
|
+
// multi-second download. Non-fatal: failing someone's import because an
|
|
413
|
+
// analytics endpoint hiccupped would be user-hostile, and a rare
|
|
414
|
+
// over-count on Unsplash's side is the safer error direction than a
|
|
415
|
+
// compliance miss. Awaited inside the try so nothing escapes unhandled.
|
|
416
|
+
const ping = photo.downloadLocation
|
|
417
|
+
? doFetch(photo.downloadLocation, {
|
|
418
|
+
headers: authHeaders(guard.key),
|
|
419
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
420
|
+
}).then(
|
|
421
|
+
() => undefined,
|
|
422
|
+
(err: unknown) => {
|
|
423
|
+
logger.warn(`Unsplash download ping failed (import continues): ${String(err)}`);
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
: Promise.resolve(undefined);
|
|
427
|
+
|
|
428
|
+
let res: Response;
|
|
429
|
+
try {
|
|
430
|
+
res = await doFetch(byteUrl, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
await ping;
|
|
433
|
+
return fromThrown(err, 'downloading the photo', logger);
|
|
434
|
+
}
|
|
435
|
+
await ping;
|
|
436
|
+
if (!res.ok) return fromStatus(res.status);
|
|
437
|
+
|
|
438
|
+
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
|
439
|
+
if (!contentType.startsWith('image/')) {
|
|
440
|
+
return fail(502, 'upstream', 'Unsplash returned something that is not an image.');
|
|
441
|
+
}
|
|
442
|
+
// Cheap pre-check; content-length can lie or be absent, so the reader
|
|
443
|
+
// below is the real bound.
|
|
444
|
+
const declared = Number(res.headers.get('content-length'));
|
|
445
|
+
if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) {
|
|
446
|
+
return fail(502, 'too-large', 'That photo is larger than the 25 MB import limit.');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let data: Buffer;
|
|
450
|
+
try {
|
|
451
|
+
data = await readCapped(res, MAX_DOWNLOAD_BYTES);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
if (err instanceof TooLarge) {
|
|
454
|
+
return fail(502, 'too-large', 'That photo is larger than the 25 MB import limit.');
|
|
455
|
+
}
|
|
456
|
+
return fromThrown(err, 'downloading the photo', logger);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Same target rule as /upload: an image() field's asset must be
|
|
460
|
+
// importable, and a requested targetDir is honoured only inside a
|
|
461
|
+
// configured asset directory.
|
|
462
|
+
const { dir, redirected } = resolveAssetTarget(root, await dirs(), req);
|
|
463
|
+
if (redirected) {
|
|
464
|
+
logger.warn(
|
|
465
|
+
`unsplash targetDir "${req.targetDir}" is not inside a configured asset ` +
|
|
466
|
+
`directory — writing to "${dir}" instead`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const stem = slugify(photo.description || photo.photographer || 'photo');
|
|
471
|
+
const saved = await saveBuffer(
|
|
472
|
+
root,
|
|
473
|
+
dir,
|
|
474
|
+
{
|
|
475
|
+
mime: 'image/jpeg',
|
|
476
|
+
data,
|
|
477
|
+
filename: `unsplash-${stem || 'photo'}-${slugify(id)}.jpg`,
|
|
478
|
+
},
|
|
479
|
+
publicDir,
|
|
480
|
+
);
|
|
481
|
+
logger.info(`imported Unsplash photo -> ${saved.webPath}`);
|
|
482
|
+
return { status: 200, body: saved };
|
|
483
|
+
},
|
|
484
|
+
onError: () =>
|
|
485
|
+
fail(500, 'upstream', 'The Unsplash import failed unexpectedly. See the dev-server log.'),
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Thrown by {@link readCapped}; distinguishes "too big" from a transport fault. */
|
|
491
|
+
class TooLarge extends Error {}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Read a response body, abandoning it the moment it exceeds `max`. Streaming
|
|
495
|
+
* rather than `arrayBuffer()` so an oversized or lying `content-length` cannot
|
|
496
|
+
* make the dev server buffer an unbounded download.
|
|
497
|
+
*/
|
|
498
|
+
async function readCapped(res: Response, max: number): Promise<Buffer> {
|
|
499
|
+
if (!res.body) return Buffer.from(await res.arrayBuffer());
|
|
500
|
+
const reader = res.body.getReader();
|
|
501
|
+
const chunks: Uint8Array[] = [];
|
|
502
|
+
let total = 0;
|
|
503
|
+
for (;;) {
|
|
504
|
+
const { done, value } = await reader.read();
|
|
505
|
+
if (done) break;
|
|
506
|
+
if (!value) continue;
|
|
507
|
+
total += value.byteLength;
|
|
508
|
+
if (total > max) {
|
|
509
|
+
await reader.cancel().catch(() => undefined);
|
|
510
|
+
throw new TooLarge('response exceeds the size cap');
|
|
511
|
+
}
|
|
512
|
+
chunks.push(value);
|
|
513
|
+
}
|
|
514
|
+
return Buffer.concat(chunks);
|
|
515
|
+
}
|