@jant/core 0.3.42 → 0.3.44
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 +1 -1
- package/bin/commands/search-reindex.js +175 -0
- package/bin/lib/hugo-markdown.js +102 -0
- package/bin/lib/site-pull-media.js +1 -4
- package/dist/app-BI9bnCkO.js +5 -0
- package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/client-BQH7AQ24.css +2 -0
- package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
- package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
- package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
- package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
- package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
- package/dist/index.js +5 -5
- package/dist/node.js +5 -5
- package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +15 -4
- package/src/app.tsx +8 -0
- package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
- package/src/client/tiptap/extensions.ts +3 -0
- package/src/client/tiptap/insert-paragraph-around.ts +79 -0
- package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
- package/src/db/migrations/0019_bored_magus.sql +2 -0
- package/src/db/migrations/meta/0018_snapshot.json +2225 -0
- package/src/db/migrations/meta/0019_snapshot.json +2238 -0
- package/src/db/migrations/meta/_journal.json +15 -1
- package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
- package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
- package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
- package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
- package/src/db/migrations/pg/meta/_journal.json +15 -1
- package/src/db/pg/schema.ts +22 -0
- package/src/db/schema.ts +27 -0
- package/src/index.ts +1 -2
- package/src/lib/__tests__/hosted-signin.test.ts +30 -0
- package/src/lib/__tests__/navigation.test.ts +4 -20
- package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
- package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
- package/src/lib/__tests__/summary.test.ts +140 -0
- package/src/lib/__tests__/view.test.ts +66 -0
- package/src/lib/feed.ts +70 -34
- package/src/lib/hosted-signin.ts +9 -3
- package/src/lib/icons.ts +37 -0
- package/src/lib/navigation.ts +11 -12
- package/src/lib/post-meta.ts +20 -2
- package/src/lib/rate-limit-d1.ts +99 -0
- package/src/lib/rate-limit-memory.ts +105 -0
- package/src/lib/rate-limit.ts +63 -0
- package/src/lib/render.tsx +9 -0
- package/src/lib/resolve-config.ts +9 -0
- package/src/lib/summary.ts +42 -7
- package/src/lib/url.ts +34 -0
- package/src/lib/view.ts +42 -8
- package/src/middleware/__tests__/auth.test.ts +44 -4
- package/src/middleware/__tests__/rate-limit.test.ts +113 -0
- package/src/middleware/__tests__/session.test.ts +85 -0
- package/src/middleware/auth.ts +62 -25
- package/src/middleware/rate-limit.ts +54 -0
- package/src/middleware/session.ts +36 -0
- package/src/routes/__tests__/compose.test.ts +1 -1
- package/src/routes/api/__tests__/search.test.ts +48 -0
- package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
- package/src/routes/api/internal/search-reindex.ts +40 -0
- package/src/routes/api/internal/sites.ts +1 -0
- package/src/routes/api/search.ts +13 -0
- package/src/routes/auth/dev.ts +1 -1
- package/src/routes/auth/signin.tsx +23 -5
- package/src/routes/dash/settings.tsx +3 -5
- package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
- package/src/routes/feed/sitemap.ts +208 -33
- package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
- package/src/routes/pages/home.tsx +24 -15
- package/src/routes/pages/page.tsx +34 -0
- package/src/routes/pages/partials.tsx +4 -15
- package/src/runtime/cloudflare.ts +4 -0
- package/src/runtime/node.ts +16 -0
- package/src/services/__tests__/post.test.ts +205 -0
- package/src/services/__tests__/search.test.ts +44 -0
- package/src/services/__tests__/site-admin.test.ts +85 -0
- package/src/services/export.ts +9 -2
- package/src/services/post.ts +200 -2
- package/src/services/site-admin.ts +66 -1
- package/src/styles/ui.css +12 -0
- package/src/types/app-context.ts +20 -0
- package/src/types/config.ts +8 -0
- package/src/types/props.ts +0 -7
- package/src/ui/feed/LinkCard.tsx +3 -20
- package/src/ui/feed/LinkPreview.tsx +5 -19
- package/src/ui/feed/PostStatusBadges.tsx +4 -38
- package/src/ui/layouts/BaseLayout.tsx +23 -29
- package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
- package/src/ui/shared/Icon.tsx +60 -0
- package/src/ui/shared/IconSprite.tsx +57 -0
- package/src/ui/shared/PostFooter.tsx +6 -62
- package/src/ui/shared/custom-icons.ts +132 -0
- package/src/ui/shared/icon-collector.ts +37 -0
- package/dist/app-DzCB4yOp.js +0 -5
- package/dist/client/_assets/client-C_kImWZj.css +0 -2
|
@@ -45,4 +45,89 @@ describe("SiteAdminService", () => {
|
|
|
45
45
|
|
|
46
46
|
expect(siteRows).toEqual([{ key: "demo-cloud" }]);
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
it("returns the existing site when replayed with the same idempotency key", async () => {
|
|
50
|
+
const { db } = createTestDatabase();
|
|
51
|
+
const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
|
|
52
|
+
siteResolutionMode: "host-based",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const first = await service.createManagedSite({
|
|
56
|
+
key: "idem-site",
|
|
57
|
+
primaryHost: "idem-site.example.com",
|
|
58
|
+
siteName: "Idempotent Site",
|
|
59
|
+
idempotencyKey: "job_abc",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const second = await service.createManagedSite({
|
|
63
|
+
key: "idem-site",
|
|
64
|
+
primaryHost: "idem-site.example.com",
|
|
65
|
+
siteName: "Idempotent Site",
|
|
66
|
+
idempotencyKey: "job_abc",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(second.site.id).toBe(first.site.id);
|
|
70
|
+
expect(second.domain.id).toBe(first.domain.id);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects reuse of an idempotency key with different key or primary host", async () => {
|
|
74
|
+
const { db } = createTestDatabase();
|
|
75
|
+
const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
|
|
76
|
+
siteResolutionMode: "host-based",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await service.createManagedSite({
|
|
80
|
+
key: "idem-site",
|
|
81
|
+
primaryHost: "idem-site.example.com",
|
|
82
|
+
siteName: "Idempotent Site",
|
|
83
|
+
idempotencyKey: "job_xyz",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await expect(
|
|
87
|
+
service.createManagedSite({
|
|
88
|
+
key: "other-site",
|
|
89
|
+
primaryHost: "idem-site.example.com",
|
|
90
|
+
siteName: "Other Site",
|
|
91
|
+
idempotencyKey: "job_xyz",
|
|
92
|
+
}),
|
|
93
|
+
).rejects.toEqual(
|
|
94
|
+
new ConflictError(
|
|
95
|
+
"Idempotency key was reused with a different site key or primary host.",
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
service.createManagedSite({
|
|
101
|
+
key: "idem-site",
|
|
102
|
+
primaryHost: "different-host.example.com",
|
|
103
|
+
siteName: "Idempotent Site",
|
|
104
|
+
idempotencyKey: "job_xyz",
|
|
105
|
+
}),
|
|
106
|
+
).rejects.toEqual(
|
|
107
|
+
new ConflictError(
|
|
108
|
+
"Idempotency key was reused with a different site key or primary host.",
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("treats requests without an idempotency key as independent creations", async () => {
|
|
114
|
+
const { db } = createTestDatabase();
|
|
115
|
+
const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
|
|
116
|
+
siteResolutionMode: "host-based",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await service.createManagedSite({
|
|
120
|
+
key: "no-idem-site",
|
|
121
|
+
primaryHost: "no-idem-site.example.com",
|
|
122
|
+
siteName: "No Idem Site",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await expect(
|
|
126
|
+
service.createManagedSite({
|
|
127
|
+
key: "no-idem-site",
|
|
128
|
+
primaryHost: "no-idem-site-2.example.com",
|
|
129
|
+
siteName: "No Idem Site",
|
|
130
|
+
}),
|
|
131
|
+
).rejects.toEqual(new ConflictError("Site key is already in use."));
|
|
132
|
+
});
|
|
48
133
|
});
|
package/src/services/export.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getDefaultJantFaviconIcoBytes,
|
|
29
29
|
} from "../lib/jant-branding.js";
|
|
30
30
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
31
|
+
import { extractBodyText } from "../lib/summary.js";
|
|
31
32
|
import { getMediaUrl, getPublicUrlForProvider } from "../lib/image.js";
|
|
32
33
|
import { render as renderMarkdown } from "../lib/markdown.js";
|
|
33
34
|
import { formatRelativeAge, toISOString } from "../lib/time.js";
|
|
@@ -1074,10 +1075,16 @@ function getArchiveSummaryText(post: Post): string | null {
|
|
|
1074
1075
|
// serialized as `link_url` and rendered as a domain badge, so using
|
|
1075
1076
|
// it as `summary_text` would duplicate that information.
|
|
1076
1077
|
// - Note: body only. If the body is empty, there's nothing to describe.
|
|
1078
|
+
//
|
|
1079
|
+
// Note: we re-derive the body text from `post.body` (TipTap JSON) rather
|
|
1080
|
+
// than reusing `post.bodyText`, because `bodyText` is written with
|
|
1081
|
+
// `includeLinkHrefs: true` for FTS search indexing — that pollutes the
|
|
1082
|
+
// stored text with trailing link URLs. Here we need clean prose.
|
|
1083
|
+
const cleanBodyText = post.body ? extractBodyText(post.body) : null;
|
|
1077
1084
|
const candidates =
|
|
1078
1085
|
post.format === "quote"
|
|
1079
|
-
? [post.summary,
|
|
1080
|
-
: [post.summary,
|
|
1086
|
+
? [post.summary, cleanBodyText, post.quoteText]
|
|
1087
|
+
: [post.summary, cleanBodyText];
|
|
1081
1088
|
|
|
1082
1089
|
for (const candidate of candidates) {
|
|
1083
1090
|
const normalized = normalizeArchiveText(candidate);
|
package/src/services/post.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
isNotNull,
|
|
20
20
|
asc,
|
|
21
21
|
lte,
|
|
22
|
+
gt,
|
|
22
23
|
} from "drizzle-orm";
|
|
23
24
|
import {
|
|
24
25
|
type Database,
|
|
@@ -156,6 +157,17 @@ export interface PostBodyContent {
|
|
|
156
157
|
chars: number;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/** Minimal projection used by the sitemap renderer. */
|
|
161
|
+
export interface SitemapPostEntry {
|
|
162
|
+
id: string;
|
|
163
|
+
/** Canonical slug from `path_registry` */
|
|
164
|
+
slug: string;
|
|
165
|
+
/** Primary alias, if the post has one; used in preference to `slug` for URLs */
|
|
166
|
+
alias: string | null;
|
|
167
|
+
updatedAt: number;
|
|
168
|
+
featuredAt: number | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
export interface PostService {
|
|
160
172
|
getById(id: string): Promise<Post | null>;
|
|
161
173
|
getBodyContent(id: string): Promise<PostBodyContent | null>;
|
|
@@ -167,6 +179,35 @@ export interface PostService {
|
|
|
167
179
|
}): Promise<string>;
|
|
168
180
|
checkSlugAvailability(slug: string, excludePostId?: string): Promise<boolean>;
|
|
169
181
|
list(filters?: PostFilters): Promise<Post[]>;
|
|
182
|
+
/**
|
|
183
|
+
* List minimal fields needed to render sitemap entries, paginated by `id`
|
|
184
|
+
* (ascending). Excludes replies, private posts, deleted posts, and drafts.
|
|
185
|
+
*
|
|
186
|
+
* Uses keyset pagination on the primary key so old sitemap shards are cheap
|
|
187
|
+
* to serve and stable across shard boundaries: a newly created post always
|
|
188
|
+
* gets a larger TypeID than any previously-committed post, so it lands in
|
|
189
|
+
* the last shard and never rewrites older ones.
|
|
190
|
+
*
|
|
191
|
+
* @param options.afterId Exclusive lower bound on `id`. Omit for the first
|
|
192
|
+
* shard.
|
|
193
|
+
* @param options.limit Maximum rows to return.
|
|
194
|
+
*/
|
|
195
|
+
listForSitemap(options: {
|
|
196
|
+
afterId?: string;
|
|
197
|
+
limit: number;
|
|
198
|
+
}): Promise<SitemapPostEntry[]>;
|
|
199
|
+
/** Count posts that qualify for the sitemap (same filters as `listForSitemap`) */
|
|
200
|
+
countForSitemap(): Promise<number>;
|
|
201
|
+
/**
|
|
202
|
+
* Return the id at the given 0-based offset in the sitemap ordering.
|
|
203
|
+
* Used to compute keyset cursors for sharded sitemap endpoints.
|
|
204
|
+
*
|
|
205
|
+
* Returns `null` when the offset is beyond the available rows.
|
|
206
|
+
*
|
|
207
|
+
* Walks the primary-key index with `ORDER BY id ASC LIMIT 1 OFFSET ?` —
|
|
208
|
+
* SQLite/D1 scan only the index for this, not the row data.
|
|
209
|
+
*/
|
|
210
|
+
getSitemapIdAt(offset: number): Promise<string | null>;
|
|
170
211
|
/** Count posts matching filters (ignores cursor, offset, limit) */
|
|
171
212
|
count(filters?: PostFilters): Promise<number>;
|
|
172
213
|
/** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
|
|
@@ -292,6 +333,28 @@ export interface PostService {
|
|
|
292
333
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
293
334
|
/** For each thread ID, return the ID of the last published, non-deleted post */
|
|
294
335
|
getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
|
|
336
|
+
/**
|
|
337
|
+
* Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
|
|
338
|
+
* by post id. For each row, recomputes the plain-text extraction via
|
|
339
|
+
* `extractBodyText(body)` and writes it back only when it differs from the
|
|
340
|
+
* stored value. FTS indexes (SQLite trigger / Postgres generated column)
|
|
341
|
+
* refresh automatically on the UPDATE.
|
|
342
|
+
*
|
|
343
|
+
* Idempotent: re-running after a no-op pass returns `updated: 0`.
|
|
344
|
+
*
|
|
345
|
+
* @param options.limit Batch size (1..500, default 50)
|
|
346
|
+
* @param options.cursor Exclusive lower bound on post id; pass the previous
|
|
347
|
+
* response's `nextCursor` to continue
|
|
348
|
+
* @returns processed/updated/skipped counts, the next cursor, and a `done`
|
|
349
|
+
* flag the caller uses to terminate the loop
|
|
350
|
+
*/
|
|
351
|
+
reindexBodyText(options?: { limit?: number; cursor?: string }): Promise<{
|
|
352
|
+
processed: number;
|
|
353
|
+
updated: number;
|
|
354
|
+
skipped: number;
|
|
355
|
+
nextCursor: string | null;
|
|
356
|
+
done: boolean;
|
|
357
|
+
}>;
|
|
295
358
|
}
|
|
296
359
|
|
|
297
360
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
@@ -1300,6 +1363,83 @@ export function createPostService(
|
|
|
1300
1363
|
return hydratePosts(rows);
|
|
1301
1364
|
},
|
|
1302
1365
|
|
|
1366
|
+
async listForSitemap({ afterId, limit }) {
|
|
1367
|
+
// Share the filter conditions with `list()` so visibility/reply/deleted
|
|
1368
|
+
// semantics stay consistent if they ever change.
|
|
1369
|
+
const conditions = buildFilterConditions({
|
|
1370
|
+
status: "published",
|
|
1371
|
+
excludePrivate: true,
|
|
1372
|
+
excludeReplies: true,
|
|
1373
|
+
});
|
|
1374
|
+
if (afterId !== undefined) {
|
|
1375
|
+
conditions.push(sql`${posts.id} > ${afterId}`);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const rows = await db
|
|
1379
|
+
.select({
|
|
1380
|
+
id: posts.id,
|
|
1381
|
+
updatedAt: posts.updatedAt,
|
|
1382
|
+
featuredAt: posts.featuredAt,
|
|
1383
|
+
})
|
|
1384
|
+
.from(posts)
|
|
1385
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1386
|
+
.orderBy(asc(posts.id))
|
|
1387
|
+
.limit(limit);
|
|
1388
|
+
|
|
1389
|
+
if (rows.length === 0) return [];
|
|
1390
|
+
|
|
1391
|
+
const ids = rows.map((row) => row.id);
|
|
1392
|
+
const [slugMap, aliasesMap] = await Promise.all([
|
|
1393
|
+
resolvedPaths.getPostSlugMap(ids),
|
|
1394
|
+
resolvedPaths.getPostAliases(ids),
|
|
1395
|
+
]);
|
|
1396
|
+
|
|
1397
|
+
return rows
|
|
1398
|
+
.map((row): SitemapPostEntry | null => {
|
|
1399
|
+
const slug = slugMap.get(row.id);
|
|
1400
|
+
if (!slug) return null;
|
|
1401
|
+
const alias = aliasesMap.get(row.id)?.[0] ?? null;
|
|
1402
|
+
return {
|
|
1403
|
+
id: row.id,
|
|
1404
|
+
slug,
|
|
1405
|
+
alias,
|
|
1406
|
+
updatedAt: row.updatedAt,
|
|
1407
|
+
featuredAt: row.featuredAt,
|
|
1408
|
+
};
|
|
1409
|
+
})
|
|
1410
|
+
.filter((entry): entry is SitemapPostEntry => entry !== null);
|
|
1411
|
+
},
|
|
1412
|
+
|
|
1413
|
+
async countForSitemap() {
|
|
1414
|
+
const conditions = buildFilterConditions({
|
|
1415
|
+
status: "published",
|
|
1416
|
+
excludePrivate: true,
|
|
1417
|
+
excludeReplies: true,
|
|
1418
|
+
});
|
|
1419
|
+
const result = await db
|
|
1420
|
+
.select({ count: sql<number>`CAST(count(*) AS INTEGER)`.as("count") })
|
|
1421
|
+
.from(posts)
|
|
1422
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
|
1423
|
+
return result[0]?.count ?? 0;
|
|
1424
|
+
},
|
|
1425
|
+
|
|
1426
|
+
async getSitemapIdAt(offset) {
|
|
1427
|
+
if (offset < 0) return null;
|
|
1428
|
+
const conditions = buildFilterConditions({
|
|
1429
|
+
status: "published",
|
|
1430
|
+
excludePrivate: true,
|
|
1431
|
+
excludeReplies: true,
|
|
1432
|
+
});
|
|
1433
|
+
const rows = await db
|
|
1434
|
+
.select({ id: posts.id })
|
|
1435
|
+
.from(posts)
|
|
1436
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1437
|
+
.orderBy(asc(posts.id))
|
|
1438
|
+
.limit(1)
|
|
1439
|
+
.offset(offset);
|
|
1440
|
+
return rows[0]?.id ?? null;
|
|
1441
|
+
},
|
|
1442
|
+
|
|
1303
1443
|
async count(filters = {}) {
|
|
1304
1444
|
const conditions = buildFilterConditions(filters);
|
|
1305
1445
|
|
|
@@ -1377,7 +1517,9 @@ export function createPostService(
|
|
|
1377
1517
|
});
|
|
1378
1518
|
|
|
1379
1519
|
const bodyHtml = body ? renderTiptapJson(body) : null;
|
|
1380
|
-
const bodyText = body
|
|
1520
|
+
const bodyText = body
|
|
1521
|
+
? extractBodyText(body, { includeLinkHrefs: true })
|
|
1522
|
+
: null;
|
|
1381
1523
|
|
|
1382
1524
|
// Generate summary for titled notes with body content
|
|
1383
1525
|
let summary: string | null = null;
|
|
@@ -1879,7 +2021,7 @@ export function createPostService(
|
|
|
1879
2021
|
? renderTiptapJson(normalizedBody)
|
|
1880
2022
|
: null;
|
|
1881
2023
|
updates.bodyText = normalizedBody
|
|
1882
|
-
? extractBodyText(normalizedBody)
|
|
2024
|
+
? extractBodyText(normalizedBody, { includeLinkHrefs: true })
|
|
1883
2025
|
: null;
|
|
1884
2026
|
}
|
|
1885
2027
|
|
|
@@ -2967,5 +3109,61 @@ export function createPostService(
|
|
|
2967
3109
|
|
|
2968
3110
|
return rows.map((r) => parseInt(r.year, 10));
|
|
2969
3111
|
},
|
|
3112
|
+
|
|
3113
|
+
async reindexBodyText(options = {}) {
|
|
3114
|
+
const requested = options.limit ?? 50;
|
|
3115
|
+
const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
|
|
3116
|
+
const cursor = options.cursor;
|
|
3117
|
+
|
|
3118
|
+
const whereConditions = [
|
|
3119
|
+
eq(posts.siteId, siteId),
|
|
3120
|
+
isNull(posts.deletedAt),
|
|
3121
|
+
];
|
|
3122
|
+
if (cursor) whereConditions.push(gt(posts.id, cursor));
|
|
3123
|
+
|
|
3124
|
+
// Fetch one extra row to detect end-of-data without a separate COUNT.
|
|
3125
|
+
const rows = await db
|
|
3126
|
+
.select({
|
|
3127
|
+
id: posts.id,
|
|
3128
|
+
body: posts.body,
|
|
3129
|
+
bodyText: posts.bodyText,
|
|
3130
|
+
})
|
|
3131
|
+
.from(posts)
|
|
3132
|
+
.where(and(...whereConditions))
|
|
3133
|
+
.orderBy(asc(posts.id))
|
|
3134
|
+
.limit(limit + 1);
|
|
3135
|
+
|
|
3136
|
+
const hasMore = rows.length > limit;
|
|
3137
|
+
const batch = hasMore ? rows.slice(0, limit) : rows;
|
|
3138
|
+
|
|
3139
|
+
let updated = 0;
|
|
3140
|
+
let skipped = 0;
|
|
3141
|
+
|
|
3142
|
+
for (const row of batch) {
|
|
3143
|
+
const nextBodyText = row.body
|
|
3144
|
+
? extractBodyText(row.body, { includeLinkHrefs: true })
|
|
3145
|
+
: null;
|
|
3146
|
+
if (nextBodyText === row.bodyText) {
|
|
3147
|
+
skipped++;
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
await db
|
|
3151
|
+
.update(posts)
|
|
3152
|
+
.set({ bodyText: nextBodyText })
|
|
3153
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, row.id)));
|
|
3154
|
+
updated++;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
const lastRow = batch.at(-1);
|
|
3158
|
+
const lastId = lastRow ? lastRow.id : null;
|
|
3159
|
+
|
|
3160
|
+
return {
|
|
3161
|
+
processed: batch.length,
|
|
3162
|
+
updated,
|
|
3163
|
+
skipped,
|
|
3164
|
+
nextCursor: hasMore ? lastId : null,
|
|
3165
|
+
done: !hasMore,
|
|
3166
|
+
};
|
|
3167
|
+
},
|
|
2970
3168
|
};
|
|
2971
3169
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq, sql } from "drizzle-orm";
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
2
|
import {
|
|
3
3
|
executeStatement,
|
|
4
4
|
type Database,
|
|
@@ -45,6 +45,13 @@ export interface CreateManagedSiteInput {
|
|
|
45
45
|
siteName: string;
|
|
46
46
|
siteLanguage?: string | null;
|
|
47
47
|
timeZone?: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Optional caller-supplied idempotency key. When provided, retrying the same
|
|
50
|
+
* request returns the previously created site instead of a 409 conflict.
|
|
51
|
+
* Reusing the key with a different `key` or `primaryHost` is rejected as a
|
|
52
|
+
* client bug.
|
|
53
|
+
*/
|
|
54
|
+
idempotencyKey?: string | null;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export interface ManagedSiteResult {
|
|
@@ -194,6 +201,47 @@ export function createSiteAdminService(
|
|
|
194
201
|
return `${protocol}//${domain.host}${pathPrefix}`;
|
|
195
202
|
}
|
|
196
203
|
|
|
204
|
+
async function loadByIdempotencyKey(
|
|
205
|
+
targetDb: Database,
|
|
206
|
+
idempotencyKey: string,
|
|
207
|
+
): Promise<ManagedSiteResult | null> {
|
|
208
|
+
const siteRow = (
|
|
209
|
+
await targetDb
|
|
210
|
+
.select()
|
|
211
|
+
.from(sites)
|
|
212
|
+
.where(eq(sites.provisioningIdempotencyKey, idempotencyKey))
|
|
213
|
+
.limit(1)
|
|
214
|
+
)[0];
|
|
215
|
+
if (!siteRow) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const domainRow = (
|
|
220
|
+
await targetDb
|
|
221
|
+
.select()
|
|
222
|
+
.from(siteDomains)
|
|
223
|
+
.where(
|
|
224
|
+
and(
|
|
225
|
+
eq(siteDomains.siteId, siteRow.id),
|
|
226
|
+
eq(siteDomains.kind, "primary"),
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
.limit(1)
|
|
230
|
+
)[0];
|
|
231
|
+
if (!domainRow) {
|
|
232
|
+
// A site row without a primary domain means the original creation aborted
|
|
233
|
+
// mid-transaction on a dialect without real transactions. Treat as not
|
|
234
|
+
// found so the caller can retry; the partial unique index will surface a
|
|
235
|
+
// genuine duplicate.
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
site: toSite(siteRow),
|
|
241
|
+
domain: toSiteDomain(domainRow),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
197
245
|
async function createWithDatabase(
|
|
198
246
|
targetDb: Database,
|
|
199
247
|
input: CreateManagedSiteInput,
|
|
@@ -201,6 +249,22 @@ export function createSiteAdminService(
|
|
|
201
249
|
const siteKey = input.key.trim();
|
|
202
250
|
const primaryHost = input.primaryHost.trim().toLowerCase();
|
|
203
251
|
const siteName = input.siteName.trim();
|
|
252
|
+
const idempotencyKey = input.idempotencyKey?.trim() || null;
|
|
253
|
+
|
|
254
|
+
if (idempotencyKey) {
|
|
255
|
+
const existing = await loadByIdempotencyKey(targetDb, idempotencyKey);
|
|
256
|
+
if (existing) {
|
|
257
|
+
if (
|
|
258
|
+
existing.site.key !== siteKey ||
|
|
259
|
+
existing.domain.host !== primaryHost
|
|
260
|
+
) {
|
|
261
|
+
throw new ConflictError(
|
|
262
|
+
"Idempotency key was reused with a different site key or primary host.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return existing;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
204
268
|
|
|
205
269
|
const existingSite = await targetDb
|
|
206
270
|
.select({ id: sites.id })
|
|
@@ -231,6 +295,7 @@ export function createSiteAdminService(
|
|
|
231
295
|
id: siteId,
|
|
232
296
|
key: siteKey,
|
|
233
297
|
status: "active",
|
|
298
|
+
provisioningIdempotencyKey: idempotencyKey,
|
|
234
299
|
createdAt: timestamp,
|
|
235
300
|
updatedAt: timestamp,
|
|
236
301
|
})
|
package/src/styles/ui.css
CHANGED
|
@@ -2100,6 +2100,10 @@
|
|
|
2100
2100
|
width: 30px;
|
|
2101
2101
|
height: 9px;
|
|
2102
2102
|
margin: 4rem 0;
|
|
2103
|
+
/* Center over the post-body column. On desktop the body column is
|
|
2104
|
+
`var(--layout-content-width)` (55%). Between 760-1024px it switches
|
|
2105
|
+
to `min(100%, 35rem)` via preset.css, so the hr must track that
|
|
2106
|
+
same rule — otherwise the glyph drifts left of the content center. */
|
|
2103
2107
|
margin-left: calc(var(--layout-content-width) / 2 - 15px);
|
|
2104
2108
|
color: var(--site-feed-divider-color);
|
|
2105
2109
|
background-color: currentColor;
|
|
@@ -2113,6 +2117,14 @@
|
|
|
2113
2117
|
mask-size: contain;
|
|
2114
2118
|
}
|
|
2115
2119
|
|
|
2120
|
+
@media (max-width: 1024px) {
|
|
2121
|
+
.site-content hr.feed-divider {
|
|
2122
|
+
/* Match preset.css `min(100%, 35rem)` body-column width so the hr
|
|
2123
|
+
stays visually aligned with the post column at this breakpoint. */
|
|
2124
|
+
margin-left: calc(min(100%, 35rem) / 2 - 15px);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2116
2128
|
.feed-card {
|
|
2117
2129
|
position: relative;
|
|
2118
2130
|
padding: 1rem 1.1rem 0.95rem;
|
package/src/types/app-context.ts
CHANGED
|
@@ -11,10 +11,17 @@ import type { Services } from "../services/index.js";
|
|
|
11
11
|
import type { HostedHandoffService } from "../services/hosted-handoff.js";
|
|
12
12
|
import type { Auth } from "../auth.js";
|
|
13
13
|
import type { AppConfig } from "./config.js";
|
|
14
|
+
import type { RateLimiter } from "../lib/rate-limit.js";
|
|
14
15
|
import type { StorageDriver } from "../lib/storage.js";
|
|
15
16
|
import type { Bindings } from "./bindings.js";
|
|
16
17
|
import type { Site, SiteDomain } from "./entities.js";
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Session payload as returned by better-auth's `getSession`.
|
|
21
|
+
* Populated once per request by the `attachSession` middleware.
|
|
22
|
+
*/
|
|
23
|
+
export type AppSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
|
|
24
|
+
|
|
18
25
|
export interface AppVariables {
|
|
19
26
|
services: Services;
|
|
20
27
|
hostedHandoff: HostedHandoffService;
|
|
@@ -27,6 +34,19 @@ export interface AppVariables {
|
|
|
27
34
|
storage: StorageDriver | null;
|
|
28
35
|
publicRequestUrl: string;
|
|
29
36
|
publicPath: string;
|
|
37
|
+
/**
|
|
38
|
+
* Cached session for the current request. `null` when unauthenticated or
|
|
39
|
+
* when the session lookup errored. Populated by `attachSession` middleware.
|
|
40
|
+
*/
|
|
41
|
+
session: AppSession;
|
|
42
|
+
/** True when `session?.user` is set. Shortcut for the common read. */
|
|
43
|
+
isAuthenticated: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Runtime-appropriate rate limiter. Populated per-request from the
|
|
46
|
+
* runtime (D1 on Workers, in-memory on Node). Middleware calls
|
|
47
|
+
* `c.var.rateLimiter.check(...)` instead of caring which impl is used.
|
|
48
|
+
*/
|
|
49
|
+
rateLimiter: RateLimiter;
|
|
30
50
|
}
|
|
31
51
|
|
|
32
52
|
export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
|
package/src/types/config.ts
CHANGED
|
@@ -476,6 +476,14 @@ export interface AppConfig {
|
|
|
476
476
|
siteAvatarUrl: string;
|
|
477
477
|
faviconVersion: string;
|
|
478
478
|
|
|
479
|
+
// Rate limiting (ENV only)
|
|
480
|
+
rateLimit: {
|
|
481
|
+
/** When true, all rate-limit middleware becomes a no-op. */
|
|
482
|
+
disabled: boolean;
|
|
483
|
+
/** Per-IP cap for `/api/search` requests per 60-second window. */
|
|
484
|
+
searchPerMinute: number;
|
|
485
|
+
};
|
|
486
|
+
|
|
479
487
|
// Settings form placeholders (ENV > Default, without DB)
|
|
480
488
|
fallbacks: {
|
|
481
489
|
siteName: string;
|
package/src/types/props.ts
CHANGED
|
@@ -138,13 +138,6 @@ export interface FeedData {
|
|
|
138
138
|
posts: FeedPostView[];
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/** Data passed to sitemap renderers */
|
|
142
|
-
export interface SitemapData {
|
|
143
|
-
siteUrl: string;
|
|
144
|
-
sitemapUrl: string;
|
|
145
|
-
posts: PostView[];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
141
|
// =============================================================================
|
|
149
142
|
// Timeline Types
|
|
150
143
|
// =============================================================================
|
package/src/ui/feed/LinkCard.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import { PostStatusBadges } from "./PostStatusBadges.js";
|
|
|
14
14
|
import { sanitizeUrl, extractDisplayDomain } from "../../lib/url.js";
|
|
15
15
|
import { MediaGallery } from "../shared/MediaGallery.js";
|
|
16
16
|
import { LinkPreview } from "./LinkPreview.js";
|
|
17
|
+
import { Icon } from "../shared/Icon.js";
|
|
17
18
|
|
|
18
19
|
export const LinkCard: FC<TimelineCardProps> = ({
|
|
19
20
|
post,
|
|
@@ -39,30 +40,12 @@ export const LinkCard: FC<TimelineCardProps> = ({
|
|
|
39
40
|
target="_blank"
|
|
40
41
|
rel="noopener noreferrer"
|
|
41
42
|
>
|
|
42
|
-
<
|
|
43
|
-
class="feed-link-domain-icon"
|
|
44
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
45
|
-
fill="none"
|
|
46
|
-
viewBox="0 0 24 24"
|
|
47
|
-
stroke-width="2"
|
|
48
|
-
stroke="currentColor"
|
|
49
|
-
>
|
|
50
|
-
<path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
51
|
-
</svg>
|
|
43
|
+
<Icon name="link-domain" class="feed-link-domain-icon" />
|
|
52
44
|
<span>{domain}</span>
|
|
53
45
|
</a>
|
|
54
46
|
) : (
|
|
55
47
|
<div class="feed-link-domain">
|
|
56
|
-
<
|
|
57
|
-
class="feed-link-domain-icon"
|
|
58
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
59
|
-
fill="none"
|
|
60
|
-
viewBox="0 0 24 24"
|
|
61
|
-
stroke-width="2"
|
|
62
|
-
stroke="currentColor"
|
|
63
|
-
>
|
|
64
|
-
<path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
65
|
-
</svg>
|
|
48
|
+
<Icon name="link-domain" class="feed-link-domain-icon" />
|
|
66
49
|
<span>{domain}</span>
|
|
67
50
|
</div>
|
|
68
51
|
));
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { FC } from "hono/jsx";
|
|
9
|
+
import { Icon } from "../shared/Icon.js";
|
|
9
10
|
|
|
10
11
|
interface LinkPreviewProps {
|
|
11
12
|
imageUrl: string;
|
|
@@ -41,31 +42,16 @@ export const LinkPreview: FC<LinkPreviewProps> = ({
|
|
|
41
42
|
<img src={imageUrl} alt="" class="link-preview-image" loading="lazy" />
|
|
42
43
|
{isVideo && (
|
|
43
44
|
<div class="link-preview-play" aria-hidden="true">
|
|
44
|
-
<
|
|
45
|
-
class="link-preview-play-icon"
|
|
46
|
-
viewBox="0 0 68 48"
|
|
47
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
48
|
-
>
|
|
49
|
-
<path
|
|
50
|
-
class="link-preview-play-bg"
|
|
51
|
-
d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"
|
|
52
|
-
fill="rgba(0,0,0,.65)"
|
|
53
|
-
/>
|
|
54
|
-
<path d="M45 24L27 14v20" fill="#fff" />
|
|
55
|
-
</svg>
|
|
45
|
+
<Icon name="link-preview-play" class="link-preview-play-icon" />
|
|
56
46
|
</div>
|
|
57
47
|
)}
|
|
58
48
|
{providerLabel && (
|
|
59
49
|
<span class="link-preview-badge" aria-hidden="true">
|
|
60
50
|
{isVideo && (
|
|
61
|
-
<
|
|
51
|
+
<Icon
|
|
52
|
+
name="link-preview-badge-play"
|
|
62
53
|
class="link-preview-badge-icon"
|
|
63
|
-
|
|
64
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
65
|
-
fill="currentColor"
|
|
66
|
-
>
|
|
67
|
-
<path d="M5.5 3.5v9l7-4.5z" />
|
|
68
|
-
</svg>
|
|
54
|
+
/>
|
|
69
55
|
)}
|
|
70
56
|
{providerLabel}
|
|
71
57
|
</span>
|