@rackbops/plugin-tracker 0.5.0 → 0.7.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 +229 -8
- package/dist/plugin.js +1647 -290
- package/package.json +2 -2
package/dist/plugin.js
CHANGED
|
@@ -191,6 +191,14 @@ async function optOut(store, task, recipientId, now, occurrenceId = null) {
|
|
|
191
191
|
at
|
|
192
192
|
});
|
|
193
193
|
}
|
|
194
|
+
async function liftBlock(store, actor, blockId, now) {
|
|
195
|
+
if (!actor.admin)
|
|
196
|
+
throw new ConsentError("only an admin lifts a block");
|
|
197
|
+
const block = await store.getBlock(blockId);
|
|
198
|
+
if (!block)
|
|
199
|
+
throw new ConsentError(`no block ${blockId}`);
|
|
200
|
+
return store.liftBlock(blockId, actor.userId, now.toISOString());
|
|
201
|
+
}
|
|
194
202
|
|
|
195
203
|
// node_modules/@rackbops/docket-core/dist/lanes.js
|
|
196
204
|
var LANES = ["notify", "execute"];
|
|
@@ -1916,6 +1924,10 @@ class ClaimStore {
|
|
|
1916
1924
|
// plugins/tracker/src/delivery-health.ts
|
|
1917
1925
|
var PAUSE_AFTER = 3;
|
|
1918
1926
|
var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
|
|
1927
|
+
function recipientPauseDetail(userId) {
|
|
1928
|
+
return `delivery to {${userId}} paused: ${PAUSE_AFTER} DMs in a row could not be delivered`;
|
|
1929
|
+
}
|
|
1930
|
+
var PERSON_REF = /\{(u[1-9][0-9]*)\}/g;
|
|
1919
1931
|
function decidePause(failures, alreadyPaused) {
|
|
1920
1932
|
return !alreadyPaused && failures >= PAUSE_AFTER;
|
|
1921
1933
|
}
|
|
@@ -1941,6 +1953,9 @@ class DeliveryHealth {
|
|
|
1941
1953
|
this.chain = next.catch(() => {});
|
|
1942
1954
|
return next;
|
|
1943
1955
|
}
|
|
1956
|
+
exclusive(fn) {
|
|
1957
|
+
return this.locked(fn);
|
|
1958
|
+
}
|
|
1944
1959
|
get(userId) {
|
|
1945
1960
|
const r = this.db.query("SELECT * FROM delivery_health WHERE user_id = ?").get(userId);
|
|
1946
1961
|
if (!r)
|
|
@@ -1959,6 +1974,8 @@ class DeliveryHealth {
|
|
|
1959
1974
|
async recordFailure(userId, error, at) {
|
|
1960
1975
|
const notices = [];
|
|
1961
1976
|
const result = await this.locked(async () => {
|
|
1977
|
+
if (!await this.store.getUser(userId))
|
|
1978
|
+
return { failures: 0, paused: false };
|
|
1962
1979
|
this.db.query(`INSERT INTO delivery_health (user_id, failures, last_error, last_failed_at) VALUES (?, 1, ?, ?)
|
|
1963
1980
|
ON CONFLICT (user_id) DO UPDATE SET failures = delivery_health.failures + 1,
|
|
1964
1981
|
last_error = excluded.last_error, last_failed_at = excluded.last_failed_at`).run(userId, error, at);
|
|
@@ -1995,7 +2012,7 @@ class DeliveryHealth {
|
|
|
1995
2012
|
if (changes === 0 || task.status !== "active")
|
|
1996
2013
|
continue;
|
|
1997
2014
|
await this.store.updateTask(task.id, { status: "paused", at });
|
|
1998
|
-
const detail = asRecipient ?
|
|
2015
|
+
const detail = asRecipient ? recipientPauseDetail(userId) : PAUSE_DETAIL;
|
|
1999
2016
|
await this.store.addTaskEvent({ taskId: task.id, actorId: null, kind: "paused", detail, at });
|
|
2000
2017
|
if (asRecipient) {
|
|
2001
2018
|
const owner = await this.store.getUser(task.ownerId);
|
|
@@ -2502,6 +2519,7 @@ async function restoreHeldRun(store, task, held, before, after, now) {
|
|
|
2502
2519
|
|
|
2503
2520
|
// plugins/tracker/src/actions.ts
|
|
2504
2521
|
var MAX_ANSWER = 2000;
|
|
2522
|
+
var NO_LONGER_LISTED = "You are no longer on this tracker's list.";
|
|
2505
2523
|
var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
|
|
2506
2524
|
function clip(text, max = MAX_ANSWER) {
|
|
2507
2525
|
return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
|
|
@@ -3031,6 +3049,8 @@ async function sendShare(d, p) {
|
|
|
3031
3049
|
async function finishShare(d, p, err) {
|
|
3032
3050
|
if (err === null)
|
|
3033
3051
|
return `Invited <@${p.targetDiscordId}> to \`${p.taskId}\`. They get a DM to accept or decline.`;
|
|
3052
|
+
if (!await d.store.getUser(p.targetId) || !await d.store.getTask(p.taskId))
|
|
3053
|
+
return `<@${p.targetDiscordId}> ${CANNOT_SHARE}`;
|
|
3034
3054
|
await d.store.removeRecipient(p.taskId, p.targetId);
|
|
3035
3055
|
await d.store.addTaskEvent({
|
|
3036
3056
|
taskId: p.taskId,
|
|
@@ -3222,12 +3242,9 @@ function runView(o, replies, people, viewer, now) {
|
|
|
3222
3242
|
answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
|
|
3223
3243
|
};
|
|
3224
3244
|
}
|
|
3225
|
-
function changeView(e, viewer, now) {
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
kind: e.kind.replaceAll("_", " "),
|
|
3229
|
-
detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
|
|
3230
|
-
};
|
|
3245
|
+
function changeView(e, viewer, now, names = new Map) {
|
|
3246
|
+
const detail = e.kind === "paused" || e.kind === "resumed" ? e.detail.replace(PERSON_REF, (_, id) => names.get(id) ?? "someone no longer on the tracker") : null;
|
|
3247
|
+
return { at: formatInstant(e.at, viewer.timeZone, now), kind: e.kind.replaceAll("_", " "), detail };
|
|
3231
3248
|
}
|
|
3232
3249
|
function runLine(v) {
|
|
3233
3250
|
const late = v.late ? " (late)" : "";
|
|
@@ -3258,6 +3275,16 @@ async function loadHistory(d, user, taskId) {
|
|
|
3258
3275
|
people.set(u.id, u);
|
|
3259
3276
|
}
|
|
3260
3277
|
}
|
|
3278
|
+
const names = new Map;
|
|
3279
|
+
for (const e of changes) {
|
|
3280
|
+
for (const [, id] of e.detail.matchAll(PERSON_REF)) {
|
|
3281
|
+
if (id === undefined || names.has(id))
|
|
3282
|
+
continue;
|
|
3283
|
+
const u = await d.store.getUser(id);
|
|
3284
|
+
if (u)
|
|
3285
|
+
names.set(id, u.displayName ?? id);
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3261
3288
|
const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
|
|
3262
3289
|
return {
|
|
3263
3290
|
task,
|
|
@@ -3265,7 +3292,7 @@ async function loadHistory(d, user, taskId) {
|
|
|
3265
3292
|
next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
|
|
3266
3293
|
runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
|
|
3267
3294
|
earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
|
|
3268
|
-
changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
|
|
3295
|
+
changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now, names)),
|
|
3269
3296
|
earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
|
|
3270
3297
|
};
|
|
3271
3298
|
}
|
|
@@ -3442,7 +3469,10 @@ async function trackPrice(d, user, input) {
|
|
|
3442
3469
|
finish: async (result) => said(await finishPrice(d, user, pending, result))
|
|
3443
3470
|
};
|
|
3444
3471
|
}
|
|
3445
|
-
async function finishPrice(d,
|
|
3472
|
+
async function finishPrice(d, asked, v, seen) {
|
|
3473
|
+
const user = await d.store.getUser(asked.id);
|
|
3474
|
+
if (!user || !d.admissions.isRegistered(user.id))
|
|
3475
|
+
return { ok: false, error: NO_LONGER_LISTED };
|
|
3446
3476
|
if (!seen.ok)
|
|
3447
3477
|
return seen;
|
|
3448
3478
|
const type = d.types.price;
|
|
@@ -4085,8 +4115,22 @@ var MIGRATIONS = [
|
|
|
4085
4115
|
);
|
|
4086
4116
|
CREATE INDEX web_sessions_user ON web_sessions (user_id);
|
|
4087
4117
|
CREATE INDEX web_sessions_expiry ON web_sessions (expires_at);
|
|
4118
|
+
`,
|
|
4119
|
+
`
|
|
4120
|
+
CREATE TABLE api_tokens (
|
|
4121
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4122
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
4123
|
+
user_id TEXT NOT NULL,
|
|
4124
|
+
name TEXT NOT NULL,
|
|
4125
|
+
created_at TEXT NOT NULL,
|
|
4126
|
+
expires_at TEXT,
|
|
4127
|
+
last_used_at TEXT,
|
|
4128
|
+
member_checked_at TEXT
|
|
4129
|
+
);
|
|
4130
|
+
CREATE INDEX api_tokens_user ON api_tokens (user_id);
|
|
4088
4131
|
`
|
|
4089
4132
|
];
|
|
4133
|
+
var BUSY_TIMEOUT_MS = 5000;
|
|
4090
4134
|
function migrate(db) {
|
|
4091
4135
|
const current = db.query("PRAGMA user_version").get().user_version;
|
|
4092
4136
|
if (current > MIGRATIONS.length) {
|
|
@@ -4098,14 +4142,224 @@ function migrate(db) {
|
|
|
4098
4142
|
db.exec(`PRAGMA user_version = ${version + 1}`);
|
|
4099
4143
|
})();
|
|
4100
4144
|
}
|
|
4145
|
+
return current;
|
|
4101
4146
|
}
|
|
4102
4147
|
function openDatabase(path) {
|
|
4103
4148
|
const db = new Database(path, { create: true });
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4149
|
+
try {
|
|
4150
|
+
if (path !== ":memory:")
|
|
4151
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
4152
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4153
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
4154
|
+
migrate(db);
|
|
4155
|
+
return db;
|
|
4156
|
+
} catch (err) {
|
|
4157
|
+
db.close();
|
|
4158
|
+
throw err;
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
var META_TABLE = "tracker_meta";
|
|
4162
|
+
var VACUUMED = "vacuumed_for_secure_delete";
|
|
4163
|
+
function vacuumOnce(db, path) {
|
|
4164
|
+
if (path === ":memory:")
|
|
4165
|
+
return { ran: false };
|
|
4166
|
+
try {
|
|
4167
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
4168
|
+
if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
|
|
4169
|
+
return { ran: false };
|
|
4170
|
+
db.exec("PRAGMA busy_timeout = 0");
|
|
4171
|
+
try {
|
|
4172
|
+
db.exec("VACUUM");
|
|
4173
|
+
const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
4174
|
+
if (r && Number(r.busy) !== 0)
|
|
4175
|
+
throw new Error("the write-ahead log is busy");
|
|
4176
|
+
} finally {
|
|
4177
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4178
|
+
}
|
|
4179
|
+
db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
|
|
4180
|
+
return { ran: true };
|
|
4181
|
+
} catch (err) {
|
|
4182
|
+
return { ran: false, error: err instanceof Error ? err.message : String(err) };
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
// plugins/tracker/src/roster.ts
|
|
4187
|
+
var FORGOTTEN = "forgotten";
|
|
4188
|
+
function historyNames(kind, detail, id) {
|
|
4189
|
+
switch (kind) {
|
|
4190
|
+
case "recipient_invited":
|
|
4191
|
+
case "recipient_accepted":
|
|
4192
|
+
case "recipient_declined":
|
|
4193
|
+
case "recipient_opted_out":
|
|
4194
|
+
return detail === id;
|
|
4195
|
+
case "blocked":
|
|
4196
|
+
return detail === `${id} 24h` || detail === `${id} permanent`;
|
|
4197
|
+
case "recipient_removed":
|
|
4198
|
+
return detail.startsWith(`${id}: `);
|
|
4199
|
+
case "paused":
|
|
4200
|
+
return detail.startsWith(`delivery to {${id}} paused:`);
|
|
4201
|
+
default:
|
|
4202
|
+
return false;
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
function eventNames(type, text, id) {
|
|
4206
|
+
return type === "delivered" && text.startsWith(`${id} `);
|
|
4207
|
+
}
|
|
4208
|
+
function errorForms(id) {
|
|
4209
|
+
return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
|
|
4210
|
+
}
|
|
4211
|
+
function errorNames(text, id) {
|
|
4212
|
+
return text !== null && errorForms(id).test(text);
|
|
4213
|
+
}
|
|
4214
|
+
function redactError(text, id) {
|
|
4215
|
+
return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
|
|
4216
|
+
}
|
|
4217
|
+
|
|
4218
|
+
class Roster {
|
|
4219
|
+
db;
|
|
4220
|
+
constructor(db) {
|
|
4221
|
+
this.db = db;
|
|
4222
|
+
}
|
|
4223
|
+
people() {
|
|
4224
|
+
const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
|
|
4225
|
+
a.admitted_by, a.registered_at, h.paused_at, h.failures
|
|
4226
|
+
FROM users u
|
|
4227
|
+
LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
|
|
4228
|
+
LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
|
|
4229
|
+
ORDER BY u.seq`).all();
|
|
4230
|
+
const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
|
|
4231
|
+
const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
|
|
4232
|
+
WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
|
|
4233
|
+
return rows.map((r) => {
|
|
4234
|
+
const id = `u${Number(r.seq)}`;
|
|
4235
|
+
const counts = { active: 0, paused: 0, done: 0, archived: 0 };
|
|
4236
|
+
for (const o of owned)
|
|
4237
|
+
if (o.owner_id === id && o.status in counts)
|
|
4238
|
+
counts[o.status] = Number(o.n);
|
|
4239
|
+
const str = (v) => v === null || v === undefined ? null : String(v);
|
|
4240
|
+
return {
|
|
4241
|
+
id,
|
|
4242
|
+
discordId: str(r.discord_id),
|
|
4243
|
+
displayName: str(r.display_name),
|
|
4244
|
+
timeZone: String(r.time_zone),
|
|
4245
|
+
preferredHour: Number(r.preferred_hour),
|
|
4246
|
+
admin: Number(r.admin) === 1,
|
|
4247
|
+
createdAt: String(r.created_at),
|
|
4248
|
+
admittedBy: str(r.admitted_by),
|
|
4249
|
+
registeredAt: str(r.registered_at),
|
|
4250
|
+
deliveryPausedAt: str(r.paused_at),
|
|
4251
|
+
failures: Number(r.failures ?? 0),
|
|
4252
|
+
owned: counts,
|
|
4253
|
+
receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
|
|
4254
|
+
};
|
|
4255
|
+
});
|
|
4256
|
+
}
|
|
4257
|
+
checkpoint() {
|
|
4258
|
+
this.db.exec("PRAGMA busy_timeout = 0");
|
|
4259
|
+
try {
|
|
4260
|
+
const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
4261
|
+
return !r || Number(r.busy) === 0;
|
|
4262
|
+
} finally {
|
|
4263
|
+
this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
othersNamed(taskId, userId, name) {
|
|
4267
|
+
const ids = new Set;
|
|
4268
|
+
for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
|
|
4269
|
+
ids.add(r.user_id);
|
|
4270
|
+
for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
|
|
4271
|
+
const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
|
|
4272
|
+
if (id)
|
|
4273
|
+
ids.add(id);
|
|
4274
|
+
}
|
|
4275
|
+
ids.delete(userId);
|
|
4276
|
+
const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
|
|
4277
|
+
return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
|
|
4278
|
+
}
|
|
4279
|
+
admins() {
|
|
4280
|
+
return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
|
|
4281
|
+
}
|
|
4282
|
+
activeBlocks(now) {
|
|
4283
|
+
const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
|
|
4284
|
+
WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
|
|
4285
|
+
return rows.map((r) => ({
|
|
4286
|
+
id: `b${Number(r.seq)}`,
|
|
4287
|
+
ownerId: String(r.owner_id),
|
|
4288
|
+
recipientId: String(r.recipient_id),
|
|
4289
|
+
expiresAt: r.expires_at === null ? null : String(r.expires_at),
|
|
4290
|
+
createdAt: String(r.created_at)
|
|
4291
|
+
}));
|
|
4292
|
+
}
|
|
4293
|
+
erase(userId) {
|
|
4294
|
+
const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
|
|
4295
|
+
if (seq === undefined)
|
|
4296
|
+
throw new Error(`no user ${userId}`);
|
|
4297
|
+
return this.db.transaction(() => {
|
|
4298
|
+
const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
|
|
4299
|
+
if (!person)
|
|
4300
|
+
throw new Error(`no user ${userId}`);
|
|
4301
|
+
const rows = {};
|
|
4302
|
+
const del = (table, where, ...params) => {
|
|
4303
|
+
rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
|
|
4304
|
+
};
|
|
4305
|
+
const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
|
|
4306
|
+
const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
|
|
4307
|
+
const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
|
|
4308
|
+
const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
|
|
4309
|
+
const recipientOf = new Set([
|
|
4310
|
+
...pausedFor,
|
|
4311
|
+
...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
|
|
4312
|
+
...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
|
|
4313
|
+
]);
|
|
4314
|
+
del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
|
|
4315
|
+
del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
|
|
4316
|
+
del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4317
|
+
del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
|
|
4318
|
+
del("series", `task_id IN (${THEIR_TASKS})`, userId);
|
|
4319
|
+
del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4320
|
+
del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4321
|
+
del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
|
|
4322
|
+
del("tasks", "owner_id = ?", userId);
|
|
4323
|
+
const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
|
|
4324
|
+
for (const r of containing("task_events", "kind, detail", "detail")) {
|
|
4325
|
+
if (historyNames(String(r.kind), String(r.detail), userId))
|
|
4326
|
+
del("task_events", "seq = ?", Number(r.seq));
|
|
4327
|
+
}
|
|
4328
|
+
for (const r of containing("events", "type, text", "text")) {
|
|
4329
|
+
if (eventNames(String(r.type), String(r.text), userId))
|
|
4330
|
+
del("events", "seq = ?", Number(r.seq));
|
|
4331
|
+
else if (errorNames(String(r.text), userId))
|
|
4332
|
+
this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
|
|
4333
|
+
}
|
|
4334
|
+
for (const r of containing("occurrences", "error", "error")) {
|
|
4335
|
+
if (errorNames(String(r.error), userId))
|
|
4336
|
+
this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
|
|
4337
|
+
}
|
|
4338
|
+
if (person.display_name !== null) {
|
|
4339
|
+
const legacy = `delivery to ${person.display_name} paused:`;
|
|
4340
|
+
const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
|
|
4341
|
+
for (const r of rowsNaming) {
|
|
4342
|
+
if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
|
|
4343
|
+
continue;
|
|
4344
|
+
if (this.othersNamed(r.task_id, userId, person.display_name))
|
|
4345
|
+
continue;
|
|
4346
|
+
del("task_events", "seq = ?", r.seq);
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
|
|
4350
|
+
this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
|
|
4351
|
+
del("admissions", "user_id = ?", userId);
|
|
4352
|
+
this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
|
|
4353
|
+
del("delivery_health", "user_id = ?", userId);
|
|
4354
|
+
del("web_sessions", "user_id = ?", userId);
|
|
4355
|
+
del("web_login_tokens", "user_id = ?", userId);
|
|
4356
|
+
del("api_tokens", "user_id = ?", userId);
|
|
4357
|
+
del("users", "seq = ?", Number(seq));
|
|
4358
|
+
const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
|
|
4359
|
+
const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
|
|
4360
|
+
return { rows, freed };
|
|
4361
|
+
})();
|
|
4362
|
+
}
|
|
4109
4363
|
}
|
|
4110
4364
|
|
|
4111
4365
|
// plugins/tracker/src/store.ts
|
|
@@ -4428,160 +4682,97 @@ class SqliteStore {
|
|
|
4428
4682
|
}
|
|
4429
4683
|
}
|
|
4430
4684
|
|
|
4431
|
-
// plugins/tracker/src/
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
4442
|
-
}
|
|
4443
|
-
async function applyEdit(d, task, user, patch) {
|
|
4444
|
-
const fields = Object.keys(patch);
|
|
4445
|
-
if (fields.length === 0)
|
|
4446
|
-
return task;
|
|
4447
|
-
const at = d.clock.now().toISOString();
|
|
4448
|
-
const updated = await d.store.updateTask(task.id, { ...patch, at });
|
|
4449
|
-
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
|
|
4450
|
-
return updated;
|
|
4451
|
-
}
|
|
4452
|
-
async function nextRun(d, task) {
|
|
4453
|
-
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
4454
|
-
return queued[0] ?? null;
|
|
4455
|
-
}
|
|
4456
|
-
function savedText(task, user, next, now, what) {
|
|
4457
|
-
const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
|
|
4458
|
-
const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
|
|
4459
|
-
return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
|
|
4685
|
+
// plugins/tracker/src/admin.ts
|
|
4686
|
+
var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
|
|
4687
|
+
var NO_SUCH_BLOCK = "There is no such block in force.";
|
|
4688
|
+
var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
|
|
4689
|
+
var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
|
|
4690
|
+
var TICK_WAIT_MS = 20000;
|
|
4691
|
+
var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
|
|
4692
|
+
var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
|
|
4693
|
+
function isConfiguredAdmin(d, u) {
|
|
4694
|
+
return u.discordId !== null && d.configuredAdmins.has(u.discordId);
|
|
4460
4695
|
}
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
const task = await owned(d, user, taskId, "reminder");
|
|
4464
|
-
if (typeof task === "string")
|
|
4465
|
-
return { ok: false, error: task };
|
|
4466
|
-
const config = task.config ?? {};
|
|
4467
|
-
const text = blank(input.text) ? String(config.text ?? "") : input.text;
|
|
4468
|
-
const repeat = input.repeat ?? repeatOf(task.schedule);
|
|
4469
|
-
const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
|
|
4470
|
-
if (!plan.ok)
|
|
4471
|
-
return plan;
|
|
4472
|
-
let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
|
|
4473
|
-
if (!same(plan.schedule, task.schedule))
|
|
4474
|
-
updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
|
|
4475
|
-
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
|
|
4696
|
+
function ticksSettled(d) {
|
|
4697
|
+
return d.lanes.idle(TICK_WAIT_MS);
|
|
4476
4698
|
}
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
|
|
4480
|
-
let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
|
|
4481
|
-
for (let i = 0;i < 1000; i++) {
|
|
4482
|
-
const due = nextDue(s, after, ctx);
|
|
4483
|
-
if (!due)
|
|
4484
|
-
return null;
|
|
4485
|
-
const on = periodDate(s, due, owner.timeZone);
|
|
4486
|
-
if (on >= date)
|
|
4487
|
-
return on;
|
|
4488
|
-
after = due;
|
|
4489
|
-
}
|
|
4490
|
-
return null;
|
|
4699
|
+
function isPersonId(id) {
|
|
4700
|
+
return /^u[1-9][0-9]{0,15}$/.test(id);
|
|
4491
4701
|
}
|
|
4492
|
-
function
|
|
4493
|
-
|
|
4494
|
-
if (s?.kind !== "period")
|
|
4495
|
-
return "";
|
|
4496
|
-
const w = wallClock(now, owner.timeZone);
|
|
4497
|
-
const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
4498
|
-
if (s.anchor >= today)
|
|
4499
|
-
return s.anchor;
|
|
4500
|
-
return periodDateFrom(s, today, owner) ?? s.anchor;
|
|
4702
|
+
async function person(d, id) {
|
|
4703
|
+
return isPersonId(id) ? d.store.getUser(id) : null;
|
|
4501
4704
|
}
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
if (old?.kind !== "period")
|
|
4505
|
-
return false;
|
|
4506
|
-
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
4507
|
-
return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
|
|
4705
|
+
function who(u) {
|
|
4706
|
+
return u.displayName ?? u.discordId ?? u.id;
|
|
4508
4707
|
}
|
|
4509
|
-
async function
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
if (
|
|
4514
|
-
return { ok: false, error:
|
|
4515
|
-
|
|
4708
|
+
async function setAdminFlag(d, admin, targetId, flag) {
|
|
4709
|
+
if (!admin.admin)
|
|
4710
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4711
|
+
const target = await person(d, targetId);
|
|
4712
|
+
if (!target)
|
|
4713
|
+
return { ok: false, error: NO_SUCH_PERSON };
|
|
4714
|
+
if (target.admin === flag)
|
|
4715
|
+
return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
|
|
4716
|
+
if (!flag && d.roster.admins() <= 1)
|
|
4717
|
+
return { ok: false, error: LAST_ADMIN };
|
|
4718
|
+
if (!flag && isConfiguredAdmin(d, target))
|
|
4719
|
+
return { ok: false, error: CONFIGURED_ADMIN };
|
|
4720
|
+
await d.store.updateUser(target.id, { admin: flag });
|
|
4721
|
+
d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
|
|
4722
|
+
return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
|
|
4723
|
+
}
|
|
4724
|
+
async function resumeDelivery(d, admin, targetId) {
|
|
4725
|
+
if (!admin.admin)
|
|
4726
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4727
|
+
const target = await person(d, targetId);
|
|
4728
|
+
if (!target)
|
|
4729
|
+
return { ok: false, error: NO_SUCH_PERSON };
|
|
4730
|
+
const resumed = await d.health.resume(target.id, d.clock.now());
|
|
4731
|
+
if (!resumed)
|
|
4732
|
+
return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
|
|
4733
|
+
return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
|
|
4734
|
+
}
|
|
4735
|
+
async function liftDeclineBlock(d, admin, blockId) {
|
|
4736
|
+
if (!admin.admin)
|
|
4737
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4738
|
+
const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
|
|
4516
4739
|
const now = d.clock.now();
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
const
|
|
4521
|
-
const
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
every: input.every ?? old.every,
|
|
4527
|
-
unit: input.unit ?? old.unit,
|
|
4528
|
-
lead: input.lead ?? old.leadDays ?? 0,
|
|
4529
|
-
...note !== undefined ? { note } : {}
|
|
4530
|
-
});
|
|
4531
|
-
if (!plan.ok)
|
|
4532
|
-
return plan;
|
|
4533
|
-
const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
|
|
4534
|
-
const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
|
|
4535
|
-
const problems = scheduleProblems(schedule);
|
|
4536
|
-
if (problems.length > 0)
|
|
4537
|
-
return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
|
|
4538
|
-
const patch = {};
|
|
4539
|
-
if (plan.name !== task.title)
|
|
4540
|
-
patch.title = plan.name;
|
|
4541
|
-
const newAmount = plan.amount !== (carried ?? config.amount);
|
|
4542
|
-
const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
|
|
4543
|
-
if (!same(nextConfig, config))
|
|
4544
|
-
patch.config = nextConfig;
|
|
4545
|
-
if (newAmount && carried !== null)
|
|
4546
|
-
patch.state = { ...state, amount: plan.amount };
|
|
4547
|
-
let updated = await applyEdit(d, task, user, patch);
|
|
4548
|
-
let next = await nextRun(d, updated);
|
|
4549
|
-
if (!same(schedule, old)) {
|
|
4550
|
-
const asked = await askedAbout(d, task, user, schedule.anchor);
|
|
4551
|
-
updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
|
|
4552
|
-
next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
|
|
4553
|
-
}
|
|
4554
|
-
const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
|
|
4555
|
-
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
4556
|
-
return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
|
|
4740
|
+
if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
|
|
4741
|
+
return { ok: false, error: NO_SUCH_BLOCK };
|
|
4742
|
+
await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
|
|
4743
|
+
const owner = await d.store.getUser(block.ownerId);
|
|
4744
|
+
const recipient = await d.store.getUser(block.recipientId);
|
|
4745
|
+
return {
|
|
4746
|
+
ok: true,
|
|
4747
|
+
text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
|
|
4748
|
+
};
|
|
4557
4749
|
}
|
|
4558
|
-
async function
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4750
|
+
async function forgetPerson(d, actor, targetId) {
|
|
4751
|
+
if (actor.id !== targetId && !actor.admin)
|
|
4752
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4753
|
+
const target = await person(d, targetId);
|
|
4754
|
+
if (!target)
|
|
4755
|
+
return { ok: false, error: NO_SUCH_PERSON };
|
|
4756
|
+
if (target.admin && d.roster.admins() <= 1)
|
|
4757
|
+
return { ok: false, error: LAST_ADMIN };
|
|
4758
|
+
if (actor.id !== target.id && isConfiguredAdmin(d, target))
|
|
4759
|
+
return { ok: false, error: CONFIGURED_ADMIN };
|
|
4760
|
+
const now = d.clock.now();
|
|
4761
|
+
const erased = await d.health.exclusive(async () => {
|
|
4762
|
+
if (d.lanes.busy())
|
|
4763
|
+
return null;
|
|
4764
|
+
const e = d.roster.erase(target.id);
|
|
4765
|
+
for (const taskId of e.freed)
|
|
4766
|
+
await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
|
|
4767
|
+
return e;
|
|
4569
4768
|
});
|
|
4570
|
-
if (
|
|
4571
|
-
return
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
if (!same(nextConfig, config))
|
|
4578
|
-
patch.config = nextConfig;
|
|
4579
|
-
let updated = await applyEdit(d, task, user, patch);
|
|
4580
|
-
if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
|
|
4581
|
-
const next = { ...schedule, every: plan.hours, unit: "hour" };
|
|
4582
|
-
updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
|
|
4583
|
-
}
|
|
4584
|
-
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
|
|
4769
|
+
if (erased === null)
|
|
4770
|
+
return { ok: false, error: BUSY };
|
|
4771
|
+
if (!d.roster.checkpoint())
|
|
4772
|
+
d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
|
|
4773
|
+
const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
|
|
4774
|
+
d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
|
|
4775
|
+
return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
|
|
4585
4776
|
}
|
|
4586
4777
|
|
|
4587
4778
|
// node_modules/@rackbops/styles/rackbops-noir/bundle.css
|
|
@@ -5810,6 +6001,7 @@ function page(f) {
|
|
|
5810
6001
|
<span class="rb-muted">${f.signedIn.name}</span>
|
|
5811
6002
|
<a class="rb-link" href="${f.base}/">My tasks</a>
|
|
5812
6003
|
<a class="rb-link" href="${f.base}/settings">Settings</a>
|
|
6004
|
+
${f.signedIn.admin ? html`<a class="rb-link" href="${f.base}/admin">Admin</a>` : null}
|
|
5813
6005
|
<form method="post" action="${f.base}/logout"><input type="hidden" name="csrf" value="${f.signedIn.csrf}"><button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Sign out</button></form>
|
|
5814
6006
|
</nav>` : null;
|
|
5815
6007
|
return html`<!doctype html>
|
|
@@ -5883,7 +6075,7 @@ function name3(u) {
|
|
|
5883
6075
|
return u.displayName ?? "you";
|
|
5884
6076
|
}
|
|
5885
6077
|
function framed(v, title, body) {
|
|
5886
|
-
return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf }, body });
|
|
6078
|
+
return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf, admin: v.user.admin }, body });
|
|
5887
6079
|
}
|
|
5888
6080
|
function signInHelpPage(base, note) {
|
|
5889
6081
|
return page({
|
|
@@ -5967,6 +6159,7 @@ function historyPage(v, h, extra = {}) {
|
|
|
5967
6159
|
<p><a class="rb-link" href="${v.base}/">My tasks</a></p>
|
|
5968
6160
|
<h1>${h.task.title}</h1>
|
|
5969
6161
|
${extra.flash ?? null}
|
|
6162
|
+
${extra.owner ? html`<p class="rb-muted">Owned by ${extra.owner}; you can see it, not change it.</p>` : null}
|
|
5970
6163
|
<p>${h.task.type}, ${h.task.status === "archived" ? "deleted" : h.task.status}, ${h.cadence}. Next: ${h.task.status === "active" ? h.next ?? "nothing scheduled" : "nothing, while it is not active"}.</p>
|
|
5971
6164
|
${extra.controls ?? null}
|
|
5972
6165
|
</section>
|
|
@@ -6001,6 +6194,14 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
|
|
|
6001
6194
|
</div>
|
|
6002
6195
|
<div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
|
|
6003
6196
|
</form>
|
|
6197
|
+
</section>
|
|
6198
|
+
<section>
|
|
6199
|
+
<h2>API tokens</h2>
|
|
6200
|
+
<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>
|
|
6201
|
+
</section>
|
|
6202
|
+
<section>
|
|
6203
|
+
<h2>Forget me</h2>
|
|
6204
|
+
<p>Delete everything the tracker holds about you, for good. <a class="rb-link" href="${v.base}/forget">Forget me</a></p>
|
|
6004
6205
|
</section>`);
|
|
6005
6206
|
}
|
|
6006
6207
|
function notFoundPage(base, v) {
|
|
@@ -6152,64 +6353,671 @@ function notice(key, error) {
|
|
|
6152
6353
|
return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
|
|
6153
6354
|
}
|
|
6154
6355
|
|
|
6155
|
-
// plugins/tracker/src/web/
|
|
6156
|
-
var
|
|
6157
|
-
var
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
|
|
6166
|
-
function int(form, name) {
|
|
6167
|
-
const raw = str2(form, name).trim();
|
|
6168
|
-
if (raw === "")
|
|
6169
|
-
return;
|
|
6170
|
-
return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
|
|
6171
|
-
}
|
|
6172
|
-
function decimal(form, name) {
|
|
6173
|
-
const raw = str2(form, name).trim();
|
|
6174
|
-
if (raw === "")
|
|
6175
|
-
return;
|
|
6176
|
-
return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
|
|
6356
|
+
// plugins/tracker/src/web/api-tokens.ts
|
|
6357
|
+
var TOKEN_PREFIX = "trk_";
|
|
6358
|
+
var MAX_TOKENS_PER_PERSON = 10;
|
|
6359
|
+
var MAX_TOKEN_NAME = 50;
|
|
6360
|
+
var TOKEN_EXPIRY_DAYS = [30, 90, 365];
|
|
6361
|
+
var DEFAULT_EXPIRY_DAYS = 90;
|
|
6362
|
+
var DAY_MS3 = 24 * 60 * 60 * 1000;
|
|
6363
|
+
var TOUCH_EVERY_MS = 60 * 1000;
|
|
6364
|
+
function isApiTokenShape(value) {
|
|
6365
|
+
return /^trk_[A-Za-z0-9_-]{43}$/.test(value);
|
|
6177
6366
|
}
|
|
6178
|
-
function
|
|
6179
|
-
return
|
|
6367
|
+
function isTokenId(id) {
|
|
6368
|
+
return /^k[1-9][0-9]{0,15}$/.test(id);
|
|
6180
6369
|
}
|
|
6181
|
-
function
|
|
6182
|
-
const
|
|
6183
|
-
return
|
|
6370
|
+
function expiryChoice(raw) {
|
|
6371
|
+
const days = Number(raw);
|
|
6372
|
+
return TOKEN_EXPIRY_DAYS.includes(days) ? days : undefined;
|
|
6184
6373
|
}
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
const lead = int(form, "lead");
|
|
6188
|
-
const unit = opt(form, "unit");
|
|
6189
|
-
const note = opt(form, "note");
|
|
6374
|
+
var COLUMNS = "seq, user_id, name, created_at, expires_at, last_used_at, member_checked_at";
|
|
6375
|
+
function toToken(r) {
|
|
6190
6376
|
return {
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
...note !== undefined ? { note } : {}
|
|
6377
|
+
id: `k${Number(r.seq)}`,
|
|
6378
|
+
userId: r.user_id,
|
|
6379
|
+
name: r.name,
|
|
6380
|
+
createdAt: r.created_at,
|
|
6381
|
+
expiresAt: r.expires_at,
|
|
6382
|
+
lastUsedAt: r.last_used_at,
|
|
6383
|
+
memberCheckedAt: r.member_checked_at
|
|
6199
6384
|
};
|
|
6200
6385
|
}
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6386
|
+
|
|
6387
|
+
class ApiTokens {
|
|
6388
|
+
db;
|
|
6389
|
+
constructor(db) {
|
|
6390
|
+
this.db = db;
|
|
6391
|
+
}
|
|
6392
|
+
create(userId, input, now, memberCheckedAt) {
|
|
6393
|
+
const name = input.name.trim();
|
|
6394
|
+
if (name.length === 0)
|
|
6395
|
+
return { ok: false, error: "Give the token a name, such as the program that will use it." };
|
|
6396
|
+
if (name.length > MAX_TOKEN_NAME)
|
|
6397
|
+
return { ok: false, error: `That name is longer than ${MAX_TOKEN_NAME} characters.` };
|
|
6398
|
+
if (/[\u0000-\u001f\u007f]/.test(name))
|
|
6399
|
+
return { ok: false, error: "That name has a control character in it." };
|
|
6400
|
+
if (!TOKEN_EXPIRY_DAYS.includes(input.days))
|
|
6401
|
+
return { ok: false, error: "Choose when the token expires." };
|
|
6402
|
+
const at = now.toISOString();
|
|
6403
|
+
return this.db.transaction(() => {
|
|
6404
|
+
const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
|
|
6405
|
+
if (seq === undefined || this.db.query("SELECT 1 FROM users WHERE seq = ?").get(Number(seq)) === null) {
|
|
6406
|
+
return { ok: false, error: "You are no longer on this tracker's list." };
|
|
6407
|
+
}
|
|
6408
|
+
this.db.query("DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at <= ?").run(at);
|
|
6409
|
+
const live = this.db.query("SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = ?").get(userId).n;
|
|
6410
|
+
if (live >= MAX_TOKENS_PER_PERSON) {
|
|
6411
|
+
return { ok: false, error: `You already have ${MAX_TOKENS_PER_PERSON} API tokens, the most one person may. Revoke one first.` };
|
|
6412
|
+
}
|
|
6413
|
+
const token = `${TOKEN_PREFIX}${randomToken()}`;
|
|
6414
|
+
const expiresAt = new Date(now.getTime() + input.days * DAY_MS3).toISOString();
|
|
6415
|
+
const r = this.db.query(`INSERT INTO api_tokens (token_hash, user_id, name, created_at, expires_at, last_used_at, member_checked_at)
|
|
6416
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?) RETURNING ${COLUMNS}`).get(sha256(token), userId, name, at, expiresAt, memberCheckedAt);
|
|
6417
|
+
return { ok: true, token, row: toToken(r) };
|
|
6418
|
+
})();
|
|
6419
|
+
}
|
|
6420
|
+
find(token, now) {
|
|
6421
|
+
if (!isApiTokenShape(token))
|
|
6422
|
+
return null;
|
|
6423
|
+
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());
|
|
6424
|
+
return r ? toToken(r) : null;
|
|
6425
|
+
}
|
|
6426
|
+
get(id) {
|
|
6427
|
+
if (!isTokenId(id))
|
|
6428
|
+
return null;
|
|
6429
|
+
const r = this.db.query(`SELECT ${COLUMNS} FROM api_tokens WHERE seq = ?`).get(Number(id.slice(1)));
|
|
6430
|
+
return r ? toToken(r) : null;
|
|
6431
|
+
}
|
|
6432
|
+
listFor(userId, now) {
|
|
6433
|
+
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());
|
|
6434
|
+
return rows.map(toToken);
|
|
6435
|
+
}
|
|
6436
|
+
touch(token, now) {
|
|
6437
|
+
if (!isTokenId(token.id))
|
|
6438
|
+
return false;
|
|
6439
|
+
if (token.lastUsedAt !== null && now.getTime() - Date.parse(token.lastUsedAt) < TOUCH_EVERY_MS && Date.parse(token.lastUsedAt) <= now.getTime())
|
|
6440
|
+
return false;
|
|
6441
|
+
const cutoff = new Date(now.getTime() - TOUCH_EVERY_MS).toISOString();
|
|
6442
|
+
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;
|
|
6443
|
+
}
|
|
6444
|
+
revokeOwn(userId, id) {
|
|
6445
|
+
if (!isTokenId(id))
|
|
6446
|
+
return false;
|
|
6447
|
+
return this.db.query("DELETE FROM api_tokens WHERE seq = ? AND user_id = ?").run(Number(id.slice(1)), userId).changes > 0;
|
|
6448
|
+
}
|
|
6449
|
+
revokeAny(id) {
|
|
6450
|
+
if (!isTokenId(id))
|
|
6451
|
+
return null;
|
|
6452
|
+
const r = this.db.query("DELETE FROM api_tokens WHERE seq = ? RETURNING user_id").get(Number(id.slice(1)));
|
|
6453
|
+
return r ? r.user_id : null;
|
|
6454
|
+
}
|
|
6455
|
+
deleteForUser(userId) {
|
|
6456
|
+
this.db.query("DELETE FROM api_tokens WHERE user_id = ?").run(userId);
|
|
6457
|
+
}
|
|
6458
|
+
confirmMember(userId, at) {
|
|
6459
|
+
this.db.query("UPDATE api_tokens SET member_checked_at = ? WHERE user_id = ?").run(at, userId);
|
|
6460
|
+
}
|
|
6461
|
+
}
|
|
6462
|
+
|
|
6463
|
+
// plugins/tracker/src/web/token-pages.ts
|
|
6464
|
+
function tokenHref(v, id) {
|
|
6465
|
+
return `${v.base}/tokens/${encodeURIComponent(id)}/revoke`;
|
|
6466
|
+
}
|
|
6467
|
+
function adminTokenHref(v, id) {
|
|
6468
|
+
return `${v.base}/admin/tokens/${encodeURIComponent(id)}/revoke`;
|
|
6469
|
+
}
|
|
6470
|
+
function at(iso, viewer, now, none) {
|
|
6471
|
+
return iso ? formatInstant(iso, viewer.timeZone, now) : none;
|
|
6472
|
+
}
|
|
6473
|
+
function tokenTable(v, tokens, now, href) {
|
|
6474
|
+
if (tokens.length === 0)
|
|
6475
|
+
return html`<p class="rb-muted">None.</p>`;
|
|
6476
|
+
return html`<div class="rb-table-scroll"><table class="rb-table">
|
|
6477
|
+
<thead><tr><th>Name</th><th>Made</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
|
6478
|
+
<tbody>${tokens.map((t) => html`<tr>
|
|
6479
|
+
<td>${t.name}</td>
|
|
6480
|
+
<td>${at(t.createdAt, v.user, now, "")}</td>
|
|
6481
|
+
<td>${at(t.lastUsedAt, v.user, now, "never")}</td>
|
|
6482
|
+
<td>${at(t.expiresAt, v.user, now, "never")}</td>
|
|
6483
|
+
<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>
|
|
6484
|
+
</tr>`)}</tbody>
|
|
6485
|
+
</table></div>`;
|
|
6486
|
+
}
|
|
6487
|
+
function tokensPage(v, tokens, now, f = {}) {
|
|
6488
|
+
const expiry = f.expiry ?? String(DEFAULT_EXPIRY_DAYS);
|
|
6489
|
+
const choices = TOKEN_EXPIRY_DAYS.map((d) => [String(d), `in ${d} days`]);
|
|
6490
|
+
return framed(v, "API tokens", html`<section>
|
|
6491
|
+
<p><a class="rb-link" href="${v.base}/settings">Settings</a></p>
|
|
6492
|
+
<h1>API tokens</h1>
|
|
6493
|
+
<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>
|
|
6494
|
+
${f.result ? html`<div class="rb-alert rb-alert--success" role="status"><p>${f.result}</p></div>` : null}
|
|
6495
|
+
${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}
|
|
6496
|
+
${tokenTable(v, tokens, now, (id) => tokenHref(v, id))}
|
|
6497
|
+
</section>
|
|
6498
|
+
<section>
|
|
6499
|
+
<h2>Make a token</h2>
|
|
6500
|
+
<form method="post" action="${v.base}/tokens" class="tr-stack">
|
|
6501
|
+
<input type="hidden" name="csrf" value="${v.csrf}">
|
|
6502
|
+
<div class="rb-field">
|
|
6503
|
+
<label class="rb-label" for="name">Name</label>
|
|
6504
|
+
<input class="rb-input" id="name" name="name" value="${f.name ?? ""}" maxlength="${MAX_TOKEN_NAME}" required>
|
|
6505
|
+
<p class="rb-field__help">What will use it, so you know which to revoke.</p>
|
|
6506
|
+
</div>
|
|
6507
|
+
<div class="rb-field">
|
|
6508
|
+
<label class="rb-label" for="expiry">Expires</label>
|
|
6509
|
+
<select class="rb-select" id="expiry" name="expiry">${choices.map(([value, label]) => html`<option value="${value}"${value === expiry ? html` selected` : null}>${label}</option>`)}</select>
|
|
6510
|
+
</div>
|
|
6511
|
+
<div><button class="rb-btn rb-btn--primary" type="submit">Make token</button></div>
|
|
6512
|
+
</form>
|
|
6513
|
+
</section>`);
|
|
6514
|
+
}
|
|
6515
|
+
|
|
6516
|
+
// plugins/tracker/src/web/admin-pages.ts
|
|
6517
|
+
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.";
|
|
6518
|
+
var CONFIRM_WORD = "forget";
|
|
6519
|
+
function nameOf(p, fallback = "someone") {
|
|
6520
|
+
if (!p)
|
|
6521
|
+
return fallback;
|
|
6522
|
+
return p.displayName ?? p.id;
|
|
6523
|
+
}
|
|
6524
|
+
function unmention(text) {
|
|
6525
|
+
return text.replace(/<@([0-9]{17,20})>/g, "$1");
|
|
6526
|
+
}
|
|
6527
|
+
function flash(result) {
|
|
6528
|
+
if (!result)
|
|
6529
|
+
return null;
|
|
6530
|
+
const text = prose(unmention(result.text));
|
|
6531
|
+
return result.ok ? html`<div class="rb-alert rb-alert--success" role="status"><p>${text}</p></div>` : html`<div class="rb-alert rb-alert--danger" role="alert"><p>${text}</p></div>`;
|
|
6532
|
+
}
|
|
6533
|
+
function hidden(v) {
|
|
6534
|
+
return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
|
|
6535
|
+
}
|
|
6536
|
+
function adminNav(v) {
|
|
6537
|
+
return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a></p>`;
|
|
6538
|
+
}
|
|
6539
|
+
function personHref(v, id, action = "") {
|
|
6540
|
+
return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
|
|
6541
|
+
}
|
|
6542
|
+
function admittedBy(p, names) {
|
|
6543
|
+
if (p.admittedBy === null)
|
|
6544
|
+
return "the configuration";
|
|
6545
|
+
if (p.admittedBy === FORGOTTEN)
|
|
6546
|
+
return "an admin since forgotten";
|
|
6547
|
+
return names.get(p.admittedBy) ?? p.admittedBy;
|
|
6548
|
+
}
|
|
6549
|
+
function delivery(p, viewer, now) {
|
|
6550
|
+
if (p.deliveryPausedAt)
|
|
6551
|
+
return `paused since ${formatInstant(p.deliveryPausedAt, viewer.timeZone, now)}`;
|
|
6552
|
+
return p.failures > 0 ? `on (${p.failures} failed in a row)` : "on";
|
|
6553
|
+
}
|
|
6554
|
+
function counts(p) {
|
|
6555
|
+
const o = p.owned;
|
|
6556
|
+
return `${o.active} active, ${o.paused} paused, ${o.done} done, ${o.archived} deleted; receives ${p.receiving}`;
|
|
6557
|
+
}
|
|
6558
|
+
function adminPage(v, data, o = {}) {
|
|
6559
|
+
const names = new Map(data.people.map((p) => [p.id, nameOf(p)]));
|
|
6560
|
+
const rows = data.people.map((p) => html`<tr>
|
|
6561
|
+
<td><a class="rb-link" href="${personHref(v, p.id)}">${nameOf(p)}</a>${p.admin ? html` <span class="rb-badge">admin</span>` : null}</td>
|
|
6562
|
+
<td>${p.registeredAt ? "registered" : "admitted, not registered"}</td>
|
|
6563
|
+
<td>${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</td>
|
|
6564
|
+
<td>${delivery(p, v.user, data.now)}</td>
|
|
6565
|
+
<td>${counts(p)}</td>
|
|
6566
|
+
</tr>`);
|
|
6567
|
+
const blocks = data.blocks.length === 0 ? html`<p class="rb-muted">No decline blocks are in force.</p>` : html`<ul>${data.blocks.map((b) => html`<li class="tr-row">${names.get(b.ownerId) ?? b.ownerId} may not invite ${names.get(b.recipientId) ?? b.recipientId} ${b.expiresAt ? `until ${formatInstant(b.expiresAt, v.user.timeZone, data.now)}` : "until an admin lifts it"}
|
|
6568
|
+
<form method="post" action="${v.base}/admin/blocks/${encodeURIComponent(b.id)}/lift">${hidden(v)}<button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Lift</button></form></li>`)}</ul>`;
|
|
6569
|
+
return framed(v, "Admin", html`<section>
|
|
6570
|
+
<h1>People</h1>
|
|
6571
|
+
${adminNav(v)}
|
|
6572
|
+
${flash(o.result ?? null)}
|
|
6573
|
+
<div class="rb-table-scroll"><table class="rb-table">
|
|
6574
|
+
<thead><tr><th scope="col">Person</th><th scope="col">Status</th><th scope="col">Zone, hour</th><th scope="col">Delivery</th><th scope="col">Tasks</th></tr></thead>
|
|
6575
|
+
<tbody>${rows}</tbody>
|
|
6576
|
+
</table></div>
|
|
6577
|
+
<p class="rb-muted">Admitted by: ${data.people.map((p) => `${nameOf(p)} -- ${admittedBy(p, names)}`).join("; ")}.</p>
|
|
6578
|
+
</section>
|
|
6579
|
+
<section>
|
|
6580
|
+
<h2>Allow a person</h2>
|
|
6581
|
+
<form method="post" action="${v.base}/admin/allow" class="tr-stack">
|
|
6582
|
+
${hidden(v)}
|
|
6583
|
+
<div class="rb-field">
|
|
6584
|
+
<label class="rb-label" for="discord_id">Discord user id</label>
|
|
6585
|
+
<input class="rb-input" id="discord_id" name="discord_id" value="${o.allow ?? ""}" maxlength="20" inputmode="numeric" required>
|
|
6586
|
+
<p class="rb-field__help">What <code>/allow</code> does: they can then <code>/register</code>.</p>
|
|
6587
|
+
</div>
|
|
6588
|
+
<div><button class="rb-btn rb-btn--primary" type="submit">Allow</button></div>
|
|
6589
|
+
</form>
|
|
6590
|
+
</section>
|
|
6591
|
+
<section>
|
|
6592
|
+
<h2>Decline blocks</h2>
|
|
6593
|
+
${blocks}
|
|
6594
|
+
</section>`);
|
|
6595
|
+
}
|
|
6596
|
+
function adminTasksPage(v, tasks) {
|
|
6597
|
+
const rows = tasks.map(({ task, owner, receiving }) => html`<tr>
|
|
6598
|
+
<td><a class="rb-link" href="${taskHref(v, task.id)}">${task.title}</a></td>
|
|
6599
|
+
<td>${task.type}</td>
|
|
6600
|
+
<td>${owner}</td>
|
|
6601
|
+
<td>${task.status === "archived" ? "deleted" : task.status}</td>
|
|
6602
|
+
<td>${receiving}</td>
|
|
6603
|
+
</tr>`);
|
|
6604
|
+
return framed(v, "All tasks", html`<section>
|
|
6605
|
+
<h1>All tasks</h1>
|
|
6606
|
+
${adminNav(v)}
|
|
6607
|
+
${tasks.length === 0 ? html`<p class="rb-muted">There are no tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
|
|
6608
|
+
<thead><tr><th scope="col">Task</th><th scope="col">Type</th><th scope="col">Owner</th><th scope="col">Status</th><th scope="col">Recipients</th></tr></thead>
|
|
6609
|
+
<tbody>${rows}</tbody>
|
|
6610
|
+
</table></div>`}
|
|
6611
|
+
</section>`);
|
|
6612
|
+
}
|
|
6613
|
+
function post2(v, p, action, label, style) {
|
|
6614
|
+
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>`;
|
|
6615
|
+
}
|
|
6616
|
+
function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
|
|
6617
|
+
return framed(v, nameOf(p), html`<section>
|
|
6618
|
+
${adminNav(v)}
|
|
6619
|
+
<h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
|
|
6620
|
+
${flash(result)}
|
|
6621
|
+
<ul>
|
|
6622
|
+
<li>Discord id: ${p.discordId ?? "none"}</li>
|
|
6623
|
+
<li>${p.registeredAt ? `Registered ${formatInstant(p.registeredAt, v.user.timeZone, now)}` : "Admitted, not registered yet"}</li>
|
|
6624
|
+
<li>Zone and hour: ${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</li>
|
|
6625
|
+
<li>Delivery: ${delivery(p, v.user, now)}</li>
|
|
6626
|
+
<li>Tasks: ${counts(p)}</li>
|
|
6627
|
+
</ul>
|
|
6628
|
+
${configured ? html`<p class="rb-muted">${CONFIGURED_NOTE}</p>` : null}
|
|
6629
|
+
<div class="tr-row">
|
|
6630
|
+
${p.admin ? configured ? null : post2(v, p, "revoke", "Revoke admin", "rb-btn--ghost") : post2(v, p, "grant", "Make admin", "rb-btn--ghost")}
|
|
6631
|
+
${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn--accent") : null}
|
|
6632
|
+
${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
|
|
6633
|
+
</div>
|
|
6634
|
+
</section>
|
|
6635
|
+
<section>
|
|
6636
|
+
<h2>Their tasks</h2>
|
|
6637
|
+
${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>`}
|
|
6638
|
+
</section>
|
|
6639
|
+
<section>
|
|
6640
|
+
<h2>Their API tokens</h2>
|
|
6641
|
+
<p class="rb-muted">Each acts as them on their own tasks. Revoking one stops it at once; they are not told.</p>
|
|
6642
|
+
${tokenTable(v, tokens, now, (id) => adminTokenHref(v, id))}
|
|
6643
|
+
</section>`);
|
|
6644
|
+
}
|
|
6645
|
+
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.";
|
|
6646
|
+
function forgetPage(v, note) {
|
|
6647
|
+
return framed(v, "Forget me", html`<section class="rb-card">
|
|
6648
|
+
<h1>Forget me</h1>
|
|
6649
|
+
<p>This deletes everything the tracker holds about you, at once and for good: ${WHAT_GOES}</p>
|
|
6650
|
+
<p>Nothing is kept or archived. Messages the bot already sent you stay in your Discord DMs, where only you can delete them. To use the tracker again later, an admin has to <code>/allow</code> you again, and you start from nothing.</p>
|
|
6651
|
+
${note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${note}</p></div>` : null}
|
|
6652
|
+
<form method="post" action="${v.base}/forget">${hidden(v)}<button class="rb-btn rb-btn--danger" type="submit">Continue</button></form>
|
|
6653
|
+
</section>`);
|
|
6654
|
+
}
|
|
6655
|
+
function confirmForgetPage(v, o) {
|
|
6656
|
+
const title = o.self ? "Delete everything about you?" : `Remove ${o.name} from the tracker?`;
|
|
6657
|
+
const what = o.self ? html`<p>Everything the tracker holds about you is deleted: ${WHAT_GOES} You are signed out.</p>` : html`<p>Everything the tracker holds about ${o.name} is deleted, as forget-me deletes it: their tasks, their replies and history, blocks, pauses, settings and sessions. They are signed out and must be allowed again to come back.</p>`;
|
|
6658
|
+
return framed(v, o.self ? "Forget me" : `Remove ${o.name}`, html`<section class="rb-card">
|
|
6659
|
+
<h1>${title}</h1>
|
|
6660
|
+
${what}
|
|
6661
|
+
${o.note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${o.note}</p></div>` : null}
|
|
6662
|
+
${o.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p>${o.error}</p></div>` : null}
|
|
6663
|
+
<form method="post" action="${o.action}" class="tr-stack">
|
|
6664
|
+
${hidden(v)}
|
|
6665
|
+
<input type="hidden" name="confirm" value="yes">
|
|
6666
|
+
<div class="rb-field">
|
|
6667
|
+
<label class="rb-label" for="word">Type ${CONFIRM_WORD} to confirm</label>
|
|
6668
|
+
<input class="rb-input" id="word" name="word" maxlength="20" autocomplete="off" required>
|
|
6669
|
+
</div>
|
|
6670
|
+
<div class="tr-row"><button class="rb-btn rb-btn--danger" type="submit">${o.self ? "Delete everything" : "Remove"}</button>
|
|
6671
|
+
<a class="rb-btn rb-btn--ghost" href="${o.self ? `${v.base}/settings` : `${v.base}/admin`}">Keep</a></div>
|
|
6672
|
+
</form>
|
|
6673
|
+
</section>`);
|
|
6674
|
+
}
|
|
6675
|
+
|
|
6676
|
+
// plugins/tracker/src/web/admin.ts
|
|
6677
|
+
function unknownPage(base) {
|
|
6678
|
+
return htmlResponse(notFoundPage(base), 404);
|
|
6679
|
+
}
|
|
6680
|
+
function fresh(a, admin, fn) {
|
|
6681
|
+
return a.queue(async () => {
|
|
6682
|
+
const me = await a.d.store.getUser(a.v.user.id);
|
|
6683
|
+
if (!me || admin && !me.admin)
|
|
6684
|
+
return null;
|
|
6685
|
+
return fn(me);
|
|
6686
|
+
});
|
|
6687
|
+
}
|
|
6688
|
+
function said2(done) {
|
|
6689
|
+
return done.ok ? { ok: true, text: done.text } : { ok: false, text: done.error };
|
|
6690
|
+
}
|
|
6691
|
+
function confirmed(form) {
|
|
6692
|
+
if (form.get("confirm") !== "yes")
|
|
6693
|
+
return "ask";
|
|
6694
|
+
return (form.get("word") ?? "").trim().toLowerCase() === CONFIRM_WORD ? "yes" : "wrong";
|
|
6695
|
+
}
|
|
6696
|
+
var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
|
|
6697
|
+
async function peoplePage(a, result, status, allow) {
|
|
6698
|
+
const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
|
|
6699
|
+
return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
|
|
6700
|
+
}
|
|
6701
|
+
async function onePerson(a, id, result = null) {
|
|
6702
|
+
const p = a.d.roster.people().find((x) => x.id === id);
|
|
6703
|
+
if (!p)
|
|
6704
|
+
return htmlResponse(notFoundPage(a.v.base, a.v), 404);
|
|
6705
|
+
const tasks = await a.d.store.listTasks({ ownerId: p.id });
|
|
6706
|
+
const tokens = a.d.apiTokens.listFor(p.id, a.d.clock.now());
|
|
6707
|
+
return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p), tokens), result && !result.ok ? 400 : 200);
|
|
6708
|
+
}
|
|
6709
|
+
async function adminGet(a, r) {
|
|
6710
|
+
if (r.kind === "admin")
|
|
6711
|
+
return peoplePage(a, null, 200);
|
|
6712
|
+
if (r.kind === "admin-person")
|
|
6713
|
+
return onePerson(a, r.id);
|
|
6714
|
+
const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
|
|
6715
|
+
const rows = [];
|
|
6716
|
+
for (const task of await a.d.store.listTasks()) {
|
|
6717
|
+
const receiving = (await a.d.store.listRecipients(task.id)).filter((x) => x.state === "accepted").length;
|
|
6718
|
+
rows.push({ task, owner: people.get(task.ownerId) ?? task.ownerId, receiving });
|
|
6719
|
+
}
|
|
6720
|
+
return htmlResponse(adminTasksPage(a.v, rows));
|
|
6721
|
+
}
|
|
6722
|
+
async function adminPost(a, r, form) {
|
|
6723
|
+
const base = a.v.base;
|
|
6724
|
+
if (r.kind === "admin-allow") {
|
|
6725
|
+
const raw = (form.get("discord_id") ?? "").trim();
|
|
6726
|
+
if (!/^[0-9]{17,20}$/.test(raw))
|
|
6727
|
+
return peoplePage(a, { ok: false, text: "That is not a Discord user id (17 to 20 digits)." }, 400, raw.slice(0, 20));
|
|
6728
|
+
const membership = await a.memberOf(raw);
|
|
6729
|
+
const text = await fresh(a, true, (me) => allowPerson(a.d, me, { discordId: raw, bot: false, membership }));
|
|
6730
|
+
if (text === null)
|
|
6731
|
+
return unknownPage(base);
|
|
6732
|
+
const ok = text.startsWith("Allowed") || text.includes("already on the list");
|
|
6733
|
+
return peoplePage(a, { ok, text }, ok ? 200 : 400);
|
|
6734
|
+
}
|
|
6735
|
+
if (r.kind === "admin-lift") {
|
|
6736
|
+
const done = await fresh(a, true, (me) => liftDeclineBlock(a.d, me, r.id));
|
|
6737
|
+
if (done === null)
|
|
6738
|
+
return unknownPage(base);
|
|
6739
|
+
return peoplePage(a, said2(done), done.ok ? 200 : 400);
|
|
6740
|
+
}
|
|
6741
|
+
if (r.kind === "admin-token-revoke") {
|
|
6742
|
+
const owner = await fresh(a, true, async (me) => {
|
|
6743
|
+
const whose = a.d.apiTokens.revokeAny(r.id);
|
|
6744
|
+
if (whose !== null)
|
|
6745
|
+
a.d.log.info(`${me.id} revoked API token ${r.id} of ${whose}`);
|
|
6746
|
+
return { whose };
|
|
6747
|
+
});
|
|
6748
|
+
if (owner === null)
|
|
6749
|
+
return unknownPage(base);
|
|
6750
|
+
if (owner.whose === null)
|
|
6751
|
+
return htmlResponse(notFoundPage(base, a.v), 404);
|
|
6752
|
+
return onePerson(a, owner.whose, { ok: true, text: "Revoked: that token no longer works." });
|
|
6753
|
+
}
|
|
6754
|
+
if (r.kind !== "admin-act")
|
|
6755
|
+
return unknownPage(base);
|
|
6756
|
+
if (r.action === "forget") {
|
|
6757
|
+
const target = a.d.roster.people().find((x) => x.id === r.id);
|
|
6758
|
+
if (!target)
|
|
6759
|
+
return htmlResponse(notFoundPage(base, a.v), 404);
|
|
6760
|
+
const step = confirmed(form);
|
|
6761
|
+
const self = target.id === a.v.user.id;
|
|
6762
|
+
const note = self && isConfiguredAdmin(a.d, target) ? { note: CONFIGURED_SELF } : {};
|
|
6763
|
+
const page = (error) => confirmForgetPage(a.v, { action: personHref(a.v, target.id, "forget"), self, name: nameOf(target), ...note, ...error ? { error } : {} });
|
|
6764
|
+
if (step === "ask")
|
|
6765
|
+
return htmlResponse(page());
|
|
6766
|
+
if (step === "wrong")
|
|
6767
|
+
return htmlResponse(page(WRONG_WORD), 400);
|
|
6768
|
+
if (!await ticksSettled(a.d))
|
|
6769
|
+
return htmlResponse(page(BUSY), 503);
|
|
6770
|
+
const done = await fresh(a, true, (me) => forgetPerson(a.d, me, target.id));
|
|
6771
|
+
if (done === null)
|
|
6772
|
+
return unknownPage(base);
|
|
6773
|
+
if (!done.ok)
|
|
6774
|
+
return htmlResponse(page(done.error), done.error === NO_SUCH_PERSON ? 404 : done.error === BUSY ? 503 : 400);
|
|
6775
|
+
if (self)
|
|
6776
|
+
return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
|
|
6777
|
+
return peoplePage(a, said2(done), 200);
|
|
6778
|
+
}
|
|
6779
|
+
const act = r.action === "grant" ? (me) => setAdminFlag(a.d, me, r.id, true) : r.action === "revoke" ? (me) => setAdminFlag(a.d, me, r.id, false) : (me) => resumeDelivery(a.d, me, r.id);
|
|
6780
|
+
const done = await fresh(a, true, act);
|
|
6781
|
+
if (done === null)
|
|
6782
|
+
return unknownPage(base);
|
|
6783
|
+
if (!done.ok && done.error === NO_SUCH_PERSON)
|
|
6784
|
+
return htmlResponse(notFoundPage(base, a.v), 404);
|
|
6785
|
+
const me = await a.d.store.getUser(a.v.user.id);
|
|
6786
|
+
if (!me?.admin)
|
|
6787
|
+
return redirect(`${base}/`);
|
|
6788
|
+
return onePerson(a, r.id, said2(done));
|
|
6789
|
+
}
|
|
6790
|
+
async function forgetRoute(a, method, form) {
|
|
6791
|
+
const base = a.v.base;
|
|
6792
|
+
const note = isConfiguredAdmin(a.d, a.v.user) ? CONFIGURED_SELF : undefined;
|
|
6793
|
+
if (method === "GET" || !form)
|
|
6794
|
+
return htmlResponse(forgetPage(a.v, note));
|
|
6795
|
+
const page = (error) => confirmForgetPage(a.v, { action: `${base}/forget`, self: true, name: nameOf(a.v.user), ...note ? { note } : {}, ...error ? { error } : {} });
|
|
6796
|
+
const step = confirmed(form);
|
|
6797
|
+
if (step === "ask")
|
|
6798
|
+
return htmlResponse(page());
|
|
6799
|
+
if (step === "wrong")
|
|
6800
|
+
return htmlResponse(page(WRONG_WORD), 400);
|
|
6801
|
+
if (!await ticksSettled(a.d))
|
|
6802
|
+
return htmlResponse(page(BUSY), 503);
|
|
6803
|
+
const done = await fresh(a, false, (me) => forgetPerson(a.d, me, me.id));
|
|
6804
|
+
if (done === null)
|
|
6805
|
+
return redirect(`${base}/signin`, [a.signOutCookie]);
|
|
6806
|
+
if (!done.ok)
|
|
6807
|
+
return htmlResponse(page(done.error), done.error === BUSY ? 503 : 400);
|
|
6808
|
+
return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
|
|
6809
|
+
}
|
|
6810
|
+
|
|
6811
|
+
// plugins/tracker/src/edit.ts
|
|
6812
|
+
async function owned(d, user, taskId, type) {
|
|
6813
|
+
const task = await ownLiveTask(d, user, taskId);
|
|
6814
|
+
if (!task || task.type !== type)
|
|
6815
|
+
return NO_SUCH_TASK;
|
|
6816
|
+
if (!editable(task))
|
|
6817
|
+
return FINISHED;
|
|
6818
|
+
return task;
|
|
6819
|
+
}
|
|
6820
|
+
function same(a, b) {
|
|
6821
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
6822
|
+
}
|
|
6823
|
+
async function applyEdit(d, task, user, patch) {
|
|
6824
|
+
const fields = Object.keys(patch);
|
|
6825
|
+
if (fields.length === 0)
|
|
6826
|
+
return task;
|
|
6827
|
+
const at = d.clock.now().toISOString();
|
|
6828
|
+
const updated = await d.store.updateTask(task.id, { ...patch, at });
|
|
6829
|
+
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
|
|
6830
|
+
return updated;
|
|
6831
|
+
}
|
|
6832
|
+
async function nextRun(d, task) {
|
|
6833
|
+
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
6834
|
+
return queued[0] ?? null;
|
|
6835
|
+
}
|
|
6836
|
+
function savedText(task, user, next, now, what) {
|
|
6837
|
+
const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
|
|
6838
|
+
const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
|
|
6839
|
+
return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
|
|
6840
|
+
}
|
|
6841
|
+
var blank = (v) => v === undefined || v.trim() === "";
|
|
6842
|
+
async function editReminder(d, user, taskId, input) {
|
|
6843
|
+
const task = await owned(d, user, taskId, "reminder");
|
|
6844
|
+
if (typeof task === "string")
|
|
6845
|
+
return { ok: false, error: task };
|
|
6846
|
+
const config = task.config ?? {};
|
|
6847
|
+
const text = blank(input.text) ? String(config.text ?? "") : input.text;
|
|
6848
|
+
const repeat = input.repeat ?? repeatOf(task.schedule);
|
|
6849
|
+
const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
|
|
6850
|
+
if (!plan.ok)
|
|
6851
|
+
return plan;
|
|
6852
|
+
let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
|
|
6853
|
+
if (!same(plan.schedule, task.schedule))
|
|
6854
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
|
|
6855
|
+
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
|
|
6856
|
+
}
|
|
6857
|
+
var DAY_MS4 = 24 * 60 * 60 * 1000;
|
|
6858
|
+
function periodDateFrom(s, date, owner) {
|
|
6859
|
+
const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
|
|
6860
|
+
let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS4);
|
|
6861
|
+
for (let i = 0;i < 1000; i++) {
|
|
6862
|
+
const due = nextDue(s, after, ctx);
|
|
6863
|
+
if (!due)
|
|
6864
|
+
return null;
|
|
6865
|
+
const on = periodDate(s, due, owner.timeZone);
|
|
6866
|
+
if (on >= date)
|
|
6867
|
+
return on;
|
|
6868
|
+
after = due;
|
|
6869
|
+
}
|
|
6870
|
+
return null;
|
|
6871
|
+
}
|
|
6872
|
+
function renewalDate(task, owner, now) {
|
|
6873
|
+
const s = task.schedule;
|
|
6874
|
+
if (s?.kind !== "period")
|
|
6875
|
+
return "";
|
|
6876
|
+
const w = wallClock(now, owner.timeZone);
|
|
6877
|
+
const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
6878
|
+
if (s.anchor >= today)
|
|
6879
|
+
return s.anchor;
|
|
6880
|
+
return periodDateFrom(s, today, owner) ?? s.anchor;
|
|
6881
|
+
}
|
|
6882
|
+
async function askedAbout(d, task, owner, date) {
|
|
6883
|
+
const old = task.schedule;
|
|
6884
|
+
if (old?.kind !== "period")
|
|
6885
|
+
return false;
|
|
6886
|
+
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
6887
|
+
return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
|
|
6888
|
+
}
|
|
6889
|
+
async function editRenewal(d, user, taskId, input) {
|
|
6890
|
+
const task = await owned(d, user, taskId, "renewal");
|
|
6891
|
+
if (typeof task === "string")
|
|
6892
|
+
return { ok: false, error: task };
|
|
6893
|
+
if (task.schedule?.kind !== "period")
|
|
6894
|
+
return { ok: false, error: FINISHED };
|
|
6895
|
+
const old = task.schedule;
|
|
6896
|
+
const now = d.clock.now();
|
|
6897
|
+
const config = task.config;
|
|
6898
|
+
const state = task.state;
|
|
6899
|
+
const carried = state && typeof state.amount === "number" ? state.amount : null;
|
|
6900
|
+
const note = input.note ?? config.note;
|
|
6901
|
+
const plan = renewalPlan(d, user, {
|
|
6902
|
+
name: blank(input.name) ? task.title : input.name,
|
|
6903
|
+
amount: input.amount ?? carried ?? config.amount,
|
|
6904
|
+
currency: blank(input.currency) ? config.currency : input.currency,
|
|
6905
|
+
renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
|
|
6906
|
+
every: input.every ?? old.every,
|
|
6907
|
+
unit: input.unit ?? old.unit,
|
|
6908
|
+
lead: input.lead ?? old.leadDays ?? 0,
|
|
6909
|
+
...note !== undefined ? { note } : {}
|
|
6910
|
+
});
|
|
6911
|
+
if (!plan.ok)
|
|
6912
|
+
return plan;
|
|
6913
|
+
const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
|
|
6914
|
+
const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
|
|
6915
|
+
const problems = scheduleProblems(schedule);
|
|
6916
|
+
if (problems.length > 0)
|
|
6917
|
+
return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
|
|
6918
|
+
const patch = {};
|
|
6919
|
+
if (plan.name !== task.title)
|
|
6920
|
+
patch.title = plan.name;
|
|
6921
|
+
const newAmount = plan.amount !== (carried ?? config.amount);
|
|
6922
|
+
const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
|
|
6923
|
+
if (!same(nextConfig, config))
|
|
6924
|
+
patch.config = nextConfig;
|
|
6925
|
+
if (newAmount && carried !== null)
|
|
6926
|
+
patch.state = { ...state, amount: plan.amount };
|
|
6927
|
+
let updated = await applyEdit(d, task, user, patch);
|
|
6928
|
+
let next = await nextRun(d, updated);
|
|
6929
|
+
if (!same(schedule, old)) {
|
|
6930
|
+
const asked = await askedAbout(d, task, user, schedule.anchor);
|
|
6931
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
|
|
6932
|
+
next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
|
|
6933
|
+
}
|
|
6934
|
+
const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
|
|
6935
|
+
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
6936
|
+
return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
|
|
6937
|
+
}
|
|
6938
|
+
async function editPrice(d, user, taskId, input) {
|
|
6939
|
+
const task = await owned(d, user, taskId, "price");
|
|
6940
|
+
if (typeof task === "string")
|
|
6941
|
+
return { ok: false, error: task };
|
|
6942
|
+
const config = task.config;
|
|
6943
|
+
const schedule = task.schedule;
|
|
6944
|
+
const plan = priceSettingsPlan({
|
|
6945
|
+
name: input.name ?? task.title,
|
|
6946
|
+
hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
|
|
6947
|
+
drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
|
|
6948
|
+
baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
|
|
6949
|
+
});
|
|
6950
|
+
if (!plan.ok)
|
|
6951
|
+
return plan;
|
|
6952
|
+
const patch = {};
|
|
6953
|
+
const title = plan.name || titleFor(config.url);
|
|
6954
|
+
if (title !== task.title)
|
|
6955
|
+
patch.title = title;
|
|
6956
|
+
const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
|
|
6957
|
+
if (!same(nextConfig, config))
|
|
6958
|
+
patch.config = nextConfig;
|
|
6959
|
+
let updated = await applyEdit(d, task, user, patch);
|
|
6960
|
+
if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
|
|
6961
|
+
const next = { ...schedule, every: plan.hours, unit: "hour" };
|
|
6962
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
|
|
6963
|
+
}
|
|
6964
|
+
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
|
|
6965
|
+
}
|
|
6966
|
+
|
|
6967
|
+
// plugins/tracker/src/web/form-input.ts
|
|
6968
|
+
var str2 = (form, name) => form.get(name) ?? "";
|
|
6969
|
+
var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
|
|
6970
|
+
function int(form, name) {
|
|
6971
|
+
const raw = str2(form, name).trim();
|
|
6972
|
+
if (raw === "")
|
|
6973
|
+
return;
|
|
6974
|
+
return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
|
|
6975
|
+
}
|
|
6976
|
+
function decimal(form, name) {
|
|
6977
|
+
const raw = str2(form, name).trim();
|
|
6978
|
+
if (raw === "")
|
|
6979
|
+
return;
|
|
6980
|
+
return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
|
|
6981
|
+
}
|
|
6982
|
+
function typed(type, mode, form) {
|
|
6983
|
+
return Object.fromEntries(fieldsFor(type, mode).map((f) => [f.name, str2(form, f.name)]));
|
|
6984
|
+
}
|
|
6985
|
+
function reminderInput(form) {
|
|
6986
|
+
const when = opt(form, "when");
|
|
6987
|
+
return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
|
|
6988
|
+
}
|
|
6989
|
+
function renewalInput(form) {
|
|
6990
|
+
const every = int(form, "every");
|
|
6991
|
+
const lead = int(form, "lead");
|
|
6992
|
+
const unit = opt(form, "unit");
|
|
6993
|
+
const note = opt(form, "note");
|
|
6994
|
+
return {
|
|
6995
|
+
name: str2(form, "name"),
|
|
6996
|
+
amount: decimal(form, "amount") ?? Number.NaN,
|
|
6997
|
+
currency: str2(form, "currency"),
|
|
6998
|
+
renews: str2(form, "renews"),
|
|
6999
|
+
...every !== undefined ? { every } : {},
|
|
7000
|
+
...lead !== undefined ? { lead } : {},
|
|
7001
|
+
...unit !== undefined ? { unit } : {},
|
|
7002
|
+
...note !== undefined ? { note } : {}
|
|
7003
|
+
};
|
|
7004
|
+
}
|
|
7005
|
+
function priceSettings(form) {
|
|
7006
|
+
const name = opt(form, "name");
|
|
7007
|
+
const hours = int(form, "hours");
|
|
7008
|
+
const drop = decimal(form, "drop");
|
|
7009
|
+
const baseline = opt(form, "baseline");
|
|
7010
|
+
return {
|
|
7011
|
+
...name !== undefined ? { name } : {},
|
|
7012
|
+
...hours !== undefined ? { hours } : {},
|
|
6209
7013
|
...drop !== undefined ? { drop } : {},
|
|
6210
7014
|
...baseline !== undefined ? { baseline } : {}
|
|
6211
7015
|
};
|
|
6212
7016
|
}
|
|
7017
|
+
function priceInput(form) {
|
|
7018
|
+
const near = opt(form, "near");
|
|
7019
|
+
return { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
|
|
7020
|
+
}
|
|
6213
7021
|
function reminderEdit(form) {
|
|
6214
7022
|
const text = opt(form, "text");
|
|
6215
7023
|
const when = opt(form, "when");
|
|
@@ -6239,54 +7047,7 @@ function priceEdit(form) {
|
|
|
6239
7047
|
const { name: _name, ...rest } = priceSettings(form);
|
|
6240
7048
|
return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
|
|
6241
7049
|
}
|
|
6242
|
-
|
|
6243
|
-
reminder: { repeat: "none" },
|
|
6244
|
-
renewal: { unit: "year", every: "1", lead: "7" },
|
|
6245
|
-
price: { hours: "12", drop: "10", baseline: "last" }
|
|
6246
|
-
};
|
|
6247
|
-
function newGet(e, type) {
|
|
6248
|
-
return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
|
|
6249
|
-
}
|
|
6250
|
-
async function newPost(e, type, form) {
|
|
6251
|
-
let made;
|
|
6252
|
-
if (type === "reminder")
|
|
6253
|
-
made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
|
|
6254
|
-
else if (type === "renewal")
|
|
6255
|
-
made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
|
|
6256
|
-
else {
|
|
6257
|
-
const near = opt(form, "near");
|
|
6258
|
-
const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
|
|
6259
|
-
const who = e.v.user.id;
|
|
6260
|
-
if (e.reading.has(who))
|
|
6261
|
-
made = { ok: false, error: READING };
|
|
6262
|
-
else {
|
|
6263
|
-
e.reading.add(who);
|
|
6264
|
-
try {
|
|
6265
|
-
const started = await asViewer(e, (u) => startPrice(e.d, u, input));
|
|
6266
|
-
if (!started.ok)
|
|
6267
|
-
made = started;
|
|
6268
|
-
else {
|
|
6269
|
-
const { pending } = started;
|
|
6270
|
-
const seen = await previewPrice(e.d, pending);
|
|
6271
|
-
made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
|
|
6272
|
-
}
|
|
6273
|
-
} finally {
|
|
6274
|
-
e.reading.delete(who);
|
|
6275
|
-
}
|
|
6276
|
-
}
|
|
6277
|
-
}
|
|
6278
|
-
if (!made.ok)
|
|
6279
|
-
return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
|
|
6280
|
-
return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
|
|
6281
|
-
}
|
|
6282
|
-
async function taskPage(e, id, done, error) {
|
|
6283
|
-
const view = await loadHistory(e.d, e.v.user, id);
|
|
6284
|
-
if (!view)
|
|
6285
|
-
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6286
|
-
const controls = view.task.ownerId === e.v.user.id ? ownerControls(e.v, view.task) : null;
|
|
6287
|
-
return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error) }), error ? 400 : 200);
|
|
6288
|
-
}
|
|
6289
|
-
function current(d, user, task) {
|
|
7050
|
+
function editValues(d, user, task) {
|
|
6290
7051
|
const now = d.clock.now();
|
|
6291
7052
|
if (task.type === "reminder")
|
|
6292
7053
|
return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
|
|
@@ -6312,28 +7073,93 @@ function current(d, user, task) {
|
|
|
6312
7073
|
const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
|
|
6313
7074
|
return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
|
|
6314
7075
|
}
|
|
7076
|
+
|
|
7077
|
+
// plugins/tracker/src/web/editor.ts
|
|
7078
|
+
var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
|
|
7079
|
+
var GONE = NO_LONGER_LISTED;
|
|
7080
|
+
var writer = (e) => ({ d: e.d, queue: e.queue, reading: e.reading, userId: e.v.user.id });
|
|
7081
|
+
function asUser(w, fn) {
|
|
7082
|
+
return w.queue(async () => {
|
|
7083
|
+
const user = await w.d.store.getUser(w.userId);
|
|
7084
|
+
return user ? fn(user) : { ok: false, error: GONE };
|
|
7085
|
+
});
|
|
7086
|
+
}
|
|
7087
|
+
async function makeTask(w, type, form) {
|
|
7088
|
+
if (type === "reminder")
|
|
7089
|
+
return asUser(w, (u) => createReminder(w.d, u, reminderInput(form)));
|
|
7090
|
+
if (type === "renewal")
|
|
7091
|
+
return asUser(w, (u) => createRenewal(w.d, u, renewalInput(form)));
|
|
7092
|
+
const input = priceInput(form);
|
|
7093
|
+
if (w.reading.has(w.userId))
|
|
7094
|
+
return { ok: false, error: READING };
|
|
7095
|
+
w.reading.add(w.userId);
|
|
7096
|
+
try {
|
|
7097
|
+
const started = await asUser(w, (u) => startPrice(w.d, u, input));
|
|
7098
|
+
if (!started.ok)
|
|
7099
|
+
return started;
|
|
7100
|
+
const { pending } = started;
|
|
7101
|
+
const seen = await previewPrice(w.d, pending);
|
|
7102
|
+
return await asUser(w, (u) => finishPrice(w.d, u, pending, seen));
|
|
7103
|
+
} finally {
|
|
7104
|
+
w.reading.delete(w.userId);
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
7107
|
+
async function editableTask(d, user, id) {
|
|
7108
|
+
const task = await ownLiveTask(d, user, id);
|
|
7109
|
+
return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
|
|
7110
|
+
}
|
|
7111
|
+
function saveEdit(w, task, form) {
|
|
7112
|
+
if (task.type === "reminder")
|
|
7113
|
+
return asUser(w, (u) => editReminder(w.d, u, task.id, reminderEdit(form)));
|
|
7114
|
+
if (task.type === "renewal")
|
|
7115
|
+
return asUser(w, (u) => editRenewal(w.d, u, task.id, renewalEdit(form)));
|
|
7116
|
+
return asUser(w, (u) => editPrice(w.d, u, task.id, priceEdit(form)));
|
|
7117
|
+
}
|
|
7118
|
+
function actOn(w, taskId, action) {
|
|
7119
|
+
const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
|
|
7120
|
+
return asUser(w, (u) => act(w.d, u, taskId));
|
|
7121
|
+
}
|
|
7122
|
+
var DEFAULTS = {
|
|
7123
|
+
reminder: { repeat: "none" },
|
|
7124
|
+
renewal: { unit: "year", every: "1", lead: "7" },
|
|
7125
|
+
price: { hours: "12", drop: "10", baseline: "last" }
|
|
7126
|
+
};
|
|
7127
|
+
function newGet(e, type) {
|
|
7128
|
+
return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
|
|
7129
|
+
}
|
|
7130
|
+
async function newPost(e, type, form) {
|
|
7131
|
+
const made = await makeTask(writer(e), type, form);
|
|
7132
|
+
if (!made.ok)
|
|
7133
|
+
return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
|
|
7134
|
+
return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
|
|
7135
|
+
}
|
|
7136
|
+
async function taskPage(e, id, done, error) {
|
|
7137
|
+
const view = await loadHistory(e.d, e.v.user, id);
|
|
7138
|
+
if (!view)
|
|
7139
|
+
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
7140
|
+
const own = view.task.ownerId === e.v.user.id;
|
|
7141
|
+
const controls = own ? ownerControls(e.v, view.task) : null;
|
|
7142
|
+
const owner = own ? null : (await e.d.store.getUser(view.task.ownerId))?.displayName ?? view.task.ownerId;
|
|
7143
|
+
return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error), owner }), error ? 400 : 200);
|
|
7144
|
+
}
|
|
6315
7145
|
function editPage(e, task, values, error) {
|
|
6316
7146
|
const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
|
|
6317
7147
|
const page = task.type === "price" ? String(task.config.url) : undefined;
|
|
6318
7148
|
return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
|
|
6319
7149
|
}
|
|
6320
|
-
async function editableTask(e, id) {
|
|
6321
|
-
const task = await ownLiveTask(e.d, e.v.user, id);
|
|
6322
|
-
return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
|
|
6323
|
-
}
|
|
6324
7150
|
async function editGet(e, id) {
|
|
6325
|
-
const task = await editableTask(e, id);
|
|
7151
|
+
const task = await editableTask(e.d, e.v.user, id);
|
|
6326
7152
|
if (!task)
|
|
6327
7153
|
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6328
7154
|
if (task.status !== "active" && task.status !== "paused")
|
|
6329
7155
|
return redirect(taskHref(e.v, task.id));
|
|
6330
|
-
return htmlResponse(editPage(e, task,
|
|
7156
|
+
return htmlResponse(editPage(e, task, editValues(e.d, e.v.user, task)));
|
|
6331
7157
|
}
|
|
6332
7158
|
async function editPost(e, id, form) {
|
|
6333
|
-
const task = await editableTask(e, id);
|
|
7159
|
+
const task = await editableTask(e.d, e.v.user, id);
|
|
6334
7160
|
if (!task)
|
|
6335
7161
|
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6336
|
-
const saved =
|
|
7162
|
+
const saved = await saveEdit(writer(e), task, form);
|
|
6337
7163
|
if (saved.ok)
|
|
6338
7164
|
return redirect(`${taskHref(e.v, task.id)}?done=saved`);
|
|
6339
7165
|
if (saved.error === NO_SUCH_TASK)
|
|
@@ -6344,10 +7170,9 @@ async function actionPost(e, id, action, form) {
|
|
|
6344
7170
|
const task = await ownLiveTask(e.d, e.v.user, id);
|
|
6345
7171
|
if (!task)
|
|
6346
7172
|
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6347
|
-
if (action === "delete" &&
|
|
7173
|
+
if (action === "delete" && form.get("confirm") !== "yes")
|
|
6348
7174
|
return htmlResponse(confirmDeletePage(e.v, task));
|
|
6349
|
-
const
|
|
6350
|
-
const done = await asViewer(e, (u) => act(e.d, u, task.id));
|
|
7175
|
+
const done = await actOn(writer(e), task.id, action);
|
|
6351
7176
|
if (!done.ok)
|
|
6352
7177
|
return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
|
|
6353
7178
|
if (action === "delete")
|
|
@@ -6355,17 +7180,469 @@ async function actionPost(e, id, action, form) {
|
|
|
6355
7180
|
return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
|
|
6356
7181
|
}
|
|
6357
7182
|
|
|
7183
|
+
// plugins/tracker/src/web/api-tasks.ts
|
|
7184
|
+
function problem(status, code, message, headers) {
|
|
7185
|
+
return { status, body: { error: { code, message } }, ...headers ? { headers } : {} };
|
|
7186
|
+
}
|
|
7187
|
+
var NOT_FOUND_MESSAGE = "No such task.";
|
|
7188
|
+
var notFound = () => problem(404, "not_found", NOT_FOUND_MESSAGE);
|
|
7189
|
+
function jsonKind(f) {
|
|
7190
|
+
if (f.kind === "number")
|
|
7191
|
+
return "integer";
|
|
7192
|
+
return f.decimal ? "number" : "string";
|
|
7193
|
+
}
|
|
7194
|
+
function typesAnswer() {
|
|
7195
|
+
const describe = (fields, mode) => fields.map((f) => ({
|
|
7196
|
+
name: f.name,
|
|
7197
|
+
type: jsonKind(f),
|
|
7198
|
+
required: mode === "new" && f.required === true,
|
|
7199
|
+
description: f.help ? `${f.label}. ${f.help}` : f.label,
|
|
7200
|
+
...f.maxlength !== undefined ? { maxLength: f.maxlength } : {},
|
|
7201
|
+
...f.min !== undefined ? { minimum: f.min } : {},
|
|
7202
|
+
...f.max !== undefined ? { maximum: f.max } : {},
|
|
7203
|
+
...f.options ? { enum: f.options.map(([value]) => value) } : {}
|
|
7204
|
+
}));
|
|
7205
|
+
return {
|
|
7206
|
+
status: 200,
|
|
7207
|
+
body: { types: EDITOR_TYPES.map((type) => ({ type, create: describe(fieldsFor(type, "new"), "new"), edit: describe(fieldsFor(type, "edit"), "edit") })) }
|
|
7208
|
+
};
|
|
7209
|
+
}
|
|
7210
|
+
function asFields(body, type, mode) {
|
|
7211
|
+
const fields = new Map(fieldsFor(type, mode).map((f) => [f.name, f]));
|
|
7212
|
+
const form = new URLSearchParams;
|
|
7213
|
+
for (const [key, value] of Object.entries(body)) {
|
|
7214
|
+
if (mode === "new" && key === "type")
|
|
7215
|
+
continue;
|
|
7216
|
+
const f = fields.get(key);
|
|
7217
|
+
if (!f)
|
|
7218
|
+
return problem(400, "unknown_field", `\`${key.slice(0, 40)}\` is not a field of a ${type}${mode === "edit" ? " edit" : ""}. GET types lists them.`);
|
|
7219
|
+
const kind = jsonKind(f);
|
|
7220
|
+
if (kind === "string") {
|
|
7221
|
+
if (typeof value !== "string")
|
|
7222
|
+
return problem(400, "invalid", `\`${key}\` must be a string.`);
|
|
7223
|
+
form.set(key, value);
|
|
7224
|
+
} else {
|
|
7225
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
7226
|
+
return problem(400, "invalid", `\`${key}\` must be a number.`);
|
|
7227
|
+
form.set(key, String(value));
|
|
7228
|
+
}
|
|
7229
|
+
}
|
|
7230
|
+
return form;
|
|
7231
|
+
}
|
|
7232
|
+
function refusal(error) {
|
|
7233
|
+
if (error === NO_SUCH_TASK)
|
|
7234
|
+
return notFound();
|
|
7235
|
+
if (error === NO_LONGER_LISTED)
|
|
7236
|
+
return problem(401, "invalid_token", "The token's owner is no longer on this tracker's list.");
|
|
7237
|
+
if (error === READING)
|
|
7238
|
+
return problem(409, "busy", error);
|
|
7239
|
+
if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
|
|
7240
|
+
return problem(409, "conflict", error);
|
|
7241
|
+
if (/^You already (have|track) [0-9]+ /.test(error))
|
|
7242
|
+
return problem(409, "limit_reached", error);
|
|
7243
|
+
if (/ (is|are) not available on this bot\.$/.test(error))
|
|
7244
|
+
return problem(503, "unavailable", error);
|
|
7245
|
+
return problem(400, "invalid", error);
|
|
7246
|
+
}
|
|
7247
|
+
async function nextAt(d, task) {
|
|
7248
|
+
if (task.status !== "active")
|
|
7249
|
+
return null;
|
|
7250
|
+
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
7251
|
+
return queued[0]?.dueAt ?? null;
|
|
7252
|
+
}
|
|
7253
|
+
async function taskJson(d, user, task) {
|
|
7254
|
+
const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
|
|
7255
|
+
const values = editValues(d, user, task);
|
|
7256
|
+
const settings = {};
|
|
7257
|
+
if (type) {
|
|
7258
|
+
for (const f of fieldsFor(type, "edit")) {
|
|
7259
|
+
if (f.name === "when")
|
|
7260
|
+
continue;
|
|
7261
|
+
const raw = values[f.name];
|
|
7262
|
+
if (raw === undefined)
|
|
7263
|
+
continue;
|
|
7264
|
+
settings[f.name] = jsonKind(f) === "string" ? raw : Number(raw);
|
|
7265
|
+
}
|
|
7266
|
+
}
|
|
7267
|
+
return {
|
|
7268
|
+
id: task.id,
|
|
7269
|
+
type: task.type,
|
|
7270
|
+
title: task.title,
|
|
7271
|
+
status: task.status === "archived" ? "deleted" : task.status,
|
|
7272
|
+
cadence: task.schedule ? describeSchedule(task.schedule, user, user.timeZone, d.clock.now()) : "no schedule",
|
|
7273
|
+
nextAt: await nextAt(d, task),
|
|
7274
|
+
createdAt: task.createdAt,
|
|
7275
|
+
updatedAt: task.updatedAt,
|
|
7276
|
+
settings,
|
|
7277
|
+
...task.type === "price" ? { url: String(task.config.url) } : {}
|
|
7278
|
+
};
|
|
7279
|
+
}
|
|
7280
|
+
async function listAnswer(d, user) {
|
|
7281
|
+
const tasks = (await d.store.listTasks({ ownerId: user.id })).filter((t) => t.status !== "archived");
|
|
7282
|
+
const out = [];
|
|
7283
|
+
for (const t of tasks)
|
|
7284
|
+
out.push(await taskJson(d, user, t));
|
|
7285
|
+
return { status: 200, body: { tasks: out } };
|
|
7286
|
+
}
|
|
7287
|
+
async function getAnswer(d, user, id) {
|
|
7288
|
+
const task = await ownTask(d, user, id);
|
|
7289
|
+
if (!task)
|
|
7290
|
+
return notFound();
|
|
7291
|
+
const view = await loadHistory(d, user, task.id);
|
|
7292
|
+
if (!view)
|
|
7293
|
+
return notFound();
|
|
7294
|
+
return {
|
|
7295
|
+
status: 200,
|
|
7296
|
+
body: {
|
|
7297
|
+
task: await taskJson(d, user, view.task),
|
|
7298
|
+
history: { next: view.next, runs: view.runs, earlierRuns: view.earlierRuns, changes: view.changes, earlierChanges: view.earlierChanges }
|
|
7299
|
+
}
|
|
7300
|
+
};
|
|
7301
|
+
}
|
|
7302
|
+
async function written(w, user, result, status, base) {
|
|
7303
|
+
if (!result.ok)
|
|
7304
|
+
return refusal(result.error);
|
|
7305
|
+
const task = await w.d.store.getTask(result.task.id) ?? result.task;
|
|
7306
|
+
return {
|
|
7307
|
+
status,
|
|
7308
|
+
body: { task: await taskJson(w.d, user, task), message: result.text },
|
|
7309
|
+
...status === 201 ? { headers: { Location: `${base}/tasks/${encodeURIComponent(task.id)}` } } : {}
|
|
7310
|
+
};
|
|
7311
|
+
}
|
|
7312
|
+
async function createAnswer(w, user, body, base) {
|
|
7313
|
+
const type = body.type;
|
|
7314
|
+
if (typeof type !== "string" || !EDITOR_TYPES.includes(type)) {
|
|
7315
|
+
return problem(400, "invalid", `\`type\` is one of ${EDITOR_TYPES.join(", ")}.`);
|
|
7316
|
+
}
|
|
7317
|
+
const form = asFields(body, type, "new");
|
|
7318
|
+
if (!(form instanceof URLSearchParams))
|
|
7319
|
+
return form;
|
|
7320
|
+
return written(w, user, await makeTask(w, type, form), 201, base);
|
|
7321
|
+
}
|
|
7322
|
+
async function editAnswer(w, user, id, body, base) {
|
|
7323
|
+
const task = await editableTask(w.d, user, id);
|
|
7324
|
+
if (!task)
|
|
7325
|
+
return notFound();
|
|
7326
|
+
const form = asFields(body, task.type, "edit");
|
|
7327
|
+
if (!(form instanceof URLSearchParams))
|
|
7328
|
+
return form;
|
|
7329
|
+
return written(w, user, await saveEdit(w, task, form), 200, base);
|
|
7330
|
+
}
|
|
7331
|
+
async function actAnswer(w, user, id, action) {
|
|
7332
|
+
const task = await ownTask(w.d, user, id);
|
|
7333
|
+
if (!task || task.status === "archived")
|
|
7334
|
+
return notFound();
|
|
7335
|
+
const done = await actOn(w, task.id, action);
|
|
7336
|
+
if (!done.ok)
|
|
7337
|
+
return refusal(done.error);
|
|
7338
|
+
const after = await w.d.store.getTask(task.id) ?? task;
|
|
7339
|
+
return { status: 200, body: { task: await taskJson(w.d, user, after), message: done.text } };
|
|
7340
|
+
}
|
|
7341
|
+
|
|
7342
|
+
// plugins/tracker/src/web/api.ts
|
|
7343
|
+
var API_PREFIX = "/api/v1";
|
|
7344
|
+
var MAX_API_BYTES = 16 * 1024;
|
|
7345
|
+
var RATE_BURST = 60;
|
|
7346
|
+
var RATE_PER_SECOND = 1;
|
|
7347
|
+
var FAILED_BURST = 30;
|
|
7348
|
+
var FAILED_PER_SECOND = 0.5;
|
|
7349
|
+
var FAILED_KEY = "failed";
|
|
7350
|
+
|
|
7351
|
+
class RateLimiter {
|
|
7352
|
+
burst;
|
|
7353
|
+
perSecond;
|
|
7354
|
+
buckets = new Map;
|
|
7355
|
+
constructor(burst, perSecond) {
|
|
7356
|
+
this.burst = burst;
|
|
7357
|
+
this.perSecond = perSecond;
|
|
7358
|
+
}
|
|
7359
|
+
take(key, nowMs) {
|
|
7360
|
+
const b = this.buckets.get(key);
|
|
7361
|
+
const elapsed = b ? Math.max(0, nowMs - b.at) / 1000 : 0;
|
|
7362
|
+
const tokens = b ? Math.min(this.burst, b.tokens + elapsed * this.perSecond) : this.burst;
|
|
7363
|
+
if (tokens < 1) {
|
|
7364
|
+
this.buckets.set(key, { tokens, at: nowMs });
|
|
7365
|
+
return Math.max(1, Math.ceil((1 - tokens) / this.perSecond));
|
|
7366
|
+
}
|
|
7367
|
+
this.buckets.set(key, { tokens: tokens - 1, at: nowMs });
|
|
7368
|
+
if (this.buckets.size > 1e4) {
|
|
7369
|
+
for (const [k, v] of this.buckets)
|
|
7370
|
+
if (v.tokens + (nowMs - v.at) / 1000 * this.perSecond >= this.burst)
|
|
7371
|
+
this.buckets.delete(k);
|
|
7372
|
+
}
|
|
7373
|
+
return 0;
|
|
7374
|
+
}
|
|
7375
|
+
}
|
|
7376
|
+
var HEADERS = {
|
|
7377
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
7378
|
+
"Cache-Control": "no-store",
|
|
7379
|
+
"X-Content-Type-Options": "nosniff",
|
|
7380
|
+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
7381
|
+
"Referrer-Policy": "no-referrer"
|
|
7382
|
+
};
|
|
7383
|
+
function json2(a) {
|
|
7384
|
+
return new Response(JSON.stringify(a.body), { status: a.status, headers: { ...HEADERS, ...a.headers ?? {} } });
|
|
7385
|
+
}
|
|
7386
|
+
var CHALLENGE = 'Bearer realm="tracker"';
|
|
7387
|
+
function bearer(header) {
|
|
7388
|
+
if (header === null)
|
|
7389
|
+
return null;
|
|
7390
|
+
const m = /^Bearer +([^\s]+) *$/i.exec(header);
|
|
7391
|
+
return m?.[1] ?? null;
|
|
7392
|
+
}
|
|
7393
|
+
var ACT = /^\/tasks\/([^/]+)\/(pause|resume)$/;
|
|
7394
|
+
var ONE = /^\/tasks\/([^/]+)$/;
|
|
7395
|
+
function segment(raw) {
|
|
7396
|
+
try {
|
|
7397
|
+
return decodeURIComponent(raw ?? "");
|
|
7398
|
+
} catch {
|
|
7399
|
+
return "";
|
|
7400
|
+
}
|
|
7401
|
+
}
|
|
7402
|
+
function route(rest) {
|
|
7403
|
+
if (rest === "/me")
|
|
7404
|
+
return { kind: "me" };
|
|
7405
|
+
if (rest === "/types")
|
|
7406
|
+
return { kind: "types" };
|
|
7407
|
+
if (rest === "/tasks")
|
|
7408
|
+
return { kind: "tasks" };
|
|
7409
|
+
const act = ACT.exec(rest);
|
|
7410
|
+
if (act)
|
|
7411
|
+
return { kind: "act", id: segment(act[1]), action: act[2] };
|
|
7412
|
+
const one = ONE.exec(rest);
|
|
7413
|
+
return one ? { kind: "task", id: segment(one[1]) } : null;
|
|
7414
|
+
}
|
|
7415
|
+
function allowedMethods(r) {
|
|
7416
|
+
if (r.kind === "tasks")
|
|
7417
|
+
return "GET, POST";
|
|
7418
|
+
if (r.kind === "task")
|
|
7419
|
+
return "GET, PATCH, DELETE";
|
|
7420
|
+
if (r.kind === "act")
|
|
7421
|
+
return "POST";
|
|
7422
|
+
return "GET";
|
|
7423
|
+
}
|
|
7424
|
+
async function readJson(request) {
|
|
7425
|
+
const type = (request.headers.get("content-type") ?? "").trim();
|
|
7426
|
+
const no = (answer) => ({ ok: false, answer });
|
|
7427
|
+
if (!/^application\/json *(;.*)?$/i.test(type))
|
|
7428
|
+
return no(problem(415, "unsupported_media_type", "Send the body as Content-Type: application/json."));
|
|
7429
|
+
const text = await readBody(request, MAX_API_BYTES);
|
|
7430
|
+
if (text === null)
|
|
7431
|
+
return no(problem(413, "too_large", `The body is over ${MAX_API_BYTES} bytes.`));
|
|
7432
|
+
let value;
|
|
7433
|
+
try {
|
|
7434
|
+
value = JSON.parse(text);
|
|
7435
|
+
} catch {
|
|
7436
|
+
return no(problem(400, "invalid_json", "The body is not JSON."));
|
|
7437
|
+
}
|
|
7438
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
7439
|
+
return no(problem(400, "invalid_json", "The body must be a JSON object."));
|
|
7440
|
+
return { ok: true, body: value };
|
|
7441
|
+
}
|
|
7442
|
+
async function authenticate(w, request) {
|
|
7443
|
+
const { d } = w;
|
|
7444
|
+
const no = (answer) => ({ ok: false, answer });
|
|
7445
|
+
const header = request.headers.get("authorization");
|
|
7446
|
+
if (header === null)
|
|
7447
|
+
return no(problem(401, "unauthorized", "Send Authorization: Bearer <token>. Make a token on the web area's API tokens page.", { "WWW-Authenticate": CHALLENGE }));
|
|
7448
|
+
const invalid = problem(401, "invalid_token", "That token is unknown, revoked or expired.", { "WWW-Authenticate": `${CHALLENGE}, error="invalid_token"` });
|
|
7449
|
+
const sent = bearer(header);
|
|
7450
|
+
const now = d.clock.now();
|
|
7451
|
+
const token = sent === null ? null : d.apiTokens.find(sent, now);
|
|
7452
|
+
if (!token) {
|
|
7453
|
+
const waitFailed = w.failed.take(FAILED_KEY, now.getTime());
|
|
7454
|
+
if (waitFailed > 0)
|
|
7455
|
+
return no(problem(429, "rate_limited", `Too many requests with bad tokens; try again in ${waitFailed} s.`, { "Retry-After": String(waitFailed) }));
|
|
7456
|
+
return no(invalid);
|
|
7457
|
+
}
|
|
7458
|
+
const wait = w.limiter.take(token.id, now.getTime());
|
|
7459
|
+
if (wait > 0)
|
|
7460
|
+
return no(problem(429, "rate_limited", `Too many requests with this token; try again in ${wait} s.`, { "Retry-After": String(wait) }));
|
|
7461
|
+
const user = await d.store.getUser(token.userId);
|
|
7462
|
+
if (!user || !d.admissions.isRegistered(user.id)) {
|
|
7463
|
+
d.apiTokens.deleteForUser(token.userId);
|
|
7464
|
+
return no(invalid);
|
|
7465
|
+
}
|
|
7466
|
+
const found = await w.recheck(user, token.memberCheckedAt);
|
|
7467
|
+
if (found === "not-member")
|
|
7468
|
+
return no(problem(403, "not_member", "The token's owner is no longer a member of this tracker's server; their tokens are revoked."));
|
|
7469
|
+
if (found === "unknown")
|
|
7470
|
+
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" }));
|
|
7471
|
+
const current = d.apiTokens.find(sent, d.clock.now());
|
|
7472
|
+
if (!current)
|
|
7473
|
+
return no(invalid);
|
|
7474
|
+
d.apiTokens.touch(current, d.clock.now());
|
|
7475
|
+
return { ok: true, token: current, user };
|
|
7476
|
+
}
|
|
7477
|
+
async function handleApi(w, request, path) {
|
|
7478
|
+
if (request.headers.get("origin") !== null)
|
|
7479
|
+
return json2(problem(403, "origin_refused", "The task API is not for browsers: send no Origin header."));
|
|
7480
|
+
const auth = await authenticate(w, request);
|
|
7481
|
+
if (!auth.ok)
|
|
7482
|
+
return json2(auth.answer);
|
|
7483
|
+
const { user } = auth;
|
|
7484
|
+
const r = path.startsWith(`${API_PREFIX}/`) ? route(path.slice(API_PREFIX.length)) : null;
|
|
7485
|
+
if (r === null)
|
|
7486
|
+
return json2(problem(404, "not_found", "No such endpoint."));
|
|
7487
|
+
const allowed = allowedMethods(r);
|
|
7488
|
+
const method = request.method;
|
|
7489
|
+
if (!allowed.split(", ").includes(method))
|
|
7490
|
+
return json2(problem(405, "method_not_allowed", `Use ${allowed}.`, { Allow: allowed }));
|
|
7491
|
+
const writer = { d: w.d, queue: w.queue, reading: w.reading, userId: user.id };
|
|
7492
|
+
const base = `${w.base}${API_PREFIX}`;
|
|
7493
|
+
if (method === "POST" || method === "PATCH") {
|
|
7494
|
+
const read = await readJson(request);
|
|
7495
|
+
if (!read.ok)
|
|
7496
|
+
return json2(read.answer);
|
|
7497
|
+
const { body } = read;
|
|
7498
|
+
if (r.kind === "tasks")
|
|
7499
|
+
return json2(await createAnswer(writer, user, body, base));
|
|
7500
|
+
if (r.kind === "task")
|
|
7501
|
+
return json2(await editAnswer(writer, user, r.id, body, base));
|
|
7502
|
+
if (r.kind === "act") {
|
|
7503
|
+
if (Object.keys(body).length > 0)
|
|
7504
|
+
return json2(problem(400, "unknown_field", `\`${r.action}\` takes no fields: send {}.`));
|
|
7505
|
+
return json2(await actAnswer(writer, user, r.id, r.action));
|
|
7506
|
+
}
|
|
7507
|
+
}
|
|
7508
|
+
if (method === "DELETE" && r.kind === "task")
|
|
7509
|
+
return json2(await actAnswer(writer, user, r.id, "delete"));
|
|
7510
|
+
if (r.kind === "types")
|
|
7511
|
+
return json2(typesAnswer());
|
|
7512
|
+
if (r.kind === "tasks")
|
|
7513
|
+
return json2(await listAnswer(w.d, user));
|
|
7514
|
+
if (r.kind === "task")
|
|
7515
|
+
return json2(await getAnswer(w.d, user, r.id));
|
|
7516
|
+
return json2({
|
|
7517
|
+
status: 200,
|
|
7518
|
+
body: {
|
|
7519
|
+
user: { id: user.id, name: user.displayName, timeZone: user.timeZone, preferredHour: user.preferredHour },
|
|
7520
|
+
token: { id: auth.token.id, name: auth.token.name, createdAt: auth.token.createdAt, expiresAt: auth.token.expiresAt }
|
|
7521
|
+
}
|
|
7522
|
+
});
|
|
7523
|
+
}
|
|
7524
|
+
|
|
7525
|
+
// plugins/tracker/src/web/tokens.ts
|
|
7526
|
+
var MAX_CREATIONS_PER_HOUR = 10;
|
|
7527
|
+
var HOUR_MS = 60 * 60 * 1000;
|
|
7528
|
+
var TOO_MANY_CREATED = `You have made ${MAX_CREATIONS_PER_HOUR} tokens in the last hour, the most one person may. Try again later.`;
|
|
7529
|
+
|
|
7530
|
+
class CreationLog {
|
|
7531
|
+
made = new Map;
|
|
7532
|
+
recent(userId, nowMs) {
|
|
7533
|
+
const times = (this.made.get(userId) ?? []).filter((t) => t > nowMs - HOUR_MS && t <= nowMs);
|
|
7534
|
+
if (times.length === 0)
|
|
7535
|
+
this.made.delete(userId);
|
|
7536
|
+
else
|
|
7537
|
+
this.made.set(userId, times);
|
|
7538
|
+
return times;
|
|
7539
|
+
}
|
|
7540
|
+
full(userId, nowMs) {
|
|
7541
|
+
return this.recent(userId, nowMs).length >= MAX_CREATIONS_PER_HOUR;
|
|
7542
|
+
}
|
|
7543
|
+
record(userId, nowMs) {
|
|
7544
|
+
this.made.set(userId, [...this.recent(userId, nowMs), nowMs]);
|
|
7545
|
+
}
|
|
7546
|
+
}
|
|
7547
|
+
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.";
|
|
7548
|
+
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.";
|
|
7549
|
+
function tokenMessage(name, token, expiresAt) {
|
|
7550
|
+
return [
|
|
7551
|
+
`Your tracker API token "${name}"${expiresAt ? `, expiring ${expiresAt.slice(0, 10)}` : ""}:`,
|
|
7552
|
+
`\`${token}\``,
|
|
7553
|
+
"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."
|
|
7554
|
+
].join(`
|
|
7555
|
+
`);
|
|
7556
|
+
}
|
|
7557
|
+
function tokensGet(d, v) {
|
|
7558
|
+
return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now()));
|
|
7559
|
+
}
|
|
7560
|
+
async function tokensPost(d, v, session, form, log) {
|
|
7561
|
+
const now = d.clock.now();
|
|
7562
|
+
const name = (form.get("name") ?? "").slice(0, 200);
|
|
7563
|
+
const expiry = form.get("expiry") ?? String(DEFAULT_EXPIRY_DAYS);
|
|
7564
|
+
const days = expiryChoice(expiry);
|
|
7565
|
+
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);
|
|
7566
|
+
if (days === undefined)
|
|
7567
|
+
return refuse("Choose when the token expires.");
|
|
7568
|
+
const discordId = v.user.discordId;
|
|
7569
|
+
if (!d.dm || !discordId)
|
|
7570
|
+
return refuse(TOKEN_NOT_SENT);
|
|
7571
|
+
if (log.full(v.user.id, now.getTime()))
|
|
7572
|
+
return refuse(TOO_MANY_CREATED);
|
|
7573
|
+
const made = d.apiTokens.create(v.user.id, { name, days }, now, session.memberCheckedAt);
|
|
7574
|
+
if (!made.ok)
|
|
7575
|
+
return refuse(made.error);
|
|
7576
|
+
log.record(v.user.id, now.getTime());
|
|
7577
|
+
try {
|
|
7578
|
+
await d.dm(discordId, { content: tokenMessage(made.row.name, made.token, made.row.expiresAt) });
|
|
7579
|
+
} catch (err) {
|
|
7580
|
+
d.apiTokens.revokeOwn(v.user.id, made.row.id);
|
|
7581
|
+
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)})`);
|
|
7582
|
+
return refuse(TOKEN_NOT_SENT);
|
|
7583
|
+
}
|
|
7584
|
+
d.log.info(`${v.user.id} made API token ${made.row.id}`);
|
|
7585
|
+
return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now(), { result: `"${made.row.name}": ${TOKEN_SENT}` }));
|
|
7586
|
+
}
|
|
7587
|
+
function tokenRevokePost(d, v, id) {
|
|
7588
|
+
if (!d.apiTokens.revokeOwn(v.user.id, id))
|
|
7589
|
+
return htmlResponse(notFoundPage(v.base, v), 404);
|
|
7590
|
+
d.log.info(`${v.user.id} revoked their API token ${id}`);
|
|
7591
|
+
const now = d.clock.now();
|
|
7592
|
+
return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, now), now, { result: "Revoked: that token no longer works." }));
|
|
7593
|
+
}
|
|
7594
|
+
|
|
6358
7595
|
// plugins/tracker/src/web/routes.ts
|
|
6359
7596
|
var TASK_PATH = /^\/tasks\/([^/]+)$/;
|
|
6360
7597
|
var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
|
|
6361
7598
|
var NEW_PATH = /^\/new\/([a-z]+)$/;
|
|
6362
|
-
|
|
7599
|
+
var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
|
|
7600
|
+
var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
|
|
7601
|
+
var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
|
|
7602
|
+
var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
|
|
7603
|
+
var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
|
|
7604
|
+
function isAdminRoute(r) {
|
|
7605
|
+
return r.kind.startsWith("admin");
|
|
7606
|
+
}
|
|
7607
|
+
function segment2(raw) {
|
|
7608
|
+
try {
|
|
7609
|
+
return decodeURIComponent(raw ?? "");
|
|
7610
|
+
} catch {
|
|
7611
|
+
return "";
|
|
7612
|
+
}
|
|
7613
|
+
}
|
|
7614
|
+
function route2(path) {
|
|
6363
7615
|
if (path === "/")
|
|
6364
7616
|
return { kind: "tasks" };
|
|
6365
7617
|
if (path === "/settings")
|
|
6366
7618
|
return { kind: "settings" };
|
|
6367
7619
|
if (path === "/logout")
|
|
6368
7620
|
return { kind: "logout" };
|
|
7621
|
+
if (path === "/forget")
|
|
7622
|
+
return { kind: "forget" };
|
|
7623
|
+
if (path === "/tokens")
|
|
7624
|
+
return { kind: "tokens" };
|
|
7625
|
+
const revoke = TOKEN_REVOKE.exec(path);
|
|
7626
|
+
if (revoke)
|
|
7627
|
+
return { kind: "token-revoke", id: segment2(revoke[1]) };
|
|
7628
|
+
const adminRevoke = ADMIN_TOKEN_REVOKE.exec(path);
|
|
7629
|
+
if (adminRevoke)
|
|
7630
|
+
return { kind: "admin-token-revoke", id: segment2(adminRevoke[1]) };
|
|
7631
|
+
if (path === "/admin")
|
|
7632
|
+
return { kind: "admin" };
|
|
7633
|
+
if (path === "/admin/tasks")
|
|
7634
|
+
return { kind: "admin-tasks" };
|
|
7635
|
+
if (path === "/admin/allow")
|
|
7636
|
+
return { kind: "admin-allow" };
|
|
7637
|
+
const personAct = PERSON_ACTION.exec(path);
|
|
7638
|
+
if (personAct)
|
|
7639
|
+
return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
|
|
7640
|
+
const onePerson = PERSON_PATH.exec(path);
|
|
7641
|
+
if (onePerson)
|
|
7642
|
+
return { kind: "admin-person", id: segment2(onePerson[1]) };
|
|
7643
|
+
const lift = BLOCK_LIFT.exec(path);
|
|
7644
|
+
if (lift)
|
|
7645
|
+
return { kind: "admin-lift", id: segment2(lift[1]) };
|
|
6369
7646
|
const created = NEW_PATH.exec(path);
|
|
6370
7647
|
if (created) {
|
|
6371
7648
|
const type = created[1];
|
|
@@ -6374,12 +7651,7 @@ function route(path) {
|
|
|
6374
7651
|
const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
|
|
6375
7652
|
if (!one)
|
|
6376
7653
|
return null;
|
|
6377
|
-
|
|
6378
|
-
try {
|
|
6379
|
-
id = decodeURIComponent(one[1] ?? "");
|
|
6380
|
-
} catch {
|
|
6381
|
-
id = "";
|
|
6382
|
-
}
|
|
7654
|
+
const id = segment2(one[1]);
|
|
6383
7655
|
const action = one[2];
|
|
6384
7656
|
if (action === undefined)
|
|
6385
7657
|
return { kind: "task", id };
|
|
@@ -6388,9 +7660,10 @@ function route(path) {
|
|
|
6388
7660
|
return { kind: "act", id, action };
|
|
6389
7661
|
}
|
|
6390
7662
|
function methodsOf(r) {
|
|
6391
|
-
if (r.kind === "logout" || r.kind === "act")
|
|
7663
|
+
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") {
|
|
6392
7664
|
return "POST";
|
|
6393
|
-
|
|
7665
|
+
}
|
|
7666
|
+
if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
|
|
6394
7667
|
return "GET, POST";
|
|
6395
7668
|
return "GET";
|
|
6396
7669
|
}
|
|
@@ -6443,6 +7716,11 @@ function plain(status, text, extra = {}) {
|
|
|
6443
7716
|
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extra }
|
|
6444
7717
|
});
|
|
6445
7718
|
}
|
|
7719
|
+
function signInNote(q) {
|
|
7720
|
+
if (q.get("forgotten") === "1")
|
|
7721
|
+
return "Everything the tracker held about you is deleted, and you are signed out.";
|
|
7722
|
+
return q.get("out") === "1" ? "You are signed out." : undefined;
|
|
7723
|
+
}
|
|
6446
7724
|
async function lookupWithin(lookup, ms) {
|
|
6447
7725
|
let timer;
|
|
6448
7726
|
const late = new Promise((resolve) => {
|
|
@@ -6462,6 +7740,9 @@ function createWebHandler(w) {
|
|
|
6462
7740
|
const inFlight = new Map;
|
|
6463
7741
|
const failedAt = new Map;
|
|
6464
7742
|
const reading = new Set;
|
|
7743
|
+
const limiter = new RateLimiter(RATE_BURST, RATE_PER_SECOND);
|
|
7744
|
+
const failed = new RateLimiter(FAILED_BURST, FAILED_PER_SECOND);
|
|
7745
|
+
const created = new CreationLog;
|
|
6465
7746
|
async function authenticate(d, request) {
|
|
6466
7747
|
const id = readCookie(request, SESSION_COOKIE);
|
|
6467
7748
|
if (!id)
|
|
@@ -6496,14 +7777,14 @@ function createWebHandler(w) {
|
|
|
6496
7777
|
const shared = pending;
|
|
6497
7778
|
return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
|
|
6498
7779
|
}
|
|
6499
|
-
async function
|
|
7780
|
+
async function recheck(d, user, checkedAt) {
|
|
6500
7781
|
if (w.guildId === null)
|
|
6501
|
-
return
|
|
7782
|
+
return "ok";
|
|
6502
7783
|
const now = d.clock.now().getTime();
|
|
6503
|
-
const age =
|
|
7784
|
+
const age = checkedAt ? now - Date.parse(checkedAt) : Number.POSITIVE_INFINITY;
|
|
6504
7785
|
const fresh = (limit) => age >= 0 && age < limit;
|
|
6505
7786
|
if (fresh(MEMBER_RECHECK_MS))
|
|
6506
|
-
return
|
|
7787
|
+
return "ok";
|
|
6507
7788
|
const lastFailed = failedAt.get(user.id);
|
|
6508
7789
|
const backingOff = lastFailed !== undefined && now - lastFailed >= 0 && now - lastFailed < MEMBER_RETRY_MS;
|
|
6509
7790
|
let found = "unknown";
|
|
@@ -6516,17 +7797,31 @@ function createWebHandler(w) {
|
|
|
6516
7797
|
}
|
|
6517
7798
|
if (found === "not-member") {
|
|
6518
7799
|
d.sessions.deleteForUser(user.id);
|
|
6519
|
-
|
|
7800
|
+
d.apiTokens.deleteForUser(user.id);
|
|
7801
|
+
return "not-member";
|
|
6520
7802
|
}
|
|
6521
7803
|
if (found === "member") {
|
|
6522
|
-
|
|
6523
|
-
|
|
7804
|
+
const at = d.clock.now().toISOString();
|
|
7805
|
+
d.sessions.confirmMember(user.id, at);
|
|
7806
|
+
d.apiTokens.confirmMember(user.id, at);
|
|
7807
|
+
return "ok";
|
|
6524
7808
|
}
|
|
6525
|
-
|
|
7809
|
+
return fresh(MEMBER_GRACE_MS) ? "ok" : "unknown";
|
|
7810
|
+
}
|
|
7811
|
+
async function recheckMembership(d, session, user) {
|
|
7812
|
+
const found = await recheck(d, user, session.memberCheckedAt);
|
|
7813
|
+
if (found === "ok")
|
|
6526
7814
|
return null;
|
|
7815
|
+
if (found === "not-member")
|
|
7816
|
+
return { kind: "signed-out", note: LEFT_SERVER };
|
|
6527
7817
|
d.sessions.deleteForUser(user.id);
|
|
6528
7818
|
return { kind: "signed-out", note: RECHECK_FAILED };
|
|
6529
7819
|
}
|
|
7820
|
+
async function memberOf(discordId) {
|
|
7821
|
+
if (w.guildId === null)
|
|
7822
|
+
return "not-checked";
|
|
7823
|
+
return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
|
|
7824
|
+
}
|
|
6530
7825
|
async function readForm(request) {
|
|
6531
7826
|
const type = request.headers.get("content-type") ?? "";
|
|
6532
7827
|
if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
|
|
@@ -6583,6 +7878,14 @@ function createWebHandler(w) {
|
|
|
6583
7878
|
return redirect(`${base}/settings?saved=1`);
|
|
6584
7879
|
}
|
|
6585
7880
|
return async (request, info) => {
|
|
7881
|
+
if (info.path === "/api" || info.path.startsWith("/api/")) {
|
|
7882
|
+
if (w.origin === null)
|
|
7883
|
+
return plain(404, "Not found");
|
|
7884
|
+
const d = w.deps();
|
|
7885
|
+
if (!d)
|
|
7886
|
+
return json2(problem(503, "starting", "The tracker is starting up; try again in a minute.", { "Retry-After": "60" }));
|
|
7887
|
+
return handleApi({ d, queue: w.queue, reading, base, limiter, failed, recheck: (user, at) => recheck(d, user, at) }, request, info.path);
|
|
7888
|
+
}
|
|
6586
7889
|
const method = request.method;
|
|
6587
7890
|
if (method !== "GET" && method !== "POST")
|
|
6588
7891
|
return plain(405, "Method not allowed", { Allow: "GET, POST" });
|
|
@@ -6605,22 +7908,28 @@ function createWebHandler(w) {
|
|
|
6605
7908
|
});
|
|
6606
7909
|
}
|
|
6607
7910
|
if (path === "/signin")
|
|
6608
|
-
return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams
|
|
7911
|
+
return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
|
|
6609
7912
|
if (path === "/login")
|
|
6610
7913
|
return method === "GET" ? loginGet(d, url) : loginPost(d, request);
|
|
6611
|
-
const r =
|
|
7914
|
+
const r = route2(path);
|
|
6612
7915
|
if (r === null)
|
|
6613
7916
|
return htmlResponse(notFoundPage(base), 404);
|
|
6614
7917
|
const allowed = methodsOf(r);
|
|
6615
|
-
|
|
7918
|
+
const admin = isAdminRoute(r);
|
|
7919
|
+
if (!admin && !allowed.split(", ").includes(method))
|
|
6616
7920
|
return plain(405, "Method not allowed", { Allow: allowed });
|
|
6617
7921
|
const auth = await authenticate(d, request);
|
|
6618
7922
|
if (auth.kind === "signed-out")
|
|
6619
7923
|
return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
|
|
6620
7924
|
if (auth.kind !== "ok")
|
|
6621
7925
|
return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
|
|
7926
|
+
if (admin && !auth.user.admin)
|
|
7927
|
+
return unknownPage(base);
|
|
7928
|
+
if (admin && !allowed.split(", ").includes(method))
|
|
7929
|
+
return plain(405, "Method not allowed", { Allow: allowed });
|
|
6622
7930
|
const v = { base, user: auth.user, csrf: auth.session.csrf };
|
|
6623
7931
|
const e = { d, v, queue: w.queue, reading };
|
|
7932
|
+
const a = { d, v, queue: w.queue, memberOf: (id) => memberOf(id), signOutCookie: clear(SESSION_COOKIE) };
|
|
6624
7933
|
if (method === "POST") {
|
|
6625
7934
|
const form = await readForm(request);
|
|
6626
7935
|
if (!form)
|
|
@@ -6639,6 +7948,17 @@ function createWebHandler(w) {
|
|
|
6639
7948
|
return editPost(e, r.id, form);
|
|
6640
7949
|
case "act":
|
|
6641
7950
|
return actionPost(e, r.id, r.action, form);
|
|
7951
|
+
case "forget":
|
|
7952
|
+
return forgetRoute(a, method, form);
|
|
7953
|
+
case "tokens":
|
|
7954
|
+
return tokensPost(d, v, auth.session, form, created);
|
|
7955
|
+
case "token-revoke":
|
|
7956
|
+
return tokenRevokePost(d, v, r.id);
|
|
7957
|
+
case "admin-allow":
|
|
7958
|
+
case "admin-act":
|
|
7959
|
+
case "admin-lift":
|
|
7960
|
+
case "admin-token-revoke":
|
|
7961
|
+
return adminPost(a, r, form);
|
|
6642
7962
|
default:
|
|
6643
7963
|
return plain(405, "Method not allowed", { Allow: allowed });
|
|
6644
7964
|
}
|
|
@@ -6654,6 +7974,14 @@ function createWebHandler(w) {
|
|
|
6654
7974
|
return editGet(e, r.id);
|
|
6655
7975
|
case "new":
|
|
6656
7976
|
return newGet(e, r.type);
|
|
7977
|
+
case "forget":
|
|
7978
|
+
return forgetRoute(a, method, null);
|
|
7979
|
+
case "tokens":
|
|
7980
|
+
return tokensGet(d, v);
|
|
7981
|
+
case "admin":
|
|
7982
|
+
case "admin-tasks":
|
|
7983
|
+
case "admin-person":
|
|
7984
|
+
return adminGet(a, r);
|
|
6657
7985
|
default:
|
|
6658
7986
|
return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
|
|
6659
7987
|
}
|
|
@@ -6746,6 +8074,28 @@ function createPlugin(host, options = {}) {
|
|
|
6746
8074
|
if (failed > 0)
|
|
6747
8075
|
host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
|
|
6748
8076
|
}
|
|
8077
|
+
const running = new Set;
|
|
8078
|
+
const tracked = (p) => {
|
|
8079
|
+
running.add(p);
|
|
8080
|
+
const done = () => void running.delete(p);
|
|
8081
|
+
p.then(done, done);
|
|
8082
|
+
return p;
|
|
8083
|
+
};
|
|
8084
|
+
const lanes = {
|
|
8085
|
+
busy: () => running.size > 0,
|
|
8086
|
+
async idle(ms) {
|
|
8087
|
+
const until = Date.now() + ms;
|
|
8088
|
+
while (running.size > 0) {
|
|
8089
|
+
const left = until - Date.now();
|
|
8090
|
+
if (left <= 0)
|
|
8091
|
+
return false;
|
|
8092
|
+
let timer;
|
|
8093
|
+
await Promise.race([Promise.allSettled([...running]), new Promise((r) => timer = setTimeout(r, left))]);
|
|
8094
|
+
clearTimeout(timer);
|
|
8095
|
+
}
|
|
8096
|
+
return true;
|
|
8097
|
+
}
|
|
8098
|
+
};
|
|
6749
8099
|
let discordClient = null;
|
|
6750
8100
|
const capture = (interaction) => {
|
|
6751
8101
|
const client = interaction.client;
|
|
@@ -6789,6 +8139,9 @@ function createPlugin(host, options = {}) {
|
|
|
6789
8139
|
const openedStore = new SqliteStore(opened);
|
|
6790
8140
|
const openedClaims = new ClaimStore(opened);
|
|
6791
8141
|
try {
|
|
8142
|
+
const vacuum = vacuumOnce(opened, path);
|
|
8143
|
+
if ("error" in vacuum)
|
|
8144
|
+
host.log.warn(`could not vacuum the tracker's database (${vacuum.error}); trying again at the next start`);
|
|
6792
8145
|
const granted = await seedAdmins(openedStore, adminIds, clock.now());
|
|
6793
8146
|
const admissions = new Admissions(opened);
|
|
6794
8147
|
for (const id of adminIds) {
|
|
@@ -6831,6 +8184,10 @@ function createPlugin(host, options = {}) {
|
|
|
6831
8184
|
notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
|
|
6832
8185
|
logins: new LoginLinks(opened),
|
|
6833
8186
|
sessions: new Sessions(opened),
|
|
8187
|
+
apiTokens: new ApiTokens(opened),
|
|
8188
|
+
roster: new Roster(opened),
|
|
8189
|
+
configuredAdmins: new Set(adminIds),
|
|
8190
|
+
lanes,
|
|
6834
8191
|
webEditor: webOrigin !== null
|
|
6835
8192
|
};
|
|
6836
8193
|
if (guildId === null)
|
|
@@ -6851,11 +8208,11 @@ function createPlugin(host, options = {}) {
|
|
|
6851
8208
|
ticks: [
|
|
6852
8209
|
{
|
|
6853
8210
|
name: "notify",
|
|
6854
|
-
run: (signal) => tick("notify", signal)
|
|
8211
|
+
run: (signal) => tracked(tick("notify", signal))
|
|
6855
8212
|
},
|
|
6856
8213
|
{
|
|
6857
8214
|
name: "poll",
|
|
6858
|
-
run: (signal) => tick("poll", signal)
|
|
8215
|
+
run: (signal) => tracked(tick("poll", signal))
|
|
6859
8216
|
}
|
|
6860
8217
|
],
|
|
6861
8218
|
async http(request, info) {
|