@hazem3500/komo 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/NODE.md +80 -0
- package/NOTICE.md +45 -0
- package/README.md +411 -2
- package/cli/agent.mjs +615 -0
- package/cli/config.mjs +34 -0
- package/cli/index.mjs +405 -0
- package/cli/project.mjs +136 -0
- package/cli/workflow.mjs +42 -0
- package/dist/MorphingMenu.d.ts +30 -0
- package/dist/PointerIcon.d.ts +2 -0
- package/dist/SmileIcon.d.ts +2 -0
- package/dist/THIRD_PARTY_NOTICES.txt +387 -0
- package/dist/accent-picker-F5UI2NP7.js +1 -0
- package/dist/accent-picker.d.ts +4 -0
- package/dist/accent.d.ts +2 -0
- package/dist/account-usage.d.ts +2 -0
- package/dist/action-menu.d.ts +10 -0
- package/dist/agent-prompt.d.ts +6 -0
- package/dist/agent-prompt.js +1 -0
- package/dist/anchors.d.ts +48 -0
- package/dist/api.d.ts +39 -0
- package/dist/approved-sites.d.ts +9 -0
- package/dist/card-motion.d.ts +8 -0
- package/dist/chunk-7YUXYVQA.js +1 -0
- package/dist/chunk-I7AUKTXE.js +1 -0
- package/dist/chunk-JQVBZ2BB.js +1 -0
- package/dist/chunk-Q7WSWLMV.js +1 -0
- package/dist/chunk-Y2GRLW4Q.js +1 -0
- package/dist/chunk-YZP3KX4Y.js +1 -0
- package/dist/chunk-Z2ABBESO.js +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/connect-project.d.ts +14 -0
- package/dist/connection-issue.d.ts +7 -0
- package/dist/design-comment-QPTW66JO.js +1 -0
- package/dist/design-comment.d.ts +42 -0
- package/dist/design-export-BRBI7S3V.js +1 -0
- package/dist/design-export.d.ts +8 -0
- package/dist/design-mode-styles.d.ts +1 -0
- package/dist/design-mode.d.ts +19 -0
- package/dist/design-mode.js +1 -0
- package/dist/design-panel-BPVXFERR.js +1 -0
- package/dist/design-panel-styles.d.ts +1 -0
- package/dist/design-panel.d.ts +33 -0
- package/dist/design-tokens.d.ts +76 -0
- package/dist/dom.d.ts +20 -0
- package/dist/drawer-expand-motion.d.ts +46 -0
- package/dist/drawer-presence.d.ts +4 -0
- package/dist/edge-sidebar.d.ts +39 -0
- package/dist/emoji-history.d.ts +8 -0
- package/dist/emoji.d.ts +1 -0
- package/dist/floating-drag.d.ts +9 -0
- package/dist/icon-markup.d.ts +4 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1 -0
- package/dist/inline-text-editor-F7B6FQQ3.js +1 -0
- package/dist/inline-text-editor.d.ts +70 -0
- package/dist/lazy-accent-picker.d.ts +4 -0
- package/dist/lazy-toolbar.d.ts +19 -0
- package/dist/message-links.d.ts +2 -0
- package/dist/morphing-menu-styles.d.ts +1 -0
- package/dist/node.mjs +2607 -0
- package/dist/onboarding-NO3RQWR7.js +1 -0
- package/dist/onboarding.d.ts +6 -0
- package/dist/optimistic.d.ts +26 -0
- package/dist/page.d.ts +2 -0
- package/dist/picker-WQN53YJ5.js +1 -0
- package/dist/pin-direction.d.ts +12 -0
- package/dist/pin-stacks.d.ts +3 -0
- package/dist/polling.d.ts +10 -0
- package/dist/postgres/001_initial.sql +128 -0
- package/dist/postgres/002_thread_tags.sql +1 -0
- package/dist/postgres/003_project_webhooks.sql +4 -0
- package/dist/project-management.d.ts +2 -0
- package/dist/react-inspector.d.ts +43 -0
- package/dist/react-props-panel-XSMDR2CY.js +1 -0
- package/dist/react-props-panel.d.ts +39 -0
- package/dist/react.d.ts +38 -0
- package/dist/react.js +1 -0
- package/dist/reaction-picker.d.ts +1 -0
- package/dist/review-layout.d.ts +23 -0
- package/dist/setup-browser.d.ts +1 -0
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +1 -0
- package/dist/shortcuts.d.ts +15 -0
- package/dist/static-svg.d.ts +3 -0
- package/dist/styles.d.ts +1 -0
- package/dist/tag-editor-VDPZ6CO3.js +1 -0
- package/dist/tag-editor.d.ts +12 -0
- package/dist/toolbar-runtime-2SIUE6SQ.js +1 -0
- package/dist/toolbar-runtime.d.ts +5 -0
- package/dist/types.d.ts +163 -0
- package/package.json +114 -4
- package/server/digest.ts +8 -0
- package/server/import-project.ts +164 -0
- package/server/index.ts +1656 -0
- package/server/migrations/0001_comments.sql +54 -0
- package/server/migrations/0002_identity.sql +2 -0
- package/server/migrations/0003_accent_color.sql +1 -0
- package/server/migrations/0004_workspaces.sql +46 -0
- package/server/migrations/0005_domains.sql +6 -0
- package/server/migrations/0006_member_budget.sql +16 -0
- package/server/migrations/0007_revisions.sql +17 -0
- package/server/migrations/0008_starter_comments.sql +1 -0
- package/server/migrations/0009_retention.sql +19 -0
- package/server/migrations/0010_reviewer_history.sql +1 -0
- package/server/migrations/0011_project_management.sql +52 -0
- package/server/migrations/0012_setup_sites.sql +3 -0
- package/server/migrations/0013_project_sites.sql +8 -0
- package/server/migrations/0014_project_site_removals.sql +3 -0
- package/server/migrations/0015_query_efficiency.sql +6 -0
- package/server/migrations/0016_thread_tags.sql +1 -0
- package/server/migrations/0017_project_webhooks.sql +4 -0
- package/server/node-webhooks.ts +126 -0
- package/server/project-management.ts +387 -0
- package/server/project-sites.ts +117 -0
- package/server/secrets.d.ts +24 -0
- package/server/setup-client.txt +2932 -0
- package/server/setup-page.ts +14 -0
- package/server/tsconfig.json +12 -0
- package/server/validation.ts +220 -0
- package/server/webhooks.ts +206 -0
- package/server/workspaces.ts +168 -0
- package/src/emoji.ts +7 -0
- package/src/page.ts +4 -0
- package/src/types.ts +166 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { importProject } from "./import-project";
|
|
2
|
+
import { projectSites, saveProjectSites } from "./project-sites";
|
|
3
|
+
import { check, string } from "./validation";
|
|
4
|
+
import { googleOwner } from "./workspaces";
|
|
5
|
+
import type { Identity } from "../src/types";
|
|
6
|
+
|
|
7
|
+
import { digest } from "./digest";
|
|
8
|
+
import {
|
|
9
|
+
encryptWebhookSecret,
|
|
10
|
+
validateWebhookUrl,
|
|
11
|
+
WEBHOOK_EVENTS,
|
|
12
|
+
} from "./webhooks";
|
|
13
|
+
export async function privateAccess(env: Env, project: string) {
|
|
14
|
+
return (
|
|
15
|
+
(
|
|
16
|
+
await env.DB.prepare(
|
|
17
|
+
"SELECT access FROM project_settings WHERE project=?"
|
|
18
|
+
)
|
|
19
|
+
.bind(project)
|
|
20
|
+
.first<{ access: string }>()
|
|
21
|
+
)?.access === "private"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
export async function requireMember(env: Env, project: string, user: Identity) {
|
|
25
|
+
check(
|
|
26
|
+
user.verified && user.id.startsWith("google:"),
|
|
27
|
+
403,
|
|
28
|
+
"Sign in with your invited Google account."
|
|
29
|
+
);
|
|
30
|
+
const access = await env.DB.prepare(
|
|
31
|
+
"SELECT user_id FROM project_owners WHERE project=? AND user_id=? UNION SELECT user_id FROM project_access WHERE project=? AND user_id=?"
|
|
32
|
+
)
|
|
33
|
+
.bind(project, user.id, project, user.id)
|
|
34
|
+
.first();
|
|
35
|
+
check(
|
|
36
|
+
access,
|
|
37
|
+
403,
|
|
38
|
+
"This project is invite-only. Ask its owner for an invitation."
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function manageProject(
|
|
43
|
+
request: Request,
|
|
44
|
+
env: Env,
|
|
45
|
+
project: string,
|
|
46
|
+
user: Identity,
|
|
47
|
+
readBody: () => Promise<Record<string, unknown>>
|
|
48
|
+
): Promise<Response> {
|
|
49
|
+
const url = new URL(request.url),
|
|
50
|
+
path = url.pathname;
|
|
51
|
+
if (path === "/project/join" && request.method === "POST") {
|
|
52
|
+
check(
|
|
53
|
+
user.verified && user.id.startsWith("google:"),
|
|
54
|
+
403,
|
|
55
|
+
"Sign in with Google to accept an invitation."
|
|
56
|
+
);
|
|
57
|
+
const data = await readBody();
|
|
58
|
+
const email = (
|
|
59
|
+
await env.DB.prepare("SELECT email FROM users WHERE id=?")
|
|
60
|
+
.bind(user.id)
|
|
61
|
+
.first<{ email: string }>()
|
|
62
|
+
)?.email;
|
|
63
|
+
check(
|
|
64
|
+
email,
|
|
65
|
+
403,
|
|
66
|
+
"Sign out and sign in with Google again to verify your email."
|
|
67
|
+
);
|
|
68
|
+
const tokenHash = await digest(string(data.invite, 200, "invitation"));
|
|
69
|
+
const result = await env.DB.batch([
|
|
70
|
+
env.DB.prepare(
|
|
71
|
+
"INSERT OR IGNORE INTO project_access(project,user_id) SELECT project,? FROM project_invites WHERE token_hash=? AND project=? AND email=? AND expires_at>?"
|
|
72
|
+
).bind(user.id, tokenHash, project, email, Date.now()),
|
|
73
|
+
env.DB.prepare(
|
|
74
|
+
"INSERT OR IGNORE INTO project_members(project,user_id) SELECT project,? FROM project_invites WHERE token_hash=? AND project=? AND email=? AND expires_at>?"
|
|
75
|
+
).bind(user.id, tokenHash, project, email, Date.now()),
|
|
76
|
+
env.DB.prepare(
|
|
77
|
+
"DELETE FROM project_invites WHERE token_hash=? AND project=? AND email=? AND expires_at>?"
|
|
78
|
+
).bind(tokenHash, project, email, Date.now()),
|
|
79
|
+
]);
|
|
80
|
+
check(
|
|
81
|
+
result[2].meta.changes,
|
|
82
|
+
403,
|
|
83
|
+
"Invitation expired, already used, or belongs to another Google account."
|
|
84
|
+
);
|
|
85
|
+
return Response.json({ ok: true });
|
|
86
|
+
}
|
|
87
|
+
await googleOwner(env, project, user);
|
|
88
|
+
if (path === "/project/webhook" && request.method === "GET") {
|
|
89
|
+
const row = await env.DB.prepare(
|
|
90
|
+
"SELECT webhook_url,webhook_secret,webhook_events,webhook_enabled FROM project_settings WHERE project=?"
|
|
91
|
+
)
|
|
92
|
+
.bind(project)
|
|
93
|
+
.first<{
|
|
94
|
+
webhook_url: string | null;
|
|
95
|
+
webhook_secret: string | null;
|
|
96
|
+
webhook_events: string | null;
|
|
97
|
+
webhook_enabled: number | null;
|
|
98
|
+
}>();
|
|
99
|
+
return Response.json({
|
|
100
|
+
url: row?.webhook_url ?? "",
|
|
101
|
+
events: JSON.parse(row?.webhook_events ?? "[]"),
|
|
102
|
+
enabled: !!row?.webhook_enabled,
|
|
103
|
+
secretConfigured: !!row?.webhook_secret,
|
|
104
|
+
deliveryAvailable:
|
|
105
|
+
typeof env.KOMO_WEBHOOK_SEND === "function" && !!env.KOMO_WEBHOOK_KEYS,
|
|
106
|
+
supportedEvents: WEBHOOK_EVENTS,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (path === "/project/webhook" && request.method === "PATCH") {
|
|
110
|
+
const data = await readBody();
|
|
111
|
+
const current = await env.DB.prepare(
|
|
112
|
+
"SELECT webhook_url,webhook_secret FROM project_settings WHERE project=?"
|
|
113
|
+
)
|
|
114
|
+
.bind(project)
|
|
115
|
+
.first<{ webhook_url: string | null; webhook_secret: string | null }>();
|
|
116
|
+
let url: string;
|
|
117
|
+
try {
|
|
118
|
+
url = validateWebhookUrl(data.url);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
check(false, 400, error instanceof Error ? error.message : "Invalid webhook URL.");
|
|
121
|
+
}
|
|
122
|
+
check(Array.isArray(data.events), 400, "Choose webhook events.");
|
|
123
|
+
const events = [...new Set(data.events)];
|
|
124
|
+
check(
|
|
125
|
+
events.length > 0 &&
|
|
126
|
+
events.every(
|
|
127
|
+
(event): event is (typeof WEBHOOK_EVENTS)[number] =>
|
|
128
|
+
typeof event === "string" &&
|
|
129
|
+
(WEBHOOK_EVENTS as readonly string[]).includes(event)
|
|
130
|
+
),
|
|
131
|
+
400,
|
|
132
|
+
"Choose supported webhook events."
|
|
133
|
+
);
|
|
134
|
+
check(typeof data.enabled === "boolean", 400, "Invalid webhook state.");
|
|
135
|
+
let secret = current?.webhook_secret ?? null;
|
|
136
|
+
if (data.secret !== undefined && data.secret !== "") {
|
|
137
|
+
check(typeof data.secret === "string", 400, "Invalid signing secret.");
|
|
138
|
+
check(env.KOMO_WEBHOOK_KEYS, 503, "Webhook encryption is not configured on this server.");
|
|
139
|
+
try {
|
|
140
|
+
secret = await encryptWebhookSecret(env.KOMO_WEBHOOK_KEYS, project, data.secret);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
check(false, 400, error instanceof Error ? error.message : "Invalid signing secret.");
|
|
143
|
+
}
|
|
144
|
+
} else if (current?.webhook_url && current.webhook_url !== url) {
|
|
145
|
+
check(false, 400, "Enter a new signing secret when changing the webhook URL.");
|
|
146
|
+
}
|
|
147
|
+
if (data.enabled) {
|
|
148
|
+
check(
|
|
149
|
+
typeof env.KOMO_WEBHOOK_SEND === "function" && !!env.KOMO_WEBHOOK_KEYS,
|
|
150
|
+
503,
|
|
151
|
+
"Secure webhook delivery is unavailable on this server."
|
|
152
|
+
);
|
|
153
|
+
check(secret, 400, "Enter a signing secret before enabling webhooks.");
|
|
154
|
+
}
|
|
155
|
+
await env.DB.prepare(
|
|
156
|
+
"INSERT INTO project_settings(project,access,webhook_url,webhook_secret,webhook_events,webhook_enabled) VALUES(?,COALESCE((SELECT access FROM project_settings WHERE project=?),'public'),?,?,?,?) ON CONFLICT(project) DO UPDATE SET webhook_url=excluded.webhook_url,webhook_secret=excluded.webhook_secret,webhook_events=excluded.webhook_events,webhook_enabled=excluded.webhook_enabled"
|
|
157
|
+
)
|
|
158
|
+
.bind(project, project, url, secret, JSON.stringify(events), Number(data.enabled))
|
|
159
|
+
.run();
|
|
160
|
+
return Response.json({ ok: true, secretConfigured: !!secret });
|
|
161
|
+
}
|
|
162
|
+
// `fixed` stays empty for widgets released before every site was removable.
|
|
163
|
+
if (path === "/project/sites" && request.method === "GET")
|
|
164
|
+
return Response.json({
|
|
165
|
+
sites: await projectSites(env, project),
|
|
166
|
+
fixed: [],
|
|
167
|
+
});
|
|
168
|
+
if (path === "/project/sites" && request.method === "PATCH") {
|
|
169
|
+
const managed = url.searchParams.get("project") === "_komo";
|
|
170
|
+
const sites = await saveProjectSites(
|
|
171
|
+
env,
|
|
172
|
+
project,
|
|
173
|
+
(await readBody()).sites,
|
|
174
|
+
managed ? undefined : (request.headers.get("Origin") ?? undefined)
|
|
175
|
+
);
|
|
176
|
+
return Response.json({ sites, fixed: [] });
|
|
177
|
+
}
|
|
178
|
+
if (path === "/project/import" && request.method === "POST")
|
|
179
|
+
return importProject(env, project, await readBody());
|
|
180
|
+
if (path === "/project" && request.method === "GET") {
|
|
181
|
+
const members = await env.DB.prepare(
|
|
182
|
+
"SELECT u.id,u.name,u.email FROM project_access a JOIN users u ON u.id=a.user_id WHERE a.project=? ORDER BY u.name"
|
|
183
|
+
)
|
|
184
|
+
.bind(project)
|
|
185
|
+
.all();
|
|
186
|
+
const invites = await env.DB.prepare(
|
|
187
|
+
"SELECT email,expires_at FROM project_invites WHERE project=? AND expires_at>? ORDER BY email"
|
|
188
|
+
)
|
|
189
|
+
.bind(project, Date.now())
|
|
190
|
+
.all();
|
|
191
|
+
const hosted = !!(await env.DB.prepare(
|
|
192
|
+
"SELECT id FROM workspaces WHERE id=?"
|
|
193
|
+
)
|
|
194
|
+
.bind(project)
|
|
195
|
+
.first());
|
|
196
|
+
return Response.json({
|
|
197
|
+
project,
|
|
198
|
+
access: (await privateAccess(env, project)) ? "private" : "public",
|
|
199
|
+
members: members.results,
|
|
200
|
+
invites: invites.results,
|
|
201
|
+
hosted,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (path === "/project" && request.method === "PATCH") {
|
|
205
|
+
const data = await readBody();
|
|
206
|
+
check(
|
|
207
|
+
data.access === "public" || data.access === "private",
|
|
208
|
+
400,
|
|
209
|
+
"Choose public or private access."
|
|
210
|
+
);
|
|
211
|
+
await env.DB.prepare(
|
|
212
|
+
"INSERT INTO project_settings(project,access) VALUES(?,?) ON CONFLICT(project) DO UPDATE SET access=excluded.access"
|
|
213
|
+
)
|
|
214
|
+
.bind(project, data.access)
|
|
215
|
+
.run();
|
|
216
|
+
return Response.json({ ok: true });
|
|
217
|
+
}
|
|
218
|
+
if (path === "/project/invites" && request.method === "POST") {
|
|
219
|
+
const data = await readBody(),
|
|
220
|
+
email = string(data.email, 254, "email").toLowerCase();
|
|
221
|
+
check(
|
|
222
|
+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
|
|
223
|
+
400,
|
|
224
|
+
"Enter a valid email address."
|
|
225
|
+
);
|
|
226
|
+
const invite = crypto.randomUUID() + crypto.randomUUID();
|
|
227
|
+
const result = await env.DB.prepare(
|
|
228
|
+
"INSERT INTO project_invites(token_hash,project,email,expires_at) SELECT ?,?,?,? WHERE (SELECT COUNT(*) FROM project_invites WHERE project=? AND expires_at>?)<50"
|
|
229
|
+
)
|
|
230
|
+
.bind(
|
|
231
|
+
await digest(invite),
|
|
232
|
+
project,
|
|
233
|
+
email,
|
|
234
|
+
Date.now() + 7 * 86400000,
|
|
235
|
+
project,
|
|
236
|
+
Date.now()
|
|
237
|
+
)
|
|
238
|
+
.run();
|
|
239
|
+
check(
|
|
240
|
+
result.meta.changes,
|
|
241
|
+
409,
|
|
242
|
+
"This project has 50 pending invitations. Revoke an invitation first."
|
|
243
|
+
);
|
|
244
|
+
return Response.json(
|
|
245
|
+
{
|
|
246
|
+
invite,
|
|
247
|
+
url: `${url.origin}/setup?project=${encodeURIComponent(project)}#invite=${encodeURIComponent(invite)}`,
|
|
248
|
+
expiresInDays: 7,
|
|
249
|
+
},
|
|
250
|
+
{ status: 201 }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (path === "/project/invites" && request.method === "DELETE") {
|
|
254
|
+
const data = await readBody();
|
|
255
|
+
await env.DB.prepare(
|
|
256
|
+
"DELETE FROM project_invites WHERE project=? AND email=?"
|
|
257
|
+
)
|
|
258
|
+
.bind(project, string(data.email, 254, "email").toLowerCase())
|
|
259
|
+
.run();
|
|
260
|
+
return Response.json({ ok: true });
|
|
261
|
+
}
|
|
262
|
+
if (path === "/project/members" && request.method === "DELETE") {
|
|
263
|
+
const data = await readBody(),
|
|
264
|
+
member = string(data.user, 150, "member");
|
|
265
|
+
check(member !== user.id, 400, "The owner cannot be removed.");
|
|
266
|
+
await env.DB.batch([
|
|
267
|
+
env.DB.prepare(
|
|
268
|
+
"DELETE FROM project_access WHERE project=? AND user_id=?"
|
|
269
|
+
).bind(project, member),
|
|
270
|
+
env.DB.prepare("DELETE FROM sessions WHERE project=? AND user_id=?").bind(
|
|
271
|
+
project,
|
|
272
|
+
member
|
|
273
|
+
),
|
|
274
|
+
]);
|
|
275
|
+
return Response.json({ ok: true });
|
|
276
|
+
}
|
|
277
|
+
if (path === "/project/export" && request.method === "GET") {
|
|
278
|
+
const table = url.searchParams.get("table") ?? "threads";
|
|
279
|
+
const queries: Record<string, string> = {
|
|
280
|
+
threads:
|
|
281
|
+
"SELECT id,repo,branch,page,anchor,resolved,resolved_by,created_at,updated_at FROM threads WHERE project=? ORDER BY id",
|
|
282
|
+
comments:
|
|
283
|
+
"SELECT c.id,c.thread_id,c.user_id,c.body,c.created_at,c.edited_at FROM comments c JOIN threads t ON t.id=c.thread_id WHERE t.project=? ORDER BY c.id",
|
|
284
|
+
reactions:
|
|
285
|
+
"SELECT r.* FROM reactions r JOIN comments c ON c.id=r.comment_id JOIN threads t ON t.id=c.thread_id WHERE t.project=? ORDER BY r.comment_id,r.user_id,r.emoji",
|
|
286
|
+
users:
|
|
287
|
+
"SELECT DISTINCT u.id,u.name,u.verified,u.avatar_url,u.accent_color FROM users u WHERE u.id IN (SELECT c.user_id FROM comments c JOIN threads t ON t.id=c.thread_id WHERE t.project=?) OR u.id IN (SELECT resolved_by FROM threads WHERE project=?) OR u.id IN (SELECT r.user_id FROM reactions r JOIN comments c ON c.id=r.comment_id JOIN threads t ON t.id=c.thread_id WHERE t.project=?) ORDER BY u.id",
|
|
288
|
+
};
|
|
289
|
+
check(Object.hasOwn(queries, table), 400, "Invalid export table.");
|
|
290
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
291
|
+
check(
|
|
292
|
+
Number.isInteger(offset) && offset >= 0 && offset <= 1000000,
|
|
293
|
+
400,
|
|
294
|
+
"Invalid export offset."
|
|
295
|
+
);
|
|
296
|
+
const stmt = env.DB.prepare(`${queries[table]} LIMIT 200 OFFSET ?`);
|
|
297
|
+
const [versionResult, rows] = await env.DB.batch([
|
|
298
|
+
env.DB.prepare(
|
|
299
|
+
"SELECT COALESCE((SELECT version FROM export_revisions WHERE project=?),0) AS version"
|
|
300
|
+
).bind(project),
|
|
301
|
+
table === "users"
|
|
302
|
+
? stmt.bind(project, project, project, offset)
|
|
303
|
+
: stmt.bind(project, offset),
|
|
304
|
+
]);
|
|
305
|
+
const revision = (versionResult.results[0] as { version: number }).version;
|
|
306
|
+
const expected = url.searchParams.get("revision");
|
|
307
|
+
check(
|
|
308
|
+
expected === null || expected === String(revision),
|
|
309
|
+
409,
|
|
310
|
+
"Comments changed during export. Please retry the export."
|
|
311
|
+
);
|
|
312
|
+
return Response.json({
|
|
313
|
+
revision,
|
|
314
|
+
format: "komo-export",
|
|
315
|
+
version: 1,
|
|
316
|
+
project,
|
|
317
|
+
table,
|
|
318
|
+
rows: rows.results,
|
|
319
|
+
next: rows.results.length === 200 ? offset + 200 : null,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (path === "/project/clear-resolved" && request.method === "POST") {
|
|
323
|
+
const data = await readBody();
|
|
324
|
+
check(
|
|
325
|
+
data.confirm === project,
|
|
326
|
+
400,
|
|
327
|
+
"Confirm the project key to permanently remove resolved threads."
|
|
328
|
+
);
|
|
329
|
+
const ids = data.threadIds;
|
|
330
|
+
check(
|
|
331
|
+
ids === undefined ||
|
|
332
|
+
(Array.isArray(ids) &&
|
|
333
|
+
ids.length > 0 &&
|
|
334
|
+
ids.length <= 250 &&
|
|
335
|
+
ids.every((id) => typeof id === "string" && id.length <= 100)),
|
|
336
|
+
400,
|
|
337
|
+
"Choose 1–250 resolved threads."
|
|
338
|
+
);
|
|
339
|
+
const result = await env.DB.prepare(
|
|
340
|
+
"DELETE FROM threads WHERE project=? AND resolved=1" +
|
|
341
|
+
(ids ? " AND id IN (SELECT value FROM json_each(?))" : "") +
|
|
342
|
+
" RETURNING id"
|
|
343
|
+
)
|
|
344
|
+
.bind(...(ids ? [project, JSON.stringify(ids)] : [project]))
|
|
345
|
+
.all();
|
|
346
|
+
return Response.json({ ok: true, deleted: result.results.length });
|
|
347
|
+
}
|
|
348
|
+
if (path === "/project" && request.method === "DELETE") {
|
|
349
|
+
const data = await readBody();
|
|
350
|
+
check(
|
|
351
|
+
data.confirm === project,
|
|
352
|
+
400,
|
|
353
|
+
"Confirm the project key to permanently delete this project."
|
|
354
|
+
);
|
|
355
|
+
check(
|
|
356
|
+
await env.DB.prepare("SELECT id FROM workspaces WHERE id=?")
|
|
357
|
+
.bind(project)
|
|
358
|
+
.first(),
|
|
359
|
+
400,
|
|
360
|
+
"Self-hosted projects must be removed from the Worker configuration. Export or clear resolved comments here."
|
|
361
|
+
);
|
|
362
|
+
await env.DB.batch([
|
|
363
|
+
env.DB.prepare("DELETE FROM threads WHERE project=?").bind(project),
|
|
364
|
+
...[
|
|
365
|
+
"sessions",
|
|
366
|
+
"project_members",
|
|
367
|
+
"project_access",
|
|
368
|
+
"project_invites",
|
|
369
|
+
"project_settings",
|
|
370
|
+
"project_owners",
|
|
371
|
+
"project_quotas",
|
|
372
|
+
"workspace_domains",
|
|
373
|
+
"scope_revisions",
|
|
374
|
+
"export_revisions",
|
|
375
|
+
].map((table) =>
|
|
376
|
+
env.DB.prepare(`DELETE FROM ${table} WHERE project=?`).bind(project)
|
|
377
|
+
),
|
|
378
|
+
env.DB.prepare("DELETE FROM oauth_states WHERE project=?").bind(project),
|
|
379
|
+
env.DB.prepare("DELETE FROM setup_requests WHERE project=?").bind(
|
|
380
|
+
project
|
|
381
|
+
),
|
|
382
|
+
env.DB.prepare("DELETE FROM workspaces WHERE id=?").bind(project),
|
|
383
|
+
]);
|
|
384
|
+
return Response.json({ ok: true });
|
|
385
|
+
}
|
|
386
|
+
return Response.json({ error: "Unknown project action." }, { status: 404 });
|
|
387
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { check, originAllowed, sitePattern } from "./validation";
|
|
2
|
+
import type { Project } from "./workspaces";
|
|
3
|
+
|
|
4
|
+
const MAX_SITES = 10;
|
|
5
|
+
|
|
6
|
+
type SiteRow = { origin: string; removed?: number };
|
|
7
|
+
|
|
8
|
+
/** Owner edits to a configured project's sites, oldest schema first. */
|
|
9
|
+
export async function siteEdits(env: Env, project: string) {
|
|
10
|
+
const query = (sql: string) =>
|
|
11
|
+
env.DB.prepare(sql).bind(project).all<SiteRow>();
|
|
12
|
+
// Only missing migration schema permits fallback. Operational errors must
|
|
13
|
+
// never restore configured origins that the owner has revoked.
|
|
14
|
+
const missing = (error: unknown, pattern: RegExp) =>
|
|
15
|
+
error instanceof Error && pattern.test(error.message);
|
|
16
|
+
const emptyIfMissing = (error: unknown) => {
|
|
17
|
+
if (missing(error, /no such table: (?:main\.)?project_sites\b/i))
|
|
18
|
+
return { results: [] as SiteRow[] };
|
|
19
|
+
throw error;
|
|
20
|
+
};
|
|
21
|
+
const rows = await query(
|
|
22
|
+
"SELECT origin,removed FROM project_sites WHERE project=?"
|
|
23
|
+
)
|
|
24
|
+
.catch((error: unknown) => {
|
|
25
|
+
if (missing(error, /no such column: (?:project_sites\.)?removed\b/i))
|
|
26
|
+
return query("SELECT origin FROM project_sites WHERE project=?").catch(emptyIfMissing);
|
|
27
|
+
return emptyIfMissing(error);
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
added: rows.results.filter((row) => !row.removed).map((row) => row.origin),
|
|
31
|
+
removed: rows.results.filter((row) => row.removed).map((row) => row.origin),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Apply owner edits on top of a configured project's origins. */
|
|
36
|
+
export function editedOrigins(
|
|
37
|
+
origins: string[],
|
|
38
|
+
edits: { added: string[]; removed: string[] }
|
|
39
|
+
) {
|
|
40
|
+
return [
|
|
41
|
+
...origins.filter((origin) => !edits.removed.includes(origin)),
|
|
42
|
+
...edits.added.filter((origin) => !origins.includes(origin)),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every site allowed to load a project's comments, all owner-editable.
|
|
48
|
+
* Hosted workspaces store them in workspace_domains; configured projects
|
|
49
|
+
* keep their PROJECTS origins with owner edits from project_sites.
|
|
50
|
+
*/
|
|
51
|
+
export async function projectSites(env: Env, project: string) {
|
|
52
|
+
const configured = (JSON.parse(env.PROJECTS) as Record<string, Project>)[
|
|
53
|
+
project
|
|
54
|
+
];
|
|
55
|
+
if (configured)
|
|
56
|
+
return editedOrigins(
|
|
57
|
+
configured.origins,
|
|
58
|
+
await siteEdits(env, project)
|
|
59
|
+
).sort();
|
|
60
|
+
const rows = await env.DB.prepare(
|
|
61
|
+
"SELECT origin FROM workspace_domains WHERE project=? ORDER BY origin"
|
|
62
|
+
)
|
|
63
|
+
.bind(project)
|
|
64
|
+
.all<{ origin: string }>();
|
|
65
|
+
return rows.results.map((row) => row.origin);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function saveProjectSites(
|
|
69
|
+
env: Env,
|
|
70
|
+
project: string,
|
|
71
|
+
value: unknown,
|
|
72
|
+
origin?: string
|
|
73
|
+
) {
|
|
74
|
+
check(Array.isArray(value), 400, "Send a list of sites.");
|
|
75
|
+
const sites = [...new Set(value.map(sitePattern))];
|
|
76
|
+
const before = await projectSites(env, project);
|
|
77
|
+
check(
|
|
78
|
+
!origin || !originAllowed(origin, before) || originAllowed(origin, sites),
|
|
79
|
+
400,
|
|
80
|
+
"You can’t remove the site you’re on."
|
|
81
|
+
);
|
|
82
|
+
const configured = (JSON.parse(env.PROJECTS) as Record<string, Project>)[
|
|
83
|
+
project
|
|
84
|
+
];
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
if (configured) {
|
|
87
|
+
const added = sites.filter((site) => !configured.origins.includes(site));
|
|
88
|
+
const removed = configured.origins.filter((site) => !sites.includes(site));
|
|
89
|
+
check(added.length <= MAX_SITES, 400, `Add up to ${MAX_SITES} sites.`);
|
|
90
|
+
await env.DB.batch([
|
|
91
|
+
env.DB.prepare("DELETE FROM project_sites WHERE project=?").bind(project),
|
|
92
|
+
...added.map((site) =>
|
|
93
|
+
env.DB.prepare(
|
|
94
|
+
"INSERT INTO project_sites(project,origin,added_at) VALUES(?,?,?)"
|
|
95
|
+
).bind(project, site, now)
|
|
96
|
+
),
|
|
97
|
+
...removed.map((site) =>
|
|
98
|
+
env.DB.prepare(
|
|
99
|
+
"INSERT INTO project_sites(project,origin,added_at,removed) VALUES(?,?,?,1)"
|
|
100
|
+
).bind(project, site, now)
|
|
101
|
+
),
|
|
102
|
+
]);
|
|
103
|
+
} else {
|
|
104
|
+
check(sites.length <= MAX_SITES, 400, `Approve up to ${MAX_SITES} sites.`);
|
|
105
|
+
await env.DB.batch([
|
|
106
|
+
env.DB.prepare("DELETE FROM workspace_domains WHERE project=?").bind(
|
|
107
|
+
project
|
|
108
|
+
),
|
|
109
|
+
...sites.map((site) =>
|
|
110
|
+
env.DB.prepare(
|
|
111
|
+
"INSERT INTO workspace_domains(project,origin,verified_at) VALUES(?,?,?)"
|
|
112
|
+
).bind(project, site, now)
|
|
113
|
+
),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
return projectSites(env, project);
|
|
117
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface Env {
|
|
2
|
+
GOOGLE_CLIENT_SECRET: string;
|
|
3
|
+
KOMO_WEBHOOK_KEYS?: string;
|
|
4
|
+
KOMO_WEBHOOK_SEND?: (
|
|
5
|
+
url: string,
|
|
6
|
+
body: string,
|
|
7
|
+
headers: Record<string, string>
|
|
8
|
+
) => Promise<void>;
|
|
9
|
+
KOMO_HOSTED?: string;
|
|
10
|
+
KOMO_PAUSED?: string;
|
|
11
|
+
/** Enables Google Cloud IAP sign-in: the IAP backend service audience. */
|
|
12
|
+
KOMO_IAP_AUDIENCE?: string;
|
|
13
|
+
/** Path prefix for IAP sign-in handlers, e.g. "/backoffice". */
|
|
14
|
+
KOMO_AUTH_PREFIX?: string;
|
|
15
|
+
/** Path the app is served under behind a proxy that strips it, e.g. "/komo". */
|
|
16
|
+
KOMO_PUBLIC_PATH?: string;
|
|
17
|
+
EDGE_LIMIT?: RateLimit;
|
|
18
|
+
KOMO_VERSION?: { id: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module "*.txt" {
|
|
22
|
+
const content: string;
|
|
23
|
+
export default content;
|
|
24
|
+
}
|