@jant/core 0.6.10 → 0.6.12
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/bin/commands/import-site.js +45 -20
- package/dist/app-B5P1b4TF.js +6 -0
- package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BUxR-E8O.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
- package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
- package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/__tests__/import-site-command.test.ts +75 -0
- package/src/__tests__/vite-dev-plugins.test.ts +34 -0
- package/src/client/__tests__/compose-launch.test.ts +50 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
- package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-command-palette.ts +67 -2
- package/src/client/components/jant-compose-dialog.ts +8 -2
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-nav-manager.ts +142 -2
- package/src/client/components/jant-settings-general.ts +56 -0
- package/src/client/components/nav-manager-types.ts +14 -0
- package/src/client/components/settings-types.ts +30 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/compose-launch.ts +30 -0
- package/src/client/compose-shortcuts.ts +46 -3
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/rehost-images.ts +108 -0
- package/src/i18n/__tests__/fallback.test.ts +24 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +54 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +64 -10
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +56 -2
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +96 -10
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/feed.ts +63 -3
- package/src/lib/post-display.ts +14 -8
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/preset.css +60 -18
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +41 -2
- package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
- package/src/routes/pages/page.tsx +44 -12
- package/src/services/__tests__/about-page.test.ts +116 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/__tests__/navigation.test.ts +190 -1
- package/src/services/__tests__/post-timeline.test.ts +119 -2
- package/src/services/about-page.ts +143 -0
- package/src/services/export-theme/layouts/_default/rss.xml +2 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
- package/src/services/export-theme/styles/main.css +46 -14
- package/src/services/index.ts +27 -17
- package/src/services/media.ts +111 -0
- package/src/services/navigation.ts +155 -1
- package/src/services/post.ts +24 -7
- package/src/styles/components.css +20 -0
- package/src/styles/tokens.css +26 -8
- package/src/styles/ui.css +149 -7
- package/src/types/views.ts +13 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
- package/src/ui/dash/settings/GeneralContent.tsx +44 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
- package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- package/dist/client/_assets/client-xWDl78yi.css +0 -2
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe remote URL fetching for server-side image sideloading.
|
|
3
|
+
*
|
|
4
|
+
* When an author pastes an article from another site, its `<img>` tags point at
|
|
5
|
+
* remote URLs. To rehost those images into the site's own storage the server
|
|
6
|
+
* must fetch the bytes itself (a browser `fetch` of a third-party image is
|
|
7
|
+
* blocked by CORS for most hosts). Because the URL comes from pasted HTML it is
|
|
8
|
+
* attacker-influenced, so every fetch passes through an SSRF guard and a bounded
|
|
9
|
+
* reader that caps size and time.
|
|
10
|
+
*
|
|
11
|
+
* Note: DNS is not resolvable from a Cloudflare Worker, so the IP-literal checks
|
|
12
|
+
* here are defense-in-depth for self-hosted (Node) deployments and for URLs that
|
|
13
|
+
* embed a literal address. They are not a substitute for network-level egress
|
|
14
|
+
* controls.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ValidationError } from "./errors.js";
|
|
18
|
+
|
|
19
|
+
/** A browser-like UA — many CDNs serving article images block unknown bots. */
|
|
20
|
+
const FETCH_USER_AGENT =
|
|
21
|
+
"Mozilla/5.0 (compatible; Jant image sideloader) AppleWebKit/537.36";
|
|
22
|
+
|
|
23
|
+
export interface FetchedImage {
|
|
24
|
+
bytes: Uint8Array;
|
|
25
|
+
/** Lowercased content-type with parameters stripped, or null if absent. */
|
|
26
|
+
contentType: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FetchImageBytesOptions {
|
|
30
|
+
/** Reject (and abort) once the body exceeds this many bytes. */
|
|
31
|
+
maxBytes: number;
|
|
32
|
+
/** Abort the whole request after this many milliseconds. */
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
/** Maximum redirect hops to follow (each re-validated). Default 3. */
|
|
35
|
+
maxRedirects?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate that a string is a public http(s) URL safe to fetch server-side.
|
|
40
|
+
*
|
|
41
|
+
* Throws {@link ValidationError} for non-http(s) protocols, embedded
|
|
42
|
+
* credentials, localhost names, and private/loopback/link-local/ULA/CGNAT IP
|
|
43
|
+
* literals (including the `169.254.169.254` cloud-metadata address).
|
|
44
|
+
*
|
|
45
|
+
* @param raw - The candidate URL string
|
|
46
|
+
* @returns The parsed {@link URL}
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const url = assertPublicHttpUrl("https://example.com/photo.jpg");
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function assertPublicHttpUrl(raw: string): URL {
|
|
53
|
+
let url: URL;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new ValidationError("That doesn't look like a valid image URL.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
61
|
+
throw new ValidationError("Only http and https image URLs can be fetched.");
|
|
62
|
+
}
|
|
63
|
+
if (url.username || url.password) {
|
|
64
|
+
throw new ValidationError("Image URLs can't include credentials.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const host = url.hostname.toLowerCase();
|
|
68
|
+
if (
|
|
69
|
+
host === "localhost" ||
|
|
70
|
+
host.endsWith(".localhost") ||
|
|
71
|
+
host === "0.0.0.0" ||
|
|
72
|
+
isPrivateIpv4(host) ||
|
|
73
|
+
isPrivateIpv6(host)
|
|
74
|
+
) {
|
|
75
|
+
throw new ValidationError("That image URL points to a private address.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch a remote image with an SSRF-checked redirect chain, a size cap, and a
|
|
83
|
+
* timeout. Reads the body in chunks and aborts the moment it exceeds `maxBytes`
|
|
84
|
+
* so an untrusted host can't exhaust memory.
|
|
85
|
+
*
|
|
86
|
+
* @param startUrl - A URL already validated by {@link assertPublicHttpUrl}
|
|
87
|
+
* @param options - Size cap, timeout, and redirect budget
|
|
88
|
+
* @returns The raw bytes and the response content-type
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const { bytes, contentType } = await fetchImageBytes(url, {
|
|
92
|
+
* maxBytes: 25 * 1024 * 1024,
|
|
93
|
+
* timeoutMs: 15000,
|
|
94
|
+
* });
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export async function fetchImageBytes(
|
|
98
|
+
startUrl: URL,
|
|
99
|
+
options: FetchImageBytesOptions,
|
|
100
|
+
): Promise<FetchedImage> {
|
|
101
|
+
const maxRedirects = options.maxRedirects ?? 3;
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
let url = startUrl;
|
|
107
|
+
let response: Response | null = null;
|
|
108
|
+
|
|
109
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
110
|
+
response = await fetch(url.toString(), {
|
|
111
|
+
method: "GET",
|
|
112
|
+
redirect: "manual",
|
|
113
|
+
signal: controller.signal,
|
|
114
|
+
headers: {
|
|
115
|
+
Accept: "image/*,*/*;q=0.8",
|
|
116
|
+
"User-Agent": FETCH_USER_AGENT,
|
|
117
|
+
// Many CDNs (Douban, WeChat, etc.) reject hotlinked image requests
|
|
118
|
+
// that lack a Referer. Sending the image's own origin satisfies the
|
|
119
|
+
// common "referer must be same-site" hotlink check.
|
|
120
|
+
Referer: `${url.origin}/`,
|
|
121
|
+
},
|
|
122
|
+
}).catch((error) => {
|
|
123
|
+
if (controller.signal.aborted) {
|
|
124
|
+
throw new ValidationError("Timed out fetching the image.");
|
|
125
|
+
}
|
|
126
|
+
throw new ValidationError(
|
|
127
|
+
error instanceof Error
|
|
128
|
+
? `Couldn't fetch the image: ${error.message}`
|
|
129
|
+
: "Couldn't fetch the image.",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (response.status >= 300 && response.status < 400) {
|
|
134
|
+
const location = response.headers.get("location");
|
|
135
|
+
if (!location) break; // No target — fall through to the (failing) checks.
|
|
136
|
+
if (hop === maxRedirects) {
|
|
137
|
+
throw new ValidationError("Too many redirects fetching the image.");
|
|
138
|
+
}
|
|
139
|
+
// Re-validate every hop so a redirect can't escape the SSRF guard.
|
|
140
|
+
url = assertPublicHttpUrl(new URL(location, url).toString());
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!response) {
|
|
147
|
+
throw new ValidationError("Couldn't fetch the image.");
|
|
148
|
+
}
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new ValidationError(
|
|
151
|
+
`Couldn't fetch the image (HTTP ${response.status}).`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const declared = Number(response.headers.get("content-length"));
|
|
156
|
+
if (Number.isFinite(declared) && declared > options.maxBytes) {
|
|
157
|
+
throw new ValidationError("That image is too large.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const contentType = normalizeContentType(
|
|
161
|
+
response.headers.get("content-type"),
|
|
162
|
+
);
|
|
163
|
+
const bytes = await readBounded(response, options.maxBytes);
|
|
164
|
+
return { bytes, contentType };
|
|
165
|
+
} finally {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readBounded(
|
|
171
|
+
response: Response,
|
|
172
|
+
maxBytes: number,
|
|
173
|
+
): Promise<Uint8Array> {
|
|
174
|
+
const body = response.body;
|
|
175
|
+
if (!body) {
|
|
176
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
177
|
+
if (buffer.byteLength > maxBytes) {
|
|
178
|
+
throw new ValidationError("That image is too large.");
|
|
179
|
+
}
|
|
180
|
+
return buffer;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const reader = body.getReader();
|
|
184
|
+
const chunks: Uint8Array[] = [];
|
|
185
|
+
let total = 0;
|
|
186
|
+
for (;;) {
|
|
187
|
+
const { done, value } = await reader.read();
|
|
188
|
+
if (done) break;
|
|
189
|
+
if (!value) continue;
|
|
190
|
+
total += value.byteLength;
|
|
191
|
+
if (total > maxBytes) {
|
|
192
|
+
await reader.cancel().catch(() => {});
|
|
193
|
+
throw new ValidationError("That image is too large.");
|
|
194
|
+
}
|
|
195
|
+
chunks.push(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const out = new Uint8Array(total);
|
|
199
|
+
let offset = 0;
|
|
200
|
+
for (const chunk of chunks) {
|
|
201
|
+
out.set(chunk, offset);
|
|
202
|
+
offset += chunk.byteLength;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeContentType(raw: string | null): string | null {
|
|
208
|
+
if (!raw) return null;
|
|
209
|
+
const type = raw.split(";")[0]?.trim().toLowerCase();
|
|
210
|
+
return type || null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* True when a canonicalized IPv4 dotted-quad host is in a private, loopback,
|
|
215
|
+
* link-local, CGNAT, or otherwise non-public range. The WHATWG URL parser
|
|
216
|
+
* already normalizes decimal/octal/hex IPv4 forms to dotted-quad, so checking
|
|
217
|
+
* `url.hostname` is sufficient.
|
|
218
|
+
*/
|
|
219
|
+
function isPrivateIpv4(host: string): boolean {
|
|
220
|
+
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
221
|
+
if (!match) return false;
|
|
222
|
+
const octets = match.slice(1, 5).map(Number);
|
|
223
|
+
if (octets.some((part) => part > 255)) return true; // Malformed → unsafe.
|
|
224
|
+
const a = octets[0] ?? 0;
|
|
225
|
+
const b = octets[1] ?? 0;
|
|
226
|
+
if (a === 0) return true; // 0.0.0.0/8 "this" network
|
|
227
|
+
if (a === 10) return true; // 10/8 private
|
|
228
|
+
if (a === 127) return true; // 127/8 loopback
|
|
229
|
+
if (a === 169 && b === 254) return true; // 169.254/16 link-local (metadata)
|
|
230
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12 private
|
|
231
|
+
if (a === 192 && b === 168) return true; // 192.168/16 private
|
|
232
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT
|
|
233
|
+
if (a === 255 && b === 255) return true; // broadcast
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** True when an IPv6 host (bracketed or bare) is loopback/link-local/ULA/mapped-private. */
|
|
238
|
+
function isPrivateIpv6(host: string): boolean {
|
|
239
|
+
let inner = host;
|
|
240
|
+
if (inner.startsWith("[") && inner.endsWith("]")) {
|
|
241
|
+
inner = inner.slice(1, -1);
|
|
242
|
+
}
|
|
243
|
+
if (!inner.includes(":")) return false;
|
|
244
|
+
const lower = inner.toLowerCase();
|
|
245
|
+
if (lower === "::" || lower === "::1") return true; // unspecified / loopback
|
|
246
|
+
|
|
247
|
+
// IPv4-mapped, dotted form (::ffff:1.2.3.4).
|
|
248
|
+
const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
249
|
+
if (dotted?.[1]) return isPrivateIpv4(dotted[1]);
|
|
250
|
+
|
|
251
|
+
// IPv4-mapped, hex form (::ffff:a00:1) — what the URL parser normalizes to.
|
|
252
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
253
|
+
if (hex) {
|
|
254
|
+
const hi = parseInt(hex[1] ?? "0", 16);
|
|
255
|
+
const lo = parseInt(hex[2] ?? "0", 16);
|
|
256
|
+
const v4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
257
|
+
return isPrivateIpv4(v4);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local
|
|
261
|
+
if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique local
|
|
262
|
+
return false;
|
|
263
|
+
}
|
package/src/preset.css
CHANGED
|
@@ -182,9 +182,9 @@ html {
|
|
|
182
182
|
--tw-prose-quote-borders: var(--color-border);
|
|
183
183
|
--tw-prose-captions: var(--color-muted-foreground);
|
|
184
184
|
--tw-prose-kbd: var(--color-foreground);
|
|
185
|
-
--tw-prose-code: var(--
|
|
186
|
-
--tw-prose-pre-code: var(--
|
|
187
|
-
--tw-prose-pre-bg: var(--
|
|
185
|
+
--tw-prose-code: var(--site-code-text);
|
|
186
|
+
--tw-prose-pre-code: var(--site-code-text);
|
|
187
|
+
--tw-prose-pre-bg: var(--site-code-block-bg);
|
|
188
188
|
--tw-prose-th-borders: var(--color-border);
|
|
189
189
|
--tw-prose-td-borders: var(--color-border);
|
|
190
190
|
font-family: var(--font-body);
|
|
@@ -213,14 +213,12 @@ html {
|
|
|
213
213
|
|
|
214
214
|
:where(h2) {
|
|
215
215
|
font-size: var(--type-content-title);
|
|
216
|
-
font-style: italic;
|
|
217
216
|
margin-top: 2.1rem;
|
|
218
217
|
margin-bottom: 1.4rem;
|
|
219
218
|
}
|
|
220
219
|
|
|
221
220
|
:where(h3) {
|
|
222
221
|
font-size: var(--type-content-subtitle);
|
|
223
|
-
font-style: italic;
|
|
224
222
|
margin-top: 2rem;
|
|
225
223
|
margin-bottom: 1.4rem;
|
|
226
224
|
}
|
|
@@ -236,12 +234,12 @@ html {
|
|
|
236
234
|
|
|
237
235
|
:where(ul, ol) {
|
|
238
236
|
margin: 1.25em 0;
|
|
239
|
-
padding-left: 1.
|
|
237
|
+
padding-left: 1.4em;
|
|
240
238
|
}
|
|
241
239
|
|
|
242
240
|
:where(li, dt) {
|
|
243
|
-
margin-top:
|
|
244
|
-
margin-bottom:
|
|
241
|
+
margin-top: 0.65em;
|
|
242
|
+
margin-bottom: 0.65em;
|
|
245
243
|
}
|
|
246
244
|
|
|
247
245
|
/* Normalize li > p so list spacing is controlled by li alone,
|
|
@@ -257,18 +255,35 @@ html {
|
|
|
257
255
|
:where(code, pre code) {
|
|
258
256
|
font-size: var(--type-code);
|
|
259
257
|
line-height: 1.42;
|
|
258
|
+
color: var(--site-code-text);
|
|
260
259
|
overflow-wrap: break-word;
|
|
261
260
|
}
|
|
262
261
|
|
|
263
262
|
:where(:not(pre) > code) {
|
|
264
|
-
padding: 0.
|
|
265
|
-
background-color: var(--site-
|
|
266
|
-
border:
|
|
267
|
-
|
|
263
|
+
padding: 0.08em 0.28em;
|
|
264
|
+
background-color: var(--site-code-bg);
|
|
265
|
+
border-radius: 0.22em;
|
|
266
|
+
box-decoration-break: clone;
|
|
267
|
+
-webkit-box-decoration-break: clone;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
:where(:not(pre) > code)::before,
|
|
271
|
+
:where(:not(pre) > code)::after {
|
|
272
|
+
content: none;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
:where(pre) {
|
|
276
|
+
background-color: var(--site-code-block-bg);
|
|
277
|
+
border: 1px solid var(--site-code-block-border);
|
|
278
|
+
border-radius: 6px;
|
|
268
279
|
}
|
|
269
280
|
|
|
270
281
|
:where(pre code) {
|
|
271
282
|
font-size: var(--type-code-block);
|
|
283
|
+
color: inherit;
|
|
284
|
+
background: transparent;
|
|
285
|
+
border: 0;
|
|
286
|
+
padding: 0;
|
|
272
287
|
}
|
|
273
288
|
|
|
274
289
|
:where(h1, h2, h3) :where(code) {
|
|
@@ -425,6 +440,37 @@ html {
|
|
|
425
440
|
margin-bottom: 0;
|
|
426
441
|
}
|
|
427
442
|
|
|
443
|
+
/*
|
|
444
|
+
* Feed body headings
|
|
445
|
+
*
|
|
446
|
+
* In list contexts, the post title owns the card heading level. Authored
|
|
447
|
+
* headings inside the body should remain visible, but they should read as
|
|
448
|
+
* sections within the card rather than as peer post titles.
|
|
449
|
+
*/
|
|
450
|
+
[data-post]:not([data-page="post"]) [data-post-body].prose {
|
|
451
|
+
:where(h1, h2) {
|
|
452
|
+
font-size: calc(var(--type-content-body) * 1.12);
|
|
453
|
+
font-weight: var(--fw-medium);
|
|
454
|
+
margin-top: 1.45rem;
|
|
455
|
+
margin-bottom: 0.55rem;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
:where(h3, h4) {
|
|
459
|
+
font-size: var(--type-content-body);
|
|
460
|
+
font-weight: var(--fw-medium);
|
|
461
|
+
margin-top: 1.2rem;
|
|
462
|
+
margin-bottom: 0.45rem;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
:where(h5, h6) {
|
|
466
|
+
font-size: var(--type-secondary);
|
|
467
|
+
font-weight: var(--fw-medium);
|
|
468
|
+
color: var(--site-text-secondary);
|
|
469
|
+
margin-top: 1rem;
|
|
470
|
+
margin-bottom: 0.35rem;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
428
474
|
/*
|
|
429
475
|
* Detail-page reading prose
|
|
430
476
|
*
|
|
@@ -455,12 +501,8 @@ html {
|
|
|
455
501
|
);
|
|
456
502
|
--tw-prose-captions: var(--site-reading-caption);
|
|
457
503
|
--tw-prose-kbd: var(--site-reading-heading);
|
|
458
|
-
--tw-prose-code: var(--site-
|
|
459
|
-
--tw-prose-pre-code:
|
|
460
|
-
in srgb,
|
|
461
|
-
var(--site-reading-caption) 86%,
|
|
462
|
-
var(--site-reading-body)
|
|
463
|
-
);
|
|
504
|
+
--tw-prose-code: var(--site-code-text);
|
|
505
|
+
--tw-prose-pre-code: var(--site-code-text);
|
|
464
506
|
--tw-prose-th-borders: color-mix(
|
|
465
507
|
in srgb,
|
|
466
508
|
var(--site-divider) 88%,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
3
3
|
import type {
|
|
4
4
|
PresignedPutOptions,
|
|
@@ -136,6 +136,68 @@ function createMockStorage(options?: {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
describe("POST /api/uploads/sideload", () => {
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
vi.restoreAllMocks();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function pngBytes(): Uint8Array {
|
|
145
|
+
return new Uint8Array([
|
|
146
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
|
147
|
+
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x06,
|
|
148
|
+
0x08, 0x06, 0x00, 0x00, 0x00,
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
it("rehosts a remote image and returns its stored URL", async () => {
|
|
153
|
+
vi.stubGlobal(
|
|
154
|
+
"fetch",
|
|
155
|
+
vi.fn(
|
|
156
|
+
async () =>
|
|
157
|
+
new Response(pngBytes(), {
|
|
158
|
+
headers: { "content-type": "image/png" },
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const storage = createMockStorage();
|
|
163
|
+
const { app, services } = createTestApp({ authenticated: true, storage });
|
|
164
|
+
app.route("/api/uploads", uploadsApiRoutes);
|
|
165
|
+
|
|
166
|
+
const res = await app.request("/api/uploads/sideload", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ url: "https://example.com/photo.png" }),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(res.status).toBe(200);
|
|
173
|
+
const data = (await res.json()) as {
|
|
174
|
+
id: string;
|
|
175
|
+
url: string;
|
|
176
|
+
mimeType: string;
|
|
177
|
+
width: number;
|
|
178
|
+
height: number;
|
|
179
|
+
};
|
|
180
|
+
expect(data.mimeType).toBe("image/png");
|
|
181
|
+
expect(data.width).toBe(4);
|
|
182
|
+
expect(data.height).toBe(6);
|
|
183
|
+
const media = await services.media.getById(data.id);
|
|
184
|
+
expect(media?.mimeType).toBe("image/png");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("requires authentication", async () => {
|
|
188
|
+
const { app } = createTestApp({ authenticated: false });
|
|
189
|
+
app.route("/api/uploads", uploadsApiRoutes);
|
|
190
|
+
|
|
191
|
+
const res = await app.request("/api/uploads/sideload", {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "Content-Type": "application/json" },
|
|
194
|
+
body: JSON.stringify({ url: "https://example.com/photo.png" }),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(res.status).toBe(401);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
139
201
|
describe("Upload Session API Routes", () => {
|
|
140
202
|
it("completes a relay image upload and stores inline media metadata", async () => {
|
|
141
203
|
const storage = createMockStorage();
|
|
@@ -35,10 +35,62 @@ const CompleteUploadSchema = z.object({
|
|
|
35
35
|
.optional(),
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
const SideloadSchema = z.object({
|
|
39
|
+
url: z.string().url(),
|
|
40
|
+
alt: z.string().max(2000).optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
38
43
|
export const uploadsApiRoutes = new Hono<Env>();
|
|
39
44
|
|
|
40
45
|
uploadsApiRoutes.use("*", requireAuthApi());
|
|
41
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Rehost a remote image into the site's own storage. Used when an author pastes
|
|
49
|
+
* an article whose `<img>` tags point at external URLs — the server fetches the
|
|
50
|
+
* bytes (bypassing browser CORS) and stores them. Returns the new media's
|
|
51
|
+
* public URL so the editor can swap the node's `src`.
|
|
52
|
+
*/
|
|
53
|
+
uploadsApiRoutes.post("/sideload", async (c) => {
|
|
54
|
+
const storage = c.var.storage;
|
|
55
|
+
if (!storage) {
|
|
56
|
+
return c.json(
|
|
57
|
+
{ error: "File storage isn't set up. Check your server config." },
|
|
58
|
+
500,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { url, alt } = parseValidated(SideloadSchema, await c.req.json());
|
|
63
|
+
|
|
64
|
+
const media = await c.var.services.media.ingestFromUrl(
|
|
65
|
+
{ url, alt },
|
|
66
|
+
{
|
|
67
|
+
storage,
|
|
68
|
+
storageDriver: c.var.appConfig.storageDriver,
|
|
69
|
+
maxFileSizeMB: c.var.appConfig.uploadMaxFileSize,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const mediaPublicUrl = getPublicUrlForProvider(
|
|
74
|
+
c.var.appConfig.storageDriver,
|
|
75
|
+
c.var.appConfig.r2PublicUrl,
|
|
76
|
+
c.var.appConfig.s3PublicUrl,
|
|
77
|
+
c.var.appConfig.localPublicUrl,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return c.json({
|
|
81
|
+
id: media.id,
|
|
82
|
+
url: getMediaUrl(
|
|
83
|
+
media.storageKey,
|
|
84
|
+
mediaPublicUrl,
|
|
85
|
+
c.var.appConfig.sitePathPrefix,
|
|
86
|
+
),
|
|
87
|
+
width: media.width,
|
|
88
|
+
height: media.height,
|
|
89
|
+
mimeType: media.mimeType,
|
|
90
|
+
size: media.size,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
42
94
|
function scheduleExpiredUploadCleanup(
|
|
43
95
|
c: {
|
|
44
96
|
executionCtx?: { waitUntil: (promise: Promise<unknown>) => void };
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
isSyncPending,
|
|
76
76
|
markSyncPending,
|
|
77
77
|
runBackgroundSync,
|
|
78
|
+
triggerGitHubSyncInline,
|
|
78
79
|
} from "../../lib/github-sync-trigger.js";
|
|
79
80
|
import { buildSyncSiteConfig } from "../../lib/github-sync-site-config.js";
|
|
80
81
|
import {
|
|
@@ -117,6 +118,10 @@ function publicPath(c: Context<Env>, path: string): string {
|
|
|
117
118
|
return toPublicPath(path, c.var.appConfig.sitePathPrefix);
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
function aboutEditPath(c: Context<Env>): string {
|
|
122
|
+
return `${publicPath(c, "/about")}?edit=1`;
|
|
123
|
+
}
|
|
124
|
+
|
|
120
125
|
/**
|
|
121
126
|
* Breadcrumb labels for admin settings pages.
|
|
122
127
|
*
|
|
@@ -302,7 +307,10 @@ settingsRoutes.get("/general", async (c) => {
|
|
|
302
307
|
const dbSiteDescription = allSettings["SITE_DESCRIPTION"] ?? "";
|
|
303
308
|
|
|
304
309
|
const saved = c.req.query("saved") !== undefined;
|
|
305
|
-
const navData = await
|
|
310
|
+
const [navData, aboutPage] = await Promise.all([
|
|
311
|
+
getNavigationData(c),
|
|
312
|
+
c.var.services.aboutPage.getStatus(),
|
|
313
|
+
]);
|
|
306
314
|
const siteUrlForDisplay =
|
|
307
315
|
appConfig.siteUrl || new URL(publicPath(c, "/"), c.req.url).toString();
|
|
308
316
|
|
|
@@ -356,6 +364,9 @@ settingsRoutes.get("/general", async (c) => {
|
|
|
356
364
|
noindex={appConfig.noindex}
|
|
357
365
|
demoMode={appConfig.demoMode}
|
|
358
366
|
timezones={TIMEZONES}
|
|
367
|
+
aboutPage={aboutPage}
|
|
368
|
+
aboutEditUrl={aboutEditPath(c)}
|
|
369
|
+
aboutCreateUrl={publicPath(c, "/settings/general/about-page")}
|
|
359
370
|
/>
|
|
360
371
|
</>
|
|
361
372
|
),
|
|
@@ -534,6 +545,32 @@ settingsRoutes.post("/general/search", async (c) => {
|
|
|
534
545
|
);
|
|
535
546
|
});
|
|
536
547
|
|
|
548
|
+
settingsRoutes.post("/general/about-page", async (c) => {
|
|
549
|
+
try {
|
|
550
|
+
await c.var.services.aboutPage.ensurePage();
|
|
551
|
+
await triggerGitHubSyncInline(c);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (error instanceof ValidationError) {
|
|
554
|
+
const wantsJson = c.req.header("accept")?.includes("application/json");
|
|
555
|
+
if (wantsJson) {
|
|
556
|
+
return c.json({ error: error.message, code: error.code }, 400);
|
|
557
|
+
}
|
|
558
|
+
return c.text(error.message, 400);
|
|
559
|
+
}
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const wantsJson = c.req.header("accept")?.includes("application/json");
|
|
564
|
+
if (wantsJson) {
|
|
565
|
+
return c.json({
|
|
566
|
+
status: "redirect" as const,
|
|
567
|
+
url: aboutEditPath(c),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return c.redirect(aboutEditPath(c));
|
|
572
|
+
});
|
|
573
|
+
|
|
537
574
|
// ===========================================================================
|
|
538
575
|
// Avatar
|
|
539
576
|
// ===========================================================================
|
|
@@ -743,9 +780,10 @@ settingsRoutes.post("/avatar/display", async (c) => {
|
|
|
743
780
|
// ===========================================================================
|
|
744
781
|
|
|
745
782
|
settingsRoutes.get("/navigation", async (c) => {
|
|
746
|
-
const [navItems, directoryData] = await Promise.all([
|
|
783
|
+
const [navItems, directoryData, suggestedLinks] = await Promise.all([
|
|
747
784
|
c.var.services.navItems.list(),
|
|
748
785
|
c.var.services.collections.listDirectoryData(),
|
|
786
|
+
c.var.services.navItems.listSuggestedLinks(),
|
|
749
787
|
]);
|
|
750
788
|
const navData = await getNavigationData(c);
|
|
751
789
|
|
|
@@ -762,6 +800,7 @@ settingsRoutes.get("/navigation", async (c) => {
|
|
|
762
800
|
<NavigationContent
|
|
763
801
|
navItems={navItems}
|
|
764
802
|
directoryData={directoryData}
|
|
803
|
+
suggestedLinks={suggestedLinks}
|
|
765
804
|
mainRssFeed={c.var.appConfig.mainRssFeed}
|
|
766
805
|
siteName={navData.siteName}
|
|
767
806
|
sitePathPrefix={c.var.appConfig.sitePathPrefix}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
3
|
+
import { pageRoutes } from "../page.js";
|
|
4
|
+
|
|
5
|
+
function createPageTestApp(options: { authenticated?: boolean } = {}) {
|
|
6
|
+
const testApp = createTestApp(options);
|
|
7
|
+
const { app } = testApp;
|
|
8
|
+
|
|
9
|
+
app.use("*", async (c, next) => {
|
|
10
|
+
c.set("publicPath", c.req.path);
|
|
11
|
+
c.set("publicRequestUrl", c.req.url);
|
|
12
|
+
await next();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
app.route("/", pageRoutes);
|
|
16
|
+
|
|
17
|
+
return testApp;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function createDraftAbout(
|
|
21
|
+
services: ReturnType<typeof createTestApp>["services"],
|
|
22
|
+
) {
|
|
23
|
+
return services.posts.create({
|
|
24
|
+
format: "note",
|
|
25
|
+
title: "About",
|
|
26
|
+
slug: "about",
|
|
27
|
+
bodyMarkdown: "Draft About body",
|
|
28
|
+
status: "draft",
|
|
29
|
+
visibility: "latest_hidden",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("draft About edit route", () => {
|
|
34
|
+
it("keeps draft About hidden from anonymous edit requests", async () => {
|
|
35
|
+
const { app, services } = createPageTestApp();
|
|
36
|
+
await createDraftAbout(services);
|
|
37
|
+
|
|
38
|
+
const res = await app.request("/about?edit=1");
|
|
39
|
+
|
|
40
|
+
expect(res.status).toBe(404);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps draft About hidden from normal authenticated page requests", async () => {
|
|
44
|
+
const { app, services } = createPageTestApp({ authenticated: true });
|
|
45
|
+
await createDraftAbout(services);
|
|
46
|
+
|
|
47
|
+
const res = await app.request("/about");
|
|
48
|
+
|
|
49
|
+
expect(res.status).toBe(404);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders draft About for authenticated edit requests", async () => {
|
|
53
|
+
const { app, services } = createPageTestApp({ authenticated: true });
|
|
54
|
+
const post = await createDraftAbout(services);
|
|
55
|
+
|
|
56
|
+
const res = await app.request("/about?edit=1");
|
|
57
|
+
|
|
58
|
+
expect(res.status).toBe(200);
|
|
59
|
+
const html = await res.text();
|
|
60
|
+
expect(html).toContain(`data-post-id="${post.id}"`);
|
|
61
|
+
expect(html).toContain("data-authenticated");
|
|
62
|
+
expect(html).not.toContain('rel="canonical"');
|
|
63
|
+
expect(html).not.toContain("application/ld+json");
|
|
64
|
+
});
|
|
65
|
+
});
|