@jant/core 0.5.4 → 0.6.1
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/telegram/register-webhooks.js +93 -0
- package/dist/app-CMSW_AYG.js +6 -0
- package/dist/{app-BtNdUAqz.js → app-DYQdDMs8.js} +2249 -387
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BRTh1ii1.js +274 -0
- package/dist/client/_assets/client-CO4b-RKd.css +2 -0
- package/dist/client/_assets/{client-auth-DJ_5wx9N.js → client-auth-CSNcTJwP.js} +81 -81
- package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
- package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
- package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
- package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
- package/dist/index.js +4 -4
- package/dist/node.js +61 -5
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +15 -2
- package/src/app.tsx +3 -0
- package/src/client/thread-context.ts +146 -2
- package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
- package/src/client/tiptap/bubble-menu.ts +1 -16
- package/src/client/tiptap/extensions.ts +2 -6
- package/src/client/tiptap/link-toolbar.ts +0 -21
- package/src/client/tiptap/toolbar-mode.ts +0 -43
- package/src/db/migrations/0022_old_gressill.sql +24 -0
- package/src/db/migrations/0023_broad_terror.sql +20 -0
- package/src/db/migrations/0024_red_the_twelve.sql +3 -0
- package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
- package/src/db/migrations/meta/0022_snapshot.json +2267 -0
- package/src/db/migrations/meta/0023_snapshot.json +2396 -0
- package/src/db/migrations/meta/0024_snapshot.json +2417 -0
- package/src/db/migrations/meta/0025_snapshot.json +2424 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
- package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
- package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
- package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
- package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
- package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
- package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
- package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +82 -0
- package/src/db/schema.ts +90 -0
- package/src/i18n/coverage.generated.ts +2 -2
- package/src/i18n/locales/public/en.po +8 -0
- package/src/i18n/locales/public/zh-Hans.po +8 -0
- package/src/i18n/locales/public/zh-Hant.po +8 -0
- package/src/i18n/locales/settings/en.po +135 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +136 -1
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +136 -1
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/image-dimensions.test.ts +314 -0
- package/src/lib/__tests__/telegram-entities.test.ts +180 -0
- package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
- package/src/lib/env.ts +45 -0
- package/src/lib/ids.ts +3 -0
- package/src/lib/image-dimensions.ts +258 -0
- package/src/lib/telegram-entities.ts +240 -0
- package/src/lib/telegram-pool-webhooks.ts +86 -0
- package/src/lib/telegram-settings-status.tsx +109 -0
- package/src/lib/telegram.ts +363 -0
- package/src/node/runtime.ts +6 -0
- package/src/routes/api/__tests__/telegram.test.ts +612 -0
- package/src/routes/api/telegram.ts +782 -0
- package/src/routes/api/upload-multipart.ts +34 -12
- package/src/routes/api/upload.ts +23 -2
- package/src/routes/dash/settings.tsx +131 -1
- package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
- package/src/routes/pages/page.tsx +3 -2
- package/src/runtime/cloudflare.ts +20 -9
- package/src/runtime/node.ts +20 -9
- package/src/runtime/site.ts +2 -1
- package/src/services/__tests__/telegram.test.ts +148 -0
- package/src/services/index.ts +9 -0
- package/src/services/telegram.ts +613 -0
- package/src/services/upload-session.ts +39 -12
- package/src/styles/tokens.css +1 -0
- package/src/styles/ui.css +117 -38
- package/src/types/app-context.ts +6 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +40 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
- package/src/ui/dash/settings/TelegramContent.tsx +549 -0
- package/src/ui/feed/ThreadPreview.tsx +90 -38
- package/src/ui/feed/__tests__/thread-preview.test.ts +66 -5
- package/src/ui/pages/PostPage.tsx +77 -15
- package/dist/app-DLINgGBd.js +0 -6
- package/dist/client/_assets/client-BErXNT6k.css +0 -2
- package/dist/client/_assets/client-CtAgWT8i.js +0 -274
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
validateStoredUploadMetadata,
|
|
30
30
|
validateStoredUploadSignature,
|
|
31
31
|
} from "../../lib/upload.js";
|
|
32
|
+
import {
|
|
33
|
+
IMAGE_DIMENSION_PEEK_BYTES,
|
|
34
|
+
parseImageDimensions,
|
|
35
|
+
} from "../../lib/image-dimensions.js";
|
|
32
36
|
import { supportsMultipart } from "../../lib/storage.js";
|
|
33
37
|
import {
|
|
34
38
|
MediaQuotaExceededError,
|
|
@@ -230,7 +234,16 @@ multipartUploadApiRoutes.post("/:id/complete", async (c) => {
|
|
|
230
234
|
data.parts,
|
|
231
235
|
);
|
|
232
236
|
|
|
233
|
-
const
|
|
237
|
+
const signaturePeekLength = getStoredUploadSignaturePeekLength(
|
|
238
|
+
data.contentType,
|
|
239
|
+
);
|
|
240
|
+
let width = data.width && data.width > 0 ? data.width : undefined;
|
|
241
|
+
let height = data.height && data.height > 0 ? data.height : undefined;
|
|
242
|
+
const needsDimensionSniff =
|
|
243
|
+
(!width || !height) && data.contentType.startsWith("image/");
|
|
244
|
+
const peekLength = needsDimensionSniff
|
|
245
|
+
? Math.max(signaturePeekLength, IMAGE_DIMENSION_PEEK_BYTES)
|
|
246
|
+
: signaturePeekLength;
|
|
234
247
|
if (peekLength > 0) {
|
|
235
248
|
const object = await storage.get(data.storageKey, {
|
|
236
249
|
range: { offset: 0, length: peekLength },
|
|
@@ -239,16 +252,25 @@ multipartUploadApiRoutes.post("/:id/complete", async (c) => {
|
|
|
239
252
|
throw new ValidationError("The uploaded file could not be found.");
|
|
240
253
|
}
|
|
241
254
|
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
255
|
+
if (signaturePeekLength > 0) {
|
|
256
|
+
const signatureError = validateStoredUploadSignature(
|
|
257
|
+
data.contentType,
|
|
258
|
+
bytes.subarray(0, signaturePeekLength),
|
|
259
|
+
);
|
|
260
|
+
if (signatureError) {
|
|
261
|
+
await storage.delete(data.storageKey).catch(() => {});
|
|
262
|
+
if (data.posterKey) {
|
|
263
|
+
await storage.delete(data.posterKey).catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
throw new ValidationError(signatureError);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (needsDimensionSniff) {
|
|
269
|
+
const dimensions = parseImageDimensions(data.contentType, bytes);
|
|
270
|
+
if (dimensions) {
|
|
271
|
+
width ??= dimensions.width;
|
|
272
|
+
height ??= dimensions.height;
|
|
250
273
|
}
|
|
251
|
-
throw new ValidationError(signatureError);
|
|
252
274
|
}
|
|
253
275
|
}
|
|
254
276
|
|
|
@@ -261,8 +283,8 @@ multipartUploadApiRoutes.post("/:id/complete", async (c) => {
|
|
|
261
283
|
size: data.size,
|
|
262
284
|
storageKey: data.storageKey,
|
|
263
285
|
provider: c.var.appConfig.storageDriver,
|
|
264
|
-
width
|
|
265
|
-
height
|
|
286
|
+
width,
|
|
287
|
+
height,
|
|
266
288
|
durationSeconds:
|
|
267
289
|
data.durationSeconds && data.durationSeconds > 0
|
|
268
290
|
? data.durationSeconds
|
package/src/routes/api/upload.ts
CHANGED
|
@@ -28,6 +28,10 @@ import {
|
|
|
28
28
|
validateStoredUploadMetadata,
|
|
29
29
|
validateStoredUploadSignature,
|
|
30
30
|
} from "../../lib/upload.js";
|
|
31
|
+
import {
|
|
32
|
+
IMAGE_DIMENSION_PEEK_BYTES,
|
|
33
|
+
parseImageDimensions,
|
|
34
|
+
} from "../../lib/image-dimensions.js";
|
|
31
35
|
import {
|
|
32
36
|
assertFound,
|
|
33
37
|
MediaQuotaExceededError,
|
|
@@ -284,6 +288,23 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
284
288
|
const blurhashRaw = (formData.get("blurhash") as string) || undefined;
|
|
285
289
|
const waveformRaw = (formData.get("waveform") as string) || undefined;
|
|
286
290
|
|
|
291
|
+
let width = widthRaw && widthRaw > 0 ? widthRaw : undefined;
|
|
292
|
+
let height = heightRaw && heightRaw > 0 ? heightRaw : undefined;
|
|
293
|
+
if ((!width || !height) && file.type.startsWith("image/")) {
|
|
294
|
+
try {
|
|
295
|
+
const headerBytes = new Uint8Array(
|
|
296
|
+
await file.slice(0, IMAGE_DIMENSION_PEEK_BYTES).arrayBuffer(),
|
|
297
|
+
);
|
|
298
|
+
const dimensions = parseImageDimensions(file.type, headerBytes);
|
|
299
|
+
if (dimensions) {
|
|
300
|
+
width ??= dimensions.width;
|
|
301
|
+
height ??= dimensions.height;
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
// Dimensions are optional — fall through with whatever the client sent.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
287
308
|
// Upload poster frame for videos (if provided by client)
|
|
288
309
|
let posterKey: string | undefined;
|
|
289
310
|
const posterFile = formData.get("poster") as File | null;
|
|
@@ -308,8 +329,8 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
308
329
|
size: file.size,
|
|
309
330
|
storageKey,
|
|
310
331
|
provider: c.var.appConfig.storageDriver,
|
|
311
|
-
width
|
|
312
|
-
height
|
|
332
|
+
width,
|
|
333
|
+
height,
|
|
313
334
|
durationSeconds:
|
|
314
335
|
durationSecondsRaw && durationSecondsRaw > 0
|
|
315
336
|
? durationSecondsRaw
|
|
@@ -44,6 +44,12 @@ import {
|
|
|
44
44
|
GitHubSyncContent,
|
|
45
45
|
type GitHubSyncStatus,
|
|
46
46
|
} from "../../ui/dash/settings/GitHubSyncContent.js";
|
|
47
|
+
import {
|
|
48
|
+
readTelegramSettingsView,
|
|
49
|
+
renderTelegramContentHtml,
|
|
50
|
+
getTelegramStatusStreamUrl,
|
|
51
|
+
} from "../../lib/telegram-settings-status.js";
|
|
52
|
+
import { TelegramContent } from "../../ui/dash/settings/TelegramContent.js";
|
|
47
53
|
import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
|
|
48
54
|
import { parseValidated, UpdateSiteSettingsSchema } from "../../lib/schemas.js";
|
|
49
55
|
import {
|
|
@@ -56,6 +62,7 @@ import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane
|
|
|
56
62
|
import {
|
|
57
63
|
getGitHubAppConfig,
|
|
58
64
|
getHostedControlPlaneSsoSecret,
|
|
65
|
+
getTelegramBotPool,
|
|
59
66
|
} from "../../lib/env.js";
|
|
60
67
|
import {
|
|
61
68
|
buildInstallUrl,
|
|
@@ -131,7 +138,8 @@ function breadcrumbLabel(
|
|
|
131
138
|
| "password"
|
|
132
139
|
| "deleteAccount"
|
|
133
140
|
| "apiTokens"
|
|
134
|
-
| "githubSync"
|
|
141
|
+
| "githubSync"
|
|
142
|
+
| "telegram",
|
|
135
143
|
): string {
|
|
136
144
|
const i18n = getI18n(c);
|
|
137
145
|
switch (key) {
|
|
@@ -197,6 +205,10 @@ function breadcrumbLabel(
|
|
|
197
205
|
return i18n._(
|
|
198
206
|
msg({ message: "GitHub Sync", comment: "@context: Breadcrumb label" }),
|
|
199
207
|
);
|
|
208
|
+
case "telegram":
|
|
209
|
+
return i18n._(
|
|
210
|
+
msg({ message: "Telegram", comment: "@context: Breadcrumb label" }),
|
|
211
|
+
);
|
|
200
212
|
}
|
|
201
213
|
}
|
|
202
214
|
|
|
@@ -2297,3 +2309,121 @@ settingsRoutes.get("/github-sync", async (c) => {
|
|
|
2297
2309
|
),
|
|
2298
2310
|
});
|
|
2299
2311
|
});
|
|
2312
|
+
|
|
2313
|
+
// ===========================================================================
|
|
2314
|
+
// Telegram
|
|
2315
|
+
// ===========================================================================
|
|
2316
|
+
|
|
2317
|
+
settingsRoutes.get("/telegram", async (c) => {
|
|
2318
|
+
const view = await readTelegramSettingsView(c);
|
|
2319
|
+
const streamUrl = getTelegramStatusStreamUrl(c);
|
|
2320
|
+
const navData = await getNavigationData(c);
|
|
2321
|
+
return renderPublicPage(c, {
|
|
2322
|
+
title: buildPageTitle("Telegram", navData.siteName),
|
|
2323
|
+
navData,
|
|
2324
|
+
content: (
|
|
2325
|
+
<>
|
|
2326
|
+
<AdminBreadcrumb
|
|
2327
|
+
parent={breadcrumbLabel(c, "settings")}
|
|
2328
|
+
parentHref={publicPath(c, "/settings")}
|
|
2329
|
+
current={breadcrumbLabel(c, "telegram")}
|
|
2330
|
+
/>
|
|
2331
|
+
<TelegramContent
|
|
2332
|
+
view={view}
|
|
2333
|
+
sitePathPrefix={c.var.appConfig.sitePathPrefix}
|
|
2334
|
+
streamUrl={streamUrl}
|
|
2335
|
+
/>
|
|
2336
|
+
</>
|
|
2337
|
+
),
|
|
2338
|
+
});
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
/**
|
|
2342
|
+
* Live status stream — swaps the connect view for the connected view the
|
|
2343
|
+
* moment a binding lands, so the user doesn't have to refresh after sending
|
|
2344
|
+
* the binding code to the bot. Same pattern as GitHub Sync's status stream:
|
|
2345
|
+
* subscribed via Datastar `data-init="@get(...)"`, each frame is a
|
|
2346
|
+
* `patchElements` with `mode: outer` on the stable `#telegram-status` id.
|
|
2347
|
+
*
|
|
2348
|
+
* The connected view ships without `data-init`, so the stream closes as
|
|
2349
|
+
* soon as we send the first "binding present" frame. A 5-minute cap bounds
|
|
2350
|
+
* an abandoned subscription.
|
|
2351
|
+
*/
|
|
2352
|
+
settingsRoutes.get("/telegram/status/stream", async (c) => {
|
|
2353
|
+
const streamUrl = getTelegramStatusStreamUrl(c);
|
|
2354
|
+
// 5 minutes is well above the time a user reasonably spends sending a
|
|
2355
|
+
// code to a bot; longer windows just close and the page can be reloaded.
|
|
2356
|
+
const MAX_DURATION_MS = 5 * 60 * 1000;
|
|
2357
|
+
// 1.5s keeps the UI snappy without hammering the binding table.
|
|
2358
|
+
const POLL_INTERVAL_MS = 1500;
|
|
2359
|
+
|
|
2360
|
+
return sse(c, async (stream) => {
|
|
2361
|
+
const startedAt = Date.now();
|
|
2362
|
+
let lastHtml: string | null = null;
|
|
2363
|
+
|
|
2364
|
+
while (true) {
|
|
2365
|
+
const view = await readTelegramSettingsView(c);
|
|
2366
|
+
const html = renderTelegramContentHtml(c, view, streamUrl);
|
|
2367
|
+
if (html !== lastHtml) {
|
|
2368
|
+
stream.patchElements(html, {
|
|
2369
|
+
mode: "outer",
|
|
2370
|
+
selector: "#telegram-status",
|
|
2371
|
+
});
|
|
2372
|
+
lastHtml = html;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// Binding landed: the patch above just shipped the connected view
|
|
2376
|
+
// (no `data-init`), so the client will close. A brief beat lets the
|
|
2377
|
+
// browser apply the patch before the server-side stream ends.
|
|
2378
|
+
if (view.binding) {
|
|
2379
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2380
|
+
break;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
if (Date.now() - startedAt >= MAX_DURATION_MS) break;
|
|
2384
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
2385
|
+
}
|
|
2386
|
+
});
|
|
2387
|
+
});
|
|
2388
|
+
|
|
2389
|
+
settingsRoutes.post("/telegram/connect", async (c) => {
|
|
2390
|
+
// Token entry is for bring-your-own deployments only. When a managed pool
|
|
2391
|
+
// is configured the bot is platform-owned and users connect via a code.
|
|
2392
|
+
if (getTelegramBotPool(c.env).length > 0) {
|
|
2393
|
+
return dsToast(
|
|
2394
|
+
"This deployment uses a managed Telegram bot. Connect with the binding code instead.",
|
|
2395
|
+
"error",
|
|
2396
|
+
);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const body = await c.req.json<{ token?: string }>();
|
|
2400
|
+
const token = body.token?.trim();
|
|
2401
|
+
if (!token) {
|
|
2402
|
+
return dsToast("Paste your bot token to continue.", "error");
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
const siteUrl = c.var.appConfig.siteUrl.replace(/\/+$/, "");
|
|
2406
|
+
try {
|
|
2407
|
+
await c.var.services.telegram.connectUserBot(token, siteUrl);
|
|
2408
|
+
} catch (err) {
|
|
2409
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2410
|
+
return dsToast(`Could not set up the bot: ${detail}`, "error");
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
return dsRedirect(publicPath(c, "/settings/telegram"));
|
|
2414
|
+
});
|
|
2415
|
+
|
|
2416
|
+
settingsRoutes.post("/telegram/remove-bot", async (c) => {
|
|
2417
|
+
await c.var.services.telegram.removeUserBot();
|
|
2418
|
+
return dsRedirect(publicPath(c, "/settings/telegram"));
|
|
2419
|
+
});
|
|
2420
|
+
|
|
2421
|
+
settingsRoutes.post("/telegram/regenerate-code", async (c) => {
|
|
2422
|
+
await c.var.services.telegram.generateCode();
|
|
2423
|
+
return dsRedirect(publicPath(c, "/settings/telegram"));
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
settingsRoutes.post("/telegram/disconnect", async (c) => {
|
|
2427
|
+
await c.var.services.telegram.disconnect();
|
|
2428
|
+
return dsRedirect(publicPath(c, "/settings/telegram"));
|
|
2429
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `<title>` tag on post pages.
|
|
3
|
+
*
|
|
4
|
+
* Post pages compose `<title>` as "Post Title - Site Name" so the site
|
|
5
|
+
* name is visible in browser tabs, bookmarks, and SEO snippets, matching
|
|
6
|
+
* the convention used by Settings, Search, Archive, and Collection pages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
11
|
+
import { pageRoutes } from "../page.js";
|
|
12
|
+
|
|
13
|
+
function createPageTestApp() {
|
|
14
|
+
const testApp = createTestApp();
|
|
15
|
+
const { app } = testApp;
|
|
16
|
+
|
|
17
|
+
app.use("*", async (c, next) => {
|
|
18
|
+
c.set("publicPath", c.req.path);
|
|
19
|
+
c.set("publicRequestUrl", c.req.url);
|
|
20
|
+
await next();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.route("/", pageRoutes);
|
|
24
|
+
|
|
25
|
+
return testApp;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractTitle(html: string): string | null {
|
|
29
|
+
const match = html.match(/<title>([^<]*)<\/title>/i);
|
|
30
|
+
return match?.[1] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Post page <title>", () => {
|
|
34
|
+
it("appends the site name to the post title", async () => {
|
|
35
|
+
const { app, services } = createPageTestApp();
|
|
36
|
+
await services.settings.set("SITE_NAME", "Owen");
|
|
37
|
+
|
|
38
|
+
const post = await services.posts.create({
|
|
39
|
+
format: "note",
|
|
40
|
+
title: "Hello world",
|
|
41
|
+
bodyMarkdown: "Body",
|
|
42
|
+
status: "published",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const res = await app.request(`/${post.slug}`);
|
|
46
|
+
expect(res.status).toBe(200);
|
|
47
|
+
|
|
48
|
+
const html = await res.text();
|
|
49
|
+
expect(extractTitle(html)).toBe("Hello world - Owen");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back to derived meta title when post has no title", async () => {
|
|
53
|
+
const { app, services } = createPageTestApp();
|
|
54
|
+
await services.settings.set("SITE_NAME", "Owen");
|
|
55
|
+
|
|
56
|
+
const post = await services.posts.create({
|
|
57
|
+
format: "note",
|
|
58
|
+
bodyMarkdown: "First sentence of the body.",
|
|
59
|
+
status: "published",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const res = await app.request(`/${post.slug}`);
|
|
63
|
+
expect(res.status).toBe(200);
|
|
64
|
+
|
|
65
|
+
const html = await res.text();
|
|
66
|
+
const title = extractTitle(html);
|
|
67
|
+
expect(title).toMatch(/- Owen$/);
|
|
68
|
+
expect(title).toContain("First sentence");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -11,6 +11,7 @@ import type { AppVariables } from "../../types/app-context.js";
|
|
|
11
11
|
import { PostPage } from "../../ui/pages/PostPage.js";
|
|
12
12
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
13
13
|
import { renderPublicPage } from "../../lib/render.js";
|
|
14
|
+
import { buildPageTitle } from "../../lib/page-title.js";
|
|
14
15
|
import { buildPostMeta } from "../../lib/post-meta.js";
|
|
15
16
|
import { assemblePostPageDisplay } from "../../lib/post-display.js";
|
|
16
17
|
import { toPublicHref, toPublicPath } from "../../lib/url.js";
|
|
@@ -93,7 +94,7 @@ async function renderPostWithTextPreview(
|
|
|
93
94
|
.replace(/>/g, "\\u003e");
|
|
94
95
|
|
|
95
96
|
return renderPublicPage(c, {
|
|
96
|
-
title: pageTitle,
|
|
97
|
+
title: buildPageTitle(pageTitle, navData.siteName),
|
|
97
98
|
description: meta.description,
|
|
98
99
|
canonicalHref,
|
|
99
100
|
navData,
|
|
@@ -162,7 +163,7 @@ async function renderPost(c: Context<Env>, post: Post) {
|
|
|
162
163
|
);
|
|
163
164
|
|
|
164
165
|
return renderPublicPage(c, {
|
|
165
|
-
title: meta.title,
|
|
166
|
+
title: buildPageTitle(meta.title, navData.siteName),
|
|
166
167
|
description: meta.description,
|
|
167
168
|
canonicalHref,
|
|
168
169
|
navData,
|
|
@@ -34,6 +34,12 @@ export interface CloudflareRequestRuntime {
|
|
|
34
34
|
hostedHandoff: HostedHandoffService;
|
|
35
35
|
rateLimiter: RateLimiter;
|
|
36
36
|
services: Services;
|
|
37
|
+
/**
|
|
38
|
+
* Builds a `Services` object scoped to an arbitrary site. Used by
|
|
39
|
+
* host-agnostic handlers (e.g. the Telegram webhook) that resolve the
|
|
40
|
+
* target site from request data rather than the hostname.
|
|
41
|
+
*/
|
|
42
|
+
servicesForSite: (siteId: string) => Services;
|
|
37
43
|
storage: StorageDriver | null;
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -81,6 +87,18 @@ export async function createCloudflareRequestRuntime(
|
|
|
81
87
|
useSecureCookies: shouldUseSecureCookies(env, publicRequestUrl),
|
|
82
88
|
});
|
|
83
89
|
|
|
90
|
+
const servicesConfig = {
|
|
91
|
+
databaseDialect: "sqlite" as const,
|
|
92
|
+
bootstrapSite: getSingleSiteBootstrapOptions(env),
|
|
93
|
+
enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
|
|
94
|
+
hostedControlPlane: createHostedControlPlaneClient(env),
|
|
95
|
+
siteResolutionMode: getSiteResolutionMode(env),
|
|
96
|
+
slugIdLength,
|
|
97
|
+
schema: sqliteSchemaBundle,
|
|
98
|
+
};
|
|
99
|
+
const servicesForSite = (siteId: string): Services =>
|
|
100
|
+
createServices(db, session, siteId, servicesConfig);
|
|
101
|
+
|
|
84
102
|
return {
|
|
85
103
|
auth,
|
|
86
104
|
currentSite: siteLookup.site,
|
|
@@ -92,15 +110,8 @@ export async function createCloudflareRequestRuntime(
|
|
|
92
110
|
secret: hostedControlPlaneSsoSecret,
|
|
93
111
|
}),
|
|
94
112
|
rateLimiter: createD1RateLimiter(db, sqliteSchemaBundle),
|
|
95
|
-
services:
|
|
96
|
-
|
|
97
|
-
bootstrapSite: getSingleSiteBootstrapOptions(env),
|
|
98
|
-
enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
|
|
99
|
-
hostedControlPlane: createHostedControlPlaneClient(env),
|
|
100
|
-
siteResolutionMode: getSiteResolutionMode(env),
|
|
101
|
-
slugIdLength,
|
|
102
|
-
schema: sqliteSchemaBundle,
|
|
103
|
-
}),
|
|
113
|
+
services: servicesForSite(siteLookup.site.id),
|
|
114
|
+
servicesForSite,
|
|
104
115
|
storage: createStorageDriver(env),
|
|
105
116
|
};
|
|
106
117
|
}
|
package/src/runtime/node.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface NodeRequestRuntime {
|
|
|
37
37
|
hostedHandoff: HostedHandoffService;
|
|
38
38
|
rateLimiter: RateLimiter;
|
|
39
39
|
services: Services;
|
|
40
|
+
/**
|
|
41
|
+
* Builds a `Services` object scoped to an arbitrary site. Used by
|
|
42
|
+
* host-agnostic handlers (e.g. the Telegram webhook) that resolve the
|
|
43
|
+
* target site from request data rather than the hostname.
|
|
44
|
+
*/
|
|
45
|
+
servicesForSite: (siteId: string) => Services;
|
|
40
46
|
storage: StorageDriver | null;
|
|
41
47
|
}
|
|
42
48
|
|
|
@@ -136,6 +142,18 @@ export async function createNodeRequestRuntime(
|
|
|
136
142
|
useSecureCookies: shouldUseSecureCookies(env, publicRequestUrl),
|
|
137
143
|
});
|
|
138
144
|
|
|
145
|
+
const servicesConfig = {
|
|
146
|
+
databaseDialect,
|
|
147
|
+
bootstrapSite: getSingleSiteBootstrapOptions(env),
|
|
148
|
+
enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
|
|
149
|
+
hostedControlPlane: createHostedControlPlaneClient(env),
|
|
150
|
+
siteResolutionMode: getSiteResolutionMode(env),
|
|
151
|
+
slugIdLength,
|
|
152
|
+
schema: databaseSchema,
|
|
153
|
+
};
|
|
154
|
+
const servicesForSite = (siteId: string): Services =>
|
|
155
|
+
createServices(db, rawQuery, siteId, servicesConfig);
|
|
156
|
+
|
|
139
157
|
return {
|
|
140
158
|
auth,
|
|
141
159
|
currentSite: siteLookup.site,
|
|
@@ -147,15 +165,8 @@ export async function createNodeRequestRuntime(
|
|
|
147
165
|
secret: hostedControlPlaneSsoSecret,
|
|
148
166
|
}),
|
|
149
167
|
rateLimiter: getNodeRateLimiter(),
|
|
150
|
-
services:
|
|
151
|
-
|
|
152
|
-
bootstrapSite: getSingleSiteBootstrapOptions(env),
|
|
153
|
-
enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
|
|
154
|
-
hostedControlPlane: createHostedControlPlaneClient(env),
|
|
155
|
-
siteResolutionMode: getSiteResolutionMode(env),
|
|
156
|
-
slugIdLength,
|
|
157
|
-
schema: databaseSchema,
|
|
158
|
-
}),
|
|
168
|
+
services: servicesForSite(siteLookup.site.id),
|
|
169
|
+
servicesForSite,
|
|
159
170
|
storage: createStorageDriver(env),
|
|
160
171
|
};
|
|
161
172
|
}
|
package/src/runtime/site.ts
CHANGED
|
@@ -91,7 +91,8 @@ export async function resolveRequestSite(
|
|
|
91
91
|
// payload's installation id) instead of relying on `currentSite`.
|
|
92
92
|
if (
|
|
93
93
|
requestUrl.pathname.startsWith("/api/internal/") ||
|
|
94
|
-
requestUrl.pathname === "/api/github-sync/app-webhook"
|
|
94
|
+
requestUrl.pathname === "/api/github-sync/app-webhook" ||
|
|
95
|
+
requestUrl.pathname.startsWith("/api/telegram/webhook/")
|
|
95
96
|
) {
|
|
96
97
|
return {
|
|
97
98
|
site: createTransientSite("internal"),
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createTestDatabase,
|
|
4
|
+
DEFAULT_TEST_SITE_ID,
|
|
5
|
+
} from "../../__tests__/helpers/db.js";
|
|
6
|
+
import type { Database } from "../../db/index.js";
|
|
7
|
+
import { createTelegramService, type TelegramService } from "../telegram.js";
|
|
8
|
+
|
|
9
|
+
const SECOND_SITE_ID = "sit_second00000000000000000000000";
|
|
10
|
+
const BOT_ID = "111111";
|
|
11
|
+
const USER_ID = "999999";
|
|
12
|
+
|
|
13
|
+
function insertSecondSite(
|
|
14
|
+
sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
|
|
15
|
+
): void {
|
|
16
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
17
|
+
sqlite
|
|
18
|
+
.prepare(
|
|
19
|
+
`INSERT INTO site (id, key, status, created_at, updated_at)
|
|
20
|
+
VALUES (?, ?, 'active', ?, ?)`,
|
|
21
|
+
)
|
|
22
|
+
.run(SECOND_SITE_ID, "second", timestamp, timestamp);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("TelegramService", () => {
|
|
26
|
+
let db: Database;
|
|
27
|
+
let sqlite: ReturnType<typeof createTestDatabase>["sqlite"];
|
|
28
|
+
let service: TelegramService;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
const testDb = createTestDatabase();
|
|
32
|
+
db = testDb.db as unknown as Database;
|
|
33
|
+
sqlite = testDb.sqlite;
|
|
34
|
+
insertSecondSite(sqlite);
|
|
35
|
+
service = createTelegramService(db, DEFAULT_TEST_SITE_ID);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("reports an empty status for a fresh site", async () => {
|
|
39
|
+
const status = await service.getStatus();
|
|
40
|
+
expect(status.binding).toBeNull();
|
|
41
|
+
expect(status.userBot).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reuses an existing code via getOrCreateCode", async () => {
|
|
45
|
+
const first = await service.getOrCreateCode();
|
|
46
|
+
const second = await service.getOrCreateCode();
|
|
47
|
+
expect(second).toBe(first);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("replaces the code on generateCode", async () => {
|
|
51
|
+
const first = await service.getOrCreateCode();
|
|
52
|
+
const next = await service.generateCode();
|
|
53
|
+
expect(next).not.toBe(first);
|
|
54
|
+
expect(await service.resolvePendingCode(first)).toBeNull();
|
|
55
|
+
expect(await service.resolvePendingCode(next)).toEqual({
|
|
56
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("resolves a pending code to its site", async () => {
|
|
61
|
+
const code = await service.getOrCreateCode();
|
|
62
|
+
expect(await service.resolvePendingCode(code)).toEqual({
|
|
63
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns null for an unknown code", async () => {
|
|
68
|
+
expect(await service.resolvePendingCode("nope")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("treats an expired code as unknown", async () => {
|
|
72
|
+
const code = await service.getOrCreateCode();
|
|
73
|
+
sqlite
|
|
74
|
+
.prepare(
|
|
75
|
+
`UPDATE telegram_pending_binding SET expires_at = ? WHERE code = ?`,
|
|
76
|
+
)
|
|
77
|
+
.run(1, code);
|
|
78
|
+
expect(await service.resolvePendingCode(code)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("binds an account and surfaces it in status", async () => {
|
|
82
|
+
await service.getOrCreateCode();
|
|
83
|
+
const binding = await service.bindAccount({
|
|
84
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
85
|
+
botId: BOT_ID,
|
|
86
|
+
telegramUserId: USER_ID,
|
|
87
|
+
telegramUsername: "alice",
|
|
88
|
+
});
|
|
89
|
+
expect(binding.siteId).toBe(DEFAULT_TEST_SITE_ID);
|
|
90
|
+
|
|
91
|
+
const status = await service.getStatus();
|
|
92
|
+
expect(status.binding).toMatchObject({
|
|
93
|
+
botId: BOT_ID,
|
|
94
|
+
telegramUserId: USER_ID,
|
|
95
|
+
telegramUsername: "alice",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The pending code is consumed on bind.
|
|
99
|
+
const found = await service.findBindingByUser(BOT_ID, USER_ID);
|
|
100
|
+
expect(found?.siteId).toBe(DEFAULT_TEST_SITE_ID);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("moves a binding to a new site on rebind (last-write-wins)", async () => {
|
|
104
|
+
await service.bindAccount({
|
|
105
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
106
|
+
botId: BOT_ID,
|
|
107
|
+
telegramUserId: USER_ID,
|
|
108
|
+
telegramUsername: "alice",
|
|
109
|
+
});
|
|
110
|
+
await service.bindAccount({
|
|
111
|
+
siteId: SECOND_SITE_ID,
|
|
112
|
+
botId: BOT_ID,
|
|
113
|
+
telegramUserId: USER_ID,
|
|
114
|
+
telegramUsername: "alice",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const found = await service.findBindingByUser(BOT_ID, USER_ID);
|
|
118
|
+
expect(found?.siteId).toBe(SECOND_SITE_ID);
|
|
119
|
+
|
|
120
|
+
// The first site no longer has a binding.
|
|
121
|
+
const firstSiteService = createTelegramService(db, DEFAULT_TEST_SITE_ID);
|
|
122
|
+
expect((await firstSiteService.getStatus()).binding).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("records the last processed update id", async () => {
|
|
126
|
+
const binding = await service.bindAccount({
|
|
127
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
128
|
+
botId: BOT_ID,
|
|
129
|
+
telegramUserId: USER_ID,
|
|
130
|
+
telegramUsername: null,
|
|
131
|
+
});
|
|
132
|
+
await service.markUpdateProcessed(binding.id, 42);
|
|
133
|
+
const found = await service.findBindingByUser(BOT_ID, USER_ID);
|
|
134
|
+
expect(found?.lastUpdateId).toBe(42);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("disconnects the active binding", async () => {
|
|
138
|
+
await service.bindAccount({
|
|
139
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
140
|
+
botId: BOT_ID,
|
|
141
|
+
telegramUserId: USER_ID,
|
|
142
|
+
telegramUsername: null,
|
|
143
|
+
});
|
|
144
|
+
await service.disconnect();
|
|
145
|
+
expect((await service.getStatus()).binding).toBeNull();
|
|
146
|
+
expect(await service.findBindingByUser(BOT_ID, USER_ID)).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/services/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
createGitHubAppInstallationsService,
|
|
44
44
|
type GitHubAppInstallationsService,
|
|
45
45
|
} from "./github-app-installations.js";
|
|
46
|
+
import { createTelegramService, type TelegramService } from "./telegram.js";
|
|
46
47
|
import type { HostedControlPlaneClient } from "../lib/hosted-control-plane.js";
|
|
47
48
|
import type { EnsureSingleSiteOptions } from "./site.js";
|
|
48
49
|
|
|
@@ -64,6 +65,7 @@ export interface Services {
|
|
|
64
65
|
siteMembers: SiteMemberService;
|
|
65
66
|
siteProfile: SiteProfileService;
|
|
66
67
|
githubAppInstallations: GitHubAppInstallationsService;
|
|
68
|
+
telegram: TelegramService;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
export function createServices(
|
|
@@ -150,6 +152,7 @@ export function createServices(
|
|
|
150
152
|
db,
|
|
151
153
|
databaseSchema,
|
|
152
154
|
),
|
|
155
|
+
telegram: createTelegramService(db, siteId, databaseSchema),
|
|
153
156
|
};
|
|
154
157
|
}
|
|
155
158
|
|
|
@@ -182,3 +185,9 @@ export type {
|
|
|
182
185
|
GitHubAccountType,
|
|
183
186
|
StoredGitHubAppInstallation,
|
|
184
187
|
} from "./github-app-installations.js";
|
|
188
|
+
export type {
|
|
189
|
+
TelegramService,
|
|
190
|
+
TelegramBinding,
|
|
191
|
+
TelegramStatus,
|
|
192
|
+
TelegramUserBot,
|
|
193
|
+
} from "./telegram.js";
|