@jant/core 0.7.0 → 0.7.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/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
getInstallation,
|
|
96
96
|
listInstallationReposPage,
|
|
97
97
|
searchInstallationRepos,
|
|
98
|
+
type InstallationAccount,
|
|
98
99
|
} from "../../lib/github-app.js";
|
|
99
100
|
import {
|
|
100
101
|
isSyncPending,
|
|
@@ -103,6 +104,7 @@ import {
|
|
|
103
104
|
triggerGitHubSyncInline,
|
|
104
105
|
} from "../../lib/github-sync-trigger.js";
|
|
105
106
|
import { buildSyncSiteConfig } from "../../lib/github-sync-site-config.js";
|
|
107
|
+
import { suggestSyncRepoName } from "../../lib/github-sync-repo-name.js";
|
|
106
108
|
import { buildConfigEditorFields } from "../../lib/api-settings.js";
|
|
107
109
|
import {
|
|
108
110
|
readGitHubSyncStatus,
|
|
@@ -182,6 +184,23 @@ function requireSessionId(c: Context<Env>): string {
|
|
|
182
184
|
return sessionId;
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The user behind the current request.
|
|
189
|
+
*
|
|
190
|
+
* Same contract as `requireSessionId`: `requireAuth` has already proven the
|
|
191
|
+
* session belongs to a member of this site, so an absent user is a wiring
|
|
192
|
+
* mistake. Used wherever a decision is scoped to the person rather than to
|
|
193
|
+
* the site — the GitHub App installations they may reuse, most of all.
|
|
194
|
+
*/
|
|
195
|
+
function requireSessionUserId(c: Context<Env>): string {
|
|
196
|
+
const userId = c.var.session?.user?.id;
|
|
197
|
+
if (!userId) {
|
|
198
|
+
throw new UnauthorizedError();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return userId;
|
|
202
|
+
}
|
|
203
|
+
|
|
185
204
|
/**
|
|
186
205
|
* Breadcrumb labels for admin settings pages.
|
|
187
206
|
*
|
|
@@ -2080,6 +2099,49 @@ settingsRoutes.post("/github-sync/disconnect", async (c) => {
|
|
|
2080
2099
|
// GitHub App install flow
|
|
2081
2100
|
// ---------------------------------------------------------------------------
|
|
2082
2101
|
|
|
2102
|
+
/**
|
|
2103
|
+
* Recreate installation bindings for sites that sync through the App
|
|
2104
|
+
* but have no row to prove it.
|
|
2105
|
+
*
|
|
2106
|
+
* GitHub is the only source for the account behind an installation id,
|
|
2107
|
+
* so the repair costs one call per distinct id — bounded by how many
|
|
2108
|
+
* sites the author has, and only ever reached when they would otherwise
|
|
2109
|
+
* be shown a GitHub page that cannot lead anywhere. Ids GitHub no longer
|
|
2110
|
+
* recognizes are skipped: an uninstalled App has nothing to rebuild.
|
|
2111
|
+
*/
|
|
2112
|
+
async function rebuildInstallationBindings(
|
|
2113
|
+
c: Context<Env>,
|
|
2114
|
+
app: import("../../lib/env.js").GitHubAppEnvConfig,
|
|
2115
|
+
userId: string,
|
|
2116
|
+
): Promise<void> {
|
|
2117
|
+
const syncing =
|
|
2118
|
+
await c.var.services.githubAppInstallations.listSyncingInstallationsForUser(
|
|
2119
|
+
userId,
|
|
2120
|
+
);
|
|
2121
|
+
if (syncing.length === 0) return;
|
|
2122
|
+
|
|
2123
|
+
const accounts = new Map<string, InstallationAccount>();
|
|
2124
|
+
for (const { installationId } of syncing) {
|
|
2125
|
+
if (accounts.has(installationId)) continue;
|
|
2126
|
+
try {
|
|
2127
|
+
const installation = await getInstallation(app, installationId);
|
|
2128
|
+
accounts.set(installationId, installation.account);
|
|
2129
|
+
} catch {
|
|
2130
|
+
continue;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
for (const { siteId, installationId } of syncing) {
|
|
2135
|
+
const account = accounts.get(installationId);
|
|
2136
|
+
if (!account) continue;
|
|
2137
|
+
await c.var.services.githubAppInstallations.upsertInstallation(
|
|
2138
|
+
installationId,
|
|
2139
|
+
siteId,
|
|
2140
|
+
account,
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2083
2145
|
/**
|
|
2084
2146
|
* Redirect the user to GitHub to install the App on their account/org.
|
|
2085
2147
|
*
|
|
@@ -2092,26 +2154,46 @@ settingsRoutes.get("/github-sync/app/install", async (c) => {
|
|
|
2092
2154
|
return c.text("GitHub App is not configured on this deployment.", 404);
|
|
2093
2155
|
}
|
|
2094
2156
|
|
|
2095
|
-
// Reinstall-same-account predicate
|
|
2096
|
-
//
|
|
2097
|
-
//
|
|
2098
|
-
//
|
|
2099
|
-
//
|
|
2157
|
+
// Reinstall-same-account predicate: when the signed-in author has
|
|
2158
|
+
// already authorized an installation — on this site or on any other
|
|
2159
|
+
// site they belong to — skip the GitHub round-trip and render the
|
|
2160
|
+
// picker directly. The GitHub redirect is a dead end once the App is
|
|
2161
|
+
// installed on the chosen account: GitHub shows its Configure page
|
|
2162
|
+
// and never comes back with installation_id.
|
|
2163
|
+
//
|
|
2164
|
+
// The scope is the user's sites rather than this one because a GitHub
|
|
2165
|
+
// App installs once per GitHub account. An author connecting their
|
|
2166
|
+
// second blog to the same account has no binding here yet and cannot
|
|
2167
|
+
// create one through GitHub, so site-scoping this check would leave
|
|
2168
|
+
// them with no way in at all.
|
|
2100
2169
|
//
|
|
2101
2170
|
// `?force=new` bypasses the predicate so the picker's "Install on
|
|
2102
2171
|
// another account" action can still reach GitHub to add a fresh
|
|
2103
2172
|
// install.
|
|
2104
2173
|
const forceNew = c.req.query("force") === "new";
|
|
2105
2174
|
if (!forceNew) {
|
|
2106
|
-
const
|
|
2107
|
-
|
|
2108
|
-
|
|
2175
|
+
const userId = requireSessionUserId(c);
|
|
2176
|
+
let existing =
|
|
2177
|
+
await c.var.services.githubAppInstallations.listInstallationsForUser(
|
|
2178
|
+
userId,
|
|
2109
2179
|
);
|
|
2180
|
+
// An author with nothing to show but a site already syncing through
|
|
2181
|
+
// the App is in the one state the redirect cannot rescue: GitHub
|
|
2182
|
+
// will not install the App a second time on that account. Rebuild
|
|
2183
|
+
// the binding from the sync settings instead of sending them into
|
|
2184
|
+
// the dead end.
|
|
2185
|
+
if (existing.length === 0) {
|
|
2186
|
+
await rebuildInstallationBindings(c, app, userId);
|
|
2187
|
+
existing =
|
|
2188
|
+
await c.var.services.githubAppInstallations.listInstallationsForUser(
|
|
2189
|
+
userId,
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2110
2192
|
if (existing.length > 0) {
|
|
2111
2193
|
const navData = await getNavigationData(c);
|
|
2112
2194
|
const base = publicPath(c, "/settings/github-sync");
|
|
2113
|
-
const
|
|
2114
|
-
const
|
|
2195
|
+
const suggestedRepoName = suggestSyncRepoName(c.var.appConfig.siteUrl);
|
|
2196
|
+
const labels = buildRepoPickerLabels(c, suggestedRepoName);
|
|
2115
2197
|
return renderPublicPage(c, {
|
|
2116
2198
|
title: buildPageTitle(
|
|
2117
2199
|
"GitHub Sync — Pick Repository",
|
|
@@ -2209,19 +2291,17 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
|
|
|
2209
2291
|
}
|
|
2210
2292
|
}
|
|
2211
2293
|
|
|
2212
|
-
// One-shot: clear the cookie so it can't be replayed.
|
|
2213
|
-
setCookie(c, "jant_gh_app_state", "", {
|
|
2214
|
-
httpOnly: true,
|
|
2215
|
-
sameSite: "Lax",
|
|
2216
|
-
path: "/",
|
|
2217
|
-
maxAge: 0,
|
|
2218
|
-
});
|
|
2219
|
-
|
|
2220
2294
|
// Fetch the account info for this installation and remember it. The
|
|
2221
|
-
//
|
|
2222
|
-
//
|
|
2223
|
-
//
|
|
2224
|
-
//
|
|
2295
|
+
// binding is what every later step reads: the picker's account
|
|
2296
|
+
// dropdown, the endpoints that authorize an installation id, and the
|
|
2297
|
+
// App webhook's fan-out. Rendering the picker without it would show an
|
|
2298
|
+
// empty account list and nothing to pick.
|
|
2299
|
+
//
|
|
2300
|
+
// So a failure here has to stay recoverable, and the only thing that
|
|
2301
|
+
// makes this page replayable is the state cookie — GitHub will not
|
|
2302
|
+
// hand out a second callback for an App that is now installed. Hence
|
|
2303
|
+
// the clearing below sits *after* the binding is written: until then,
|
|
2304
|
+
// reloading this URL is the whole retry.
|
|
2225
2305
|
try {
|
|
2226
2306
|
const installation = await getInstallation(app, installationId);
|
|
2227
2307
|
await c.var.services.githubAppInstallations.upsertInstallation(
|
|
@@ -2230,14 +2310,24 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
|
|
|
2230
2310
|
installation.account,
|
|
2231
2311
|
);
|
|
2232
2312
|
} catch {
|
|
2233
|
-
|
|
2234
|
-
|
|
2313
|
+
return c.text(
|
|
2314
|
+
"The App was installed, but GitHub did not answer with the account details. Reload this page to try again.",
|
|
2315
|
+
502,
|
|
2316
|
+
);
|
|
2235
2317
|
}
|
|
2236
2318
|
|
|
2319
|
+
// One-shot: clear the cookie so it can't be replayed.
|
|
2320
|
+
setCookie(c, "jant_gh_app_state", "", {
|
|
2321
|
+
httpOnly: true,
|
|
2322
|
+
sameSite: "Lax",
|
|
2323
|
+
path: "/",
|
|
2324
|
+
maxAge: 0,
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2237
2327
|
const navData = await getNavigationData(c);
|
|
2238
2328
|
const base = publicPath(c, "/settings/github-sync");
|
|
2239
|
-
const
|
|
2240
|
-
const
|
|
2329
|
+
const suggestedRepoName = suggestSyncRepoName(c.var.appConfig.siteUrl);
|
|
2330
|
+
const labels = buildRepoPickerLabels(c, suggestedRepoName);
|
|
2241
2331
|
|
|
2242
2332
|
return renderPublicPage(c, {
|
|
2243
2333
|
title: buildPageTitle("GitHub Sync — Pick Repository", navData.siteName),
|
|
@@ -2253,7 +2343,7 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
|
|
|
2253
2343
|
labels={labels}
|
|
2254
2344
|
api-base={`${base}/app`}
|
|
2255
2345
|
connect-url={`${base}/app/connect`}
|
|
2256
|
-
install-url={`${base}/app/install`}
|
|
2346
|
+
install-url={`${base}/app/install?force=new`}
|
|
2257
2347
|
cancel-url={publicPath(c, "/settings")}
|
|
2258
2348
|
create-repo-name-hint={suggestedRepoName}
|
|
2259
2349
|
>
|
|
@@ -2271,29 +2361,36 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
|
|
|
2271
2361
|
});
|
|
2272
2362
|
|
|
2273
2363
|
/**
|
|
2274
|
-
*
|
|
2364
|
+
* Values that keep a placeholder literal for the client to fill in.
|
|
2365
|
+
*
|
|
2366
|
+
* `i18n._()` renders a placeholder it has no value for as an empty string, so
|
|
2367
|
+
* a label whose value only exists in the browser — a repo count, the picked
|
|
2368
|
+
* repo — must be formatted with the placeholder's own token as its value.
|
|
2369
|
+
* `{repo}` then survives into the serialized labels and the component's
|
|
2370
|
+
* `.replace("{repo}", …)` finds it.
|
|
2275
2371
|
*
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2372
|
+
* @param names - Placeholder names the client interpolates.
|
|
2373
|
+
* @returns A values object mapping each name to its own `{name}` token.
|
|
2374
|
+
* @example
|
|
2375
|
+
* i18n._(msg({ message: "Type {repo} to confirm" }), keepForClient("repo"));
|
|
2376
|
+
* // "Type {repo} to confirm"
|
|
2281
2377
|
*/
|
|
2282
|
-
function
|
|
2283
|
-
|
|
2284
|
-
try {
|
|
2285
|
-
firstLabel = new URL(c.var.appConfig.siteUrl).host.split(".")[0] ?? "";
|
|
2286
|
-
} catch {
|
|
2287
|
-
/* fall through */
|
|
2288
|
-
}
|
|
2289
|
-
const slug = firstLabel
|
|
2290
|
-
.toLowerCase()
|
|
2291
|
-
.replace(/[^a-z0-9-_]+/g, "-")
|
|
2292
|
-
.replace(/^-+|-+$/g, "");
|
|
2293
|
-
return slug ? `${slug}-jant-sync` : "jant-site-sync";
|
|
2378
|
+
function keepForClient(...names: string[]): Record<string, string> {
|
|
2379
|
+
return Object.fromEntries(names.map((name) => [name, `{${name}}`]));
|
|
2294
2380
|
}
|
|
2295
2381
|
|
|
2296
|
-
|
|
2382
|
+
/**
|
|
2383
|
+
* Serialize the picker's strings for the client component.
|
|
2384
|
+
*
|
|
2385
|
+
* @param c - Request context, for the active locale.
|
|
2386
|
+
* @param suggestedRepoName - Repository name prefilled on github.com/new;
|
|
2387
|
+
* interpolated here because the client cannot format ICU messages.
|
|
2388
|
+
* @returns The labels as a JSON string for the `labels` attribute.
|
|
2389
|
+
*/
|
|
2390
|
+
function buildRepoPickerLabels(
|
|
2391
|
+
c: Context<Env>,
|
|
2392
|
+
suggestedRepoName: string,
|
|
2393
|
+
): string {
|
|
2297
2394
|
const i18n = getI18n(c);
|
|
2298
2395
|
return JSON.stringify({
|
|
2299
2396
|
pageTitle: i18n._(
|
|
@@ -2380,6 +2477,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
|
|
|
2380
2477
|
comment:
|
|
2381
2478
|
"@context: GitHub sync picker — paginated repo count hint. Placeholders {shown} and {total} are integers.",
|
|
2382
2479
|
}),
|
|
2480
|
+
keepForClient("shown", "total"),
|
|
2383
2481
|
),
|
|
2384
2482
|
repoSearchHint: i18n._(
|
|
2385
2483
|
msg({
|
|
@@ -2404,10 +2502,12 @@ function buildRepoPickerLabels(c: Context<Env>): string {
|
|
|
2404
2502
|
),
|
|
2405
2503
|
createOnGitHubHint: i18n._(
|
|
2406
2504
|
msg({
|
|
2407
|
-
message:
|
|
2505
|
+
message:
|
|
2506
|
+
"Name prefilled as {name}. The list refreshes when you return.",
|
|
2408
2507
|
comment:
|
|
2409
2508
|
"@context: GitHub sync picker — hint under the create-on-github entry. Placeholder {name} is the suggested repo name.",
|
|
2410
2509
|
}),
|
|
2510
|
+
{ name: suggestedRepoName },
|
|
2411
2511
|
),
|
|
2412
2512
|
classifyLoading: i18n._(
|
|
2413
2513
|
msg({
|
|
@@ -2437,6 +2537,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
|
|
|
2437
2537
|
comment:
|
|
2438
2538
|
"@context: GitHub sync picker — blocking message when marker belongs to another site. Placeholder {host} is the other site's host.",
|
|
2439
2539
|
}),
|
|
2540
|
+
keepForClient("host"),
|
|
2440
2541
|
),
|
|
2441
2542
|
classificationForeign: i18n._(
|
|
2442
2543
|
msg({
|
|
@@ -2459,6 +2560,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
|
|
|
2459
2560
|
comment:
|
|
2460
2561
|
"@context: GitHub sync picker — explanatory body for foreign-repo confirmation. Placeholder {repo} is the owner/repo slug.",
|
|
2461
2562
|
}),
|
|
2563
|
+
keepForClient("repo"),
|
|
2462
2564
|
),
|
|
2463
2565
|
confirmInputLabel: i18n._(
|
|
2464
2566
|
msg({
|
|
@@ -2466,6 +2568,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
|
|
|
2466
2568
|
comment:
|
|
2467
2569
|
"@context: GitHub sync picker — label above the typed-confirmation input",
|
|
2468
2570
|
}),
|
|
2571
|
+
keepForClient("repo"),
|
|
2469
2572
|
),
|
|
2470
2573
|
confirmInputPlaceholder: i18n._(
|
|
2471
2574
|
msg({
|
|
@@ -2544,6 +2647,16 @@ settingsRoutes.post("/github-sync/app/connect", async (c) => {
|
|
|
2544
2647
|
: c.text("Missing installationId or repo.", 400);
|
|
2545
2648
|
}
|
|
2546
2649
|
|
|
2650
|
+
// The installation has to be one the author already authorized, here
|
|
2651
|
+
// or on another of their sites. The account snapshot also spares us a
|
|
2652
|
+
// GitHub round-trip when recording the binding further down.
|
|
2653
|
+
const installation = await findVisibleInstallation(c, installationId);
|
|
2654
|
+
if (!installation) {
|
|
2655
|
+
return wantsJson
|
|
2656
|
+
? c.json({ error: "Unknown installation." }, 404)
|
|
2657
|
+
: c.text("Unknown installation.", 404);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2547
2660
|
const { parseRepoSlug, createGitHubClient } =
|
|
2548
2661
|
await import("../../lib/github-api.js");
|
|
2549
2662
|
const parsed = parseRepoSlug(repo);
|
|
@@ -2604,6 +2717,18 @@ settingsRoutes.post("/github-sync/app/connect", async (c) => {
|
|
|
2604
2717
|
: c.text(msg, 409);
|
|
2605
2718
|
}
|
|
2606
2719
|
|
|
2720
|
+
// Bind the installation to this site. The callback does this too, but
|
|
2721
|
+
// only for the site that ran the install; connecting a second site to
|
|
2722
|
+
// an installation the author already has is exactly the case that
|
|
2723
|
+
// never passes through there. Without the row, the App-level webhook's
|
|
2724
|
+
// fan-out (uninstall, suspend, repository removed) skips this site
|
|
2725
|
+
// while it happily keeps syncing.
|
|
2726
|
+
await c.var.services.githubAppInstallations.upsertInstallation(
|
|
2727
|
+
installationId,
|
|
2728
|
+
c.var.currentSite.id,
|
|
2729
|
+
installation.account,
|
|
2730
|
+
);
|
|
2731
|
+
|
|
2607
2732
|
// Persist config before creating webhook so the sync service can load it.
|
|
2608
2733
|
await c.var.services.settings.set("GITHUB_SYNC_AUTH_MODE", "app");
|
|
2609
2734
|
await c.var.services.settings.set(
|
|
@@ -2688,21 +2813,38 @@ async function getInstallationTokenFromApp(
|
|
|
2688
2813
|
return getInstallationToken(app, installationId);
|
|
2689
2814
|
}
|
|
2690
2815
|
|
|
2691
|
-
/**
|
|
2816
|
+
/**
|
|
2817
|
+
* List the GitHub App installations the signed-in author may connect
|
|
2818
|
+
* this site to: everything authorized on any site they belong to, this
|
|
2819
|
+
* one included.
|
|
2820
|
+
*/
|
|
2692
2821
|
settingsRoutes.get("/github-sync/app/installations", async (c) => {
|
|
2693
2822
|
const installations =
|
|
2694
|
-
await c.var.services.githubAppInstallations.
|
|
2695
|
-
c
|
|
2823
|
+
await c.var.services.githubAppInstallations.listInstallationsForUser(
|
|
2824
|
+
requireSessionUserId(c),
|
|
2696
2825
|
);
|
|
2697
|
-
return c.json({
|
|
2698
|
-
installations: installations.map((entry) => ({
|
|
2699
|
-
installationId: entry.installationId,
|
|
2700
|
-
account: entry.account,
|
|
2701
|
-
addedAt: entry.addedAt,
|
|
2702
|
-
})),
|
|
2703
|
-
});
|
|
2826
|
+
return c.json({ installations });
|
|
2704
2827
|
});
|
|
2705
2828
|
|
|
2829
|
+
/**
|
|
2830
|
+
* Resolve an `installationId` that arrived in a request into an
|
|
2831
|
+
* installation the caller is allowed to act on.
|
|
2832
|
+
*
|
|
2833
|
+
* Every endpoint below takes the id from the client, and an installation
|
|
2834
|
+
* id is a small integer — without this check a signed-in author on one
|
|
2835
|
+
* site could name another tenant's installation and read their private
|
|
2836
|
+
* repository names, or push this site's content into one of their repos.
|
|
2837
|
+
*/
|
|
2838
|
+
async function findVisibleInstallation(
|
|
2839
|
+
c: Context<Env>,
|
|
2840
|
+
installationId: string,
|
|
2841
|
+
) {
|
|
2842
|
+
return c.var.services.githubAppInstallations.findInstallationForUser(
|
|
2843
|
+
installationId,
|
|
2844
|
+
requireSessionUserId(c),
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2706
2848
|
/**
|
|
2707
2849
|
* List (or search) repositories accessible via an installation.
|
|
2708
2850
|
*
|
|
@@ -2727,18 +2869,13 @@ settingsRoutes.get("/github-sync/app/repos", async (c) => {
|
|
|
2727
2869
|
const page =
|
|
2728
2870
|
Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
|
|
2729
2871
|
|
|
2872
|
+
const installation = await findVisibleInstallation(c, installationId);
|
|
2873
|
+
if (!installation) {
|
|
2874
|
+
return c.json({ error: "Unknown installation." }, 404);
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2730
2877
|
try {
|
|
2731
2878
|
if (q) {
|
|
2732
|
-
const installations =
|
|
2733
|
-
await c.var.services.githubAppInstallations.listInstallationsForSite(
|
|
2734
|
-
c.var.currentSite.id,
|
|
2735
|
-
);
|
|
2736
|
-
const installation = installations.find(
|
|
2737
|
-
(i) => i.installationId === installationId,
|
|
2738
|
-
);
|
|
2739
|
-
if (!installation) {
|
|
2740
|
-
return c.json({ error: "Unknown installation." }, 404);
|
|
2741
|
-
}
|
|
2742
2879
|
const result = await searchInstallationRepos(
|
|
2743
2880
|
app,
|
|
2744
2881
|
installationId,
|
|
@@ -2765,11 +2902,13 @@ settingsRoutes.get("/github-sync/app/repos", async (c) => {
|
|
|
2765
2902
|
} catch (err) {
|
|
2766
2903
|
const detail = err instanceof Error ? err.message : String(err);
|
|
2767
2904
|
// GitHub returns 401 when an installation was uninstalled on their
|
|
2768
|
-
// side. Clean up our cached
|
|
2905
|
+
// side. Clean up our cached entries so the UI stops showing a dead
|
|
2906
|
+
// owner — across the user's sites, since the binding that surfaced
|
|
2907
|
+
// this account may well belong to another one of them.
|
|
2769
2908
|
if (/\b401\b/.test(detail) || /\b404\b/.test(detail)) {
|
|
2770
|
-
await c.var.services.githubAppInstallations.
|
|
2909
|
+
await c.var.services.githubAppInstallations.removeInstallationForUser(
|
|
2771
2910
|
installationId,
|
|
2772
|
-
c
|
|
2911
|
+
requireSessionUserId(c),
|
|
2773
2912
|
);
|
|
2774
2913
|
return c.json(
|
|
2775
2914
|
{ error: "Installation is no longer accessible.", removed: true },
|
|
@@ -2799,6 +2938,9 @@ settingsRoutes.post("/github-sync/app/classify", async (c) => {
|
|
|
2799
2938
|
if (!installationId || !repo) {
|
|
2800
2939
|
return c.json({ error: "Missing installationId or repo." }, 400);
|
|
2801
2940
|
}
|
|
2941
|
+
if (!(await findVisibleInstallation(c, installationId))) {
|
|
2942
|
+
return c.json({ error: "Unknown installation." }, 404);
|
|
2943
|
+
}
|
|
2802
2944
|
|
|
2803
2945
|
const { parseRepoSlug, createGitHubClient } =
|
|
2804
2946
|
await import("../../lib/github-api.js");
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-trip budget for the post page.
|
|
3
|
+
*
|
|
4
|
+
* On Workers a public page's cost is almost entirely the number of sequential
|
|
5
|
+
* D1 reads it makes — the same render against local SQLite is about a
|
|
6
|
+
* millisecond. Serving `/{slug}` used to read `path_registry` twice for one
|
|
7
|
+
* address and the `post` table four times for one post, and nothing failed
|
|
8
|
+
* when it did. These tests are that missing failure.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
13
|
+
import { withConfig } from "../../../middleware/config.js";
|
|
14
|
+
import { i18nMiddleware } from "../../../i18n/index.js";
|
|
15
|
+
import { normalizePath, toPublicHref } from "../../../lib/url.js";
|
|
16
|
+
import { pageRoutes } from "../page.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mount the catch-all behind the middleware `createApp()` puts in front of it.
|
|
20
|
+
*
|
|
21
|
+
* The stored-redirect middleware is reproduced rather than imported because it
|
|
22
|
+
* is defined inline in `app.tsx`; what is under test is that it hands its
|
|
23
|
+
* lookup on and the route takes it, so the stash is the part that must match.
|
|
24
|
+
*/
|
|
25
|
+
function createPostPageApp() {
|
|
26
|
+
const testApp = createTestApp();
|
|
27
|
+
const { app } = testApp;
|
|
28
|
+
|
|
29
|
+
app.use("*", async (c, next) => {
|
|
30
|
+
c.set("publicPath", c.req.path);
|
|
31
|
+
c.set("publicRequestUrl", c.req.url);
|
|
32
|
+
await next();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.use("*", async (c, next) => {
|
|
36
|
+
const storedPath = normalizePath(new URL(c.req.url).pathname);
|
|
37
|
+
const record = await c.var.services.paths.resolve(storedPath);
|
|
38
|
+
c.set("pathLookup", { path: storedPath, record });
|
|
39
|
+
if (record?.kind === "redirect" && record.redirectToPath) {
|
|
40
|
+
return c.redirect(
|
|
41
|
+
toPublicHref(`/${record.redirectToPath}`),
|
|
42
|
+
record.redirectType ?? 301,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
await next();
|
|
46
|
+
});
|
|
47
|
+
app.use("*", withConfig());
|
|
48
|
+
app.use("*", i18nMiddleware());
|
|
49
|
+
app.route("/", pageRoutes);
|
|
50
|
+
|
|
51
|
+
return testApp;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every SQL statement prepared while `run` executes. */
|
|
55
|
+
async function captureStatements<T>(
|
|
56
|
+
sqlite: ReturnType<typeof createTestApp>["sqlite"],
|
|
57
|
+
run: () => Promise<T>,
|
|
58
|
+
): Promise<[T, string[]]> {
|
|
59
|
+
const original = sqlite.prepare.bind(sqlite);
|
|
60
|
+
const statements: string[] = [];
|
|
61
|
+
const spy = vi.spyOn(sqlite, "prepare").mockImplementation(((
|
|
62
|
+
source: string,
|
|
63
|
+
) => {
|
|
64
|
+
statements.push(source.replace(/\s+/g, " "));
|
|
65
|
+
return original(source);
|
|
66
|
+
}) as typeof sqlite.prepare);
|
|
67
|
+
try {
|
|
68
|
+
return [await run(), statements];
|
|
69
|
+
} finally {
|
|
70
|
+
spy.mockRestore();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reads that resolve one address, i.e. filter `path_registry` by `path`. */
|
|
75
|
+
function countAddressLookups(statements: string[]): number {
|
|
76
|
+
return statements.filter(
|
|
77
|
+
(sql) =>
|
|
78
|
+
/from "path_registry"/i.test(sql) &&
|
|
79
|
+
/"path_registry"\."path" = \?/i.test(sql),
|
|
80
|
+
).length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reads that scan the `post` table. */
|
|
84
|
+
function countPostReads(statements: string[]): number {
|
|
85
|
+
return statements.filter((sql) => /from "post"/i.test(sql)).length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("post page round trips", () => {
|
|
89
|
+
it("resolves the requested address once, and keeps its post reads to three", async () => {
|
|
90
|
+
const { app, services, sqlite } = createPostPageApp();
|
|
91
|
+
const post = await services.posts.create({
|
|
92
|
+
format: "note",
|
|
93
|
+
title: "Budget",
|
|
94
|
+
bodyMarkdown: "Body",
|
|
95
|
+
status: "published",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const [res, statements] = await captureStatements(sqlite, () =>
|
|
99
|
+
app.request(`/${post.slug}`),
|
|
100
|
+
);
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
|
|
103
|
+
// The middleware's lookup is the route's lookup.
|
|
104
|
+
expect(countAddressLookups(statements)).toBe(1);
|
|
105
|
+
|
|
106
|
+
// Three reads, and each is for something different: the post with its
|
|
107
|
+
// canonical alias, its thread, and the thread ids that collection
|
|
108
|
+
// membership is keyed by. A root post's visibility rides along in its own
|
|
109
|
+
// row, so nothing goes back for that.
|
|
110
|
+
expect(countPostReads(statements)).toBe(3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("redirects a slug to its custom URL without a separate alias lookup", async () => {
|
|
114
|
+
const { app, services, sqlite } = createPostPageApp();
|
|
115
|
+
const post = await services.posts.create({
|
|
116
|
+
format: "note",
|
|
117
|
+
title: "Renamed",
|
|
118
|
+
bodyMarkdown: "Body",
|
|
119
|
+
status: "published",
|
|
120
|
+
});
|
|
121
|
+
await services.customUrls.create({
|
|
122
|
+
path: "renamed-url",
|
|
123
|
+
targetType: "post",
|
|
124
|
+
targetId: post.id,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const [res, statements] = await captureStatements(sqlite, () =>
|
|
128
|
+
app.request(`/${post.slug}`),
|
|
129
|
+
);
|
|
130
|
+
expect(res.status).toBe(301);
|
|
131
|
+
expect(res.headers.get("location")).toBe("/renamed-url");
|
|
132
|
+
|
|
133
|
+
// The address lookup, and nothing more: the alias came back with the post.
|
|
134
|
+
expect(countAddressLookups(statements)).toBe(1);
|
|
135
|
+
expect(countPostReads(statements)).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("takes the newest alias as the canonical address", async () => {
|
|
139
|
+
const { app, services } = createPostPageApp();
|
|
140
|
+
const post = await services.posts.create({
|
|
141
|
+
format: "note",
|
|
142
|
+
title: "Renamed twice",
|
|
143
|
+
bodyMarkdown: "Body",
|
|
144
|
+
status: "published",
|
|
145
|
+
});
|
|
146
|
+
await services.customUrls.create({
|
|
147
|
+
path: "first-name",
|
|
148
|
+
targetType: "post",
|
|
149
|
+
targetId: post.id,
|
|
150
|
+
});
|
|
151
|
+
await services.customUrls.create({
|
|
152
|
+
path: "second-name",
|
|
153
|
+
targetType: "post",
|
|
154
|
+
targetId: post.id,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const res = await app.request(`/${post.slug}`);
|
|
158
|
+
expect(res.status).toBe(301);
|
|
159
|
+
expect(res.headers.get("location")).toBe("/second-name");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("post list round trips", () => {
|
|
164
|
+
it("reads thread-root visibility from the rows it already loaded", async () => {
|
|
165
|
+
const { services, sqlite } = createTestApp();
|
|
166
|
+
for (let i = 0; i < 3; i++) {
|
|
167
|
+
await services.posts.create({
|
|
168
|
+
format: "note",
|
|
169
|
+
title: `Post ${i}`,
|
|
170
|
+
bodyMarkdown: "Body",
|
|
171
|
+
status: "published",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const [listed, statements] = await captureStatements(sqlite, () =>
|
|
176
|
+
services.posts.list({ status: "published", excludeReplies: true }),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(listed).toHaveLength(3);
|
|
180
|
+
// A list of thread roots carries its own visibility, so the only `post`
|
|
181
|
+
// read is the list itself.
|
|
182
|
+
expect(countPostReads(statements)).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("still looks up the root for a reply loaded on its own", async () => {
|
|
186
|
+
const { services, sqlite } = createTestApp();
|
|
187
|
+
const root = await services.posts.create({
|
|
188
|
+
format: "note",
|
|
189
|
+
title: "Root",
|
|
190
|
+
bodyMarkdown: "Body",
|
|
191
|
+
status: "published",
|
|
192
|
+
visibility: "private",
|
|
193
|
+
});
|
|
194
|
+
const reply = await services.posts.create({
|
|
195
|
+
format: "note",
|
|
196
|
+
bodyMarkdown: "Reply",
|
|
197
|
+
replyToId: root.id,
|
|
198
|
+
status: "published",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const [loaded, statements] = await captureStatements(sqlite, () =>
|
|
202
|
+
services.posts.getById(reply.id),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// A reply's own visibility column is null — it inherits the root's — and
|
|
206
|
+
// the root is not among the rows loaded here, so the second read is what
|
|
207
|
+
// makes the reply private too.
|
|
208
|
+
expect(countPostReads(statements)).toBe(2);
|
|
209
|
+
expect(loaded?.visibility).toBe("private");
|
|
210
|
+
});
|
|
211
|
+
});
|