@igstack/app-catalog-backend-core 6.10.2 → 6.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/syncAppCatalog.mjs +1 -1
- package/dist/generated/prisma/client.d.mts +1 -1
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/commonInputTypes.d.mts +33 -3
- package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
- package/dist/generated/prisma/enums.d.mts +6 -1
- package/dist/generated/prisma/enums.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +84 -0
- package/dist/generated/prisma/internal/class.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +617 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityTool.d.mts +2852 -0
- package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts +1102 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts +1110 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts +1073 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts +1149 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbFeedback.d.mts +361 -2
- package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -1
- package/dist/generated/prisma/models.d.mts +9 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -1
- package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
- package/dist/middleware/createAcMiddleware.mjs +15 -1
- package/dist/middleware/createAcMiddleware.mjs.map +1 -1
- package/dist/middleware/database.d.mts +1 -1
- package/dist/middleware/types.d.mts +20 -1
- package/dist/middleware/types.d.mts.map +1 -1
- package/dist/modules/communityTools/capture.d.mts +27 -0
- package/dist/modules/communityTools/capture.d.mts.map +1 -0
- package/dist/modules/communityTools/capture.mjs +192 -0
- package/dist/modules/communityTools/capture.mjs.map +1 -0
- package/dist/modules/communityTools/healthJob.mjs +132 -0
- package/dist/modules/communityTools/healthJob.mjs.map +1 -0
- package/dist/modules/communityTools/router.d.mts +150 -0
- package/dist/modules/communityTools/router.d.mts.map +1 -0
- package/dist/modules/communityTools/router.mjs +91 -0
- package/dist/modules/communityTools/router.mjs.map +1 -0
- package/dist/modules/communityTools/service.mjs +252 -0
- package/dist/modules/communityTools/service.mjs.map +1 -0
- package/dist/modules/communityTools/toView.mjs +81 -0
- package/dist/modules/communityTools/toView.mjs.map +1 -0
- package/dist/modules/communityTools/writeService.d.mts +55 -0
- package/dist/modules/communityTools/writeService.d.mts.map +1 -0
- package/dist/modules/communityTools/writeService.mjs +280 -0
- package/dist/modules/communityTools/writeService.mjs.map +1 -0
- package/dist/modules/feedback/feedbackRouter.d.mts +3 -0
- package/dist/modules/feedback/feedbackRouter.mjs +6 -2
- package/dist/modules/feedback/feedbackRouter.mjs.map +1 -1
- package/dist/modules/feedback/service.d.mts +3 -0
- package/dist/modules/feedback/service.d.mts.map +1 -1
- package/dist/modules/feedback/service.mjs +50 -17
- package/dist/modules/feedback/service.mjs.map +1 -1
- package/dist/server/controller.d.mts +143 -0
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +2 -0
- package/dist/server/controller.mjs.map +1 -1
- package/dist/server/trpcSetup.mjs +1 -1
- package/package.json +12 -5
- package/prisma/schema.prisma +161 -0
- package/src/__tests__/communityTools.test.ts +781 -0
- package/src/__tests__/communityToolsCapture.test.ts +319 -0
- package/src/__tests__/communityToolsHealth.test.ts +154 -0
- package/src/__tests__/communityToolsRouter.test.ts +58 -0
- package/src/__tests__/communityToolsWrite.test.ts +561 -0
- package/src/__tests__/feedbackService.test.ts +160 -0
- package/src/generated/prisma/browser.ts +35 -0
- package/src/generated/prisma/client.ts +35 -0
- package/src/generated/prisma/commonInputTypes.ts +34 -0
- package/src/generated/prisma/enums.ts +8 -0
- package/src/generated/prisma/internal/class.ts +74 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +635 -2
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +95 -1
- package/src/generated/prisma/models/DbCommunityTool.ts +3135 -0
- package/src/generated/prisma/models/DbCommunityToolLike.ts +1313 -0
- package/src/generated/prisma/models/DbCommunityToolNamedOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolPin.ts +1325 -0
- package/src/generated/prisma/models/DbCommunityToolTag.ts +1283 -0
- package/src/generated/prisma/models/DbCommunityToolView.ts +1361 -0
- package/src/generated/prisma/models/DbFeedback.ts +379 -1
- package/src/generated/prisma/models.ts +7 -0
- package/src/index.ts +16 -0
- package/src/middleware/createAcMiddleware.ts +20 -0
- package/src/middleware/types.ts +21 -0
- package/src/modules/communityTools/capture.ts +332 -0
- package/src/modules/communityTools/healthJob.ts +191 -0
- package/src/modules/communityTools/router.ts +172 -0
- package/src/modules/communityTools/service.ts +380 -0
- package/src/modules/communityTools/toView.ts +112 -0
- package/src/modules/communityTools/writeService.ts +408 -0
- package/src/modules/feedback/feedbackRouter.ts +20 -6
- package/src/modules/feedback/service.ts +68 -12
- package/src/server/controller.ts +8 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { isCaptureAllowed } from "@igstack/app-catalog-shared-core";
|
|
3
|
+
|
|
4
|
+
//#region src/modules/communityTools/capture.ts
|
|
5
|
+
const VIEWPORT = {
|
|
6
|
+
width: 1280,
|
|
7
|
+
height: 800
|
|
8
|
+
};
|
|
9
|
+
const TIMEOUT_MS = 2e4;
|
|
10
|
+
/** After the network goes quiet, for the page to render what it fetched. */
|
|
11
|
+
const SETTLE_MS = 1500;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the browser may load this URL: navigations, redirects and subresources alike.
|
|
14
|
+
* Inline `data:`/`blob:` URLs reach no host, and pages need them to render.
|
|
15
|
+
*/
|
|
16
|
+
function allowRequest(url, allowedHosts) {
|
|
17
|
+
return url.startsWith("data:") || url.startsWith("blob:") || isCaptureAllowed(url, allowedHosts);
|
|
18
|
+
}
|
|
19
|
+
const MAX_REDIRECTS = 5;
|
|
20
|
+
const HOP_TIMEOUT_MS = 1e4;
|
|
21
|
+
/** Hops a browser repeats as a bodyless GET; 307 and 308 keep the method and body. */
|
|
22
|
+
const REDIRECTS_TO_GET = [
|
|
23
|
+
301,
|
|
24
|
+
302,
|
|
25
|
+
303
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Fetches a URL, following redirects only while every hop stays on the allowed hosts.
|
|
29
|
+
* Null when a hop leaves them, or after too many hops; the off-list hop is never requested.
|
|
30
|
+
*
|
|
31
|
+
* Done by hand because Chromium follows a redirect, even one answered through
|
|
32
|
+
* `route.fulfill`, without consulting the route again (measured against a real browser).
|
|
33
|
+
*/
|
|
34
|
+
async function followWithinHosts(fetch, url, allowedHosts) {
|
|
35
|
+
let hop = url;
|
|
36
|
+
let mode = "same";
|
|
37
|
+
for (let hops = 0;; hops++) {
|
|
38
|
+
const response = await fetch(hop, mode);
|
|
39
|
+
const location = response.headers()["location"];
|
|
40
|
+
const status = response.status();
|
|
41
|
+
if (status < 300 || status >= 400 || !location) return response;
|
|
42
|
+
if (REDIRECTS_TO_GET.includes(status)) mode = "get";
|
|
43
|
+
hop = new URL(location, hop).href;
|
|
44
|
+
if (hops >= MAX_REDIRECTS || !isCaptureAllowed(hop, allowedHosts)) return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Answers one browser request: off-list is aborted, on-list is fetched hop by hop.
|
|
49
|
+
*
|
|
50
|
+
* Never rejects. A rejection in a route handler surfaces as an unhandledRejection and
|
|
51
|
+
* exits Node (measured), so one dead image on a tool's page would take the pod down.
|
|
52
|
+
*/
|
|
53
|
+
async function fenceRoute(route, allowedHosts) {
|
|
54
|
+
try {
|
|
55
|
+
if (!allowRequest(route.url, allowedHosts)) await route.abort("blockedbyclient");
|
|
56
|
+
else if (route.url.startsWith("data:") || route.url.startsWith("blob:")) await route.continue();
|
|
57
|
+
else {
|
|
58
|
+
const response = await followWithinHosts(route.fetch, route.url, allowedHosts);
|
|
59
|
+
if (response) await route.fulfill(response);
|
|
60
|
+
else await route.abort("blockedbyclient");
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
await route.abort("failed").catch(() => void 0);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Chromium flags that fence the network stack itself, below anything a page can reach:
|
|
68
|
+
* every name but the capture hosts fails to resolve (IP literals included), which also
|
|
69
|
+
* covers worker fetches and sockets that `route` never sees; WebRTC gets no direct UDP.
|
|
70
|
+
* A host that is not a plain name is dropped, so config cannot inject resolver rules.
|
|
71
|
+
*/
|
|
72
|
+
function chromiumFenceArgs(allowedHosts) {
|
|
73
|
+
return [
|
|
74
|
+
`--host-resolver-rules=${["MAP * ~NOTFOUND", ...allowedHosts.filter((host) => /^[a-z0-9.-]+$/i.test(host)).flatMap((host) => [`EXCLUDE *.${host}`, `EXCLUDE ${host}`])].join(", ")}`,
|
|
75
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
76
|
+
"--webrtc-ip-handling-policy=disable_non_proxied_udp"
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
/** Navigates an already-fenced page and collects the screenshot, title and favicon. */
|
|
80
|
+
async function capturePage(page, url, allowedHosts) {
|
|
81
|
+
page.setDefaultTimeout(TIMEOUT_MS);
|
|
82
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
83
|
+
const response = await page.goto(url, {
|
|
84
|
+
timeout: TIMEOUT_MS,
|
|
85
|
+
waitUntil: "load"
|
|
86
|
+
});
|
|
87
|
+
if (!isCaptureAllowed(page.url(), allowedHosts)) return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: "redirected off the allowed hosts"
|
|
90
|
+
};
|
|
91
|
+
if (!response || !response.ok()) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: response ? `HTTP ${response.status()}` : "No response"
|
|
94
|
+
};
|
|
95
|
+
const idleBudget = deadline - Date.now() - SETTLE_MS;
|
|
96
|
+
if (idleBudget > 0) await page.waitForLoadState("networkidle", { timeout: idleBudget }).catch(() => void 0);
|
|
97
|
+
const settle = Math.min(SETTLE_MS, deadline - Date.now());
|
|
98
|
+
if (settle > 0) await page.waitForTimeout(settle);
|
|
99
|
+
const png = await page.screenshot({ type: "png" });
|
|
100
|
+
const title = (await page.title()).trim() || null;
|
|
101
|
+
const iconHref = await page.evaluate(`document.querySelector('link[rel~="icon"]')?.href ?? null`);
|
|
102
|
+
const iconUrl = typeof iconHref === "string" ? iconHref : new URL("/favicon.ico", page.url()).href;
|
|
103
|
+
let favicon = null;
|
|
104
|
+
if (isCaptureAllowed(iconUrl, allowedHosts)) try {
|
|
105
|
+
const icon = await page.request.get(iconUrl, {
|
|
106
|
+
timeout: 5e3,
|
|
107
|
+
maxRedirects: 0
|
|
108
|
+
});
|
|
109
|
+
if (icon.ok()) favicon = await icon.body();
|
|
110
|
+
} catch {}
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
png,
|
|
114
|
+
title,
|
|
115
|
+
favicon,
|
|
116
|
+
lastModified: httpDate(response.headers()["last-modified"])
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** An HTTP date header as a Date; null when absent or unreadable. */
|
|
120
|
+
function httpDate(value) {
|
|
121
|
+
if (!value) return null;
|
|
122
|
+
const date = new Date(value);
|
|
123
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
124
|
+
}
|
|
125
|
+
/** Runs captures one at a time, so concurrent previews cannot start a browser each. */
|
|
126
|
+
function oneAtATime(capture) {
|
|
127
|
+
let queue = Promise.resolve();
|
|
128
|
+
let waiting = 0;
|
|
129
|
+
return {
|
|
130
|
+
waiting: () => waiting,
|
|
131
|
+
capture: (url, allowedHosts) => {
|
|
132
|
+
waiting++;
|
|
133
|
+
const run = queue.then(() => {
|
|
134
|
+
waiting--;
|
|
135
|
+
return capture(url, allowedHosts);
|
|
136
|
+
});
|
|
137
|
+
queue = run.catch(() => void 0);
|
|
138
|
+
return run;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A capturer backed by the optional `playwright-core` peer, or null when it is not
|
|
144
|
+
* installed — the feature then works without screenshots.
|
|
145
|
+
*/
|
|
146
|
+
function createPlaywrightCapturer() {
|
|
147
|
+
try {
|
|
148
|
+
createRequire(import.meta.url).resolve("playwright-core");
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return oneAtATime(async (url, allowedHosts) => {
|
|
153
|
+
const { chromium } = await import("playwright-core");
|
|
154
|
+
const browser = await chromium.launch({
|
|
155
|
+
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || void 0,
|
|
156
|
+
args: chromiumFenceArgs(allowedHosts)
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const context = await browser.newContext({
|
|
160
|
+
viewport: VIEWPORT,
|
|
161
|
+
serviceWorkers: "block"
|
|
162
|
+
});
|
|
163
|
+
await context.route("**/*", (route) => fenceRoute({
|
|
164
|
+
url: route.request().url(),
|
|
165
|
+
abort: (code) => route.abort(code),
|
|
166
|
+
continue: () => route.continue(),
|
|
167
|
+
fetch: (hop, mode) => mode === "get" ? context.request.get(hop, {
|
|
168
|
+
maxRedirects: 0,
|
|
169
|
+
timeout: HOP_TIMEOUT_MS
|
|
170
|
+
}) : route.fetch({
|
|
171
|
+
url: hop,
|
|
172
|
+
maxRedirects: 0,
|
|
173
|
+
timeout: HOP_TIMEOUT_MS
|
|
174
|
+
}),
|
|
175
|
+
fulfill: (response) => route.fulfill({ response })
|
|
176
|
+
}, allowedHosts));
|
|
177
|
+
await context.routeWebSocket(/.*/, (ws) => ws.close());
|
|
178
|
+
return await capturePage(await context.newPage(), url, allowedHosts);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: error instanceof Error ? error.message : String(error)
|
|
183
|
+
};
|
|
184
|
+
} finally {
|
|
185
|
+
await browser.close();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
export { createPlaywrightCapturer };
|
|
192
|
+
//# sourceMappingURL=capture.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capture.mjs","names":[],"sources":["../../../src/modules/communityTools/capture.ts"],"sourcesContent":["import { createRequire } from 'node:module'\nimport { isCaptureAllowed } from '@igstack/app-catalog-shared-core'\n\nexport type CaptureResult =\n | {\n ok: true\n png: Buffer\n title: string | null\n favicon: Buffer | null\n /** The page's `Last-Modified`: a static host sets it on each deploy. */\n lastModified: Date | null\n }\n | { ok: false; error: string }\n\n/**\n * Visits a page and photographs it, reaching only `allowedHosts`.\n *\n * Resolves `{ ok: false }` when the page itself failed (no response, an error status, a\n * timeout, a redirect off the list) — that is the tool's health. Throws when the\n * browser could not be started, which is ours: the health job must not mark every tool\n * broken because Chromium is missing on one pod.\n */\nexport interface ScreenshotCapturer {\n capture: (url: string, allowedHosts: string[]) => Promise<CaptureResult>\n /** Captures queued and not started yet; a capturer without a queue leaves it out. */\n waiting?: () => number\n}\n\n/** The part of a playwright `Page` a capture uses, so it can be tested without a browser. */\nexport interface CapturePage {\n setDefaultTimeout: (ms: number) => void\n goto: (\n url: string,\n options: { timeout: number; waitUntil: 'load' },\n ) => Promise<{\n ok: () => boolean\n status: () => number\n headers: () => Record<string, string>\n } | null>\n waitForLoadState: (\n state: 'networkidle',\n options: { timeout: number },\n ) => Promise<void>\n waitForTimeout: (ms: number) => Promise<void>\n url: () => string\n screenshot: (options: { type: 'png' }) => Promise<Buffer>\n title: () => Promise<string>\n evaluate: (expression: string) => Promise<unknown>\n request: {\n get: (\n url: string,\n options: { timeout: number; maxRedirects: number },\n ) => Promise<{ ok: () => boolean; body: () => Promise<Buffer> }>\n }\n}\n\nconst VIEWPORT = { width: 1280, height: 800 }\nconst TIMEOUT_MS = 20_000\n/** After the network goes quiet, for the page to render what it fetched. */\nconst SETTLE_MS = 1_500\n\n/**\n * Whether the browser may load this URL: navigations, redirects and subresources alike.\n * Inline `data:`/`blob:` URLs reach no host, and pages need them to render.\n */\nexport function allowRequest(url: string, allowedHosts: string[]): boolean {\n return (\n url.startsWith('data:') ||\n url.startsWith('blob:') ||\n isCaptureAllowed(url, allowedHosts)\n )\n}\n\nconst MAX_REDIRECTS = 5\nconst HOP_TIMEOUT_MS = 10_000\n/** Hops a browser repeats as a bodyless GET; 307 and 308 keep the method and body. */\nconst REDIRECTS_TO_GET = [301, 302, 303]\n\n/** `same` repeats the original method and body; `get` is a bodyless GET. */\nexport type HopMode = 'same' | 'get'\n\n/**\n * Fetches a URL, following redirects only while every hop stays on the allowed hosts.\n * Null when a hop leaves them, or after too many hops; the off-list hop is never requested.\n *\n * Done by hand because Chromium follows a redirect, even one answered through\n * `route.fulfill`, without consulting the route again (measured against a real browser).\n */\nexport async function followWithinHosts<\n TResponse extends {\n status: () => number\n headers: () => Record<string, string>\n },\n>(\n fetch: (url: string, mode: HopMode) => Promise<TResponse>,\n url: string,\n allowedHosts: string[],\n): Promise<TResponse | null> {\n let hop = url\n let mode: HopMode = 'same'\n for (let hops = 0; ; hops++) {\n const response = await fetch(hop, mode)\n const location = response.headers()['location']\n const status = response.status()\n if (status < 300 || status >= 400 || !location) return response\n if (REDIRECTS_TO_GET.includes(status)) mode = 'get'\n hop = new URL(location, hop).href\n if (hops >= MAX_REDIRECTS || !isCaptureAllowed(hop, allowedHosts)) {\n return null\n }\n }\n}\n\n/** The parts of a playwright `Route` the fence uses, so it can be tested without a browser. */\nexport interface FenceRoute<TResponse> {\n url: string\n abort: (code: 'blockedbyclient' | 'failed') => Promise<void>\n continue: () => Promise<void>\n fetch: (url: string, mode: HopMode) => Promise<TResponse>\n fulfill: (response: TResponse) => Promise<void>\n}\n\n/**\n * Answers one browser request: off-list is aborted, on-list is fetched hop by hop.\n *\n * Never rejects. A rejection in a route handler surfaces as an unhandledRejection and\n * exits Node (measured), so one dead image on a tool's page would take the pod down.\n */\nexport async function fenceRoute<\n TResponse extends {\n status: () => number\n headers: () => Record<string, string>\n },\n>(route: FenceRoute<TResponse>, allowedHosts: string[]): Promise<void> {\n try {\n if (!allowRequest(route.url, allowedHosts)) {\n await route.abort('blockedbyclient')\n } else if (route.url.startsWith('data:') || route.url.startsWith('blob:')) {\n await route.continue()\n } else {\n const response = await followWithinHosts(\n route.fetch,\n route.url,\n allowedHosts,\n )\n if (response) await route.fulfill(response)\n else await route.abort('blockedbyclient')\n }\n } catch {\n await route.abort('failed').catch(() => undefined)\n }\n}\n\n/**\n * Chromium flags that fence the network stack itself, below anything a page can reach:\n * every name but the capture hosts fails to resolve (IP literals included), which also\n * covers worker fetches and sockets that `route` never sees; WebRTC gets no direct UDP.\n * A host that is not a plain name is dropped, so config cannot inject resolver rules.\n */\nexport function chromiumFenceArgs(allowedHosts: string[]): string[] {\n const rules = [\n 'MAP * ~NOTFOUND',\n ...allowedHosts\n .filter((host) => /^[a-z0-9.-]+$/i.test(host))\n .flatMap((host) => [`EXCLUDE *.${host}`, `EXCLUDE ${host}`]),\n ]\n return [\n `--host-resolver-rules=${rules.join(', ')}`,\n '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',\n '--webrtc-ip-handling-policy=disable_non_proxied_udp',\n ]\n}\n\n/** Navigates an already-fenced page and collects the screenshot, title and favicon. */\nexport async function capturePage(\n page: CapturePage,\n url: string,\n allowedHosts: string[],\n): Promise<CaptureResult> {\n page.setDefaultTimeout(TIMEOUT_MS)\n const deadline = Date.now() + TIMEOUT_MS\n const response = await page.goto(url, {\n timeout: TIMEOUT_MS,\n waitUntil: 'load',\n })\n // The route fence aborts an off-list redirect, but the result must not depend on it.\n if (!isCaptureAllowed(page.url(), allowedHosts)) {\n return { ok: false, error: 'redirected off the allowed hosts' }\n }\n if (!response || !response.ok()) {\n return {\n ok: false,\n error: response ? `HTTP ${response.status()}` : 'No response',\n }\n }\n // `load` is only the HTML and its assets: a page that fetches its data after\n // that would be photographed saying \"Loading…\". So wait for the network to go\n // quiet, then a moment for it to render, all inside the one timeout. A page\n // that never goes quiet (polling, a live feed) is photographed as it is.\n const idleBudget = deadline - Date.now() - SETTLE_MS\n if (idleBudget > 0) {\n await page\n .waitForLoadState('networkidle', { timeout: idleBudget })\n .catch(() => undefined)\n }\n const settle = Math.min(SETTLE_MS, deadline - Date.now())\n if (settle > 0) await page.waitForTimeout(settle)\n const png = await page.screenshot({ type: 'png' })\n const title = (await page.title()).trim() || null\n\n // A string, so this file needs no DOM types.\n const iconHref = await page.evaluate(\n `document.querySelector('link[rel~=\"icon\"]')?.href ?? null`,\n )\n const iconUrl =\n typeof iconHref === 'string'\n ? iconHref\n : new URL('/favicon.ico', page.url()).href\n let favicon: Buffer | null = null\n // `page.request` bypasses the route fence, so the href the page chose is checked here,\n // and a redirect is not followed anywhere.\n if (isCaptureAllowed(iconUrl, allowedHosts)) {\n try {\n const icon = await page.request.get(iconUrl, {\n timeout: 5_000,\n maxRedirects: 0,\n })\n if (icon.ok()) favicon = await icon.body()\n } catch {\n // No favicon is fine: the card falls back to a monogram.\n }\n }\n\n return {\n ok: true,\n png,\n title,\n favicon,\n lastModified: httpDate(response.headers()['last-modified']),\n }\n}\n\n/** An HTTP date header as a Date; null when absent or unreadable. */\nexport function httpDate(value: string | undefined): Date | null {\n if (!value) return null\n const date = new Date(value)\n return Number.isNaN(date.getTime()) ? null : date\n}\n\n/** Runs captures one at a time, so concurrent previews cannot start a browser each. */\nexport function oneAtATime(\n capture: ScreenshotCapturer['capture'],\n): Required<ScreenshotCapturer> {\n let queue: Promise<unknown> = Promise.resolve()\n let waiting = 0\n return {\n waiting: () => waiting,\n capture: (url, allowedHosts) => {\n waiting++\n const run = queue.then(() => {\n waiting--\n return capture(url, allowedHosts)\n })\n queue = run.catch(() => undefined)\n return run\n },\n }\n}\n\n/**\n * A capturer backed by the optional `playwright-core` peer, or null when it is not\n * installed — the feature then works without screenshots.\n */\nexport function createPlaywrightCapturer(): ScreenshotCapturer | null {\n try {\n createRequire(import.meta.url).resolve('playwright-core')\n } catch {\n return null\n }\n\n return oneAtATime(async (url, allowedHosts) => {\n const { chromium } = await import('playwright-core')\n // Launched per capture because the fence flags depend on the hosts; the queue\n // keeps that to one browser at a time.\n const browser = await chromium.launch({\n executablePath:\n process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,\n args: chromiumFenceArgs(allowedHosts),\n })\n try {\n // A service worker could answer requests the route never sees.\n const context = await browser.newContext({\n viewport: VIEWPORT,\n serviceWorkers: 'block',\n })\n // Second layer under the resolver fence: per-request and per-hop checks.\n await context.route('**/*', (route) =>\n fenceRoute(\n {\n url: route.request().url(),\n abort: (code) => route.abort(code),\n continue: () => route.continue(),\n fetch: (hop, mode) =>\n mode === 'get'\n ? context.request.get(hop, {\n maxRedirects: 0,\n timeout: HOP_TIMEOUT_MS,\n })\n : route.fetch({\n url: hop,\n maxRedirects: 0,\n timeout: HOP_TIMEOUT_MS,\n }),\n fulfill: (response) => route.fulfill({ response }),\n },\n allowedHosts,\n ),\n )\n // A screenshot needs no live socket, and `route` does not see them.\n await context.routeWebSocket(/.*/, (ws) => ws.close())\n const page = await context.newPage()\n return await capturePage(page, url, allowedHosts)\n } catch (error) {\n return {\n ok: false,\n error: error instanceof Error ? error.message : String(error),\n }\n } finally {\n await browser.close()\n }\n })\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getDbClient } from "../../db/client.mjs";
|
|
2
|
+
import { resizeImage } from "../assets/assetUtils.mjs";
|
|
3
|
+
import { upsertAsset } from "../assets/upsertAsset.mjs";
|
|
4
|
+
import { isCaptureAllowed, nextLifecycle } from "@igstack/app-catalog-shared-core";
|
|
5
|
+
|
|
6
|
+
//#region src/modules/communityTools/healthJob.ts
|
|
7
|
+
const MINUTE_MS = 60 * 1e3;
|
|
8
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
9
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
10
|
+
/** Wall time one run may spend; what is left is due again on the next tick. */
|
|
11
|
+
const RUN_BUDGET_MS = 40 * MINUTE_MS;
|
|
12
|
+
/** Past the budget plus one capture, and short of the next tick. */
|
|
13
|
+
const RUN_TRANSACTION_TIMEOUT_MS = 55 * MINUTE_MS;
|
|
14
|
+
const FIRST_RUN_DELAY_MS = MINUTE_MS;
|
|
15
|
+
/** Stores the screenshot and, when it decodes, the favicon as assets. */
|
|
16
|
+
async function storeCapture(slug, result, now) {
|
|
17
|
+
const prisma = getDbClient();
|
|
18
|
+
const screenshotAssetId = await upsertAsset({
|
|
19
|
+
prisma,
|
|
20
|
+
buffer: result.png,
|
|
21
|
+
name: `community-${slug}-screenshot`,
|
|
22
|
+
originalFilename: `community-${slug}-screenshot.png`,
|
|
23
|
+
assetType: "screenshot"
|
|
24
|
+
});
|
|
25
|
+
let faviconAssetId;
|
|
26
|
+
if (result.favicon) try {
|
|
27
|
+
faviconAssetId = await upsertAsset({
|
|
28
|
+
prisma,
|
|
29
|
+
buffer: await resizeImage(result.favicon, 64, 64, "png"),
|
|
30
|
+
name: `community-${slug}-icon`,
|
|
31
|
+
originalFilename: `community-${slug}-icon.png`,
|
|
32
|
+
assetType: "icon"
|
|
33
|
+
});
|
|
34
|
+
} catch {}
|
|
35
|
+
return {
|
|
36
|
+
screenshotAssetId,
|
|
37
|
+
...faviconAssetId && { faviconAssetId },
|
|
38
|
+
capturedAt: now
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Visits one tool and records the result. Hosts outside the configured suffixes, and a
|
|
43
|
+
* deployment without a capturer, are left untouched: no check means no state change.
|
|
44
|
+
*/
|
|
45
|
+
async function checkTool(tool, deps, now) {
|
|
46
|
+
if (!deps.capturer || !isCaptureAllowed(tool.url, deps.captureHosts)) return;
|
|
47
|
+
let result;
|
|
48
|
+
try {
|
|
49
|
+
result = await deps.capturer.capture(tool.url, deps.captureHosts);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await getDbClient().dbCommunityTool.update({
|
|
52
|
+
where: { id: tool.id },
|
|
53
|
+
data: { lastCheckAt: now }
|
|
54
|
+
});
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
await getDbClient().dbCommunityTool.update({
|
|
58
|
+
where: { id: tool.id },
|
|
59
|
+
data: {
|
|
60
|
+
lastCheckAt: now,
|
|
61
|
+
...nextLifecycle(tool, result.ok, now),
|
|
62
|
+
...result.ok && await storeCapture(tool.slug, result, now),
|
|
63
|
+
...result.ok && result.lastModified && { siteUpdatedAt: result.lastModified }
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One pass over every tool not checked in the last day.
|
|
69
|
+
*
|
|
70
|
+
* The transaction-scoped lock rather than `pg_try_advisory_lock`: a session lock is
|
|
71
|
+
* held by whichever pooled connection ran the query, and the unlock may run on another,
|
|
72
|
+
* leaving the lock stuck forever. The transaction pins one connection and releases the
|
|
73
|
+
* lock when it ends, even if the pod dies mid-run.
|
|
74
|
+
*
|
|
75
|
+
* ponytail: one transaction holds the lock for the whole run, so a run is capped at
|
|
76
|
+
* RUN_BUDGET_MS (about 90 tools at 25 s each); per-tool short transactions or a job
|
|
77
|
+
* queue if the catalog outgrows that.
|
|
78
|
+
*/
|
|
79
|
+
async function runHealthCheckOnce(deps, now, clock = Date.now) {
|
|
80
|
+
const db = getDbClient();
|
|
81
|
+
await db.$transaction(async (tx) => {
|
|
82
|
+
const [row] = await tx.$queryRaw`
|
|
83
|
+
SELECT pg_try_advisory_xact_lock(hashtext('community-tools-health')) AS locked`;
|
|
84
|
+
if (!row?.locked) return;
|
|
85
|
+
const cutoff = now.getTime() - DAY_MS;
|
|
86
|
+
const due = (await db.dbCommunityTool.findMany({ select: {
|
|
87
|
+
id: true,
|
|
88
|
+
slug: true,
|
|
89
|
+
url: true,
|
|
90
|
+
lastCheckAt: true,
|
|
91
|
+
failingSince: true,
|
|
92
|
+
archivedAt: true,
|
|
93
|
+
archivedBy: true
|
|
94
|
+
} })).filter((t) => !(t.archivedAt && t.archivedBy !== "auto") && (!t.lastCheckAt || t.lastCheckAt.getTime() < cutoff));
|
|
95
|
+
const startedAt = clock();
|
|
96
|
+
for (const tool of due) {
|
|
97
|
+
if (clock() - startedAt >= RUN_BUDGET_MS) break;
|
|
98
|
+
try {
|
|
99
|
+
await checkTool(tool, deps, now);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(`[communityTools] health check of ${tool.slug}:`, error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, {
|
|
105
|
+
maxWait: 1e4,
|
|
106
|
+
timeout: RUN_TRANSACTION_TIMEOUT_MS
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Starts the pass a minute after boot, then hourly: a pod restarted more often than
|
|
111
|
+
* hourly would otherwise never check. The lock keeps pods from overlapping.
|
|
112
|
+
* Returns a stop function.
|
|
113
|
+
*/
|
|
114
|
+
function startHealthJob(deps, now = () => /* @__PURE__ */ new Date()) {
|
|
115
|
+
const run = () => {
|
|
116
|
+
runHealthCheckOnce(deps, now()).catch((error) => {
|
|
117
|
+
console.error("[communityTools] health run failed:", error);
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
const first = setTimeout(run, FIRST_RUN_DELAY_MS);
|
|
121
|
+
const timer = setInterval(run, HOUR_MS);
|
|
122
|
+
first.unref();
|
|
123
|
+
timer.unref();
|
|
124
|
+
return () => {
|
|
125
|
+
clearTimeout(first);
|
|
126
|
+
clearInterval(timer);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
//#endregion
|
|
131
|
+
export { checkTool, startHealthJob };
|
|
132
|
+
//# sourceMappingURL=healthJob.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"healthJob.mjs","names":[],"sources":["../../../src/modules/communityTools/healthJob.ts"],"sourcesContent":["import {\n isCaptureAllowed,\n nextLifecycle,\n} from '@igstack/app-catalog-shared-core'\nimport { getDbClient } from '../../db/client'\nimport { resizeImage } from '../assets/assetUtils'\nimport { upsertAsset } from '../assets/upsertAsset'\nimport type { CaptureResult, ScreenshotCapturer } from './capture'\n\nexport interface CaptureDeps {\n captureHosts: string[]\n capturer: ScreenshotCapturer | null\n}\n\nconst MINUTE_MS = 60 * 1000\nconst HOUR_MS = 60 * MINUTE_MS\nconst DAY_MS = 24 * HOUR_MS\n/** Wall time one run may spend; what is left is due again on the next tick. */\nconst RUN_BUDGET_MS = 40 * MINUTE_MS\n/** Past the budget plus one capture, and short of the next tick. */\nconst RUN_TRANSACTION_TIMEOUT_MS = 55 * MINUTE_MS\nconst FIRST_RUN_DELAY_MS = MINUTE_MS\n\ninterface CheckableTool {\n id: string\n slug: string\n url: string\n failingSince: Date | null\n archivedAt: Date | null\n archivedBy: string | null\n}\n\n/** Stores the screenshot and, when it decodes, the favicon as assets. */\nasync function storeCapture(\n slug: string,\n result: Extract<CaptureResult, { ok: true }>,\n now: Date,\n) {\n const prisma = getDbClient()\n const screenshotAssetId = await upsertAsset({\n prisma,\n buffer: result.png,\n name: `community-${slug}-screenshot`,\n originalFilename: `community-${slug}-screenshot.png`,\n assetType: 'screenshot',\n })\n\n let faviconAssetId: string | undefined\n if (result.favicon) {\n try {\n // ponytail: sharp cannot decode .ico, so those tools get no favicon; add an ico decoder if it matters\n const png = await resizeImage(result.favicon, 64, 64, 'png')\n faviconAssetId = await upsertAsset({\n prisma,\n buffer: png,\n name: `community-${slug}-icon`,\n originalFilename: `community-${slug}-icon.png`,\n assetType: 'icon',\n })\n } catch {\n // An undecodable favicon leaves the previous one, or none.\n }\n }\n\n return {\n screenshotAssetId,\n ...(faviconAssetId && { faviconAssetId }),\n capturedAt: now,\n }\n}\n\n/**\n * Visits one tool and records the result. Hosts outside the configured suffixes, and a\n * deployment without a capturer, are left untouched: no check means no state change.\n */\nexport async function checkTool(\n tool: CheckableTool,\n deps: CaptureDeps,\n now: Date,\n): Promise<void> {\n if (!deps.capturer || !isCaptureAllowed(tool.url, deps.captureHosts)) return\n\n let result: CaptureResult\n try {\n result = await deps.capturer.capture(tool.url, deps.captureHosts)\n } catch (error) {\n // A browser that would not start says nothing about the page, so the\n // lifecycle stays as it was. The attempt is still recorded: without it a\n // new tool reads \"capture pending\" forever and its open card keeps asking.\n await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: { lastCheckAt: now },\n })\n throw error\n }\n await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: {\n lastCheckAt: now,\n ...nextLifecycle(tool, result.ok, now),\n ...(result.ok && (await storeCapture(tool.slug, result, now))),\n // A visit without the header leaves the date it last had.\n ...(result.ok &&\n result.lastModified && {\n siteUpdatedAt: result.lastModified,\n }),\n },\n })\n}\n\n/**\n * One pass over every tool not checked in the last day.\n *\n * The transaction-scoped lock rather than `pg_try_advisory_lock`: a session lock is\n * held by whichever pooled connection ran the query, and the unlock may run on another,\n * leaving the lock stuck forever. The transaction pins one connection and releases the\n * lock when it ends, even if the pod dies mid-run.\n *\n * ponytail: one transaction holds the lock for the whole run, so a run is capped at\n * RUN_BUDGET_MS (about 90 tools at 25 s each); per-tool short transactions or a job\n * queue if the catalog outgrows that.\n */\nexport async function runHealthCheckOnce(\n deps: CaptureDeps,\n now: Date,\n clock: () => number = Date.now,\n): Promise<void> {\n const db = getDbClient()\n await db.$transaction(\n async (tx) => {\n const [row] = await tx.$queryRaw<{ locked: boolean }[]>`\n SELECT pg_try_advisory_xact_lock(hashtext('community-tools-health')) AS locked`\n if (!row?.locked) return\n\n const cutoff = now.getTime() - DAY_MS\n // ponytail: reads every tool each hour; move the filters into the query if the table grows large\n const tools = await db.dbCommunityTool.findMany({\n select: {\n id: true,\n slug: true,\n url: true,\n lastCheckAt: true,\n failingSince: true,\n archivedAt: true,\n archivedBy: true,\n },\n })\n const due = tools.filter(\n (t) =>\n !(t.archivedAt && t.archivedBy !== 'auto') &&\n (!t.lastCheckAt || t.lastCheckAt.getTime() < cutoff),\n )\n\n const startedAt = clock()\n for (const tool of due) {\n if (clock() - startedAt >= RUN_BUDGET_MS) break\n try {\n await checkTool(tool, deps, now)\n } catch (error) {\n console.error(`[communityTools] health check of ${tool.slug}:`, error)\n }\n }\n },\n { maxWait: 10_000, timeout: RUN_TRANSACTION_TIMEOUT_MS },\n )\n}\n\n/**\n * Starts the pass a minute after boot, then hourly: a pod restarted more often than\n * hourly would otherwise never check. The lock keeps pods from overlapping.\n * Returns a stop function.\n */\nexport function startHealthJob(\n deps: CaptureDeps,\n now: () => Date = () => new Date(),\n): () => void {\n const run = () => {\n runHealthCheckOnce(deps, now()).catch((error: unknown) => {\n console.error('[communityTools] health run failed:', error)\n })\n }\n const first = setTimeout(run, FIRST_RUN_DELAY_MS)\n const timer = setInterval(run, HOUR_MS)\n // Neither keeps the process, or a test runner, alive.\n first.unref()\n timer.unref()\n return () => {\n clearTimeout(first)\n clearInterval(timer)\n }\n}\n"],"mappings":";;;;;;AAcA,MAAM,YAAY,KAAK;AACvB,MAAM,UAAU,KAAK;AACrB,MAAM,SAAS,KAAK;;AAEpB,MAAM,gBAAgB,KAAK;;AAE3B,MAAM,6BAA6B,KAAK;AACxC,MAAM,qBAAqB;;AAY3B,eAAe,aACb,MACA,QACA,KACA;CACA,MAAM,SAAS,aAAa;CAC5B,MAAM,oBAAoB,MAAM,YAAY;EAC1C;EACA,QAAQ,OAAO;EACf,MAAM,aAAa,KAAK;EACxB,kBAAkB,aAAa,KAAK;EACpC,WAAW;EACZ,CAAC;CAEF,IAAI;AACJ,KAAI,OAAO,QACT,KAAI;AAGF,mBAAiB,MAAM,YAAY;GACjC;GACA,QAHU,MAAM,YAAY,OAAO,SAAS,IAAI,IAAI,MAAM;GAI1D,MAAM,aAAa,KAAK;GACxB,kBAAkB,aAAa,KAAK;GACpC,WAAW;GACZ,CAAC;SACI;AAKV,QAAO;EACL;EACA,GAAI,kBAAkB,EAAE,gBAAgB;EACxC,YAAY;EACb;;;;;;AAOH,eAAsB,UACpB,MACA,MACA,KACe;AACf,KAAI,CAAC,KAAK,YAAY,CAAC,iBAAiB,KAAK,KAAK,KAAK,aAAa,CAAE;CAEtE,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,KAAK,SAAS,QAAQ,KAAK,KAAK,KAAK,aAAa;UAC1D,OAAO;AAId,QAAM,aAAa,CAAC,gBAAgB,OAAO;GACzC,OAAO,EAAE,IAAI,KAAK,IAAI;GACtB,MAAM,EAAE,aAAa,KAAK;GAC3B,CAAC;AACF,QAAM;;AAER,OAAM,aAAa,CAAC,gBAAgB,OAAO;EACzC,OAAO,EAAE,IAAI,KAAK,IAAI;EACtB,MAAM;GACJ,aAAa;GACb,GAAG,cAAc,MAAM,OAAO,IAAI,IAAI;GACtC,GAAI,OAAO,MAAO,MAAM,aAAa,KAAK,MAAM,QAAQ,IAAI;GAE5D,GAAI,OAAO,MACT,OAAO,gBAAgB,EACrB,eAAe,OAAO,cACvB;GACJ;EACF,CAAC;;;;;;;;;;;;;;AAeJ,eAAsB,mBACpB,MACA,KACA,QAAsB,KAAK,KACZ;CACf,MAAM,KAAK,aAAa;AACxB,OAAM,GAAG,aACP,OAAO,OAAO;EACZ,MAAM,CAAC,OAAO,MAAM,GAAG,SAAgC;;AAEvD,MAAI,CAAC,KAAK,OAAQ;EAElB,MAAM,SAAS,IAAI,SAAS,GAAG;EAa/B,MAAM,OAXQ,MAAM,GAAG,gBAAgB,SAAS,EAC9C,QAAQ;GACN,IAAI;GACJ,MAAM;GACN,KAAK;GACL,aAAa;GACb,cAAc;GACd,YAAY;GACZ,YAAY;GACb,EACF,CAAC,EACgB,QACf,MACC,EAAE,EAAE,cAAc,EAAE,eAAe,YAClC,CAAC,EAAE,eAAe,EAAE,YAAY,SAAS,GAAG,QAChD;EAED,MAAM,YAAY,OAAO;AACzB,OAAK,MAAM,QAAQ,KAAK;AACtB,OAAI,OAAO,GAAG,aAAa,cAAe;AAC1C,OAAI;AACF,UAAM,UAAU,MAAM,MAAM,IAAI;YACzB,OAAO;AACd,YAAQ,MAAM,oCAAoC,KAAK,KAAK,IAAI,MAAM;;;IAI5E;EAAE,SAAS;EAAQ,SAAS;EAA4B,CACzD;;;;;;;AAQH,SAAgB,eACd,MACA,4BAAwB,IAAI,MAAM,EACtB;CACZ,MAAM,YAAY;AAChB,qBAAmB,MAAM,KAAK,CAAC,CAAC,OAAO,UAAmB;AACxD,WAAQ,MAAM,uCAAuC,MAAM;IAC3D;;CAEJ,MAAM,QAAQ,WAAW,KAAK,mBAAmB;CACjD,MAAM,QAAQ,YAAY,KAAK,QAAQ;AAEvC,OAAM,OAAO;AACb,OAAM,OAAO;AACb,cAAa;AACX,eAAa,MAAM;AACnB,gBAAc,MAAM"}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { AcTrpcContext } from "../../server/acTrpcContext.mjs";
|
|
2
|
+
import { CommunityToolsOptions } from "../../middleware/types.mjs";
|
|
3
|
+
import { ToolPreview } from "./writeService.mjs";
|
|
4
|
+
import * as _trpc_server0 from "@trpc/server";
|
|
5
|
+
import * as _igstack_app_catalog_shared_core0 from "@igstack/app-catalog-shared-core";
|
|
6
|
+
|
|
7
|
+
//#region src/modules/communityTools/router.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* `options.capturer` is used as given: resolving the playwright default is the caller's
|
|
10
|
+
* job (`createAcMiddleware`), so a router built directly never launches a browser.
|
|
11
|
+
*/
|
|
12
|
+
declare function createCommunityToolsRouter(options?: CommunityToolsOptions): _trpc_server0.TRPCBuiltRouter<{
|
|
13
|
+
ctx: AcTrpcContext;
|
|
14
|
+
meta: object;
|
|
15
|
+
errorShape: _trpc_server0.TRPCDefaultErrorShape;
|
|
16
|
+
transformer: false;
|
|
17
|
+
}, _trpc_server0.TRPCDecorateCreateRouterOptions<{
|
|
18
|
+
config: _trpc_server0.TRPCQueryProcedure<{
|
|
19
|
+
input: void;
|
|
20
|
+
output: {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
};
|
|
23
|
+
meta: object;
|
|
24
|
+
}>;
|
|
25
|
+
list: _trpc_server0.TRPCQueryProcedure<{
|
|
26
|
+
input: void;
|
|
27
|
+
output: _igstack_app_catalog_shared_core0.CommunityTool[];
|
|
28
|
+
meta: object;
|
|
29
|
+
}>;
|
|
30
|
+
countByApp: _trpc_server0.TRPCQueryProcedure<{
|
|
31
|
+
input: void;
|
|
32
|
+
output: Record<string, number>;
|
|
33
|
+
meta: object;
|
|
34
|
+
}>;
|
|
35
|
+
get: _trpc_server0.TRPCQueryProcedure<{
|
|
36
|
+
input: {
|
|
37
|
+
slug: string;
|
|
38
|
+
};
|
|
39
|
+
output: _igstack_app_catalog_shared_core0.CommunityTool;
|
|
40
|
+
meta: object;
|
|
41
|
+
}>;
|
|
42
|
+
recordView: _trpc_server0.TRPCMutationProcedure<{
|
|
43
|
+
input: {
|
|
44
|
+
slug: string;
|
|
45
|
+
};
|
|
46
|
+
output: {
|
|
47
|
+
views30d: number;
|
|
48
|
+
viewsAll: number;
|
|
49
|
+
};
|
|
50
|
+
meta: object;
|
|
51
|
+
}>;
|
|
52
|
+
pins: _trpc_server0.TRPCQueryProcedure<{
|
|
53
|
+
input: void;
|
|
54
|
+
output: _igstack_app_catalog_shared_core0.CommunityTool[];
|
|
55
|
+
meta: object;
|
|
56
|
+
}>;
|
|
57
|
+
pin: _trpc_server0.TRPCMutationProcedure<{
|
|
58
|
+
input: {
|
|
59
|
+
slug: string;
|
|
60
|
+
};
|
|
61
|
+
output: void;
|
|
62
|
+
meta: object;
|
|
63
|
+
}>;
|
|
64
|
+
like: _trpc_server0.TRPCMutationProcedure<{
|
|
65
|
+
input: {
|
|
66
|
+
slug: string;
|
|
67
|
+
};
|
|
68
|
+
output: void;
|
|
69
|
+
meta: object;
|
|
70
|
+
}>;
|
|
71
|
+
unlike: _trpc_server0.TRPCMutationProcedure<{
|
|
72
|
+
input: {
|
|
73
|
+
slug: string;
|
|
74
|
+
};
|
|
75
|
+
output: void;
|
|
76
|
+
meta: object;
|
|
77
|
+
}>;
|
|
78
|
+
unpin: _trpc_server0.TRPCMutationProcedure<{
|
|
79
|
+
input: {
|
|
80
|
+
slug: string;
|
|
81
|
+
};
|
|
82
|
+
output: void;
|
|
83
|
+
meta: object;
|
|
84
|
+
}>;
|
|
85
|
+
reorderPins: _trpc_server0.TRPCMutationProcedure<{
|
|
86
|
+
input: {
|
|
87
|
+
slugs: string[];
|
|
88
|
+
};
|
|
89
|
+
output: void;
|
|
90
|
+
meta: object;
|
|
91
|
+
}>;
|
|
92
|
+
preview: _trpc_server0.TRPCMutationProcedure<{
|
|
93
|
+
input: {
|
|
94
|
+
url: string;
|
|
95
|
+
};
|
|
96
|
+
output: ToolPreview;
|
|
97
|
+
meta: object;
|
|
98
|
+
}>;
|
|
99
|
+
add: _trpc_server0.TRPCMutationProcedure<{
|
|
100
|
+
input: {
|
|
101
|
+
url: string;
|
|
102
|
+
name: string;
|
|
103
|
+
summary: string;
|
|
104
|
+
descriptionMd: string;
|
|
105
|
+
ownerSlugs: string[];
|
|
106
|
+
tags: {
|
|
107
|
+
kind: "app" | "topic";
|
|
108
|
+
value: string;
|
|
109
|
+
}[];
|
|
110
|
+
ownerNames?: string[] | undefined;
|
|
111
|
+
};
|
|
112
|
+
output: _igstack_app_catalog_shared_core0.CommunityTool;
|
|
113
|
+
meta: object;
|
|
114
|
+
}>;
|
|
115
|
+
edit: _trpc_server0.TRPCMutationProcedure<{
|
|
116
|
+
input: {
|
|
117
|
+
url: string;
|
|
118
|
+
name: string;
|
|
119
|
+
summary: string;
|
|
120
|
+
descriptionMd: string;
|
|
121
|
+
ownerSlugs: string[];
|
|
122
|
+
tags: {
|
|
123
|
+
kind: "app" | "topic";
|
|
124
|
+
value: string;
|
|
125
|
+
}[];
|
|
126
|
+
slug: string;
|
|
127
|
+
ownerNames?: string[] | undefined;
|
|
128
|
+
};
|
|
129
|
+
output: _igstack_app_catalog_shared_core0.CommunityTool;
|
|
130
|
+
meta: object;
|
|
131
|
+
}>;
|
|
132
|
+
archive: _trpc_server0.TRPCMutationProcedure<{
|
|
133
|
+
input: {
|
|
134
|
+
slug: string;
|
|
135
|
+
reason: string;
|
|
136
|
+
};
|
|
137
|
+
output: void;
|
|
138
|
+
meta: object;
|
|
139
|
+
}>;
|
|
140
|
+
approve: _trpc_server0.TRPCMutationProcedure<{
|
|
141
|
+
input: {
|
|
142
|
+
slug: string;
|
|
143
|
+
};
|
|
144
|
+
output: void;
|
|
145
|
+
meta: object;
|
|
146
|
+
}>;
|
|
147
|
+
}>>;
|
|
148
|
+
//#endregion
|
|
149
|
+
export { createCommunityToolsRouter };
|
|
150
|
+
//# sourceMappingURL=router.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.mts","names":[],"sources":["../../../src/modules/communityTools/router.ts"],"mappings":";;;;;;;;;;;iBAyCgB,0BAAA,CAA2B,OAAA,GAAU,qBAAA,iBAAqB,eAAA;OAAA,aAAA"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { adminProcedure, protectedProcedure, publicProcedure, router, t } from "../../server/trpcSetup.mjs";
|
|
2
|
+
import { countByApp, getTool, likeTool, listPins, listTools, pinTool, recordView, reorderPins, unlikeTool, unpinTool } from "./service.mjs";
|
|
3
|
+
import { addTool, approveTool, archiveTool, editTool, previewTool, toolInputSchema } from "./writeService.mjs";
|
|
4
|
+
import { TRPCError } from "@trpc/server";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
//#region src/modules/communityTools/router.ts
|
|
8
|
+
function requireActor(actor) {
|
|
9
|
+
if (!actor) throw new TRPCError({
|
|
10
|
+
code: "PRECONDITION_FAILED",
|
|
11
|
+
message: "This action needs cookies enabled"
|
|
12
|
+
});
|
|
13
|
+
return actor;
|
|
14
|
+
}
|
|
15
|
+
function writerOf(ctx) {
|
|
16
|
+
return {
|
|
17
|
+
user: ctx.user,
|
|
18
|
+
isAdmin: ctx.isAdmin,
|
|
19
|
+
actor: requireActor(ctx.actor)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `options.capturer` is used as given: resolving the playwright default is the caller's
|
|
24
|
+
* job (`createAcMiddleware`), so a router built directly never launches a browser.
|
|
25
|
+
*/
|
|
26
|
+
function createCommunityToolsRouter(options) {
|
|
27
|
+
const enabled = options?.enabled ?? false;
|
|
28
|
+
const deps = {
|
|
29
|
+
captureHosts: options?.captureHosts ?? [],
|
|
30
|
+
capturer: options?.capturer ?? null
|
|
31
|
+
};
|
|
32
|
+
const whenEnabled = t.middleware(({ next }) => {
|
|
33
|
+
if (!enabled) throw new TRPCError({
|
|
34
|
+
code: "NOT_FOUND",
|
|
35
|
+
message: "Community tools are disabled"
|
|
36
|
+
});
|
|
37
|
+
return next();
|
|
38
|
+
});
|
|
39
|
+
const publicProcedure$1 = publicProcedure.use(whenEnabled);
|
|
40
|
+
const protectedProcedure$1 = protectedProcedure.use(whenEnabled);
|
|
41
|
+
const adminProcedure$1 = adminProcedure.use(whenEnabled);
|
|
42
|
+
return router({
|
|
43
|
+
config: publicProcedure.query(() => ({ enabled })),
|
|
44
|
+
list: publicProcedure$1.query(({ ctx }) => {
|
|
45
|
+
return listTools(ctx.user, ctx.isAdmin, /* @__PURE__ */ new Date(), deps);
|
|
46
|
+
}),
|
|
47
|
+
countByApp: publicProcedure$1.query(() => countByApp()),
|
|
48
|
+
get: publicProcedure$1.input(z.object({ slug: z.string() })).query(async ({ input, ctx }) => {
|
|
49
|
+
const tool = await getTool(input.slug, ctx.user, ctx.isAdmin, /* @__PURE__ */ new Date(), deps);
|
|
50
|
+
if (!tool) throw new TRPCError({
|
|
51
|
+
code: "NOT_FOUND",
|
|
52
|
+
message: "Tool not found"
|
|
53
|
+
});
|
|
54
|
+
return tool;
|
|
55
|
+
}),
|
|
56
|
+
recordView: publicProcedure$1.input(z.object({ slug: z.string() })).mutation(({ input, ctx }) => {
|
|
57
|
+
const actor = requireActor(ctx.actor);
|
|
58
|
+
return recordView(input.slug, actor, ctx.user, /* @__PURE__ */ new Date());
|
|
59
|
+
}),
|
|
60
|
+
pins: protectedProcedure$1.query(({ ctx }) => {
|
|
61
|
+
return listPins(ctx.user.id, ctx.user, ctx.isAdmin, /* @__PURE__ */ new Date(), deps);
|
|
62
|
+
}),
|
|
63
|
+
pin: protectedProcedure$1.input(z.object({ slug: z.string() })).mutation(({ input, ctx }) => {
|
|
64
|
+
return pinTool(input.slug, ctx.user.id);
|
|
65
|
+
}),
|
|
66
|
+
like: protectedProcedure$1.input(z.object({ slug: z.string() })).mutation(({ input, ctx }) => {
|
|
67
|
+
return likeTool(input.slug, ctx.user);
|
|
68
|
+
}),
|
|
69
|
+
unlike: protectedProcedure$1.input(z.object({ slug: z.string() })).mutation(({ input, ctx }) => {
|
|
70
|
+
return unlikeTool(input.slug, ctx.user.id);
|
|
71
|
+
}),
|
|
72
|
+
unpin: protectedProcedure$1.input(z.object({ slug: z.string() })).mutation(({ input, ctx }) => {
|
|
73
|
+
return unpinTool(input.slug, ctx.user.id);
|
|
74
|
+
}),
|
|
75
|
+
reorderPins: protectedProcedure$1.input(z.object({ slugs: z.array(z.string()) })).mutation(({ input, ctx }) => {
|
|
76
|
+
return reorderPins(input.slugs, ctx.user.id);
|
|
77
|
+
}),
|
|
78
|
+
preview: protectedProcedure$1.input(z.object({ url: z.string().max(2e3) })).mutation(({ input }) => previewTool(input.url, deps)),
|
|
79
|
+
add: protectedProcedure$1.input(toolInputSchema).mutation(({ input, ctx }) => addTool(input, writerOf(ctx), deps, /* @__PURE__ */ new Date())),
|
|
80
|
+
edit: protectedProcedure$1.input(toolInputSchema.extend({ slug: z.string() })).mutation(({ input, ctx }) => editTool(input, writerOf(ctx), deps, /* @__PURE__ */ new Date())),
|
|
81
|
+
archive: protectedProcedure$1.input(z.object({
|
|
82
|
+
slug: z.string(),
|
|
83
|
+
reason: z.string().trim().min(1).max(500)
|
|
84
|
+
})).mutation(({ input, ctx }) => archiveTool(input.slug, input.reason, writerOf(ctx), /* @__PURE__ */ new Date())),
|
|
85
|
+
approve: adminProcedure$1.input(z.object({ slug: z.string() })).mutation(({ input, ctx }) => approveTool(input.slug, writerOf(ctx), /* @__PURE__ */ new Date()))
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//#endregion
|
|
90
|
+
export { createCommunityToolsRouter };
|
|
91
|
+
//# sourceMappingURL=router.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.mjs","names":["publicProcedure","publicBase","protectedProcedure","protectedBase","adminProcedure","adminBase","service.listTools","service.countByApp","service.getTool","service.recordView","service.listPins","service.pinTool","service.likeTool","service.unlikeTool","service.unpinTool","service.reorderPins","write.previewTool","write.toolInputSchema","write.addTool","write.editTool","write.archiveTool","write.approveTool"],"sources":["../../../src/modules/communityTools/router.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport { z } from 'zod'\nimport type { User } from 'better-auth/types'\nimport type { CommunityToolsOptions } from '../../middleware/types'\nimport {\n adminProcedure as adminBase,\n protectedProcedure as protectedBase,\n publicProcedure as publicBase,\n router,\n t,\n} from '../../server/trpcSetup'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport * as service from './service'\nimport * as write from './writeService'\n\nfunction requireActor(actor: Actor | null): Actor {\n if (!actor) {\n throw new TRPCError({\n code: 'PRECONDITION_FAILED',\n message: 'This action needs cookies enabled',\n })\n }\n return actor\n}\n\nfunction writerOf(ctx: {\n user: User\n isAdmin: boolean\n actor: Actor | null\n}): write.Writer {\n return {\n user: ctx.user,\n isAdmin: ctx.isAdmin,\n actor: requireActor(ctx.actor),\n }\n}\n\n/**\n * `options.capturer` is used as given: resolving the playwright default is the caller's\n * job (`createAcMiddleware`), so a router built directly never launches a browser.\n */\nexport function createCommunityToolsRouter(options?: CommunityToolsOptions) {\n const enabled = options?.enabled ?? false\n const deps = {\n captureHosts: options?.captureHosts ?? [],\n capturer: options?.capturer ?? null,\n }\n\n // One router shape whether enabled or not, so clients see every procedure in the\n // type; when disabled every procedure but `config` answers NOT_FOUND.\n const whenEnabled = t.middleware(({ next }) => {\n if (!enabled) {\n throw new TRPCError({\n code: 'NOT_FOUND',\n message: 'Community tools are disabled',\n })\n }\n return next()\n })\n const publicProcedure = publicBase.use(whenEnabled)\n const protectedProcedure = protectedBase.use(whenEnabled)\n const adminProcedure = adminBase.use(whenEnabled)\n\n return router({\n config: publicBase.query(() => ({ enabled })),\n\n list: publicProcedure.query(({ ctx }) => {\n return service.listTools(ctx.user, ctx.isAdmin, new Date(), deps)\n }),\n\n countByApp: publicProcedure.query(() => service.countByApp()),\n\n get: publicProcedure\n .input(z.object({ slug: z.string() }))\n .query(async ({ input, ctx }) => {\n const tool = await service.getTool(\n input.slug,\n ctx.user,\n ctx.isAdmin,\n new Date(),\n deps,\n )\n if (!tool) {\n throw new TRPCError({\n code: 'NOT_FOUND',\n message: 'Tool not found',\n })\n }\n return tool\n }),\n\n recordView: publicProcedure\n .input(z.object({ slug: z.string() }))\n .mutation(({ input, ctx }) => {\n const actor = requireActor(ctx.actor)\n return service.recordView(input.slug, actor, ctx.user, new Date())\n }),\n\n pins: protectedProcedure.query(({ ctx }) => {\n return service.listPins(\n ctx.user.id,\n ctx.user,\n ctx.isAdmin,\n new Date(),\n deps,\n )\n }),\n\n pin: protectedProcedure\n .input(z.object({ slug: z.string() }))\n .mutation(({ input, ctx }) => {\n return service.pinTool(input.slug, ctx.user.id)\n }),\n\n like: protectedProcedure\n .input(z.object({ slug: z.string() }))\n .mutation(({ input, ctx }) => {\n return service.likeTool(input.slug, ctx.user)\n }),\n\n unlike: protectedProcedure\n .input(z.object({ slug: z.string() }))\n .mutation(({ input, ctx }) => {\n return service.unlikeTool(input.slug, ctx.user.id)\n }),\n\n unpin: protectedProcedure\n .input(z.object({ slug: z.string() }))\n .mutation(({ input, ctx }) => {\n return service.unpinTool(input.slug, ctx.user.id)\n }),\n\n reorderPins: protectedProcedure\n .input(z.object({ slugs: z.array(z.string()) }))\n .mutation(({ input, ctx }) => {\n return service.reorderPins(input.slugs, ctx.user.id)\n }),\n\n preview: protectedProcedure\n .input(z.object({ url: z.string().max(2000) }))\n .mutation(({ input }) => write.previewTool(input.url, deps)),\n\n add: protectedProcedure\n .input(write.toolInputSchema)\n .mutation(({ input, ctx }) =>\n write.addTool(input, writerOf(ctx), deps, new Date()),\n ),\n\n edit: protectedProcedure\n .input(write.toolInputSchema.extend({ slug: z.string() }))\n .mutation(({ input, ctx }) =>\n write.editTool(input, writerOf(ctx), deps, new Date()),\n ),\n\n archive: protectedProcedure\n .input(\n z.object({\n slug: z.string(),\n reason: z.string().trim().min(1).max(500),\n }),\n )\n .mutation(({ input, ctx }) =>\n write.archiveTool(input.slug, input.reason, writerOf(ctx), new Date()),\n ),\n\n approve: adminProcedure\n .input(z.object({ slug: z.string() }))\n .mutation(({ input, ctx }) =>\n write.approveTool(input.slug, writerOf(ctx), new Date()),\n ),\n })\n}\n"],"mappings":"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"}
|