@rackbops/plugin-tracker 0.6.0 → 0.8.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/README.md +154 -19
- package/dist/plugin.js +783 -110
- package/package.json +4 -4
package/dist/plugin.js
CHANGED
|
@@ -1841,13 +1841,18 @@ function decideAccess(input) {
|
|
|
1841
1841
|
return NOT_REGISTERED;
|
|
1842
1842
|
return null;
|
|
1843
1843
|
}
|
|
1844
|
-
function
|
|
1844
|
+
function parseGuildIds(raw) {
|
|
1845
1845
|
if (raw === undefined || raw.trim() === "")
|
|
1846
1846
|
return null;
|
|
1847
|
-
const
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1847
|
+
const ids = [];
|
|
1848
|
+
for (const entry of raw.split(",")) {
|
|
1849
|
+
const id = entry.trim();
|
|
1850
|
+
if (!/^[0-9]{17,20}$/.test(id))
|
|
1851
|
+
throw new Error(`TRACKER_GUILD_ID: "${id}" is not a Discord server id`);
|
|
1852
|
+
if (!ids.includes(id))
|
|
1853
|
+
ids.push(id);
|
|
1854
|
+
}
|
|
1855
|
+
return ids;
|
|
1851
1856
|
}
|
|
1852
1857
|
|
|
1853
1858
|
// plugins/tracker/src/admissions.ts
|
|
@@ -2372,13 +2377,22 @@ var EPHEMERAL = { flags: MessageFlags.Ephemeral };
|
|
|
2372
2377
|
var NO_MENTIONS = { allowedMentions: { parse: [] } };
|
|
2373
2378
|
var UNKNOWN_MEMBER = 10007;
|
|
2374
2379
|
var UNKNOWN_USER = 10013;
|
|
2375
|
-
async function lookupMembership(interaction,
|
|
2376
|
-
if (
|
|
2380
|
+
async function lookupMembership(interaction, guildIds, discordId, log) {
|
|
2381
|
+
if (guildIds === null)
|
|
2377
2382
|
return "not-checked";
|
|
2378
|
-
if (interaction.guildId
|
|
2383
|
+
if (interaction.guildId !== null && guildIds.includes(interaction.guildId) && interaction.user.id === discordId)
|
|
2379
2384
|
return "member";
|
|
2385
|
+
const lookups = guildIds.map((guildId) => memberOf(interaction.client, guildId, discordId, log));
|
|
2386
|
+
const firstYes = new Promise((resolve) => {
|
|
2387
|
+
for (const l of lookups)
|
|
2388
|
+
l.then((a) => a === "member" && resolve("member"));
|
|
2389
|
+
});
|
|
2390
|
+
const all = Promise.all(lookups).then((answers) => answers.includes("unknown") ? "unknown" : answers.includes("member") ? "member" : "not-member");
|
|
2391
|
+
return Promise.race([firstYes, all]);
|
|
2392
|
+
}
|
|
2393
|
+
async function memberOf(client, guildId, discordId, log) {
|
|
2380
2394
|
try {
|
|
2381
|
-
const guild = await
|
|
2395
|
+
const guild = await client.guilds.fetch(guildId);
|
|
2382
2396
|
await guild.members.fetch({ user: discordId, force: true });
|
|
2383
2397
|
return "member";
|
|
2384
2398
|
} catch (err) {
|
|
@@ -3687,7 +3701,7 @@ function createSurface(w) {
|
|
|
3687
3701
|
const ctx = {
|
|
3688
3702
|
deps: w.deps,
|
|
3689
3703
|
queue: w.queue ?? serial(),
|
|
3690
|
-
membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.
|
|
3704
|
+
membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildIds, discordId, w.log),
|
|
3691
3705
|
log: w.log
|
|
3692
3706
|
};
|
|
3693
3707
|
const membershipOf = (interaction, discordId) => ctx.membershipOf(interaction, discordId);
|
|
@@ -4115,6 +4129,19 @@ var MIGRATIONS = [
|
|
|
4115
4129
|
);
|
|
4116
4130
|
CREATE INDEX web_sessions_user ON web_sessions (user_id);
|
|
4117
4131
|
CREATE INDEX web_sessions_expiry ON web_sessions (expires_at);
|
|
4132
|
+
`,
|
|
4133
|
+
`
|
|
4134
|
+
CREATE TABLE api_tokens (
|
|
4135
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4136
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
4137
|
+
user_id TEXT NOT NULL,
|
|
4138
|
+
name TEXT NOT NULL,
|
|
4139
|
+
created_at TEXT NOT NULL,
|
|
4140
|
+
expires_at TEXT,
|
|
4141
|
+
last_used_at TEXT,
|
|
4142
|
+
member_checked_at TEXT
|
|
4143
|
+
);
|
|
4144
|
+
CREATE INDEX api_tokens_user ON api_tokens (user_id);
|
|
4118
4145
|
`
|
|
4119
4146
|
];
|
|
4120
4147
|
var BUSY_TIMEOUT_MS = 5000;
|
|
@@ -4340,6 +4367,7 @@ class Roster {
|
|
|
4340
4367
|
del("delivery_health", "user_id = ?", userId);
|
|
4341
4368
|
del("web_sessions", "user_id = ?", userId);
|
|
4342
4369
|
del("web_login_tokens", "user_id = ?", userId);
|
|
4370
|
+
del("api_tokens", "user_id = ?", userId);
|
|
4343
4371
|
del("users", "seq = ?", Number(seq));
|
|
4344
4372
|
const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
|
|
4345
4373
|
const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
|
|
@@ -6182,6 +6210,10 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
|
|
|
6182
6210
|
</form>
|
|
6183
6211
|
</section>
|
|
6184
6212
|
<section>
|
|
6213
|
+
<h2>API tokens</h2>
|
|
6214
|
+
<p>For a program that uses the tracker's task API as you. <a class="rb-link" href="${v.base}/tokens">API tokens</a></p>
|
|
6215
|
+
</section>
|
|
6216
|
+
<section>
|
|
6185
6217
|
<h2>Forget me</h2>
|
|
6186
6218
|
<p>Delete everything the tracker holds about you, for good. <a class="rb-link" href="${v.base}/forget">Forget me</a></p>
|
|
6187
6219
|
</section>`);
|
|
@@ -6335,6 +6367,166 @@ function notice(key, error) {
|
|
|
6335
6367
|
return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
|
|
6336
6368
|
}
|
|
6337
6369
|
|
|
6370
|
+
// plugins/tracker/src/web/api-tokens.ts
|
|
6371
|
+
var TOKEN_PREFIX = "trk_";
|
|
6372
|
+
var MAX_TOKENS_PER_PERSON = 10;
|
|
6373
|
+
var MAX_TOKEN_NAME = 50;
|
|
6374
|
+
var TOKEN_EXPIRY_DAYS = [30, 90, 365];
|
|
6375
|
+
var DEFAULT_EXPIRY_DAYS = 90;
|
|
6376
|
+
var DAY_MS3 = 24 * 60 * 60 * 1000;
|
|
6377
|
+
var TOUCH_EVERY_MS = 60 * 1000;
|
|
6378
|
+
function isApiTokenShape(value) {
|
|
6379
|
+
return /^trk_[A-Za-z0-9_-]{43}$/.test(value);
|
|
6380
|
+
}
|
|
6381
|
+
function isTokenId(id) {
|
|
6382
|
+
return /^k[1-9][0-9]{0,15}$/.test(id);
|
|
6383
|
+
}
|
|
6384
|
+
function expiryChoice(raw) {
|
|
6385
|
+
const days = Number(raw);
|
|
6386
|
+
return TOKEN_EXPIRY_DAYS.includes(days) ? days : undefined;
|
|
6387
|
+
}
|
|
6388
|
+
var COLUMNS = "seq, user_id, name, created_at, expires_at, last_used_at, member_checked_at";
|
|
6389
|
+
function toToken(r) {
|
|
6390
|
+
return {
|
|
6391
|
+
id: `k${Number(r.seq)}`,
|
|
6392
|
+
userId: r.user_id,
|
|
6393
|
+
name: r.name,
|
|
6394
|
+
createdAt: r.created_at,
|
|
6395
|
+
expiresAt: r.expires_at,
|
|
6396
|
+
lastUsedAt: r.last_used_at,
|
|
6397
|
+
memberCheckedAt: r.member_checked_at
|
|
6398
|
+
};
|
|
6399
|
+
}
|
|
6400
|
+
|
|
6401
|
+
class ApiTokens {
|
|
6402
|
+
db;
|
|
6403
|
+
constructor(db) {
|
|
6404
|
+
this.db = db;
|
|
6405
|
+
}
|
|
6406
|
+
create(userId, input, now, memberCheckedAt) {
|
|
6407
|
+
const name = input.name.trim();
|
|
6408
|
+
if (name.length === 0)
|
|
6409
|
+
return { ok: false, error: "Give the token a name, such as the program that will use it." };
|
|
6410
|
+
if (name.length > MAX_TOKEN_NAME)
|
|
6411
|
+
return { ok: false, error: `That name is longer than ${MAX_TOKEN_NAME} characters.` };
|
|
6412
|
+
if (/[\u0000-\u001f\u007f]/.test(name))
|
|
6413
|
+
return { ok: false, error: "That name has a control character in it." };
|
|
6414
|
+
if (!TOKEN_EXPIRY_DAYS.includes(input.days))
|
|
6415
|
+
return { ok: false, error: "Choose when the token expires." };
|
|
6416
|
+
const at = now.toISOString();
|
|
6417
|
+
return this.db.transaction(() => {
|
|
6418
|
+
const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
|
|
6419
|
+
if (seq === undefined || this.db.query("SELECT 1 FROM users WHERE seq = ?").get(Number(seq)) === null) {
|
|
6420
|
+
return { ok: false, error: "You are no longer on this tracker's list." };
|
|
6421
|
+
}
|
|
6422
|
+
this.db.query("DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at <= ?").run(at);
|
|
6423
|
+
const live = this.db.query("SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = ?").get(userId).n;
|
|
6424
|
+
if (live >= MAX_TOKENS_PER_PERSON) {
|
|
6425
|
+
return { ok: false, error: `You already have ${MAX_TOKENS_PER_PERSON} API tokens, the most one person may. Revoke one first.` };
|
|
6426
|
+
}
|
|
6427
|
+
const token = `${TOKEN_PREFIX}${randomToken()}`;
|
|
6428
|
+
const expiresAt = new Date(now.getTime() + input.days * DAY_MS3).toISOString();
|
|
6429
|
+
const r = this.db.query(`INSERT INTO api_tokens (token_hash, user_id, name, created_at, expires_at, last_used_at, member_checked_at)
|
|
6430
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?) RETURNING ${COLUMNS}`).get(sha256(token), userId, name, at, expiresAt, memberCheckedAt);
|
|
6431
|
+
return { ok: true, token, row: toToken(r) };
|
|
6432
|
+
})();
|
|
6433
|
+
}
|
|
6434
|
+
find(token, now) {
|
|
6435
|
+
if (!isApiTokenShape(token))
|
|
6436
|
+
return null;
|
|
6437
|
+
const r = this.db.query(`SELECT ${COLUMNS} FROM api_tokens WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`).get(sha256(token), now.toISOString());
|
|
6438
|
+
return r ? toToken(r) : null;
|
|
6439
|
+
}
|
|
6440
|
+
get(id) {
|
|
6441
|
+
if (!isTokenId(id))
|
|
6442
|
+
return null;
|
|
6443
|
+
const r = this.db.query(`SELECT ${COLUMNS} FROM api_tokens WHERE seq = ?`).get(Number(id.slice(1)));
|
|
6444
|
+
return r ? toToken(r) : null;
|
|
6445
|
+
}
|
|
6446
|
+
listFor(userId, now) {
|
|
6447
|
+
const rows = this.db.query(`SELECT ${COLUMNS} FROM api_tokens WHERE user_id = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq`).all(userId, now.toISOString());
|
|
6448
|
+
return rows.map(toToken);
|
|
6449
|
+
}
|
|
6450
|
+
touch(token, now) {
|
|
6451
|
+
if (!isTokenId(token.id))
|
|
6452
|
+
return false;
|
|
6453
|
+
if (token.lastUsedAt !== null && now.getTime() - Date.parse(token.lastUsedAt) < TOUCH_EVERY_MS && Date.parse(token.lastUsedAt) <= now.getTime())
|
|
6454
|
+
return false;
|
|
6455
|
+
const cutoff = new Date(now.getTime() - TOUCH_EVERY_MS).toISOString();
|
|
6456
|
+
return this.db.query("UPDATE api_tokens SET last_used_at = ? WHERE seq = ? AND (last_used_at IS NULL OR last_used_at <= ? OR last_used_at > ?)").run(now.toISOString(), Number(token.id.slice(1)), cutoff, now.toISOString()).changes > 0;
|
|
6457
|
+
}
|
|
6458
|
+
revokeOwn(userId, id) {
|
|
6459
|
+
if (!isTokenId(id))
|
|
6460
|
+
return false;
|
|
6461
|
+
return this.db.query("DELETE FROM api_tokens WHERE seq = ? AND user_id = ?").run(Number(id.slice(1)), userId).changes > 0;
|
|
6462
|
+
}
|
|
6463
|
+
revokeAny(id) {
|
|
6464
|
+
if (!isTokenId(id))
|
|
6465
|
+
return null;
|
|
6466
|
+
const r = this.db.query("DELETE FROM api_tokens WHERE seq = ? RETURNING user_id").get(Number(id.slice(1)));
|
|
6467
|
+
return r ? r.user_id : null;
|
|
6468
|
+
}
|
|
6469
|
+
deleteForUser(userId) {
|
|
6470
|
+
this.db.query("DELETE FROM api_tokens WHERE user_id = ?").run(userId);
|
|
6471
|
+
}
|
|
6472
|
+
confirmMember(userId, at) {
|
|
6473
|
+
this.db.query("UPDATE api_tokens SET member_checked_at = ? WHERE user_id = ?").run(at, userId);
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
|
|
6477
|
+
// plugins/tracker/src/web/token-pages.ts
|
|
6478
|
+
function tokenHref(v, id) {
|
|
6479
|
+
return `${v.base}/tokens/${encodeURIComponent(id)}/revoke`;
|
|
6480
|
+
}
|
|
6481
|
+
function adminTokenHref(v, id) {
|
|
6482
|
+
return `${v.base}/admin/tokens/${encodeURIComponent(id)}/revoke`;
|
|
6483
|
+
}
|
|
6484
|
+
function at(iso, viewer, now, none) {
|
|
6485
|
+
return iso ? formatInstant(iso, viewer.timeZone, now) : none;
|
|
6486
|
+
}
|
|
6487
|
+
function tokenTable(v, tokens, now, href) {
|
|
6488
|
+
if (tokens.length === 0)
|
|
6489
|
+
return html`<p class="rb-muted">None.</p>`;
|
|
6490
|
+
return html`<div class="rb-table-scroll"><table class="rb-table">
|
|
6491
|
+
<thead><tr><th>Name</th><th>Made</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
|
6492
|
+
<tbody>${tokens.map((t) => html`<tr>
|
|
6493
|
+
<td>${t.name}</td>
|
|
6494
|
+
<td>${at(t.createdAt, v.user, now, "")}</td>
|
|
6495
|
+
<td>${at(t.lastUsedAt, v.user, now, "never")}</td>
|
|
6496
|
+
<td>${at(t.expiresAt, v.user, now, "never")}</td>
|
|
6497
|
+
<td><form method="post" action="${href(t.id)}"><input type="hidden" name="csrf" value="${v.csrf}"><button class="rb-btn rb-btn--danger rb-btn--sm" type="submit">Revoke</button></form></td>
|
|
6498
|
+
</tr>`)}</tbody>
|
|
6499
|
+
</table></div>`;
|
|
6500
|
+
}
|
|
6501
|
+
function tokensPage(v, tokens, now, f = {}) {
|
|
6502
|
+
const expiry = f.expiry ?? String(DEFAULT_EXPIRY_DAYS);
|
|
6503
|
+
const choices = TOKEN_EXPIRY_DAYS.map((d) => [String(d), `in ${d} days`]);
|
|
6504
|
+
return framed(v, "API tokens", html`<section>
|
|
6505
|
+
<p><a class="rb-link" href="${v.base}/settings">Settings</a></p>
|
|
6506
|
+
<h1>API tokens</h1>
|
|
6507
|
+
<p>A token lets a program use the tracker's task API as you: list, read, make, edit, pause, resume and delete your own tasks, nothing more. Send it as <code>Authorization: Bearer <token></code> to <code>${v.base}/api/v1/</code>. Anyone holding it can act as you, so keep it secret, and revoke it when the program no longer needs it. A new token is sent to you by Discord DM, once, and never shown here. At most ${MAX_TOKENS_PER_PERSON}, each for a year at most.</p>
|
|
6508
|
+
${f.result ? html`<div class="rb-alert rb-alert--success" role="status"><p>${f.result}</p></div>` : null}
|
|
6509
|
+
${f.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class="rb-alert__title">Not made</p><p>${f.error}</p></div>` : null}
|
|
6510
|
+
${tokenTable(v, tokens, now, (id) => tokenHref(v, id))}
|
|
6511
|
+
</section>
|
|
6512
|
+
<section>
|
|
6513
|
+
<h2>Make a token</h2>
|
|
6514
|
+
<form method="post" action="${v.base}/tokens" class="tr-stack">
|
|
6515
|
+
<input type="hidden" name="csrf" value="${v.csrf}">
|
|
6516
|
+
<div class="rb-field">
|
|
6517
|
+
<label class="rb-label" for="name">Name</label>
|
|
6518
|
+
<input class="rb-input" id="name" name="name" value="${f.name ?? ""}" maxlength="${MAX_TOKEN_NAME}" required>
|
|
6519
|
+
<p class="rb-field__help">What will use it, so you know which to revoke.</p>
|
|
6520
|
+
</div>
|
|
6521
|
+
<div class="rb-field">
|
|
6522
|
+
<label class="rb-label" for="expiry">Expires</label>
|
|
6523
|
+
<select class="rb-select" id="expiry" name="expiry">${choices.map(([value, label]) => html`<option value="${value}"${value === expiry ? html` selected` : null}>${label}</option>`)}</select>
|
|
6524
|
+
</div>
|
|
6525
|
+
<div><button class="rb-btn rb-btn--primary" type="submit">Make token</button></div>
|
|
6526
|
+
</form>
|
|
6527
|
+
</section>`);
|
|
6528
|
+
}
|
|
6529
|
+
|
|
6338
6530
|
// plugins/tracker/src/web/admin-pages.ts
|
|
6339
6531
|
var CONFIGURED_NOTE = "Named in TRACKER_ADMIN_DISCORD_IDS: made an admin again at every start, so they cannot be revoked or removed here. Remove them from the configuration first.";
|
|
6340
6532
|
var CONFIRM_WORD = "forget";
|
|
@@ -6435,7 +6627,7 @@ ${tasks.length === 0 ? html`<p class="rb-muted">There are no tasks.</p>` : html`
|
|
|
6435
6627
|
function post2(v, p, action, label, style) {
|
|
6436
6628
|
return html`<form method="post" action="${personHref(v, p.id, action)}">${hidden(v)}<button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
|
|
6437
6629
|
}
|
|
6438
|
-
function personPage(v, p, tasks, now, result = null, configured = false) {
|
|
6630
|
+
function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
|
|
6439
6631
|
return framed(v, nameOf(p), html`<section>
|
|
6440
6632
|
${adminNav(v)}
|
|
6441
6633
|
<h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
|
|
@@ -6457,9 +6649,14 @@ ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from t
|
|
|
6457
6649
|
<section>
|
|
6458
6650
|
<h2>Their tasks</h2>
|
|
6459
6651
|
${tasks.length === 0 ? html`<p class="rb-muted">None.</p>` : html`<ul>${tasks.map((t) => html`<li><a class="rb-link" href="${taskHref(v, t.id)}">${t.title}</a> -- ${t.type}, ${t.status === "archived" ? "deleted" : t.status}</li>`)}</ul>`}
|
|
6652
|
+
</section>
|
|
6653
|
+
<section>
|
|
6654
|
+
<h2>Their API tokens</h2>
|
|
6655
|
+
<p class="rb-muted">Each acts as them on their own tasks. Revoking one stops it at once; they are not told.</p>
|
|
6656
|
+
${tokenTable(v, tokens, now, (id) => adminTokenHref(v, id))}
|
|
6460
6657
|
</section>`);
|
|
6461
6658
|
}
|
|
6462
|
-
var WHAT_GOES = "every task (deleted ones too) with its runs, replies and history; your replies, answers and history on other people's tasks, " + "and your place on the tasks shared with you; decline blocks either way; delivery pauses; your settings
|
|
6659
|
+
var WHAT_GOES = "every task (deleted ones too) with its runs, replies and history; your replies, answers and history on other people's tasks, " + "and your place on the tasks shared with you; decline blocks either way; delivery pauses; your settings, sign-in sessions and API tokens; and your place on the list.";
|
|
6463
6660
|
function forgetPage(v, note) {
|
|
6464
6661
|
return framed(v, "Forget me", html`<section class="rb-card">
|
|
6465
6662
|
<h1>Forget me</h1>
|
|
@@ -6520,7 +6717,8 @@ async function onePerson(a, id, result = null) {
|
|
|
6520
6717
|
if (!p)
|
|
6521
6718
|
return htmlResponse(notFoundPage(a.v.base, a.v), 404);
|
|
6522
6719
|
const tasks = await a.d.store.listTasks({ ownerId: p.id });
|
|
6523
|
-
|
|
6720
|
+
const tokens = a.d.apiTokens.listFor(p.id, a.d.clock.now());
|
|
6721
|
+
return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p), tokens), result && !result.ok ? 400 : 200);
|
|
6524
6722
|
}
|
|
6525
6723
|
async function adminGet(a, r) {
|
|
6526
6724
|
if (r.kind === "admin")
|
|
@@ -6554,6 +6752,19 @@ async function adminPost(a, r, form) {
|
|
|
6554
6752
|
return unknownPage(base);
|
|
6555
6753
|
return peoplePage(a, said2(done), done.ok ? 200 : 400);
|
|
6556
6754
|
}
|
|
6755
|
+
if (r.kind === "admin-token-revoke") {
|
|
6756
|
+
const owner = await fresh(a, true, async (me) => {
|
|
6757
|
+
const whose = a.d.apiTokens.revokeAny(r.id);
|
|
6758
|
+
if (whose !== null)
|
|
6759
|
+
a.d.log.info(`${me.id} revoked API token ${r.id} of ${whose}`);
|
|
6760
|
+
return { whose };
|
|
6761
|
+
});
|
|
6762
|
+
if (owner === null)
|
|
6763
|
+
return unknownPage(base);
|
|
6764
|
+
if (owner.whose === null)
|
|
6765
|
+
return htmlResponse(notFoundPage(base, a.v), 404);
|
|
6766
|
+
return onePerson(a, owner.whose, { ok: true, text: "Revoked: that token no longer works." });
|
|
6767
|
+
}
|
|
6557
6768
|
if (r.kind !== "admin-act")
|
|
6558
6769
|
return unknownPage(base);
|
|
6559
6770
|
if (r.action === "forget") {
|
|
@@ -6657,10 +6868,10 @@ async function editReminder(d, user, taskId, input) {
|
|
|
6657
6868
|
updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
|
|
6658
6869
|
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
|
|
6659
6870
|
}
|
|
6660
|
-
var
|
|
6871
|
+
var DAY_MS4 = 24 * 60 * 60 * 1000;
|
|
6661
6872
|
function periodDateFrom(s, date, owner) {
|
|
6662
6873
|
const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
|
|
6663
|
-
let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) *
|
|
6874
|
+
let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS4);
|
|
6664
6875
|
for (let i = 0;i < 1000; i++) {
|
|
6665
6876
|
const due = nextDue(s, after, ctx);
|
|
6666
6877
|
if (!due)
|
|
@@ -6767,15 +6978,7 @@ async function editPrice(d, user, taskId, input) {
|
|
|
6767
6978
|
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
|
|
6768
6979
|
}
|
|
6769
6980
|
|
|
6770
|
-
// plugins/tracker/src/web/
|
|
6771
|
-
var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
|
|
6772
|
-
var GONE = NO_LONGER_LISTED;
|
|
6773
|
-
function asViewer(e, fn) {
|
|
6774
|
-
return e.queue(async () => {
|
|
6775
|
-
const user = await e.d.store.getUser(e.v.user.id);
|
|
6776
|
-
return user ? fn(user) : { ok: false, error: GONE };
|
|
6777
|
-
});
|
|
6778
|
-
}
|
|
6981
|
+
// plugins/tracker/src/web/form-input.ts
|
|
6779
6982
|
var str2 = (form, name) => form.get(name) ?? "";
|
|
6780
6983
|
var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
|
|
6781
6984
|
function int(form, name) {
|
|
@@ -6825,6 +7028,10 @@ function priceSettings(form) {
|
|
|
6825
7028
|
...baseline !== undefined ? { baseline } : {}
|
|
6826
7029
|
};
|
|
6827
7030
|
}
|
|
7031
|
+
function priceInput(form) {
|
|
7032
|
+
const near = opt(form, "near");
|
|
7033
|
+
return { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
|
|
7034
|
+
}
|
|
6828
7035
|
function reminderEdit(form) {
|
|
6829
7036
|
const text = opt(form, "text");
|
|
6830
7037
|
const when = opt(form, "when");
|
|
@@ -6854,56 +7061,7 @@ function priceEdit(form) {
|
|
|
6854
7061
|
const { name: _name, ...rest } = priceSettings(form);
|
|
6855
7062
|
return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
|
|
6856
7063
|
}
|
|
6857
|
-
|
|
6858
|
-
reminder: { repeat: "none" },
|
|
6859
|
-
renewal: { unit: "year", every: "1", lead: "7" },
|
|
6860
|
-
price: { hours: "12", drop: "10", baseline: "last" }
|
|
6861
|
-
};
|
|
6862
|
-
function newGet(e, type) {
|
|
6863
|
-
return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
|
|
6864
|
-
}
|
|
6865
|
-
async function newPost(e, type, form) {
|
|
6866
|
-
let made;
|
|
6867
|
-
if (type === "reminder")
|
|
6868
|
-
made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
|
|
6869
|
-
else if (type === "renewal")
|
|
6870
|
-
made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
|
|
6871
|
-
else {
|
|
6872
|
-
const near = opt(form, "near");
|
|
6873
|
-
const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
|
|
6874
|
-
const who = e.v.user.id;
|
|
6875
|
-
if (e.reading.has(who))
|
|
6876
|
-
made = { ok: false, error: READING };
|
|
6877
|
-
else {
|
|
6878
|
-
e.reading.add(who);
|
|
6879
|
-
try {
|
|
6880
|
-
const started = await asViewer(e, (u) => startPrice(e.d, u, input));
|
|
6881
|
-
if (!started.ok)
|
|
6882
|
-
made = started;
|
|
6883
|
-
else {
|
|
6884
|
-
const { pending } = started;
|
|
6885
|
-
const seen = await previewPrice(e.d, pending);
|
|
6886
|
-
made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
|
|
6887
|
-
}
|
|
6888
|
-
} finally {
|
|
6889
|
-
e.reading.delete(who);
|
|
6890
|
-
}
|
|
6891
|
-
}
|
|
6892
|
-
}
|
|
6893
|
-
if (!made.ok)
|
|
6894
|
-
return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
|
|
6895
|
-
return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
|
|
6896
|
-
}
|
|
6897
|
-
async function taskPage(e, id, done, error) {
|
|
6898
|
-
const view = await loadHistory(e.d, e.v.user, id);
|
|
6899
|
-
if (!view)
|
|
6900
|
-
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6901
|
-
const own = view.task.ownerId === e.v.user.id;
|
|
6902
|
-
const controls = own ? ownerControls(e.v, view.task) : null;
|
|
6903
|
-
const owner = own ? null : (await e.d.store.getUser(view.task.ownerId))?.displayName ?? view.task.ownerId;
|
|
6904
|
-
return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error), owner }), error ? 400 : 200);
|
|
6905
|
-
}
|
|
6906
|
-
function current(d, user, task) {
|
|
7064
|
+
function editValues(d, user, task) {
|
|
6907
7065
|
const now = d.clock.now();
|
|
6908
7066
|
if (task.type === "reminder")
|
|
6909
7067
|
return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
|
|
@@ -6929,28 +7087,93 @@ function current(d, user, task) {
|
|
|
6929
7087
|
const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
|
|
6930
7088
|
return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
|
|
6931
7089
|
}
|
|
7090
|
+
|
|
7091
|
+
// plugins/tracker/src/web/editor.ts
|
|
7092
|
+
var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
|
|
7093
|
+
var GONE = NO_LONGER_LISTED;
|
|
7094
|
+
var writer = (e) => ({ d: e.d, queue: e.queue, reading: e.reading, userId: e.v.user.id });
|
|
7095
|
+
function asUser(w, fn) {
|
|
7096
|
+
return w.queue(async () => {
|
|
7097
|
+
const user = await w.d.store.getUser(w.userId);
|
|
7098
|
+
return user ? fn(user) : { ok: false, error: GONE };
|
|
7099
|
+
});
|
|
7100
|
+
}
|
|
7101
|
+
async function makeTask(w, type, form) {
|
|
7102
|
+
if (type === "reminder")
|
|
7103
|
+
return asUser(w, (u) => createReminder(w.d, u, reminderInput(form)));
|
|
7104
|
+
if (type === "renewal")
|
|
7105
|
+
return asUser(w, (u) => createRenewal(w.d, u, renewalInput(form)));
|
|
7106
|
+
const input = priceInput(form);
|
|
7107
|
+
if (w.reading.has(w.userId))
|
|
7108
|
+
return { ok: false, error: READING };
|
|
7109
|
+
w.reading.add(w.userId);
|
|
7110
|
+
try {
|
|
7111
|
+
const started = await asUser(w, (u) => startPrice(w.d, u, input));
|
|
7112
|
+
if (!started.ok)
|
|
7113
|
+
return started;
|
|
7114
|
+
const { pending } = started;
|
|
7115
|
+
const seen = await previewPrice(w.d, pending);
|
|
7116
|
+
return await asUser(w, (u) => finishPrice(w.d, u, pending, seen));
|
|
7117
|
+
} finally {
|
|
7118
|
+
w.reading.delete(w.userId);
|
|
7119
|
+
}
|
|
7120
|
+
}
|
|
7121
|
+
async function editableTask(d, user, id) {
|
|
7122
|
+
const task = await ownLiveTask(d, user, id);
|
|
7123
|
+
return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
|
|
7124
|
+
}
|
|
7125
|
+
function saveEdit(w, task, form) {
|
|
7126
|
+
if (task.type === "reminder")
|
|
7127
|
+
return asUser(w, (u) => editReminder(w.d, u, task.id, reminderEdit(form)));
|
|
7128
|
+
if (task.type === "renewal")
|
|
7129
|
+
return asUser(w, (u) => editRenewal(w.d, u, task.id, renewalEdit(form)));
|
|
7130
|
+
return asUser(w, (u) => editPrice(w.d, u, task.id, priceEdit(form)));
|
|
7131
|
+
}
|
|
7132
|
+
function actOn(w, taskId, action) {
|
|
7133
|
+
const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
|
|
7134
|
+
return asUser(w, (u) => act(w.d, u, taskId));
|
|
7135
|
+
}
|
|
7136
|
+
var DEFAULTS = {
|
|
7137
|
+
reminder: { repeat: "none" },
|
|
7138
|
+
renewal: { unit: "year", every: "1", lead: "7" },
|
|
7139
|
+
price: { hours: "12", drop: "10", baseline: "last" }
|
|
7140
|
+
};
|
|
7141
|
+
function newGet(e, type) {
|
|
7142
|
+
return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
|
|
7143
|
+
}
|
|
7144
|
+
async function newPost(e, type, form) {
|
|
7145
|
+
const made = await makeTask(writer(e), type, form);
|
|
7146
|
+
if (!made.ok)
|
|
7147
|
+
return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
|
|
7148
|
+
return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
|
|
7149
|
+
}
|
|
7150
|
+
async function taskPage(e, id, done, error) {
|
|
7151
|
+
const view = await loadHistory(e.d, e.v.user, id);
|
|
7152
|
+
if (!view)
|
|
7153
|
+
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
7154
|
+
const own = view.task.ownerId === e.v.user.id;
|
|
7155
|
+
const controls = own ? ownerControls(e.v, view.task) : null;
|
|
7156
|
+
const owner = own ? null : (await e.d.store.getUser(view.task.ownerId))?.displayName ?? view.task.ownerId;
|
|
7157
|
+
return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error), owner }), error ? 400 : 200);
|
|
7158
|
+
}
|
|
6932
7159
|
function editPage(e, task, values, error) {
|
|
6933
7160
|
const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
|
|
6934
7161
|
const page = task.type === "price" ? String(task.config.url) : undefined;
|
|
6935
7162
|
return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
|
|
6936
7163
|
}
|
|
6937
|
-
async function editableTask(e, id) {
|
|
6938
|
-
const task = await ownLiveTask(e.d, e.v.user, id);
|
|
6939
|
-
return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
|
|
6940
|
-
}
|
|
6941
7164
|
async function editGet(e, id) {
|
|
6942
|
-
const task = await editableTask(e, id);
|
|
7165
|
+
const task = await editableTask(e.d, e.v.user, id);
|
|
6943
7166
|
if (!task)
|
|
6944
7167
|
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6945
7168
|
if (task.status !== "active" && task.status !== "paused")
|
|
6946
7169
|
return redirect(taskHref(e.v, task.id));
|
|
6947
|
-
return htmlResponse(editPage(e, task,
|
|
7170
|
+
return htmlResponse(editPage(e, task, editValues(e.d, e.v.user, task)));
|
|
6948
7171
|
}
|
|
6949
7172
|
async function editPost(e, id, form) {
|
|
6950
|
-
const task = await editableTask(e, id);
|
|
7173
|
+
const task = await editableTask(e.d, e.v.user, id);
|
|
6951
7174
|
if (!task)
|
|
6952
7175
|
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6953
|
-
const saved =
|
|
7176
|
+
const saved = await saveEdit(writer(e), task, form);
|
|
6954
7177
|
if (saved.ok)
|
|
6955
7178
|
return redirect(`${taskHref(e.v, task.id)}?done=saved`);
|
|
6956
7179
|
if (saved.error === NO_SUCH_TASK)
|
|
@@ -6961,10 +7184,9 @@ async function actionPost(e, id, action, form) {
|
|
|
6961
7184
|
const task = await ownLiveTask(e.d, e.v.user, id);
|
|
6962
7185
|
if (!task)
|
|
6963
7186
|
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6964
|
-
if (action === "delete" &&
|
|
7187
|
+
if (action === "delete" && form.get("confirm") !== "yes")
|
|
6965
7188
|
return htmlResponse(confirmDeletePage(e.v, task));
|
|
6966
|
-
const
|
|
6967
|
-
const done = await asViewer(e, (u) => act(e.d, u, task.id));
|
|
7189
|
+
const done = await actOn(writer(e), task.id, action);
|
|
6968
7190
|
if (!done.ok)
|
|
6969
7191
|
return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
|
|
6970
7192
|
if (action === "delete")
|
|
@@ -6972,6 +7194,418 @@ async function actionPost(e, id, action, form) {
|
|
|
6972
7194
|
return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
|
|
6973
7195
|
}
|
|
6974
7196
|
|
|
7197
|
+
// plugins/tracker/src/web/api-tasks.ts
|
|
7198
|
+
function problem(status, code, message, headers) {
|
|
7199
|
+
return { status, body: { error: { code, message } }, ...headers ? { headers } : {} };
|
|
7200
|
+
}
|
|
7201
|
+
var NOT_FOUND_MESSAGE = "No such task.";
|
|
7202
|
+
var notFound = () => problem(404, "not_found", NOT_FOUND_MESSAGE);
|
|
7203
|
+
function jsonKind(f) {
|
|
7204
|
+
if (f.kind === "number")
|
|
7205
|
+
return "integer";
|
|
7206
|
+
return f.decimal ? "number" : "string";
|
|
7207
|
+
}
|
|
7208
|
+
function typesAnswer() {
|
|
7209
|
+
const describe = (fields, mode) => fields.map((f) => ({
|
|
7210
|
+
name: f.name,
|
|
7211
|
+
type: jsonKind(f),
|
|
7212
|
+
required: mode === "new" && f.required === true,
|
|
7213
|
+
description: f.help ? `${f.label}. ${f.help}` : f.label,
|
|
7214
|
+
...f.maxlength !== undefined ? { maxLength: f.maxlength } : {},
|
|
7215
|
+
...f.min !== undefined ? { minimum: f.min } : {},
|
|
7216
|
+
...f.max !== undefined ? { maximum: f.max } : {},
|
|
7217
|
+
...f.options ? { enum: f.options.map(([value]) => value) } : {}
|
|
7218
|
+
}));
|
|
7219
|
+
return {
|
|
7220
|
+
status: 200,
|
|
7221
|
+
body: { types: EDITOR_TYPES.map((type) => ({ type, create: describe(fieldsFor(type, "new"), "new"), edit: describe(fieldsFor(type, "edit"), "edit") })) }
|
|
7222
|
+
};
|
|
7223
|
+
}
|
|
7224
|
+
function asFields(body, type, mode) {
|
|
7225
|
+
const fields = new Map(fieldsFor(type, mode).map((f) => [f.name, f]));
|
|
7226
|
+
const form = new URLSearchParams;
|
|
7227
|
+
for (const [key, value] of Object.entries(body)) {
|
|
7228
|
+
if (mode === "new" && key === "type")
|
|
7229
|
+
continue;
|
|
7230
|
+
const f = fields.get(key);
|
|
7231
|
+
if (!f)
|
|
7232
|
+
return problem(400, "unknown_field", `\`${key.slice(0, 40)}\` is not a field of a ${type}${mode === "edit" ? " edit" : ""}. GET types lists them.`);
|
|
7233
|
+
const kind = jsonKind(f);
|
|
7234
|
+
if (kind === "string") {
|
|
7235
|
+
if (typeof value !== "string")
|
|
7236
|
+
return problem(400, "invalid", `\`${key}\` must be a string.`);
|
|
7237
|
+
form.set(key, value);
|
|
7238
|
+
} else {
|
|
7239
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
7240
|
+
return problem(400, "invalid", `\`${key}\` must be a number.`);
|
|
7241
|
+
form.set(key, String(value));
|
|
7242
|
+
}
|
|
7243
|
+
}
|
|
7244
|
+
return form;
|
|
7245
|
+
}
|
|
7246
|
+
function refusal(error) {
|
|
7247
|
+
if (error === NO_SUCH_TASK)
|
|
7248
|
+
return notFound();
|
|
7249
|
+
if (error === NO_LONGER_LISTED)
|
|
7250
|
+
return problem(401, "invalid_token", "The token's owner is no longer on this tracker's list.");
|
|
7251
|
+
if (error === READING)
|
|
7252
|
+
return problem(409, "busy", error);
|
|
7253
|
+
if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
|
|
7254
|
+
return problem(409, "conflict", error);
|
|
7255
|
+
if (/^You already (have|track) [0-9]+ /.test(error))
|
|
7256
|
+
return problem(409, "limit_reached", error);
|
|
7257
|
+
if (/ (is|are) not available on this bot\.$/.test(error))
|
|
7258
|
+
return problem(503, "unavailable", error);
|
|
7259
|
+
return problem(400, "invalid", error);
|
|
7260
|
+
}
|
|
7261
|
+
async function nextAt(d, task) {
|
|
7262
|
+
if (task.status !== "active")
|
|
7263
|
+
return null;
|
|
7264
|
+
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
7265
|
+
return queued[0]?.dueAt ?? null;
|
|
7266
|
+
}
|
|
7267
|
+
async function taskJson(d, user, task) {
|
|
7268
|
+
const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
|
|
7269
|
+
const values = editValues(d, user, task);
|
|
7270
|
+
const settings = {};
|
|
7271
|
+
if (type) {
|
|
7272
|
+
for (const f of fieldsFor(type, "edit")) {
|
|
7273
|
+
if (f.name === "when")
|
|
7274
|
+
continue;
|
|
7275
|
+
const raw = values[f.name];
|
|
7276
|
+
if (raw === undefined)
|
|
7277
|
+
continue;
|
|
7278
|
+
settings[f.name] = jsonKind(f) === "string" ? raw : Number(raw);
|
|
7279
|
+
}
|
|
7280
|
+
}
|
|
7281
|
+
return {
|
|
7282
|
+
id: task.id,
|
|
7283
|
+
type: task.type,
|
|
7284
|
+
title: task.title,
|
|
7285
|
+
status: task.status === "archived" ? "deleted" : task.status,
|
|
7286
|
+
cadence: task.schedule ? describeSchedule(task.schedule, user, user.timeZone, d.clock.now()) : "no schedule",
|
|
7287
|
+
nextAt: await nextAt(d, task),
|
|
7288
|
+
createdAt: task.createdAt,
|
|
7289
|
+
updatedAt: task.updatedAt,
|
|
7290
|
+
settings,
|
|
7291
|
+
...task.type === "price" ? { url: String(task.config.url) } : {}
|
|
7292
|
+
};
|
|
7293
|
+
}
|
|
7294
|
+
async function listAnswer(d, user) {
|
|
7295
|
+
const tasks = (await d.store.listTasks({ ownerId: user.id })).filter((t) => t.status !== "archived");
|
|
7296
|
+
const out = [];
|
|
7297
|
+
for (const t of tasks)
|
|
7298
|
+
out.push(await taskJson(d, user, t));
|
|
7299
|
+
return { status: 200, body: { tasks: out } };
|
|
7300
|
+
}
|
|
7301
|
+
async function getAnswer(d, user, id) {
|
|
7302
|
+
const task = await ownTask(d, user, id);
|
|
7303
|
+
if (!task)
|
|
7304
|
+
return notFound();
|
|
7305
|
+
const view = await loadHistory(d, user, task.id);
|
|
7306
|
+
if (!view)
|
|
7307
|
+
return notFound();
|
|
7308
|
+
return {
|
|
7309
|
+
status: 200,
|
|
7310
|
+
body: {
|
|
7311
|
+
task: await taskJson(d, user, view.task),
|
|
7312
|
+
history: { next: view.next, runs: view.runs, earlierRuns: view.earlierRuns, changes: view.changes, earlierChanges: view.earlierChanges }
|
|
7313
|
+
}
|
|
7314
|
+
};
|
|
7315
|
+
}
|
|
7316
|
+
async function written(w, user, result, status, base) {
|
|
7317
|
+
if (!result.ok)
|
|
7318
|
+
return refusal(result.error);
|
|
7319
|
+
const task = await w.d.store.getTask(result.task.id) ?? result.task;
|
|
7320
|
+
return {
|
|
7321
|
+
status,
|
|
7322
|
+
body: { task: await taskJson(w.d, user, task), message: result.text },
|
|
7323
|
+
...status === 201 ? { headers: { Location: `${base}/tasks/${encodeURIComponent(task.id)}` } } : {}
|
|
7324
|
+
};
|
|
7325
|
+
}
|
|
7326
|
+
async function createAnswer(w, user, body, base) {
|
|
7327
|
+
const type = body.type;
|
|
7328
|
+
if (typeof type !== "string" || !EDITOR_TYPES.includes(type)) {
|
|
7329
|
+
return problem(400, "invalid", `\`type\` is one of ${EDITOR_TYPES.join(", ")}.`);
|
|
7330
|
+
}
|
|
7331
|
+
const form = asFields(body, type, "new");
|
|
7332
|
+
if (!(form instanceof URLSearchParams))
|
|
7333
|
+
return form;
|
|
7334
|
+
return written(w, user, await makeTask(w, type, form), 201, base);
|
|
7335
|
+
}
|
|
7336
|
+
async function editAnswer(w, user, id, body, base) {
|
|
7337
|
+
const task = await editableTask(w.d, user, id);
|
|
7338
|
+
if (!task)
|
|
7339
|
+
return notFound();
|
|
7340
|
+
const form = asFields(body, task.type, "edit");
|
|
7341
|
+
if (!(form instanceof URLSearchParams))
|
|
7342
|
+
return form;
|
|
7343
|
+
return written(w, user, await saveEdit(w, task, form), 200, base);
|
|
7344
|
+
}
|
|
7345
|
+
async function actAnswer(w, user, id, action) {
|
|
7346
|
+
const task = await ownTask(w.d, user, id);
|
|
7347
|
+
if (!task || task.status === "archived")
|
|
7348
|
+
return notFound();
|
|
7349
|
+
const done = await actOn(w, task.id, action);
|
|
7350
|
+
if (!done.ok)
|
|
7351
|
+
return refusal(done.error);
|
|
7352
|
+
const after = await w.d.store.getTask(task.id) ?? task;
|
|
7353
|
+
return { status: 200, body: { task: await taskJson(w.d, user, after), message: done.text } };
|
|
7354
|
+
}
|
|
7355
|
+
|
|
7356
|
+
// plugins/tracker/src/web/api.ts
|
|
7357
|
+
var API_PREFIX = "/api/v1";
|
|
7358
|
+
var MAX_API_BYTES = 16 * 1024;
|
|
7359
|
+
var RATE_BURST = 60;
|
|
7360
|
+
var RATE_PER_SECOND = 1;
|
|
7361
|
+
var FAILED_BURST = 30;
|
|
7362
|
+
var FAILED_PER_SECOND = 0.5;
|
|
7363
|
+
var FAILED_KEY = "failed";
|
|
7364
|
+
|
|
7365
|
+
class RateLimiter {
|
|
7366
|
+
burst;
|
|
7367
|
+
perSecond;
|
|
7368
|
+
buckets = new Map;
|
|
7369
|
+
constructor(burst, perSecond) {
|
|
7370
|
+
this.burst = burst;
|
|
7371
|
+
this.perSecond = perSecond;
|
|
7372
|
+
}
|
|
7373
|
+
take(key, nowMs) {
|
|
7374
|
+
const b = this.buckets.get(key);
|
|
7375
|
+
const elapsed = b ? Math.max(0, nowMs - b.at) / 1000 : 0;
|
|
7376
|
+
const tokens = b ? Math.min(this.burst, b.tokens + elapsed * this.perSecond) : this.burst;
|
|
7377
|
+
if (tokens < 1) {
|
|
7378
|
+
this.buckets.set(key, { tokens, at: nowMs });
|
|
7379
|
+
return Math.max(1, Math.ceil((1 - tokens) / this.perSecond));
|
|
7380
|
+
}
|
|
7381
|
+
this.buckets.set(key, { tokens: tokens - 1, at: nowMs });
|
|
7382
|
+
if (this.buckets.size > 1e4) {
|
|
7383
|
+
for (const [k, v] of this.buckets)
|
|
7384
|
+
if (v.tokens + (nowMs - v.at) / 1000 * this.perSecond >= this.burst)
|
|
7385
|
+
this.buckets.delete(k);
|
|
7386
|
+
}
|
|
7387
|
+
return 0;
|
|
7388
|
+
}
|
|
7389
|
+
}
|
|
7390
|
+
var HEADERS = {
|
|
7391
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
7392
|
+
"Cache-Control": "no-store",
|
|
7393
|
+
"X-Content-Type-Options": "nosniff",
|
|
7394
|
+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
7395
|
+
"Referrer-Policy": "no-referrer"
|
|
7396
|
+
};
|
|
7397
|
+
function json2(a) {
|
|
7398
|
+
return new Response(JSON.stringify(a.body), { status: a.status, headers: { ...HEADERS, ...a.headers ?? {} } });
|
|
7399
|
+
}
|
|
7400
|
+
var CHALLENGE = 'Bearer realm="tracker"';
|
|
7401
|
+
function bearer(header) {
|
|
7402
|
+
if (header === null)
|
|
7403
|
+
return null;
|
|
7404
|
+
const m = /^Bearer +([^\s]+) *$/i.exec(header);
|
|
7405
|
+
return m?.[1] ?? null;
|
|
7406
|
+
}
|
|
7407
|
+
var ACT = /^\/tasks\/([^/]+)\/(pause|resume)$/;
|
|
7408
|
+
var ONE = /^\/tasks\/([^/]+)$/;
|
|
7409
|
+
function segment(raw) {
|
|
7410
|
+
try {
|
|
7411
|
+
return decodeURIComponent(raw ?? "");
|
|
7412
|
+
} catch {
|
|
7413
|
+
return "";
|
|
7414
|
+
}
|
|
7415
|
+
}
|
|
7416
|
+
function route(rest) {
|
|
7417
|
+
if (rest === "/me")
|
|
7418
|
+
return { kind: "me" };
|
|
7419
|
+
if (rest === "/types")
|
|
7420
|
+
return { kind: "types" };
|
|
7421
|
+
if (rest === "/tasks")
|
|
7422
|
+
return { kind: "tasks" };
|
|
7423
|
+
const act = ACT.exec(rest);
|
|
7424
|
+
if (act)
|
|
7425
|
+
return { kind: "act", id: segment(act[1]), action: act[2] };
|
|
7426
|
+
const one = ONE.exec(rest);
|
|
7427
|
+
return one ? { kind: "task", id: segment(one[1]) } : null;
|
|
7428
|
+
}
|
|
7429
|
+
function allowedMethods(r) {
|
|
7430
|
+
if (r.kind === "tasks")
|
|
7431
|
+
return "GET, POST";
|
|
7432
|
+
if (r.kind === "task")
|
|
7433
|
+
return "GET, PATCH, DELETE";
|
|
7434
|
+
if (r.kind === "act")
|
|
7435
|
+
return "POST";
|
|
7436
|
+
return "GET";
|
|
7437
|
+
}
|
|
7438
|
+
async function readJson(request) {
|
|
7439
|
+
const type = (request.headers.get("content-type") ?? "").trim();
|
|
7440
|
+
const no = (answer) => ({ ok: false, answer });
|
|
7441
|
+
if (!/^application\/json *(;.*)?$/i.test(type))
|
|
7442
|
+
return no(problem(415, "unsupported_media_type", "Send the body as Content-Type: application/json."));
|
|
7443
|
+
const text = await readBody(request, MAX_API_BYTES);
|
|
7444
|
+
if (text === null)
|
|
7445
|
+
return no(problem(413, "too_large", `The body is over ${MAX_API_BYTES} bytes.`));
|
|
7446
|
+
let value;
|
|
7447
|
+
try {
|
|
7448
|
+
value = JSON.parse(text);
|
|
7449
|
+
} catch {
|
|
7450
|
+
return no(problem(400, "invalid_json", "The body is not JSON."));
|
|
7451
|
+
}
|
|
7452
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
7453
|
+
return no(problem(400, "invalid_json", "The body must be a JSON object."));
|
|
7454
|
+
return { ok: true, body: value };
|
|
7455
|
+
}
|
|
7456
|
+
async function authenticate(w, request) {
|
|
7457
|
+
const { d } = w;
|
|
7458
|
+
const no = (answer) => ({ ok: false, answer });
|
|
7459
|
+
const header = request.headers.get("authorization");
|
|
7460
|
+
if (header === null)
|
|
7461
|
+
return no(problem(401, "unauthorized", "Send Authorization: Bearer <token>. Make a token on the web area's API tokens page.", { "WWW-Authenticate": CHALLENGE }));
|
|
7462
|
+
const invalid = problem(401, "invalid_token", "That token is unknown, revoked or expired.", { "WWW-Authenticate": `${CHALLENGE}, error="invalid_token"` });
|
|
7463
|
+
const sent = bearer(header);
|
|
7464
|
+
const now = d.clock.now();
|
|
7465
|
+
const token = sent === null ? null : d.apiTokens.find(sent, now);
|
|
7466
|
+
if (!token) {
|
|
7467
|
+
const waitFailed = w.failed.take(FAILED_KEY, now.getTime());
|
|
7468
|
+
if (waitFailed > 0)
|
|
7469
|
+
return no(problem(429, "rate_limited", `Too many requests with bad tokens; try again in ${waitFailed} s.`, { "Retry-After": String(waitFailed) }));
|
|
7470
|
+
return no(invalid);
|
|
7471
|
+
}
|
|
7472
|
+
const wait = w.limiter.take(token.id, now.getTime());
|
|
7473
|
+
if (wait > 0)
|
|
7474
|
+
return no(problem(429, "rate_limited", `Too many requests with this token; try again in ${wait} s.`, { "Retry-After": String(wait) }));
|
|
7475
|
+
const user = await d.store.getUser(token.userId);
|
|
7476
|
+
if (!user || !d.admissions.isRegistered(user.id)) {
|
|
7477
|
+
d.apiTokens.deleteForUser(token.userId);
|
|
7478
|
+
return no(invalid);
|
|
7479
|
+
}
|
|
7480
|
+
const found = await w.recheck(user, token.memberCheckedAt);
|
|
7481
|
+
if (found === "not-member")
|
|
7482
|
+
return no(problem(403, "not_member", "The token's owner is no longer a member of this tracker's server; their tokens are revoked."));
|
|
7483
|
+
if (found === "unknown")
|
|
7484
|
+
return no(problem(503, "membership_unknown", "I could not check that the token's owner is still a member of this tracker's server; try again later.", { "Retry-After": "60" }));
|
|
7485
|
+
const current = d.apiTokens.find(sent, d.clock.now());
|
|
7486
|
+
if (!current)
|
|
7487
|
+
return no(invalid);
|
|
7488
|
+
d.apiTokens.touch(current, d.clock.now());
|
|
7489
|
+
return { ok: true, token: current, user };
|
|
7490
|
+
}
|
|
7491
|
+
async function handleApi(w, request, path) {
|
|
7492
|
+
if (request.headers.get("origin") !== null)
|
|
7493
|
+
return json2(problem(403, "origin_refused", "The task API is not for browsers: send no Origin header."));
|
|
7494
|
+
const auth = await authenticate(w, request);
|
|
7495
|
+
if (!auth.ok)
|
|
7496
|
+
return json2(auth.answer);
|
|
7497
|
+
const { user } = auth;
|
|
7498
|
+
const r = path.startsWith(`${API_PREFIX}/`) ? route(path.slice(API_PREFIX.length)) : null;
|
|
7499
|
+
if (r === null)
|
|
7500
|
+
return json2(problem(404, "not_found", "No such endpoint."));
|
|
7501
|
+
const allowed = allowedMethods(r);
|
|
7502
|
+
const method = request.method;
|
|
7503
|
+
if (!allowed.split(", ").includes(method))
|
|
7504
|
+
return json2(problem(405, "method_not_allowed", `Use ${allowed}.`, { Allow: allowed }));
|
|
7505
|
+
const writer = { d: w.d, queue: w.queue, reading: w.reading, userId: user.id };
|
|
7506
|
+
const base = `${w.base}${API_PREFIX}`;
|
|
7507
|
+
if (method === "POST" || method === "PATCH") {
|
|
7508
|
+
const read = await readJson(request);
|
|
7509
|
+
if (!read.ok)
|
|
7510
|
+
return json2(read.answer);
|
|
7511
|
+
const { body } = read;
|
|
7512
|
+
if (r.kind === "tasks")
|
|
7513
|
+
return json2(await createAnswer(writer, user, body, base));
|
|
7514
|
+
if (r.kind === "task")
|
|
7515
|
+
return json2(await editAnswer(writer, user, r.id, body, base));
|
|
7516
|
+
if (r.kind === "act") {
|
|
7517
|
+
if (Object.keys(body).length > 0)
|
|
7518
|
+
return json2(problem(400, "unknown_field", `\`${r.action}\` takes no fields: send {}.`));
|
|
7519
|
+
return json2(await actAnswer(writer, user, r.id, r.action));
|
|
7520
|
+
}
|
|
7521
|
+
}
|
|
7522
|
+
if (method === "DELETE" && r.kind === "task")
|
|
7523
|
+
return json2(await actAnswer(writer, user, r.id, "delete"));
|
|
7524
|
+
if (r.kind === "types")
|
|
7525
|
+
return json2(typesAnswer());
|
|
7526
|
+
if (r.kind === "tasks")
|
|
7527
|
+
return json2(await listAnswer(w.d, user));
|
|
7528
|
+
if (r.kind === "task")
|
|
7529
|
+
return json2(await getAnswer(w.d, user, r.id));
|
|
7530
|
+
return json2({
|
|
7531
|
+
status: 200,
|
|
7532
|
+
body: {
|
|
7533
|
+
user: { id: user.id, name: user.displayName, timeZone: user.timeZone, preferredHour: user.preferredHour },
|
|
7534
|
+
token: { id: auth.token.id, name: auth.token.name, createdAt: auth.token.createdAt, expiresAt: auth.token.expiresAt }
|
|
7535
|
+
}
|
|
7536
|
+
});
|
|
7537
|
+
}
|
|
7538
|
+
|
|
7539
|
+
// plugins/tracker/src/web/tokens.ts
|
|
7540
|
+
var MAX_CREATIONS_PER_HOUR = 10;
|
|
7541
|
+
var HOUR_MS = 60 * 60 * 1000;
|
|
7542
|
+
var TOO_MANY_CREATED = `You have made ${MAX_CREATIONS_PER_HOUR} tokens in the last hour, the most one person may. Try again later.`;
|
|
7543
|
+
|
|
7544
|
+
class CreationLog {
|
|
7545
|
+
made = new Map;
|
|
7546
|
+
recent(userId, nowMs) {
|
|
7547
|
+
const times = (this.made.get(userId) ?? []).filter((t) => t > nowMs - HOUR_MS && t <= nowMs);
|
|
7548
|
+
if (times.length === 0)
|
|
7549
|
+
this.made.delete(userId);
|
|
7550
|
+
else
|
|
7551
|
+
this.made.set(userId, times);
|
|
7552
|
+
return times;
|
|
7553
|
+
}
|
|
7554
|
+
full(userId, nowMs) {
|
|
7555
|
+
return this.recent(userId, nowMs).length >= MAX_CREATIONS_PER_HOUR;
|
|
7556
|
+
}
|
|
7557
|
+
record(userId, nowMs) {
|
|
7558
|
+
this.made.set(userId, [...this.recent(userId, nowMs), nowMs]);
|
|
7559
|
+
}
|
|
7560
|
+
}
|
|
7561
|
+
var TOKEN_SENT = "Your new token is in your Discord DMs from this bot. It is not shown here, and the tracker keeps only a hash of it.";
|
|
7562
|
+
var TOKEN_NOT_SENT = "I could not DM you the token, so none was made. Open your DMs from this server's members and try again.";
|
|
7563
|
+
function tokenMessage(name, token, expiresAt) {
|
|
7564
|
+
return [
|
|
7565
|
+
`Your tracker API token "${name}"${expiresAt ? `, expiring ${expiresAt.slice(0, 10)}` : ""}:`,
|
|
7566
|
+
`\`${token}\``,
|
|
7567
|
+
"Anyone holding it can use the tracker's task API as you: keep it secret, and revoke it on the web area's API tokens page when you no longer need it. If you did not make it, revoke it now."
|
|
7568
|
+
].join(`
|
|
7569
|
+
`);
|
|
7570
|
+
}
|
|
7571
|
+
function tokensGet(d, v) {
|
|
7572
|
+
return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now()));
|
|
7573
|
+
}
|
|
7574
|
+
async function tokensPost(d, v, session, form, log) {
|
|
7575
|
+
const now = d.clock.now();
|
|
7576
|
+
const name = (form.get("name") ?? "").slice(0, 200);
|
|
7577
|
+
const expiry = form.get("expiry") ?? String(DEFAULT_EXPIRY_DAYS);
|
|
7578
|
+
const days = expiryChoice(expiry);
|
|
7579
|
+
const refuse = (error) => htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now(), { error, name, expiry: days === undefined ? String(DEFAULT_EXPIRY_DAYS) : expiry }), 400);
|
|
7580
|
+
if (days === undefined)
|
|
7581
|
+
return refuse("Choose when the token expires.");
|
|
7582
|
+
const discordId = v.user.discordId;
|
|
7583
|
+
if (!d.dm || !discordId)
|
|
7584
|
+
return refuse(TOKEN_NOT_SENT);
|
|
7585
|
+
if (log.full(v.user.id, now.getTime()))
|
|
7586
|
+
return refuse(TOO_MANY_CREATED);
|
|
7587
|
+
const made = d.apiTokens.create(v.user.id, { name, days }, now, session.memberCheckedAt);
|
|
7588
|
+
if (!made.ok)
|
|
7589
|
+
return refuse(made.error);
|
|
7590
|
+
log.record(v.user.id, now.getTime());
|
|
7591
|
+
try {
|
|
7592
|
+
await d.dm(discordId, { content: tokenMessage(made.row.name, made.token, made.row.expiresAt) });
|
|
7593
|
+
} catch (err) {
|
|
7594
|
+
d.apiTokens.revokeOwn(v.user.id, made.row.id);
|
|
7595
|
+
d.log.warn(`${v.user.id}'s API token ${made.row.id} deleted: the DM carrying it failed (${err instanceof Error ? err.message : String(err)})`);
|
|
7596
|
+
return refuse(TOKEN_NOT_SENT);
|
|
7597
|
+
}
|
|
7598
|
+
d.log.info(`${v.user.id} made API token ${made.row.id}`);
|
|
7599
|
+
return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now(), { result: `"${made.row.name}": ${TOKEN_SENT}` }));
|
|
7600
|
+
}
|
|
7601
|
+
function tokenRevokePost(d, v, id) {
|
|
7602
|
+
if (!d.apiTokens.revokeOwn(v.user.id, id))
|
|
7603
|
+
return htmlResponse(notFoundPage(v.base, v), 404);
|
|
7604
|
+
d.log.info(`${v.user.id} revoked their API token ${id}`);
|
|
7605
|
+
const now = d.clock.now();
|
|
7606
|
+
return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, now), now, { result: "Revoked: that token no longer works." }));
|
|
7607
|
+
}
|
|
7608
|
+
|
|
6975
7609
|
// plugins/tracker/src/web/routes.ts
|
|
6976
7610
|
var TASK_PATH = /^\/tasks\/([^/]+)$/;
|
|
6977
7611
|
var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
|
|
@@ -6979,17 +7613,19 @@ var NEW_PATH = /^\/new\/([a-z]+)$/;
|
|
|
6979
7613
|
var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
|
|
6980
7614
|
var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
|
|
6981
7615
|
var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
|
|
7616
|
+
var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
|
|
7617
|
+
var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
|
|
6982
7618
|
function isAdminRoute(r) {
|
|
6983
7619
|
return r.kind.startsWith("admin");
|
|
6984
7620
|
}
|
|
6985
|
-
function
|
|
7621
|
+
function segment2(raw) {
|
|
6986
7622
|
try {
|
|
6987
7623
|
return decodeURIComponent(raw ?? "");
|
|
6988
7624
|
} catch {
|
|
6989
7625
|
return "";
|
|
6990
7626
|
}
|
|
6991
7627
|
}
|
|
6992
|
-
function
|
|
7628
|
+
function route2(path) {
|
|
6993
7629
|
if (path === "/")
|
|
6994
7630
|
return { kind: "tasks" };
|
|
6995
7631
|
if (path === "/settings")
|
|
@@ -6998,6 +7634,14 @@ function route(path) {
|
|
|
6998
7634
|
return { kind: "logout" };
|
|
6999
7635
|
if (path === "/forget")
|
|
7000
7636
|
return { kind: "forget" };
|
|
7637
|
+
if (path === "/tokens")
|
|
7638
|
+
return { kind: "tokens" };
|
|
7639
|
+
const revoke = TOKEN_REVOKE.exec(path);
|
|
7640
|
+
if (revoke)
|
|
7641
|
+
return { kind: "token-revoke", id: segment2(revoke[1]) };
|
|
7642
|
+
const adminRevoke = ADMIN_TOKEN_REVOKE.exec(path);
|
|
7643
|
+
if (adminRevoke)
|
|
7644
|
+
return { kind: "admin-token-revoke", id: segment2(adminRevoke[1]) };
|
|
7001
7645
|
if (path === "/admin")
|
|
7002
7646
|
return { kind: "admin" };
|
|
7003
7647
|
if (path === "/admin/tasks")
|
|
@@ -7006,13 +7650,13 @@ function route(path) {
|
|
|
7006
7650
|
return { kind: "admin-allow" };
|
|
7007
7651
|
const personAct = PERSON_ACTION.exec(path);
|
|
7008
7652
|
if (personAct)
|
|
7009
|
-
return { kind: "admin-act", id:
|
|
7653
|
+
return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
|
|
7010
7654
|
const onePerson = PERSON_PATH.exec(path);
|
|
7011
7655
|
if (onePerson)
|
|
7012
|
-
return { kind: "admin-person", id:
|
|
7656
|
+
return { kind: "admin-person", id: segment2(onePerson[1]) };
|
|
7013
7657
|
const lift = BLOCK_LIFT.exec(path);
|
|
7014
7658
|
if (lift)
|
|
7015
|
-
return { kind: "admin-lift", id:
|
|
7659
|
+
return { kind: "admin-lift", id: segment2(lift[1]) };
|
|
7016
7660
|
const created = NEW_PATH.exec(path);
|
|
7017
7661
|
if (created) {
|
|
7018
7662
|
const type = created[1];
|
|
@@ -7021,7 +7665,7 @@ function route(path) {
|
|
|
7021
7665
|
const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
|
|
7022
7666
|
if (!one)
|
|
7023
7667
|
return null;
|
|
7024
|
-
const id =
|
|
7668
|
+
const id = segment2(one[1]);
|
|
7025
7669
|
const action = one[2];
|
|
7026
7670
|
if (action === undefined)
|
|
7027
7671
|
return { kind: "task", id };
|
|
@@ -7030,9 +7674,10 @@ function route(path) {
|
|
|
7030
7674
|
return { kind: "act", id, action };
|
|
7031
7675
|
}
|
|
7032
7676
|
function methodsOf(r) {
|
|
7033
|
-
if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift")
|
|
7677
|
+
if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
|
|
7034
7678
|
return "POST";
|
|
7035
|
-
|
|
7679
|
+
}
|
|
7680
|
+
if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
|
|
7036
7681
|
return "GET, POST";
|
|
7037
7682
|
return "GET";
|
|
7038
7683
|
}
|
|
@@ -7109,6 +7754,9 @@ function createWebHandler(w) {
|
|
|
7109
7754
|
const inFlight = new Map;
|
|
7110
7755
|
const failedAt = new Map;
|
|
7111
7756
|
const reading = new Set;
|
|
7757
|
+
const limiter = new RateLimiter(RATE_BURST, RATE_PER_SECOND);
|
|
7758
|
+
const failed = new RateLimiter(FAILED_BURST, FAILED_PER_SECOND);
|
|
7759
|
+
const created = new CreationLog;
|
|
7112
7760
|
async function authenticate(d, request) {
|
|
7113
7761
|
const id = readCookie(request, SESSION_COOKIE);
|
|
7114
7762
|
if (!id)
|
|
@@ -7143,14 +7791,14 @@ function createWebHandler(w) {
|
|
|
7143
7791
|
const shared = pending;
|
|
7144
7792
|
return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
|
|
7145
7793
|
}
|
|
7146
|
-
async function
|
|
7147
|
-
if (w.
|
|
7148
|
-
return
|
|
7794
|
+
async function recheck(d, user, checkedAt) {
|
|
7795
|
+
if (w.guildIds === null)
|
|
7796
|
+
return "ok";
|
|
7149
7797
|
const now = d.clock.now().getTime();
|
|
7150
|
-
const age =
|
|
7798
|
+
const age = checkedAt ? now - Date.parse(checkedAt) : Number.POSITIVE_INFINITY;
|
|
7151
7799
|
const fresh = (limit) => age >= 0 && age < limit;
|
|
7152
7800
|
if (fresh(MEMBER_RECHECK_MS))
|
|
7153
|
-
return
|
|
7801
|
+
return "ok";
|
|
7154
7802
|
const lastFailed = failedAt.get(user.id);
|
|
7155
7803
|
const backingOff = lastFailed !== undefined && now - lastFailed >= 0 && now - lastFailed < MEMBER_RETRY_MS;
|
|
7156
7804
|
let found = "unknown";
|
|
@@ -7163,19 +7811,28 @@ function createWebHandler(w) {
|
|
|
7163
7811
|
}
|
|
7164
7812
|
if (found === "not-member") {
|
|
7165
7813
|
d.sessions.deleteForUser(user.id);
|
|
7166
|
-
|
|
7814
|
+
d.apiTokens.deleteForUser(user.id);
|
|
7815
|
+
return "not-member";
|
|
7167
7816
|
}
|
|
7168
7817
|
if (found === "member") {
|
|
7169
|
-
|
|
7170
|
-
|
|
7818
|
+
const at = d.clock.now().toISOString();
|
|
7819
|
+
d.sessions.confirmMember(user.id, at);
|
|
7820
|
+
d.apiTokens.confirmMember(user.id, at);
|
|
7821
|
+
return "ok";
|
|
7171
7822
|
}
|
|
7172
|
-
|
|
7823
|
+
return fresh(MEMBER_GRACE_MS) ? "ok" : "unknown";
|
|
7824
|
+
}
|
|
7825
|
+
async function recheckMembership(d, session, user) {
|
|
7826
|
+
const found = await recheck(d, user, session.memberCheckedAt);
|
|
7827
|
+
if (found === "ok")
|
|
7173
7828
|
return null;
|
|
7829
|
+
if (found === "not-member")
|
|
7830
|
+
return { kind: "signed-out", note: LEFT_SERVER };
|
|
7174
7831
|
d.sessions.deleteForUser(user.id);
|
|
7175
7832
|
return { kind: "signed-out", note: RECHECK_FAILED };
|
|
7176
7833
|
}
|
|
7177
7834
|
async function memberOf(discordId) {
|
|
7178
|
-
if (w.
|
|
7835
|
+
if (w.guildIds === null)
|
|
7179
7836
|
return "not-checked";
|
|
7180
7837
|
return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
|
|
7181
7838
|
}
|
|
@@ -7235,6 +7892,14 @@ function createWebHandler(w) {
|
|
|
7235
7892
|
return redirect(`${base}/settings?saved=1`);
|
|
7236
7893
|
}
|
|
7237
7894
|
return async (request, info) => {
|
|
7895
|
+
if (info.path === "/api" || info.path.startsWith("/api/")) {
|
|
7896
|
+
if (w.origin === null)
|
|
7897
|
+
return plain(404, "Not found");
|
|
7898
|
+
const d = w.deps();
|
|
7899
|
+
if (!d)
|
|
7900
|
+
return json2(problem(503, "starting", "The tracker is starting up; try again in a minute.", { "Retry-After": "60" }));
|
|
7901
|
+
return handleApi({ d, queue: w.queue, reading, base, limiter, failed, recheck: (user, at) => recheck(d, user, at) }, request, info.path);
|
|
7902
|
+
}
|
|
7238
7903
|
const method = request.method;
|
|
7239
7904
|
if (method !== "GET" && method !== "POST")
|
|
7240
7905
|
return plain(405, "Method not allowed", { Allow: "GET, POST" });
|
|
@@ -7260,7 +7925,7 @@ function createWebHandler(w) {
|
|
|
7260
7925
|
return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
|
|
7261
7926
|
if (path === "/login")
|
|
7262
7927
|
return method === "GET" ? loginGet(d, url) : loginPost(d, request);
|
|
7263
|
-
const r =
|
|
7928
|
+
const r = route2(path);
|
|
7264
7929
|
if (r === null)
|
|
7265
7930
|
return htmlResponse(notFoundPage(base), 404);
|
|
7266
7931
|
const allowed = methodsOf(r);
|
|
@@ -7299,9 +7964,14 @@ function createWebHandler(w) {
|
|
|
7299
7964
|
return actionPost(e, r.id, r.action, form);
|
|
7300
7965
|
case "forget":
|
|
7301
7966
|
return forgetRoute(a, method, form);
|
|
7967
|
+
case "tokens":
|
|
7968
|
+
return tokensPost(d, v, auth.session, form, created);
|
|
7969
|
+
case "token-revoke":
|
|
7970
|
+
return tokenRevokePost(d, v, r.id);
|
|
7302
7971
|
case "admin-allow":
|
|
7303
7972
|
case "admin-act":
|
|
7304
7973
|
case "admin-lift":
|
|
7974
|
+
case "admin-token-revoke":
|
|
7305
7975
|
return adminPost(a, r, form);
|
|
7306
7976
|
default:
|
|
7307
7977
|
return plain(405, "Method not allowed", { Allow: allowed });
|
|
@@ -7320,6 +7990,8 @@ function createWebHandler(w) {
|
|
|
7320
7990
|
return newGet(e, r.type);
|
|
7321
7991
|
case "forget":
|
|
7322
7992
|
return forgetRoute(a, method, null);
|
|
7993
|
+
case "tokens":
|
|
7994
|
+
return tokensGet(d, v);
|
|
7323
7995
|
case "admin":
|
|
7324
7996
|
case "admin-tasks":
|
|
7325
7997
|
case "admin-person":
|
|
@@ -7366,7 +8038,7 @@ var NO_DM = {
|
|
|
7366
8038
|
};
|
|
7367
8039
|
function createPlugin(host, options = {}) {
|
|
7368
8040
|
const adminIds = parseAdminIds(host.env.TRACKER_ADMIN_DISCORD_IDS);
|
|
7369
|
-
const
|
|
8041
|
+
const guildIds = parseGuildIds(host.env.TRACKER_GUILD_ID);
|
|
7370
8042
|
const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
|
|
7371
8043
|
const clock = options.clock ?? { now: () => new Date };
|
|
7372
8044
|
const types = options.types ?? TRACKER_TYPES;
|
|
@@ -7382,10 +8054,10 @@ function createPlugin(host, options = {}) {
|
|
|
7382
8054
|
const queue = serial();
|
|
7383
8055
|
const surface = createSurface({
|
|
7384
8056
|
deps: () => deps,
|
|
7385
|
-
|
|
8057
|
+
guildIds,
|
|
7386
8058
|
log: host.log,
|
|
7387
8059
|
queue,
|
|
7388
|
-
web: webOrigin ? { origin: webOrigin, name: host.name, gated:
|
|
8060
|
+
web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildIds !== null } : null,
|
|
7389
8061
|
...options.membership ? { membership: options.membership } : {}
|
|
7390
8062
|
});
|
|
7391
8063
|
async function tick(kind, signal) {
|
|
@@ -7447,14 +8119,14 @@ function createPlugin(host, options = {}) {
|
|
|
7447
8119
|
const clientMembership = async (discordId) => {
|
|
7448
8120
|
if (!discordClient)
|
|
7449
8121
|
return null;
|
|
7450
|
-
return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient },
|
|
8122
|
+
return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient }, guildIds, discordId, host.log);
|
|
7451
8123
|
};
|
|
7452
8124
|
const web = createWebHandler({
|
|
7453
8125
|
name: host.name,
|
|
7454
8126
|
origin: webOrigin,
|
|
7455
8127
|
deps: () => deps,
|
|
7456
8128
|
queue,
|
|
7457
|
-
|
|
8129
|
+
guildIds,
|
|
7458
8130
|
membership: options.webMembership ?? clientMembership
|
|
7459
8131
|
});
|
|
7460
8132
|
const interactions = surface.interactions;
|
|
@@ -7526,12 +8198,13 @@ function createPlugin(host, options = {}) {
|
|
|
7526
8198
|
notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
|
|
7527
8199
|
logins: new LoginLinks(opened),
|
|
7528
8200
|
sessions: new Sessions(opened),
|
|
8201
|
+
apiTokens: new ApiTokens(opened),
|
|
7529
8202
|
roster: new Roster(opened),
|
|
7530
8203
|
configuredAdmins: new Set(adminIds),
|
|
7531
8204
|
lanes,
|
|
7532
8205
|
webEditor: webOrigin !== null
|
|
7533
8206
|
};
|
|
7534
|
-
if (
|
|
8207
|
+
if (guildIds === null)
|
|
7535
8208
|
host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
|
|
7536
8209
|
health.blocked = typeof host.dm === "function" ? null : "this bot has no host.dm (it predates rackbops-discord-bot#736)";
|
|
7537
8210
|
health.activatedAt = clock.now();
|