@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.17
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 +51 -11
- package/Resources/GameKit/apple/LampPage.swift +202 -35
- package/Resources/GameKit/apple/LampWebGame.swift +1 -1
- package/Resources/GameKit/page/lamp-live.js +672 -0
- package/Resources/GameKit/web/lamp-game.js +7 -1
- package/Resources/share-rules.json +64 -0
- package/Resources/skills/game/SKILL.md +2 -0
- package/Resources/skills/github-projects/SKILL.md +8 -5
- package/Resources/skills/web-app/SKILL.md +49 -15
- package/Resources/skills/web-or-native/SKILL.md +6 -1
- package/cli/ai.mjs +237 -0
- package/cli/app-ai.mjs +473 -0
- package/cli/browser.mjs +18 -2
- package/cli/calls.mjs +213 -9
- package/cli/dev.mjs +61 -14
- package/cli/lamp.mjs +347 -37
- package/cli/lampapp.mjs +9 -9
- package/cli/lamps/android-host/LampPageGateway.kt +2 -2
- package/cli/lamps/lamp-for-games.js +95 -36
- package/cli/lamps/lamp.js +95 -36
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +403 -285
- package/cli/lamps/web-starter/lamp.js +95 -36
- package/cli/link.mjs +77 -13
- package/cli/local-ai.mjs +193 -0
- package/cli/openrouter.mjs +133 -0
- package/cli/outside.mjs +65 -2
- package/cli/page.js +160 -8
- package/cli/share.mjs +80 -12
- package/cli/skill/SKILL.md +35 -10
- package/cli/state.mjs +3 -0
- package/cli/update.mjs +117 -12
- package/package.json +1 -1
- package/site/lib/shared-wishes.js +848 -117
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
3
|
import { inflateRawSync } from "node:zlib";
|
|
4
4
|
import { redis, reserve } from "./quota.js";
|
|
@@ -71,7 +71,7 @@ export const isID = (id) => typeof id === "string" && id.length === ID_LENGTH &&
|
|
|
71
71
|
|
|
72
72
|
/** The secret that lets the sharer's Lamp change or remove the link. Only its hash is kept here. */
|
|
73
73
|
export const newSecret = () => randomBytes(32).toString("base64url");
|
|
74
|
-
const hashSecret = (secret) => createHash("sha256").update(secret).digest("hex");
|
|
74
|
+
export const hashSecret = (secret) => createHash("sha256").update(secret).digest("hex");
|
|
75
75
|
function secretMatches(secret, hash) {
|
|
76
76
|
if (typeof secret !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(secret) || typeof hash !== "string") return false;
|
|
77
77
|
const a = Buffer.from(hashSecret(secret), "hex"), b = Buffer.from(hash, "hex");
|
|
@@ -138,9 +138,10 @@ function release(value) {
|
|
|
138
138
|
|
|
139
139
|
/** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
|
|
140
140
|
* from before them), so a change keeps the ones the link has. */
|
|
141
|
+
const GITHUB_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
|
|
141
142
|
export function readShare(body) {
|
|
142
143
|
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
143
|
-
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon"];
|
|
144
|
+
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github"];
|
|
144
145
|
if (Object.keys(body).some((key) => !known.includes(key))) return null;
|
|
145
146
|
const name = tidy(body.name, LIMITS.name);
|
|
146
147
|
const words = tidy(body.words, LIMITS.words, { lines: true });
|
|
@@ -156,16 +157,19 @@ export function readShare(body) {
|
|
|
156
157
|
? body.releases.map(release) : [null];
|
|
157
158
|
const shot = picture(body.picture, LIMITS.picture);
|
|
158
159
|
const icon = picture(body.icon, LIMITS.icon, { square: true });
|
|
159
|
-
|
|
160
|
+
// The app's public repository on GitHub, as "owner/name", which the page links to: nothing else is taken.
|
|
161
|
+
const github = body.github == null || body.github === "" ? null : GITHUB_REPO.test(body.github) ? body.github : undefined;
|
|
162
|
+
if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined) return null;
|
|
160
163
|
return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
|
|
161
|
-
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null };
|
|
164
|
+
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github };
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
/** What anyone may see of a shared wish: never its secret. */
|
|
165
168
|
export const shown = (record) => record && ({
|
|
166
169
|
id: record.id, name: record.name, by: record.by ?? "", line: record.line, words: record.words, ideas: record.ideas ?? [], asks: record.asks ?? [], shape: record.shape ?? null,
|
|
167
|
-
releases: record.releases ?? [], picture: record.picture, icon: record.icon, file: record.file ?? null, updated: record.updated,
|
|
170
|
+
releases: record.releases ?? [], picture: record.picture, icon: record.icon, file: record.file ?? null, updated: record.updated, github: record.github ?? null,
|
|
168
171
|
page: record.page ? { version: record.page.version } : null,
|
|
172
|
+
...(record.owner && record.app ? { owner: record.owner, app: record.app } : {}),
|
|
169
173
|
});
|
|
170
174
|
|
|
171
175
|
/** Every file kept for a record: its pictures, the wish's file and any parts of one still being sent. */
|
|
@@ -186,6 +190,8 @@ export const FOLLOW_FORMAT = 1;
|
|
|
186
190
|
export const latest = (record) => ({
|
|
187
191
|
format: FOLLOW_FORMAT, id: record.id, name: record.name, by: record.by ?? "",
|
|
188
192
|
version: record.file ? (record.file.version ?? 1) : 0, file: record.file?.url ?? null,
|
|
193
|
+
// The version of its page that plays now, which lamp waits on after sending one (it's stored after the answer).
|
|
194
|
+
page: record.page?.version ?? null,
|
|
189
195
|
});
|
|
190
196
|
|
|
191
197
|
/**
|
|
@@ -202,6 +208,50 @@ export const linkFor = (origin, id, name) => {
|
|
|
202
208
|
return `${origin}/w/${id}${slug ? `-${slug}` : ""}`;
|
|
203
209
|
};
|
|
204
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The link a wish is known by: its maker's username and the app's name (`/matt-rubens/bricks`) once its maker has an
|
|
213
|
+
* account, else the old kind (`linkFor`), which still opens it and sends anyone to the new one.
|
|
214
|
+
*/
|
|
215
|
+
export const linkOf = (origin, record) => (record?.owner && record?.app ? `${origin}/${record.owner}/${record.app}` : linkFor(origin, record.id, record.name));
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A browser signed in to an account: the email's link, opened in it, leaves a key of its own in this cookie, which only
|
|
219
|
+
* lamp.dev reads (HttpOnly, and Lax, so another site's form never sends it). Ideas sent from it are by that username.
|
|
220
|
+
*/
|
|
221
|
+
export const ACCOUNT_COOKIE = "lamp_account";
|
|
222
|
+
export const cookieKey = (request) => new RegExp(`(?:^|;\\s*)${ACCOUNT_COOKIE}=([A-Za-z0-9_-]{43})(?:;|$)`).exec(request.headers.get("cookie") || "")?.[1] ?? null;
|
|
223
|
+
export const signInCookie = (key, url) => `${ACCOUNT_COOKIE}=${key}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${new URL(url).protocol === "https:" ? "; Secure" : ""}`;
|
|
224
|
+
export const signOutCookie = (url) => `${ACCOUNT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${new URL(url).protocol === "https:" ? "; Secure" : ""}`;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Asks someone who sent an idea with their address to confirm it, by email: its link signs their browser in to the
|
|
228
|
+
* address's account, or makes one with a username they choose. Emails about their ideas go only to a confirmed
|
|
229
|
+
* address (`writeToSender`), so nobody hears about an idea that someone else typed their address into.
|
|
230
|
+
*/
|
|
231
|
+
export async function askToConfirm(kept, { email, name, app, origin, env, fetcher }) {
|
|
232
|
+
const token = randomBytes(16).toString("hex");
|
|
233
|
+
await kept.waitForAccount(token, { email, browser: true, ...(name ? { suggested: name } : {}), at: new Date().toISOString() });
|
|
234
|
+
const link = `${origin}/api/accounts?confirm=${token}`;
|
|
235
|
+
return sendMail(env, fetcher, { to: [email], subject: `Confirm your email to hear back about ${app}`,
|
|
236
|
+
text: `You sent an idea for ${app} on Lamp. To hear back about it, confirm your email here:\n\n${link}\n\nIf you didn't send it, you can ignore this email.` });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The account a share's key (`X-Lamp-Account`) belongs to, by its username, or null. */
|
|
240
|
+
export async function accountOf(kept, key) {
|
|
241
|
+
if (typeof key !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(key) || !kept.accountForKey) return null;
|
|
242
|
+
return kept.accountForKey(hashSecret(key));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A name for the app under its maker's username, free or already this link's: its own, then -2, -3… */
|
|
246
|
+
async function claimAppName(kept, owner, name, id) {
|
|
247
|
+
const base = slugFor(name) || "app";
|
|
248
|
+
for (let n = 1; n < 100; n++) {
|
|
249
|
+
const app = n === 1 ? base : `${base}-${n}`;
|
|
250
|
+
if (await kept.claimApp(owner, app, id)) return app;
|
|
251
|
+
}
|
|
252
|
+
throw new Error("No name free");
|
|
253
|
+
}
|
|
254
|
+
|
|
205
255
|
/** The wish's id in its link's last part, whatever name follows it after a dash. Null when it doesn't start with one. */
|
|
206
256
|
export const wishIDFrom = (part) => {
|
|
207
257
|
const id = typeof part === "string" ? part.slice(0, ID_LENGTH) : null;
|
|
@@ -214,11 +264,40 @@ export const wishIDFrom = (part) => {
|
|
|
214
264
|
* The record of each shared wish in Upstash (the store the site's quotas already use), its pictures in Vercel
|
|
215
265
|
* Blob, public at addresses with a random part. Preview deployments keep theirs apart from production's.
|
|
216
266
|
*/
|
|
267
|
+
/**
|
|
268
|
+
* A write to the blob store that answers in time: one that hasn't after `seconds` is cancelled and tried again, a few
|
|
269
|
+
* times, since a write that never answers held a whole page up until its function's time ran out.
|
|
270
|
+
*/
|
|
271
|
+
async function inTime(write, { seconds = 30, tries = 3, what = "a file" } = {}) {
|
|
272
|
+
for (let attempt = 1; ; attempt++) {
|
|
273
|
+
// Given up on at its time whatever the blob library is doing: its own retries of a failed request wait between
|
|
274
|
+
// tries and don't hear a cancel then, so a write that keeps failing could otherwise hold on for minutes.
|
|
275
|
+
const signal = AbortSignal.timeout(seconds * 1000);
|
|
276
|
+
let timer;
|
|
277
|
+
const late = new Promise((_, failed) => { timer = setTimeout(() => failed(new Error(`no answer in ${seconds}s`)), seconds * 1000 + 1000); });
|
|
278
|
+
try { return await Promise.race([write(signal), late]); } catch (error) {
|
|
279
|
+
if (attempt >= tries) throw error;
|
|
280
|
+
console.warn(`Storing ${what} again (try ${attempt + 1} of ${tries}): ${error?.message ?? error}`);
|
|
281
|
+
} finally { clearTimeout(timer); }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
217
285
|
export function cloudStore(env, fetcher = fetch) {
|
|
286
|
+
// The blob library tries a failed request again 10 times by default, waiting longer each time; `inTime` tries again
|
|
287
|
+
// itself, and says why, so the library's own are few.
|
|
288
|
+
process.env.VERCEL_BLOB_RETRIES ??= "2";
|
|
218
289
|
const db = redis(env);
|
|
219
290
|
const scope = env.VERCEL_ENV === "production" ? "production" : "preview";
|
|
220
291
|
const key = (id) => `lamp:shared-wish:{${scope}}:${id}`;
|
|
221
292
|
const ideasKey = (id) => `lamp:shared-wish:{${scope}}:ideas:${id}`;
|
|
293
|
+
const mailKey = (box) => `lamp:shared-wish:{${scope}}:idea-mail:${box}`;
|
|
294
|
+
const makerKey = (id) => `lamp:shared-wish:{${scope}}:maker:${id}`;
|
|
295
|
+
// Makers' accounts: a username, its confirmed email, the keys their Lamps hold, and the apps under it by name.
|
|
296
|
+
const accountKey = (name) => `lamp:shared-wish:{${scope}}:account:${name}`;
|
|
297
|
+
const accountEmailKey = (email) => `lamp:shared-wish:{${scope}}:account-email:${email.toLowerCase()}`;
|
|
298
|
+
const accountByKeyKey = (hash) => `lamp:shared-wish:{${scope}}:account-key:${hash}`;
|
|
299
|
+
const accountWaitingKey = (token) => `lamp:shared-wish:{${scope}}:account-waiting:${token}`;
|
|
300
|
+
const appKey = (name, app) => `lamp:shared-wish:{${scope}}:app:${name}:${app}`;
|
|
222
301
|
const command = async (args) => {
|
|
223
302
|
const response = await fetcher(db.url, {
|
|
224
303
|
method: "POST", redirect: "error", signal: AbortSignal.timeout(3000),
|
|
@@ -250,7 +329,44 @@ export function cloudStore(env, fetcher = fetch) {
|
|
|
250
329
|
return (await command(["EVAL", "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('SET', KEYS[1], ARGV[2]) end return false",
|
|
251
330
|
"1", key(after.id), JSON.stringify(before), JSON.stringify(after)])) === "OK";
|
|
252
331
|
},
|
|
253
|
-
async remove(id) { await command(["DEL", key(id), ideasKey(id)]); },
|
|
332
|
+
async remove(id) { await command(["DEL", key(id), ideasKey(id), makerKey(id)]); },
|
|
333
|
+
/** A maker's account by its username: `{ name, email, created }`, or null. */
|
|
334
|
+
async account(name) { const text = await command(["GET", accountKey(name)]); return typeof text === "string" ? JSON.parse(text) : null; },
|
|
335
|
+
async accountForEmail(email) { const name = await command(["GET", accountEmailKey(email)]); return typeof name === "string" ? name : null; },
|
|
336
|
+
async accountForKey(hash) { const name = await command(["GET", accountByKeyKey(hash)]); return typeof name === "string" ? name : null; },
|
|
337
|
+
/** Makes the account, and its address its own, together: false when either is someone's already. */
|
|
338
|
+
async createAccount(account) {
|
|
339
|
+
return (await command(["EVAL", `if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end
|
|
340
|
+
redis.call('SET', KEYS[1], ARGV[1]) redis.call('SET', KEYS[2], ARGV[2]) return 1`,
|
|
341
|
+
"2", accountKey(account.name), accountEmailKey(account.email), JSON.stringify(account), account.name])) === 1;
|
|
342
|
+
},
|
|
343
|
+
async addAccountKey(hash, name) { await command(["SET", accountByKeyKey(hash), name]); },
|
|
344
|
+
/** What waits on an email's confirmation, a day at most; let go once it's done, so a try that fails can be tried again. */
|
|
345
|
+
async waitForAccount(token, waiting) { await command(["SET", accountWaitingKey(token), JSON.stringify(waiting), "EX", "86400"]); },
|
|
346
|
+
async accountWaiting(token) { const text = await command(["GET", accountWaitingKey(token)]); return typeof text === "string" ? JSON.parse(text) : null; },
|
|
347
|
+
async doneWaiting(token) { await command(["DEL", accountWaitingKey(token)]); },
|
|
348
|
+
/** Claims an app's name under a username for a link: false when another link has it. */
|
|
349
|
+
async claimApp(name, app, id) {
|
|
350
|
+
return (await command(["EVAL", `local had = redis.call('GET', KEYS[1]) if had and had ~= ARGV[1] then return 0 end
|
|
351
|
+
redis.call('SET', KEYS[1], ARGV[1]) return 1`, "1", appKey(name, app), id])) === 1;
|
|
352
|
+
},
|
|
353
|
+
async appID(name, app) { const id = await command(["GET", appKey(name, app)]); return typeof id === "string" ? id : null; },
|
|
354
|
+
async releaseApp(name, app) { await command(["DEL", appKey(name, app)]); },
|
|
355
|
+
/** The maker's own address for a link's ideas, as `{ email, confirmed, key }`, or null. */
|
|
356
|
+
async maker(id) { const text = await command(["GET", makerKey(id)]); return typeof text === "string" ? JSON.parse(text) : null; },
|
|
357
|
+
async setMaker(id, value) { await command(value ? ["SET", makerKey(id), JSON.stringify(value)] : ["DEL", makerKey(id)]); },
|
|
358
|
+
/** Confirms ("confirm") or lets go ("stop") the maker's address in one step, only while `key` is still its key. */
|
|
359
|
+
async makerStep(id, key, step) {
|
|
360
|
+
await command(["EVAL", `local text = redis.call('GET', KEYS[1])
|
|
361
|
+
if not text then return 0 end
|
|
362
|
+
local maker = cjson.decode(text)
|
|
363
|
+
if maker.key ~= ARGV[1] then return 0 end
|
|
364
|
+
if ARGV[2] == "confirm" then
|
|
365
|
+
maker.confirmed = true
|
|
366
|
+
redis.call('SET', KEYS[1], cjson.encode(maker))
|
|
367
|
+
else redis.call('DEL', KEYS[1]) end
|
|
368
|
+
return 1`, "1", makerKey(id), key, step]);
|
|
369
|
+
},
|
|
254
370
|
/** Keeps an idea for the link, numbered in the order they came: null when the link already holds `IDEA_LIMITS.kept`. */
|
|
255
371
|
async addIdea(id, idea) {
|
|
256
372
|
// Its number is its field, given as it's kept, so two at once never share one. The cap counts those still open
|
|
@@ -272,22 +388,91 @@ export function cloudStore(env, fetcher = fetch) {
|
|
|
272
388
|
/** True when this call took it away: two at once never both do. */
|
|
273
389
|
async removeIdea(id, n) { return (await command(["HDEL", ideasKey(id), String(n)])) === 1; },
|
|
274
390
|
/**
|
|
275
|
-
* Gives idea `n` its outcome ("in" or "dismissed"),
|
|
276
|
-
*
|
|
391
|
+
* Gives idea `n` its outcome ("in" or "dismissed"), with the maker's note to its sender as the first of its thread
|
|
392
|
+
* if they wrote one, in one step, and answers it as it was, its address included, to whichever call gave it: null
|
|
393
|
+
* when it's gone or already has one, so it's told once. The picture goes with this step, and the address too when
|
|
394
|
+
* it's dismissed with nothing said, since nothing more will be written to it.
|
|
277
395
|
*/
|
|
278
|
-
async finishIdea(id, n, status, at) {
|
|
396
|
+
async finishIdea(id, n, status, at, message) {
|
|
279
397
|
const text = await command(["EVAL", `local text = redis.call('HGET', KEYS[1], ARGV[1])
|
|
280
398
|
if not text then return false end
|
|
281
399
|
local idea = cjson.decode(text)
|
|
282
400
|
if idea.status then return false end
|
|
283
401
|
idea.status = ARGV[2]
|
|
284
402
|
idea.done = ARGV[3]
|
|
285
|
-
idea.
|
|
403
|
+
idea.last = ARGV[3]
|
|
404
|
+
idea.picture = nil
|
|
405
|
+
idea.waiting = nil
|
|
406
|
+
if ARGV[4] ~= "" then
|
|
407
|
+
local thread = idea.thread or {}
|
|
408
|
+
table.insert(thread, cjson.decode(ARGV[4]))
|
|
409
|
+
idea.thread = thread
|
|
410
|
+
end
|
|
411
|
+
if ARGV[2] == "dismissed" and not idea.thread then idea.email = nil end
|
|
286
412
|
redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(idea))
|
|
287
413
|
redis.call('HINCRBY', KEYS[1], 'open', -1)
|
|
288
|
-
return text`, "1", ideasKey(id), String(n), status, at]);
|
|
414
|
+
return text`, "1", ideasKey(id), String(n), status, at, message ? JSON.stringify(message) : ""]);
|
|
415
|
+
return typeof text === "string" ? { ...JSON.parse(text), n } : null;
|
|
416
|
+
},
|
|
417
|
+
/**
|
|
418
|
+
* One step in an idea's conversation, answering the idea as it was before it, or null when it's gone or the step
|
|
419
|
+
* doesn't apply: "say" adds a message to its thread (one of a message id already there, or past
|
|
420
|
+
* `IDEA_LIMITS.thread`, doesn't), and an answer from its sender leaves it waiting on the maker; "settle" says the
|
|
421
|
+
* maker has seen to it; "box" gives it its mailbox and the subject of the first email, unless another call gave it
|
|
422
|
+
* one first (whose is in what's answered), and "makerbox" the same for its maker's mailbox; "stop", with its mailbox,
|
|
423
|
+
* lets its address go.
|
|
424
|
+
*/
|
|
425
|
+
async talk(id, n, op, value = "", extra = "") {
|
|
426
|
+
const text = await command(["EVAL", `local text = redis.call('HGET', KEYS[1], ARGV[1])
|
|
427
|
+
if not text then return false end
|
|
428
|
+
local idea = cjson.decode(text)
|
|
429
|
+
local op = ARGV[2]
|
|
430
|
+
if op == "say" then
|
|
431
|
+
local said = cjson.decode(ARGV[3])
|
|
432
|
+
-- A note given before ideas had threads starts its thread.
|
|
433
|
+
if idea.note and not idea.thread then
|
|
434
|
+
idea.thread = {{by = "maker", text = idea.note, at = idea.done or idea.at}}
|
|
435
|
+
idea.note = nil
|
|
436
|
+
end
|
|
437
|
+
local thread = idea.thread or {}
|
|
438
|
+
if #thread >= tonumber(ARGV[4]) then return false end
|
|
439
|
+
for _, one in ipairs(thread) do
|
|
440
|
+
if (said.mid and one.mid == said.mid) or (said.via and one.via == said.via) then return false end
|
|
441
|
+
end
|
|
442
|
+
table.insert(thread, said)
|
|
443
|
+
idea.thread = thread
|
|
444
|
+
idea.last = said.at
|
|
445
|
+
if said.by == "sender" then idea.waiting = true else idea.waiting = nil end
|
|
446
|
+
elseif op == "settle" then idea.waiting = nil
|
|
447
|
+
elseif op == "box" then
|
|
448
|
+
if idea.mailbox then return text end
|
|
449
|
+
idea.mailbox = ARGV[3]; idea.subject = ARGV[4]
|
|
450
|
+
elseif op == "makerbox" then
|
|
451
|
+
if idea.makerBox then return text end
|
|
452
|
+
idea.makerBox = ARGV[3]
|
|
453
|
+
elseif op == "stop" then
|
|
454
|
+
if idea.mailbox ~= ARGV[3] then return false end
|
|
455
|
+
idea.email = nil
|
|
456
|
+
else return false end
|
|
457
|
+
redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(idea))
|
|
458
|
+
return text`, "1", ideasKey(id), String(n), op, typeof value === "string" ? value : JSON.stringify(value), String(extra || IDEA_LIMITS.thread)]);
|
|
289
459
|
return typeof text === "string" ? { ...JSON.parse(text), n } : null;
|
|
290
460
|
},
|
|
461
|
+
/**
|
|
462
|
+
* An idea's mailbox: the address replies to its emails come to, kept a while past the last of them. Each idea has
|
|
463
|
+
* one for its sender's replies and, once its maker has emails of their own, one for the maker's (`side` "maker").
|
|
464
|
+
*/
|
|
465
|
+
async openMailbox(id, n, side = "sender") {
|
|
466
|
+
const box = randomBytes(10).toString("hex");
|
|
467
|
+
await command(["SET", mailKey(box), `${id}:${n}${side === "maker" ? ":maker" : ""}`, "EX", String(MAILBOX_SECONDS)]);
|
|
468
|
+
return box;
|
|
469
|
+
},
|
|
470
|
+
async mailbox(box) {
|
|
471
|
+
const found = await command(["GETEX", mailKey(box), "EX", String(MAILBOX_SECONDS)]);
|
|
472
|
+
const [id, n, side] = typeof found === "string" ? found.split(":") : [];
|
|
473
|
+
return isID(id) && /^\d+$/.test(n ?? "") ? { id, n: Number(n), side: side === "maker" ? "maker" : "sender" } : null;
|
|
474
|
+
},
|
|
475
|
+
async closeMailbox(box) { await command(["DEL", mailKey(box)]); },
|
|
291
476
|
async putImage(id, name, image) {
|
|
292
477
|
const { put } = await import("@vercel/blob");
|
|
293
478
|
const blob = await put(`${scope}/w/${id}/${name}.${image.extension}`, image.bytes, {
|
|
@@ -304,40 +489,67 @@ export function cloudStore(env, fetcher = fetch) {
|
|
|
304
489
|
// the pictures are, and only ever read back from the store's own host.
|
|
305
490
|
async putFile(id, name, bytes) {
|
|
306
491
|
const { put } = await import("@vercel/blob");
|
|
307
|
-
const blob = await put(`${scope}/w/${id}/${name}`, bytes, {
|
|
308
|
-
access: "public", contentType: "application/octet-stream", addRandomSuffix: true, cacheControlMaxAge: 300, ...auth,
|
|
309
|
-
});
|
|
492
|
+
const blob = await inTime((abortSignal) => put(`${scope}/w/${id}/${name}`, bytes, {
|
|
493
|
+
access: "public", contentType: "application/octet-stream", addRandomSuffix: true, cacheControlMaxAge: 300, abortSignal, ...auth,
|
|
494
|
+
}), { seconds: 60, what: name });
|
|
310
495
|
return blob.url;
|
|
311
496
|
},
|
|
312
497
|
// One file of a playable page, under the version's own random folder: read back by the play handler, never served
|
|
313
498
|
// from Blob itself (which hands HTML out as a download).
|
|
314
499
|
async putPageFile(id, token, path, bytes) {
|
|
315
500
|
const { put } = await import("@vercel/blob");
|
|
316
|
-
const blob = await put(`${scope}/w/${id}/page/${token}/${path}`, bytes, {
|
|
317
|
-
// Kept as what it is, so a browser sent straight to a big one (`DIRECT_FILE`) runs or reads it.
|
|
318
|
-
|
|
319
|
-
|
|
501
|
+
const blob = await inTime((abortSignal) => put(`${scope}/w/${id}/page/${token}/${path}`, bytes, {
|
|
502
|
+
// Kept as what it is, so a browser sent straight to a big one (`DIRECT_FILE`) runs or reads it. Written again
|
|
503
|
+
// in place when a try was cancelled after it got there.
|
|
504
|
+
access: "public", contentType: pageType(path), addRandomSuffix: false, allowOverwrite: true, cacheControlMaxAge: 300, abortSignal, ...auth,
|
|
505
|
+
}), { seconds: 30, what: path });
|
|
320
506
|
return blob.url;
|
|
321
507
|
},
|
|
322
508
|
async readFile(url) {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const response = await fetcher(url, { redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
326
|
-
if (!response.ok) throw new Error("Part unavailable");
|
|
327
|
-
return Buffer.from(await response.arrayBuffer());
|
|
509
|
+
const { body } = await this.openFile(url);
|
|
510
|
+
return Buffer.from(await new Response(body).arrayBuffer());
|
|
328
511
|
},
|
|
329
512
|
// A page's file as it comes, passed on without being held whole: a big one (a game's map data, tens of MB) is more
|
|
330
513
|
// than a function's answer may hold at once, and more than it can read in the time a whole read is given.
|
|
514
|
+
// A small one is kept in the instance after its first read (`remembered`): a page's files never change, and a game
|
|
515
|
+
// asks for some many times over (a worker's script, under an address of its own each time), which the CDN can't
|
|
516
|
+
// keep. The store turns this function's reads away for a while when they come in bursts (403), and asking again
|
|
517
|
+
// soon only keeps that going: a refused read is asked once more, a second later, and said with what the store
|
|
518
|
+
// gave as its reason.
|
|
331
519
|
async openFile(url) {
|
|
332
520
|
const address = new URL(url);
|
|
333
521
|
if (address.protocol !== "https:" || !address.hostname.endsWith(".public.blob.vercel-storage.com")) throw new Error("Not the store's");
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
522
|
+
const kept = remembered.get(url);
|
|
523
|
+
if (kept) { remembered.delete(url); remembered.set(url, kept); return { body: kept, length: String(kept.length) }; }
|
|
524
|
+
let said = null;
|
|
525
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
526
|
+
if (attempt > 1) await new Promise((done) => setTimeout(done, 1000 + Math.random() * 500));
|
|
527
|
+
// The time limit is for the store to start answering: a big file's body may take longer to pass on.
|
|
528
|
+
const waiting = new AbortController();
|
|
529
|
+
const timer = setTimeout(() => waiting.abort(), 10000);
|
|
530
|
+
let response;
|
|
531
|
+
try { response = await fetcher(url, { redirect: "error", signal: waiting.signal }); }
|
|
532
|
+
catch (error) { said = error?.message ?? String(error); continue; }
|
|
533
|
+
finally { clearTimeout(timer); }
|
|
534
|
+
const length = response.headers.get("content-length");
|
|
535
|
+
if (response.ok && response.body) {
|
|
536
|
+
if (length === null || Number(length) > REMEMBERED_FILE) return { body: response.body, length };
|
|
537
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
538
|
+
remember(url, bytes);
|
|
539
|
+
return { body: bytes, length: String(bytes.length) };
|
|
540
|
+
}
|
|
541
|
+
// The start of the store's words, read for a moment at most: a body that stalls never holds up the next try.
|
|
542
|
+
let words = "";
|
|
543
|
+
const reader = response.body?.getReader();
|
|
544
|
+
if (reader) {
|
|
545
|
+
const first = await Promise.race([reader.read().catch(() => null), new Promise((done) => setTimeout(done, 1000, null))]);
|
|
546
|
+
if (first?.value) words = Buffer.from(first.value).subarray(0, 100).toString("utf8").replace(/\s+/g, " ");
|
|
547
|
+
reader.cancel().catch(() => {});
|
|
548
|
+
}
|
|
549
|
+
said = [response.status, response.headers.get("x-vercel-error"), response.headers.get("x-vercel-mitigated") && `mitigated ${response.headers.get("x-vercel-mitigated")}`, words && `"${words}"`].filter(Boolean).join(" ");
|
|
550
|
+
if (!(response.status === 403 || response.status === 429 || response.status >= 500)) break;
|
|
551
|
+
}
|
|
552
|
+
throw new Error(`Part unavailable (${said})`);
|
|
341
553
|
},
|
|
342
554
|
// The page and its card are cached by Vercel's CDN, tagged with the wish (see `cacheTags`): a change or a removal
|
|
343
555
|
// takes them out at once rather than when they'd have run out.
|
|
@@ -350,12 +562,46 @@ export function cloudStore(env, fetcher = fetch) {
|
|
|
350
562
|
};
|
|
351
563
|
}
|
|
352
564
|
|
|
565
|
+
// A page's small files, as read from the store by this instance, the most recently served last: at most REMEMBERED in
|
|
566
|
+
// all, the least recently served going first.
|
|
567
|
+
const REMEMBERED = 96 * 1024 * 1024, REMEMBERED_FILE = 2 * 1024 * 1024;
|
|
568
|
+
const remembered = new Map();
|
|
569
|
+
let rememberedBytes = 0;
|
|
570
|
+
function remember(url, bytes) {
|
|
571
|
+
// Reads of one file at once each arrive here: the one already kept is counted out before its copy is counted in.
|
|
572
|
+
rememberedBytes -= remembered.get(url)?.length ?? 0;
|
|
573
|
+
remembered.delete(url);
|
|
574
|
+
remembered.set(url, bytes);
|
|
575
|
+
rememberedBytes += bytes.length;
|
|
576
|
+
for (const [oldest, kept] of remembered) {
|
|
577
|
+
if (rememberedBytes <= REMEMBERED) break;
|
|
578
|
+
remembered.delete(oldest);
|
|
579
|
+
rememberedBytes -= kept.length;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
353
583
|
/** Kept in memory, for `npm run dev` and the tests: pictures as data: URLs, so nothing else needs serving. */
|
|
354
584
|
export function memoryStore() {
|
|
355
|
-
const records = new Map(), pages = new Map(), ideas = new Map();
|
|
585
|
+
const records = new Map(), pages = new Map(), ideas = new Map(), mailboxes = new Map(), makers = new Map();
|
|
586
|
+
const accounts = new Map(), emails = new Map(), keys = new Map(), waiting = new Map(), apps = new Map();
|
|
356
587
|
return {
|
|
357
588
|
limiter: null,
|
|
358
589
|
records,
|
|
590
|
+
accounts,
|
|
591
|
+
async account(name) { return accounts.has(name) ? structuredClone(accounts.get(name)) : null; },
|
|
592
|
+
async accountForEmail(email) { return emails.get(email.toLowerCase()) ?? null; },
|
|
593
|
+
async accountForKey(hash) { return keys.get(hash) ?? null; },
|
|
594
|
+
async createAccount(account) {
|
|
595
|
+
if (accounts.has(account.name) || emails.has(account.email.toLowerCase())) return false;
|
|
596
|
+
accounts.set(account.name, structuredClone(account)); emails.set(account.email.toLowerCase(), account.name); return true;
|
|
597
|
+
},
|
|
598
|
+
async addAccountKey(hash, name) { keys.set(hash, name); },
|
|
599
|
+
async waitForAccount(token, value) { waiting.set(token, structuredClone(value)); },
|
|
600
|
+
async accountWaiting(token) { return waiting.has(token) ? structuredClone(waiting.get(token)) : null; },
|
|
601
|
+
async doneWaiting(token) { waiting.delete(token); },
|
|
602
|
+
async claimApp(name, app, id) { const had = apps.get(`${name}/${app}`); if (had && had !== id) return false; apps.set(`${name}/${app}`, id); return true; },
|
|
603
|
+
async appID(name, app) { return apps.get(`${name}/${app}`) ?? null; },
|
|
604
|
+
async releaseApp(name, app) { apps.delete(`${name}/${app}`); },
|
|
359
605
|
async get(id) { return records.has(id) ? structuredClone(records.get(id)) : null; },
|
|
360
606
|
async create(record) { if (records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
|
|
361
607
|
async replace(record) { if (!records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
|
|
@@ -364,7 +610,14 @@ export function memoryStore() {
|
|
|
364
610
|
records.set(after.id, structuredClone(after));
|
|
365
611
|
return true;
|
|
366
612
|
},
|
|
367
|
-
async remove(id) { records.delete(id); ideas.delete(id); },
|
|
613
|
+
async remove(id) { records.delete(id); ideas.delete(id); makers.delete(id); },
|
|
614
|
+
async maker(id) { return makers.has(id) ? structuredClone(makers.get(id)) : null; },
|
|
615
|
+
async setMaker(id, value) { if (value) makers.set(id, structuredClone(value)); else makers.delete(id); },
|
|
616
|
+
async makerStep(id, key, step) {
|
|
617
|
+
const maker = makers.get(id);
|
|
618
|
+
if (maker?.key !== key) return;
|
|
619
|
+
if (step === "confirm") maker.confirmed = true; else makers.delete(id);
|
|
620
|
+
},
|
|
368
621
|
async addIdea(id, idea) {
|
|
369
622
|
const kept = ideas.get(id) ?? { next: 0, all: new Map() };
|
|
370
623
|
if ([...kept.all.values()].filter((one) => !one.status).length >= IDEA_LIMITS.kept) return null;
|
|
@@ -375,14 +628,38 @@ export function memoryStore() {
|
|
|
375
628
|
},
|
|
376
629
|
async ideas(id) { return [...(ideas.get(id)?.all.values() ?? [])].map((idea) => structuredClone(idea)).sort((a, b) => a.n - b.n); },
|
|
377
630
|
async removeIdea(id, n) { return ideas.get(id)?.all.delete(n) ?? false; },
|
|
378
|
-
async finishIdea(id, n, status, at) {
|
|
631
|
+
async finishIdea(id, n, status, at, message) {
|
|
379
632
|
const idea = ideas.get(id)?.all.get(n);
|
|
380
633
|
if (!idea || idea.status) return null;
|
|
381
634
|
const before = structuredClone(idea);
|
|
382
|
-
Object.assign(idea, { status, done: at });
|
|
383
|
-
delete idea.
|
|
635
|
+
Object.assign(idea, { status, done: at, last: at });
|
|
636
|
+
delete idea.waiting;
|
|
637
|
+
if (message) idea.thread = [...(idea.thread ?? []), message];
|
|
638
|
+
if (status === "dismissed" && !idea.thread) delete idea.email;
|
|
639
|
+
delete idea.picture;
|
|
384
640
|
return before;
|
|
385
641
|
},
|
|
642
|
+
async talk(id, n, op, value = "", extra = "") {
|
|
643
|
+
const idea = ideas.get(id)?.all.get(n);
|
|
644
|
+
if (!idea) return null;
|
|
645
|
+
const before = structuredClone(idea);
|
|
646
|
+
if (op === "say") {
|
|
647
|
+
if (idea.note && !idea.thread) { idea.thread = [{ by: "maker", text: idea.note, at: idea.done ?? idea.at }]; delete idea.note; }
|
|
648
|
+
const thread = idea.thread ?? [];
|
|
649
|
+
if (thread.length >= IDEA_LIMITS.thread || thread.some((one) => (value.mid && one.mid === value.mid) || (value.via && one.via === value.via))) return null;
|
|
650
|
+
idea.thread = [...thread, value];
|
|
651
|
+
idea.last = value.at;
|
|
652
|
+
if (value.by === "sender") idea.waiting = true; else delete idea.waiting;
|
|
653
|
+
} else if (op === "settle") delete idea.waiting;
|
|
654
|
+
else if (op === "box") { if (!idea.mailbox) Object.assign(idea, { mailbox: value, subject: extra }); }
|
|
655
|
+
else if (op === "makerbox") { if (!idea.makerBox) idea.makerBox = value; }
|
|
656
|
+
else if (op === "stop" && idea.mailbox === value) delete idea.email;
|
|
657
|
+
else return null;
|
|
658
|
+
return before;
|
|
659
|
+
},
|
|
660
|
+
async openMailbox(id, n, side = "sender") { const box = randomBytes(10).toString("hex"); mailboxes.set(box, { id, n, side }); return box; },
|
|
661
|
+
async mailbox(box) { return mailboxes.get(box) ?? null; },
|
|
662
|
+
async closeMailbox(box) { mailboxes.delete(box); },
|
|
386
663
|
async putImage(_id, _name, image) { return `data:${image.type};base64,${image.bytes.toString("base64")}`; },
|
|
387
664
|
async removeImages() {},
|
|
388
665
|
async putFile(_id, _name, bytes) { return `data:application/octet-stream;base64,${bytes.toString("base64")}`; },
|
|
@@ -435,13 +712,17 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
435
712
|
const origin = request.headers.get("origin");
|
|
436
713
|
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
437
714
|
const id = url.searchParams.get("id");
|
|
438
|
-
|
|
439
|
-
|
|
715
|
+
const user = url.searchParams.get("user"), app = url.searchParams.get("app");
|
|
716
|
+
const byName = method === "GET" && !id && typeof user === "string" && typeof app === "string" && /^[a-z0-9-]{1,60}$/.test(user) && /^[a-z0-9-]{1,60}$/.test(app);
|
|
717
|
+
if (method !== "POST" && !isID(id) && !byName) return reply(404, { error: "not_found" });
|
|
718
|
+
// Asked by the Lamps that follow this link: public, as the page is, and cheap (one read, no quota). By its id, or
|
|
719
|
+
// by its maker's username and its name there.
|
|
440
720
|
if (method === "GET") {
|
|
441
721
|
let kept;
|
|
442
722
|
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
443
723
|
try {
|
|
444
|
-
const
|
|
724
|
+
const found = byName ? await kept.appID(user, app) : id;
|
|
725
|
+
const record = found ? await kept.get(found) : null;
|
|
445
726
|
return record ? reply(200, latest(record)) : reply(404, { error: "not_found" });
|
|
446
727
|
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
447
728
|
}
|
|
@@ -466,14 +747,19 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
466
747
|
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
467
748
|
}
|
|
468
749
|
|
|
469
|
-
let uploaded = [];
|
|
750
|
+
let uploaded = [], claimed = null;
|
|
470
751
|
try {
|
|
471
752
|
const earlier = method === "POST" ? null : await kept.get(id);
|
|
472
753
|
if (method !== "POST" && (!earlier || !secretMatches(bearer(request), earlier.secret))) return reply(404, { error: "not_found" });
|
|
754
|
+
// Whose it is: a share sent with its maker's account key (their Lamp sends it) goes under their username, and an
|
|
755
|
+
// old link sent again with one goes there from then on. One without a key is shared as links always were.
|
|
756
|
+
const owner = method === "DELETE" ? null : await accountOf(kept, request.headers.get("x-lamp-account"));
|
|
473
757
|
const oldImages = [earlier?.picture?.url, earlier?.icon?.url].filter(Boolean);
|
|
474
758
|
if (method === "DELETE") {
|
|
759
|
+
const ideaShots = ideaPictures(await kept.ideas(id).catch(() => []));
|
|
475
760
|
await kept.remove(id);
|
|
476
|
-
await kept.
|
|
761
|
+
if (earlier.owner && earlier.app) await kept.releaseApp(earlier.owner, earlier.app);
|
|
762
|
+
await kept.removeImages([...blobsOf(earlier), ...ideaShots]);
|
|
477
763
|
await kept.forget(id);
|
|
478
764
|
return reply(204);
|
|
479
765
|
}
|
|
@@ -486,23 +772,34 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
486
772
|
const picture = share.picture && { url: await put("picture", share.picture), width: share.picture.width, height: share.picture.height };
|
|
487
773
|
const icon = share.icon && { url: await put("icon", share.icon) };
|
|
488
774
|
const time = now().toISOString();
|
|
489
|
-
const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape,
|
|
775
|
+
const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape, github: share.github,
|
|
490
776
|
...(share.releases ? { releases: share.releases } : {}), picture: picture || null, icon: icon || null };
|
|
777
|
+
// Its name under the account stays what it was first given, whatever the app is called later.
|
|
778
|
+
const named = earlier?.owner ? { owner: earlier.owner, app: earlier.app }
|
|
779
|
+
: owner ? { owner, app: await claimAppName(kept, owner, words.name, wishID) } : {};
|
|
780
|
+
// A name claimed for this share now, let go again if the record can't be kept.
|
|
781
|
+
if (!earlier?.owner && named.owner) claimed = named;
|
|
491
782
|
if (method === "PUT") {
|
|
492
|
-
|
|
783
|
+
const record = { ...earlier, ...words, ...named, updated: time };
|
|
784
|
+
await kept.replace(record);
|
|
785
|
+
// Kept: the name is the record's now, whatever the cleanup after it does.
|
|
493
786
|
uploaded = [];
|
|
787
|
+
claimed = null;
|
|
494
788
|
await kept.removeImages(oldImages);
|
|
495
789
|
await kept.forget(id);
|
|
496
|
-
return reply(200, { id, url:
|
|
790
|
+
return reply(200, { id, url: linkOf(url.origin, record) });
|
|
497
791
|
}
|
|
498
792
|
const secret = newSecret();
|
|
499
|
-
|
|
793
|
+
const record = { id: wishID, ...words, ...named, secret: hashSecret(secret), created: time, updated: time };
|
|
794
|
+
if (!(await kept.create(record))) {
|
|
500
795
|
throw new Error("Id taken");
|
|
501
796
|
}
|
|
502
797
|
uploaded = [];
|
|
503
|
-
|
|
798
|
+
claimed = null;
|
|
799
|
+
return reply(201, { id: wishID, url: linkOf(url.origin, record), secret });
|
|
504
800
|
} catch {
|
|
505
801
|
try { await kept.removeImages(uploaded); } catch {}
|
|
802
|
+
try { if (claimed) await kept.releaseApp(claimed.owner, claimed.app); } catch {}
|
|
506
803
|
return reply(503, { error: "service_unavailable" });
|
|
507
804
|
}
|
|
508
805
|
};
|
|
@@ -511,64 +808,189 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
511
808
|
// MARK: Ideas for the maker
|
|
512
809
|
|
|
513
810
|
/** What someone playing a shared wish may send its maker: their idea, and an address to hear back at if they want. */
|
|
514
|
-
export const IDEA_LIMITS = { text: 1000, email: 200, kept: 500, asked: 20, keptDone: 90 * 86400_000 };
|
|
811
|
+
export const IDEA_LIMITS = { text: 1000, note: 2000, thread: 60, earlier: 100, name: 60, email: 200, picture: 400_000, kept: 500, asked: 20, keptDone: 90 * 86400_000 };
|
|
515
812
|
// A visitor sends a handful an hour; a link takes a few hundred a day; overall, a ceiling on a flood.
|
|
516
813
|
export const IDEA_QUOTAS = [
|
|
517
814
|
{ name: "idea-ip", perIP: true, limit: 12, seconds: 3600 },
|
|
518
815
|
{ name: "idea-ip-day", perIP: true, limit: 40, seconds: 86400 },
|
|
519
816
|
{ name: "idea-day", limit: 5000, seconds: 86400 },
|
|
520
817
|
];
|
|
818
|
+
// Confirmations to a maker's address: one a minute per link, and a few an hour from one place, a ceiling overall.
|
|
819
|
+
export const MAKER_QUOTAS = [
|
|
820
|
+
{ name: "maker-ip", perIP: true, limit: 6, seconds: 3600 },
|
|
821
|
+
{ name: "maker-day", limit: 1000, seconds: 86400 },
|
|
822
|
+
];
|
|
823
|
+
const MAKER_RESEND_MS = 60_000;
|
|
521
824
|
const EMAIL = /^[^\s@<>()",;:]+@[^\s@<>()",;:]+\.[^\s@<>()",;:]+$/;
|
|
825
|
+
// An idea's mailbox outlives its last message by a little more than the idea itself does (`IDEA_LIMITS.keptDone`).
|
|
826
|
+
const MAILBOX_SECONDS = 100 * 86400;
|
|
827
|
+
|
|
828
|
+
/** The domain of the address the site's emails come from (`LAMP_IDEAS_FROM`), for the message ids it gives them. */
|
|
829
|
+
const mailDomain = (env) => /@([^\s>]+)>?\s*$/.exec(env.LAMP_IDEAS_FROM ?? "")?.[1] ?? "lamp.dev";
|
|
830
|
+
/** A new message id for something the maker says, which the email carrying it goes out with. */
|
|
831
|
+
const newMessageID = (env) => `<idea.${randomBytes(12).toString("hex")}@${mailDomain(env)}>`;
|
|
522
832
|
|
|
523
833
|
/**
|
|
524
|
-
* Writes to
|
|
525
|
-
* `
|
|
526
|
-
*
|
|
834
|
+
* Writes to an idea's sender through Resend (`RESEND_API_KEY`, from `LAMP_IDEAS_FROM`, an address on a domain Resend
|
|
835
|
+
* has verified): `kind` "in" when it's in (with the maker's note, if any), "reply" for something the maker says after.
|
|
836
|
+
* The first email gives the idea a mailbox: with `LAMP_IDEAS_REPLY_DOMAIN` set, a reply to any of them goes to
|
|
837
|
+
* `idea-<mailbox>@` it, and comes back to the idea's thread (`createIdeaMailHandler`); each also has a link to stop
|
|
838
|
+
* them. Every one after the first answers the one before, so they read as one conversation. False, and nothing
|
|
839
|
+
* sent, without an address or Resend, or when Resend refuses.
|
|
527
840
|
*/
|
|
528
|
-
export async function
|
|
841
|
+
export async function writeToSender(kept, wishID, idea, record, link, { env = process.env, fetcher = fetch, kind = "in", said, origin, once } = {}) {
|
|
529
842
|
const key = env.RESEND_API_KEY, from = env.LAMP_IDEAS_FROM;
|
|
530
843
|
if (!key || !from || !idea.email) return false;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
844
|
+
// Only an address its owner has confirmed (one with an account) hears back.
|
|
845
|
+
if (!kept.accountForEmail || !(await kept.accountForEmail(idea.email.toLowerCase()))) return false;
|
|
846
|
+
let subject = idea.subject || (kind === "in" ? `Your idea is in ${record.name}` : `About your idea for ${record.name}`);
|
|
847
|
+
let box = idea.mailbox;
|
|
848
|
+
if (!box) {
|
|
849
|
+
// Two first emails at once each open one, and both go with whichever the idea kept.
|
|
850
|
+
box = await kept.openMailbox(wishID, idea.n);
|
|
851
|
+
const before = await kept.talk(wishID, idea.n, "box", box, subject);
|
|
852
|
+
if (before?.mailbox) {
|
|
853
|
+
try { await kept.closeMailbox(box); } catch {}
|
|
854
|
+
box = before.mailbox;
|
|
855
|
+
subject = before.subject || subject;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const by = record.by?.trim();
|
|
859
|
+
const maker = by || "Whoever made it";
|
|
860
|
+
const stop = `${origin}/api/wish-ideas?id=${wishID}&n=${idea.n}&stop=${box}`;
|
|
861
|
+
const replies = env.LAMP_IDEAS_REPLY_DOMAIN ? `idea-${box}@${env.LAMP_IDEAS_REPLY_DOMAIN}` : null;
|
|
862
|
+
const note = said?.text;
|
|
863
|
+
const text = (kind === "in"
|
|
864
|
+
? `${maker} added your idea to ${record.name}:\n\n${idea.text}\n\n` + (note ? `${by ? `${by} wrote` : "Their note"}:\n\n${note}\n\n` : "")
|
|
865
|
+
: `${by ? `${by} wrote` : "A note"} about your idea for ${record.name}:\n\n${note}\n\nYour idea: ${idea.text}\n\n`) +
|
|
866
|
+
`Play it: ${link}\n\n` + (replies ? `Reply to this email to answer ${by || "them"}.\n` : "") +
|
|
867
|
+
`No more emails about this idea: ${stop}`;
|
|
868
|
+
// The thread so far, the emails each way, for mail apps to keep them together.
|
|
869
|
+
const ids = (idea.thread ?? []).map((one) => one.mid).filter((mid) => typeof mid === "string" && /^<[^<>\s]+>$/.test(mid));
|
|
870
|
+
const headers = {
|
|
871
|
+
...(said?.mid ? { "Message-ID": said.mid } : {}),
|
|
872
|
+
...(ids.length ? { "In-Reply-To": ids.at(-1), References: ids.slice(-20).join(" ") } : {}),
|
|
873
|
+
"List-Unsubscribe": `<${stop}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
874
|
+
};
|
|
534
875
|
try {
|
|
535
876
|
const response = await fetcher("https://api.resend.com/emails", {
|
|
536
877
|
method: "POST", redirect: "error", signal: AbortSignal.timeout(5000),
|
|
537
878
|
// Resend sends one email for a key however often it's asked within a day.
|
|
538
879
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...(once ? { "Idempotency-Key": once } : {}) },
|
|
539
|
-
body: JSON.stringify({
|
|
880
|
+
body: JSON.stringify({
|
|
881
|
+
from, to: [idea.email], subject: idea.mailbox ? `Re: ${subject}` : subject, text, headers,
|
|
882
|
+
...(replies ? { reply_to: [replies] } : {}),
|
|
883
|
+
}),
|
|
884
|
+
});
|
|
885
|
+
return response.ok;
|
|
886
|
+
} catch { return false; }
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Sends one email through Resend; false, and nothing sent, without Resend or when it refuses. */
|
|
890
|
+
export async function sendMail(env, fetcher, message, once) {
|
|
891
|
+
if (!env.RESEND_API_KEY || !env.LAMP_IDEAS_FROM) return false;
|
|
892
|
+
try {
|
|
893
|
+
const response = await fetcher("https://api.resend.com/emails", {
|
|
894
|
+
method: "POST", redirect: "error", signal: AbortSignal.timeout(5000),
|
|
895
|
+
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, "Content-Type": "application/json", ...(once ? { "Idempotency-Key": once } : {}) },
|
|
896
|
+
body: JSON.stringify({ from: env.LAMP_IDEAS_FROM, ...message }),
|
|
540
897
|
});
|
|
541
898
|
return response.ok;
|
|
542
899
|
} catch { return false; }
|
|
543
900
|
}
|
|
544
901
|
|
|
545
|
-
/**
|
|
902
|
+
/**
|
|
903
|
+
* Writes to a link's maker at their own address (`maker`, once they've confirmed it): `kind` "idea" for a new idea,
|
|
904
|
+
* "reply" for its sender's answer. The first gives the idea a mailbox of the maker's, and a reply to any of them comes
|
|
905
|
+
* back as the maker's own word on its thread, sent on to the sender (`createIdeaMailHandler`). Each idea's emails to
|
|
906
|
+
* the maker are one conversation of their own, and each has a link to stop them for the whole link.
|
|
907
|
+
*/
|
|
908
|
+
export async function writeToMaker(kept, wishID, idea, record, link, { env = process.env, fetcher = fetch, kind = "idea", said, origin, maker, once } = {}) {
|
|
909
|
+
if (!env.RESEND_API_KEY || !env.LAMP_IDEAS_FROM || !maker?.confirmed || !maker.email) return false;
|
|
910
|
+
let box = idea.makerBox;
|
|
911
|
+
if (!box) {
|
|
912
|
+
box = await kept.openMailbox(wishID, idea.n, "maker");
|
|
913
|
+
const before = await kept.talk(wishID, idea.n, "makerbox", box);
|
|
914
|
+
if (before?.makerBox) {
|
|
915
|
+
try { await kept.closeMailbox(box); } catch {}
|
|
916
|
+
box = before.makerBox;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const replies = env.LAMP_IDEAS_REPLY_DOMAIN ? `idea-${box}@${env.LAMP_IDEAS_REPLY_DOMAIN}` : null;
|
|
920
|
+
const stop = `${origin}/api/wish-ideas?id=${wishID}&stopmaker=${maker.key}`;
|
|
921
|
+
const flat = idea.text.replace(/\s+/g, " ");
|
|
922
|
+
const subject = `Idea for ${record.name}: ${[...flat].length > 60 ? `${[...flat].slice(0, 60).join("").trimEnd()}…` : flat}`;
|
|
923
|
+
const root = `<idea-${box}@${mailDomain(env)}>`;
|
|
924
|
+
const text = (kind === "idea"
|
|
925
|
+
? `${idea.name || "Someone"} playing ${record.name} sent an idea:\n\n${idea.text}\n\n` +
|
|
926
|
+
(typeof idea.picture === "string" && idea.picture.startsWith("https://") ? `The app as they saw it: ${idea.picture}\n\n` : "") +
|
|
927
|
+
(idea.email ? "They'd like to hear back.\n\n" : "They left no address, so what you say shows on the link's page.\n\n")
|
|
928
|
+
: `${idea.name || "They"} answered about their idea for ${record.name}:\n\n${said.text}\n\nTheir idea: ${idea.text}\n\n`) +
|
|
929
|
+
(replies ? "Reply to this email to answer them. It's in Lamp too.\n" : "Answer them in Lamp.\n") +
|
|
930
|
+
`Play it: ${link}\n\nNo more emails about ideas for ${record.name}: ${stop}`;
|
|
931
|
+
return sendMail(env, fetcher, {
|
|
932
|
+
to: [maker.email], subject: kind === "idea" ? subject : `Re: ${subject}`, text,
|
|
933
|
+
headers: {
|
|
934
|
+
...(kind === "idea" ? { "Message-ID": root } : { "In-Reply-To": root, References: root }),
|
|
935
|
+
"List-Unsubscribe": `<${stop}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
936
|
+
},
|
|
937
|
+
...(replies ? { reply_to: [replies] } : {}),
|
|
938
|
+
}, once);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* An idea as sent from a link's page, or null: its words, an address to hear back at only if it looks like one, and
|
|
943
|
+
* the picture of the app the page took when the sender opened the box, if they kept it in (a PNG or JPEG, base64).
|
|
944
|
+
*/
|
|
546
945
|
export function readIdea(body) {
|
|
547
946
|
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
548
947
|
const text = tidy(body.text, IDEA_LIMITS.text, { lines: true });
|
|
549
948
|
if (!text) return null;
|
|
550
|
-
|
|
949
|
+
const shot = picture(body.picture, IDEA_LIMITS.picture);
|
|
950
|
+
if (!shot) return null;
|
|
951
|
+
// Their name, if they gave one, for the maker: shown in Lamp and in the maker's emails, where the address never is.
|
|
952
|
+
const name = body.name == null || body.name === "" ? "" : tidy(body.name, IDEA_LIMITS.name);
|
|
953
|
+
if (name == null) return null;
|
|
954
|
+
const idea = { text, ...(name ? { name } : {}), ...(shot.bytes ? { picture: shot } : {}) };
|
|
955
|
+
if (body.email == null || body.email === "") return idea;
|
|
551
956
|
const email = tidy(body.email, IDEA_LIMITS.email);
|
|
552
|
-
return email && EMAIL.test(email) ? {
|
|
957
|
+
return email && EMAIL.test(email) ? { ...idea, email } : null;
|
|
553
958
|
}
|
|
554
959
|
|
|
960
|
+
/** The pictures a link's ideas carry, to go with them. */
|
|
961
|
+
const ideaPictures = (ideas) => ideas.map((one) => one.picture).filter((url) => typeof url === "string" && url.startsWith("https://"));
|
|
962
|
+
|
|
555
963
|
/**
|
|
556
964
|
* Ideas for a link's maker (`/api/wish-ideas?id=`). POST, from the link's page once the sender confirms it, keeps one:
|
|
557
|
-
* what someone using it would change,
|
|
558
|
-
*
|
|
559
|
-
*
|
|
965
|
+
* what someone using it would change, an address to hear back at, if they gave one, and the picture of the app the
|
|
966
|
+
* page took as they opened the box, if they kept it in (in the store, until the idea has its outcome). GET, with the
|
|
967
|
+
* link's secret, answers for Lamp to show the maker those still open and those whose sender has answered since
|
|
968
|
+
* (`waiting`), or with `earlier=1` those seen to, each with its picture's address and its thread: never the keys or the addresses, only whether someone
|
|
969
|
+
* wants to hear back. DELETE ?n= with the secret lets one go: with `built=1` it's in, and the site writes to whoever
|
|
970
|
+
* asked (`writeToSender`); without, it's dismissed. Its JSON body may carry the maker's `note`, the first of the idea's
|
|
971
|
+
* thread. PATCH ?n= with the secret and a `note` says something more, written to the sender if they gave an address;
|
|
972
|
+
* without one it only says the maker has seen to their answer. Every idea is kept a while past its last word, for the
|
|
560
973
|
* sender's own browser, which kept the number and a token the POST answered, to ask after it on a later visit (GET
|
|
561
|
-
* `?id=&mine=<n>.<token>,…`, answering only those
|
|
974
|
+
* `?id=&mine=<n>.<token>,…`, answering only those, with the maker's last word), and for the sender's replies by email.
|
|
975
|
+
* A link in each email (`?stop=<mailbox>`, GET or a mail app's one-click POST) lets the address go. Never logged,
|
|
976
|
+
* never on the page for anyone else, gone with the link.
|
|
562
977
|
*/
|
|
563
978
|
export function createIdeasHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
|
|
564
979
|
return async (request) => {
|
|
565
980
|
const method = request.method;
|
|
566
|
-
if (!["GET", "POST", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, DELETE" });
|
|
981
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, PUT, PATCH, DELETE" });
|
|
567
982
|
const url = new URL(request.url);
|
|
568
|
-
const origin = request.headers.get("origin");
|
|
569
|
-
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
570
983
|
const id = url.searchParams.get("id");
|
|
571
984
|
if (!isID(id)) return reply(404, { error: "not_found" });
|
|
985
|
+
const stop = url.searchParams.get("stop");
|
|
986
|
+
if (stop != null && (method === "GET" || method === "POST")) return stopEmails(id, Number(url.searchParams.get("n")), stop);
|
|
987
|
+
// The links in the maker's own emails: confirming their address, and stopping them.
|
|
988
|
+
for (const step of ["confirm", "stopmaker"]) {
|
|
989
|
+
const given = url.searchParams.get(step);
|
|
990
|
+
if (given != null && (method === "GET" || method === "POST")) return makerLink(id, step, given);
|
|
991
|
+
}
|
|
992
|
+
const origin = request.headers.get("origin");
|
|
993
|
+
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
572
994
|
|
|
573
995
|
let idea = null;
|
|
574
996
|
if (method === "POST") {
|
|
@@ -577,16 +999,38 @@ export function createIdeasHandler({ env = process.env, fetcher = fetch, store,
|
|
|
577
999
|
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_idea" }); }
|
|
578
1000
|
if (!idea) return reply(400, { error: "invalid_idea" });
|
|
579
1001
|
}
|
|
580
|
-
|
|
1002
|
+
// The maker's own address for the link's ideas (PUT ?maker=1 with the secret): checked here, kept once the secret is.
|
|
1003
|
+
let makerEmail;
|
|
1004
|
+
if (method === "PUT") {
|
|
1005
|
+
if (url.searchParams.get("maker") !== "1") return reply(405, { error: "method_not_allowed" });
|
|
1006
|
+
let given;
|
|
1007
|
+
try { given = JSON.parse((await readBody(request)) || "{}")?.email; }
|
|
1008
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_email" }); }
|
|
1009
|
+
makerEmail = given == null || given === "" ? "" : tidy(given, IDEA_LIMITS.email);
|
|
1010
|
+
if (makerEmail === null || (makerEmail && !EMAIL.test(makerEmail))) return reply(400, { error: "invalid_email" });
|
|
1011
|
+
}
|
|
1012
|
+
const n = method === "DELETE" || method === "PATCH" ? Number(url.searchParams.get("n")) : null;
|
|
581
1013
|
if (n !== null && (!Number.isInteger(n) || n < 1)) return reply(400, { error: "invalid_idea" });
|
|
1014
|
+
// The maker's note to the sender, if they wrote one: a body with nothing in it, or none, is no note.
|
|
1015
|
+
let note = null;
|
|
1016
|
+
if (n !== null && /^application\/json/i.test(request.headers.get("content-type") || "")) {
|
|
1017
|
+
let given;
|
|
1018
|
+
try { given = JSON.parse((await readBody(request)) || "{}")?.note; }
|
|
1019
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_idea" }); }
|
|
1020
|
+
if (given != null && given !== "") {
|
|
1021
|
+
note = tidy(given, IDEA_LIMITS.note, { lines: true });
|
|
1022
|
+
if (!note) return reply(400, { error: "invalid_idea" });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
582
1025
|
|
|
583
1026
|
let kept;
|
|
584
1027
|
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
585
|
-
|
|
1028
|
+
// A new idea, or a confirmation to a maker's address: each an email or more, so each is counted.
|
|
1029
|
+
if (limited && (method === "POST" || (method === "PUT" && makerEmail))) {
|
|
586
1030
|
const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
|
|
587
1031
|
if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
|
|
588
1032
|
let retry;
|
|
589
|
-
try { retry = await reserve(kept.limiter, ip, IDEA_QUOTAS, fetcher); }
|
|
1033
|
+
try { retry = await reserve(kept.limiter, ip, method === "POST" ? IDEA_QUOTAS : MAKER_QUOTAS, fetcher); }
|
|
590
1034
|
catch { return reply(503, { error: "service_unavailable" }); }
|
|
591
1035
|
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
592
1036
|
}
|
|
@@ -596,35 +1040,250 @@ export function createIdeasHandler({ env = process.env, fetcher = fetch, store,
|
|
|
596
1040
|
if (!record) return reply(404, { error: "not_found" });
|
|
597
1041
|
if (method === "POST") {
|
|
598
1042
|
const token = randomBytes(12).toString("base64url");
|
|
599
|
-
|
|
600
|
-
|
|
1043
|
+
// The picture goes to the store first, at an address with a random part, and goes again if the idea can't.
|
|
1044
|
+
const { picture: shot, ...given } = idea;
|
|
1045
|
+
// From a signed-in browser, it's by that username, and its news goes to the account's address. Otherwise an
|
|
1046
|
+
// address not yet confirmed is asked to be, once the idea is kept.
|
|
1047
|
+
let words = given, confirming = false;
|
|
1048
|
+
const user = await accountOf(kept, cookieKey(request));
|
|
1049
|
+
const account = user ? await kept.account(user) : null;
|
|
1050
|
+
if (account) words = { ...given, name: user, user, email: account.email };
|
|
1051
|
+
else if (given.email) confirming = !(await kept.accountForEmail(given.email.toLowerCase()));
|
|
1052
|
+
const at = shot ? await kept.putImage(id, "idea", shot) : null;
|
|
1053
|
+
let number = null;
|
|
1054
|
+
const added = { ...words, ...(at ? { picture: at } : {}), token, at: now().toISOString() };
|
|
1055
|
+
try { number = await kept.addIdea(id, added); }
|
|
1056
|
+
finally { if (!number && at) { try { await kept.removeImages([at]); } catch {} } }
|
|
1057
|
+
if (!number) return reply(429, { error: "full" });
|
|
1058
|
+
// To the maker's own address too, if they gave one, and the sender asked to confirm theirs: both at once, so the
|
|
1059
|
+
// answer that carries the sender's token comes inside the page's wait. The idea is kept whether or not they go.
|
|
1060
|
+
const [, confirm] = await Promise.all([
|
|
1061
|
+
(async () => {
|
|
1062
|
+
const maker = await kept.maker(id);
|
|
1063
|
+
if (maker?.confirmed) await writeToMaker(kept, id, { ...added, n: number }, record, linkOf(url.origin, record), { env, fetcher, origin: url.origin, maker, once: `maker-${id}-${number}` });
|
|
1064
|
+
})().catch(() => {}),
|
|
1065
|
+
confirming ? askToConfirm(kept, { email: words.email, name: words.name, app: record.name, origin: url.origin, env, fetcher }).catch(() => false) : false,
|
|
1066
|
+
]);
|
|
1067
|
+
return reply(201, { n: number, token, ...(user ? { user } : {}), ...(confirm ? { confirm: true } : {}) });
|
|
601
1068
|
}
|
|
602
|
-
// The sender's own ideas, asked by number and token from their browser: what became of each,
|
|
1069
|
+
// The sender's own ideas, asked by number and token from their browser: what became of each, and the maker's
|
|
1070
|
+
// last word to them, nothing else.
|
|
603
1071
|
const mine = method === "GET" ? url.searchParams.get("mine") : null;
|
|
604
1072
|
if (mine != null) {
|
|
605
1073
|
const asked = new Map(mine.split(",").slice(0, IDEA_LIMITS.asked).map((one) => one.split("."))
|
|
606
1074
|
.filter(([n, token]) => /^\d{1,6}$/.test(n ?? "") && /^[A-Za-z0-9_-]{16}$/.test(token ?? "")).map(([n, token]) => [Number(n), token]));
|
|
607
1075
|
const all = await kept.ideas(id);
|
|
608
|
-
return reply(200, { ideas: all.filter((one) => one.token && asked.get(one.n) === one.token)
|
|
609
|
-
|
|
1076
|
+
return reply(200, { ideas: all.filter((one) => one.token && asked.get(one.n) === one.token).map((one) => {
|
|
1077
|
+
// A note given before ideas had threads was kept on its own.
|
|
1078
|
+
const last = (one.thread ?? []).filter((said) => said.by === "maker").at(-1)?.text ?? one.note;
|
|
1079
|
+
return { n: one.n, status: one.status ?? "open", ...(one.done ? { done: one.done } : {}), ...(last ? { note: last } : {}) };
|
|
1080
|
+
}) });
|
|
610
1081
|
}
|
|
611
1082
|
if (!secretMatches(bearer(request), record.secret)) return reply(404, { error: "not_found" });
|
|
612
|
-
|
|
613
|
-
//
|
|
1083
|
+
if (url.searchParams.get("maker") === "1" && (method === "GET" || method === "PUT")) return setMaker(kept, record, id, url.origin, method === "PUT" ? makerEmail : undefined);
|
|
1084
|
+
// The maker sees the ideas still open, and those answered since, and whether someone wants to hear back, never
|
|
1085
|
+
// their address: the site writes to them. Those settled are kept a while past their last word, then go.
|
|
614
1086
|
if (method === "GET") {
|
|
615
1087
|
const all = await kept.ideas(id);
|
|
616
|
-
const old = all.filter((one) => one.done && now() - new Date(one.done) > IDEA_LIMITS.keptDone);
|
|
617
|
-
for (const one of old)
|
|
618
|
-
|
|
1088
|
+
const old = all.filter((one) => one.done && now() - new Date(one.last ?? one.done) > IDEA_LIMITS.keptDone);
|
|
1089
|
+
for (const one of old) {
|
|
1090
|
+
await kept.removeIdea(id, one.n);
|
|
1091
|
+
for (const box of [one.mailbox, one.makerBox]) if (box) { try { await kept.closeMailbox(box); } catch {} }
|
|
1092
|
+
}
|
|
1093
|
+
try { await kept.removeImages(ideaPictures(old)); } catch {}
|
|
1094
|
+
// `earlier=1`: those seen to, the latest word first, for the maker to look back on or answer again.
|
|
1095
|
+
if (url.searchParams.get("earlier") === "1") {
|
|
1096
|
+
const settled = all.filter((one) => one.status && !one.waiting && !old.includes(one))
|
|
1097
|
+
.sort((a, b) => String(b.last ?? b.done).localeCompare(String(a.last ?? a.done)));
|
|
1098
|
+
return reply(200, { ideas: settled.slice(0, IDEA_LIMITS.earlier).map(shownToMaker) });
|
|
1099
|
+
}
|
|
1100
|
+
return reply(200, { ideas: all.filter((one) => !one.status || one.waiting).map(shownToMaker) });
|
|
1101
|
+
}
|
|
1102
|
+
const link = linkOf(url.origin, record);
|
|
1103
|
+
const write = (to, kind, said, once) => writeToSender(kept, id, to, record, link, { env, fetcher, kind, said, origin: url.origin, once });
|
|
1104
|
+
const said = note ? { by: "maker", text: note, at: now().toISOString(), mid: newMessageID(env) } : null;
|
|
1105
|
+
if (method === "PATCH") {
|
|
1106
|
+
if (!said) return (await kept.talk(id, n, "settle")) ? reply(200, { notified: false }) : reply(404, { error: "not_found" });
|
|
1107
|
+
const was = await kept.talk(id, n, "say", said);
|
|
1108
|
+
if (!was) return (await kept.ideas(id)).some((one) => one.n === n) ? reply(429, { error: "full" }) : reply(404, { error: "not_found" });
|
|
1109
|
+
return reply(200, { notified: await write(was, "reply", said, `idea-${id}-${n}-${said.mid}`) });
|
|
619
1110
|
}
|
|
620
1111
|
// An outcome, given once: only the call that gave it writes to the sender, so two at once, or one tried again,
|
|
621
1112
|
// send one email.
|
|
622
1113
|
const built = url.searchParams.get("built") === "1";
|
|
623
|
-
|
|
624
|
-
|
|
1114
|
+
// Its picture was for the maker, who has seen to it: it goes first, so a store that turns that away leaves the
|
|
1115
|
+
// idea open, with its picture, to be let go again, rather than the picture left with nothing pointing to it.
|
|
1116
|
+
const open = (await kept.ideas(id)).find((one) => one.n === n && !one.status);
|
|
1117
|
+
if (open) await kept.removeImages(ideaPictures([open]));
|
|
1118
|
+
const was = await kept.finishIdea(id, n, built ? "in" : "dismissed", now().toISOString(), said);
|
|
1119
|
+
const notified = built && was?.email ? await write(was, "in", said, `idea-${id}-${n}`) : false;
|
|
625
1120
|
return reply(200, { notified });
|
|
626
1121
|
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
627
1122
|
};
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The maker's own address: as it is (`email` undefined), or given (asked to confirm it first, by an email with a
|
|
1126
|
+
* link in it, unless it's the one they confirmed already) or taken away (""). Never anyone else's to see.
|
|
1127
|
+
*/
|
|
1128
|
+
async function setMaker(kept, record, id, origin, email) {
|
|
1129
|
+
let maker = await kept.maker(id);
|
|
1130
|
+
let sent = false;
|
|
1131
|
+
if (email === "") { await kept.setMaker(id, null); maker = null; }
|
|
1132
|
+
else if (email !== undefined && !(maker?.confirmed && maker.email.toLowerCase() === email.toLowerCase())) {
|
|
1133
|
+
// Send Again, or another address, at most once a minute for a link.
|
|
1134
|
+
const since = now() - new Date(maker?.asked ?? 0);
|
|
1135
|
+
if (since < MAKER_RESEND_MS) return reply(429, { error: "rate_limited" }, { "Retry-After": String(Math.ceil((MAKER_RESEND_MS - since) / 1000)) });
|
|
1136
|
+
maker = { email, confirmed: false, key: randomBytes(10).toString("hex"), asked: now().toISOString() };
|
|
1137
|
+
await kept.setMaker(id, maker);
|
|
1138
|
+
sent = await sendMail(env, fetcher, {
|
|
1139
|
+
to: [email], subject: `Get ideas for ${record.name} by email`,
|
|
1140
|
+
text: `To get the ideas people send for ${record.name}, and their replies, at this address, confirm it here:\n\n` +
|
|
1141
|
+
`${origin}/api/wish-ideas?id=${id}&confirm=${maker.key}\n\nIf you didn't ask for this, you can ignore this email.`,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
return reply(200, { email: maker?.email ?? null, confirmed: maker?.confirmed === true, ...(email && !maker?.confirmed ? { sent } : {}) });
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** A link from the maker's emails: their address confirmed, or let go, if the key is theirs. */
|
|
1148
|
+
async function makerLink(id, step, key) {
|
|
1149
|
+
if (!/^[0-9a-f]{20}$/.test(key)) return reply(404, { error: "not_found" });
|
|
1150
|
+
try {
|
|
1151
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
1152
|
+
await kept.makerStep(id, key, step === "confirm" ? "confirm" : "stop");
|
|
1153
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
1154
|
+
const words = step === "confirm" ? "Ideas for this link, and replies to them, will come to your email." : "You won't get any more emails about this link's ideas.";
|
|
1155
|
+
return new Response(`${words}\n`, { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** A link from one of the emails: the idea's address goes, if the mailbox is the idea's own. */
|
|
1159
|
+
async function stopEmails(id, n, box) {
|
|
1160
|
+
if (!Number.isInteger(n) || n < 1 || !/^[0-9a-f]{20}$/.test(box)) return reply(404, { error: "not_found" });
|
|
1161
|
+
try {
|
|
1162
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
1163
|
+
await kept.talk(id, n, "stop", box);
|
|
1164
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
1165
|
+
// The same words whether or not it was theirs, so the link says nothing about an idea to anyone else.
|
|
1166
|
+
return new Response("You won't get any more emails about this idea.\n", { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* An idea as its maker sees it: never its sender's address, its token or its mailbox, only whether they want to hear.
|
|
1172
|
+
* A note given before ideas had threads is shown as the start of one.
|
|
1173
|
+
*/
|
|
1174
|
+
const shownToMaker = ({ email, token: _token, mailbox: _mailbox, makerBox: _makerBox, subject: _subject, note, ...given }) => {
|
|
1175
|
+
const shown = note && !given.thread ? { ...given, thread: [{ by: "maker", text: note, at: given.done ?? given.at }] } : given;
|
|
1176
|
+
return {
|
|
1177
|
+
...shown,
|
|
1178
|
+
...(shown.thread ? { thread: shown.thread.map(({ by, text, at }) => ({ by, text, at })) } : {}),
|
|
1179
|
+
...(email ? { wantsNews: true } : {}),
|
|
1180
|
+
};
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
/** The address in a From line, in lower case: `Someone <a@b.c>` or `a@b.c`. */
|
|
1184
|
+
const addressIn = (from) => (/<([^<>\s]+@[^<>\s]+)>/.exec(from ?? "")?.[1] ?? String(from ?? "").trim()).toLowerCase();
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* A reply's own words: what's above the earlier email it quotes (a line "On … wrote:", a "-----Original Message-----"
|
|
1188
|
+
* or a "From:" line after a blank one), without lines quoted with ">", tidied and cut to `IDEA_LIMITS.note`.
|
|
1189
|
+
*/
|
|
1190
|
+
export function replyWords(text) {
|
|
1191
|
+
const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
1192
|
+
const kept = [];
|
|
1193
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1194
|
+
const line = lines[i];
|
|
1195
|
+
const two = `${line} ${lines[i + 1] ?? ""}`;
|
|
1196
|
+
if (/^\s*-{2,}\s*Original Message\s*-{2,}/i.test(line) || /^\s*_{10,}\s*$/.test(line)) break;
|
|
1197
|
+
if (/^\s*On\b.{0,300}\bwrote:\s*$/i.test(line) || (/^\s*On\b/i.test(line) && /^\s*On\b.{0,300}\bwrote:\s*$/i.test(two))) break;
|
|
1198
|
+
if (/^\s*From:\s/i.test(line) && kept.length && kept.at(-1).trim() === "") break;
|
|
1199
|
+
if (/^\s*>/.test(line)) continue;
|
|
1200
|
+
kept.push(line);
|
|
1201
|
+
}
|
|
1202
|
+
const words = kept.join("\n").trim();
|
|
1203
|
+
return tidy([...words].slice(0, IDEA_LIMITS.note).join(""), IDEA_LIMITS.note, { lines: true });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Whether a webhook from Resend is its own (`RESEND_WEBHOOK_SECRET`, `whsec_` and base64): Svix's signature, an
|
|
1208
|
+
* HMAC-SHA256 of `<id>.<timestamp>.<body>`, sent no more than five minutes ago.
|
|
1209
|
+
*/
|
|
1210
|
+
export function signedByResend(headers, body, secret, now = Date.now()) {
|
|
1211
|
+
const id = headers.get("svix-id"), at = headers.get("svix-timestamp"), signatures = headers.get("svix-signature");
|
|
1212
|
+
if (!secret || !id || !at || !signatures || !/^\d+$/.test(at) || Math.abs(now / 1000 - Number(at)) > 300) return false;
|
|
1213
|
+
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
|
|
1214
|
+
const expected = createHmac("sha256", key).update(`${id}.${at}.${body}`).digest();
|
|
1215
|
+
return signatures.split(" ").some((one) => {
|
|
1216
|
+
const [version, signature] = one.split(",");
|
|
1217
|
+
if (version !== "v1" || !signature) return false;
|
|
1218
|
+
const given = Buffer.from(signature, "base64");
|
|
1219
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Replies to an idea's emails (`/api/idea-mail`, Resend's `email.received` webhook, for mail to
|
|
1225
|
+
* `LAMP_IDEAS_REPLY_DOMAIN`). Each to `idea-<mailbox>@` it goes on that idea's thread, its own words only: from the
|
|
1226
|
+
* sender's mailbox, only from the address the idea was sent with, leaving it waiting on the maker and sent on to the
|
|
1227
|
+
* maker's own address if they gave one; from the maker's, only from their confirmed address, as their word, sent on to
|
|
1228
|
+
* the sender. Anything else is let go. Answers 200 to
|
|
1229
|
+
* whatever Resend signed, so it isn't sent again, and 401 to anything else.
|
|
1230
|
+
*/
|
|
1231
|
+
export function createIdeaMailHandler({ env = process.env, fetcher = fetch, store, now = () => new Date() } = {}) {
|
|
1232
|
+
return async (request) => {
|
|
1233
|
+
if (request.method !== "POST") return reply(405, { error: "method_not_allowed" }, { Allow: "POST" });
|
|
1234
|
+
let body;
|
|
1235
|
+
try { body = await readBody(request); } catch { return reply(413, { error: "too_large" }); }
|
|
1236
|
+
if (!signedByResend(request.headers, body, env.RESEND_WEBHOOK_SECRET, now().getTime())) return reply(401, { error: "unsigned" });
|
|
1237
|
+
let event;
|
|
1238
|
+
try { event = JSON.parse(body); } catch { return reply(200, {}); }
|
|
1239
|
+
const mail = event?.data;
|
|
1240
|
+
const domain = (env.LAMP_IDEAS_REPLY_DOMAIN ?? "").toLowerCase();
|
|
1241
|
+
if (event?.type !== "email.received" || !domain || typeof mail?.email_id !== "string") return reply(200, {});
|
|
1242
|
+
const boxes = [...new Set([...(mail.to ?? []), ...(mail.received_for ?? [])].map(addressIn)
|
|
1243
|
+
.map((to) => new RegExp(`^idea-([0-9a-f]{20})@${domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`).exec(to)?.[1]).filter(Boolean))];
|
|
1244
|
+
if (!boxes.length) return reply(200, {});
|
|
1245
|
+
const origin = new URL(request.url).origin;
|
|
1246
|
+
try {
|
|
1247
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
1248
|
+
for (const box of boxes.slice(0, 3)) {
|
|
1249
|
+
const found = await kept.mailbox(box);
|
|
1250
|
+
if (!found) continue;
|
|
1251
|
+
const idea = (await kept.ideas(found.id)).find((one) => one.n === found.n);
|
|
1252
|
+
if (!idea) continue;
|
|
1253
|
+
const maker = await kept.maker(found.id);
|
|
1254
|
+
// Only from the address the idea's emails went to on that side, and only while it's kept.
|
|
1255
|
+
const from = addressIn(mail.from);
|
|
1256
|
+
const fromMaker = found.side === "maker";
|
|
1257
|
+
if (fromMaker ? !(maker?.confirmed && idea.makerBox === box && from === maker.email.toLowerCase())
|
|
1258
|
+
: !(idea.email && idea.mailbox === box && from === idea.email.toLowerCase())) continue;
|
|
1259
|
+
const response = await fetcher(`https://api.resend.com/emails/receiving/${encodeURIComponent(mail.email_id)}`, {
|
|
1260
|
+
redirect: "error", signal: AbortSignal.timeout(5000), headers: { Authorization: `Bearer ${env.RESEND_API_KEY}` },
|
|
1261
|
+
});
|
|
1262
|
+
if (!response.ok) return reply(503, { error: "service_unavailable" });
|
|
1263
|
+
const got = await response.json();
|
|
1264
|
+
const words = replyWords(got.text ?? String(got.html ?? "").replace(/<br\s*\/?>|<\/p>|<\/div>/gi, "\n").replace(/<[^>]*>/g, "")
|
|
1265
|
+
.replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&"));
|
|
1266
|
+
if (!words) continue;
|
|
1267
|
+
const mid = typeof mail.message_id === "string" && /^<[^<>\s]+>$/.test(mail.message_id) ? mail.message_id : undefined;
|
|
1268
|
+
const record = await kept.get(found.id);
|
|
1269
|
+
if (!record) continue;
|
|
1270
|
+
const link = linkOf(origin, record);
|
|
1271
|
+
// The email as Resend received it, which a webhook sent again names the same, keeps it from going on twice.
|
|
1272
|
+
const via = `resend:${mail.email_id}`;
|
|
1273
|
+
if (fromMaker) {
|
|
1274
|
+
// The maker's own word, as if said in Lamp: on the thread, and on to the sender, with a message id of its own.
|
|
1275
|
+
const said = { by: "maker", text: words, at: now().toISOString(), mid: newMessageID(env), via };
|
|
1276
|
+
const was = await kept.talk(found.id, found.n, "say", said);
|
|
1277
|
+
if (was?.email) await writeToSender(kept, found.id, was, record, link, { env, fetcher, kind: "reply", said, origin, once: `idea-${found.id}-${found.n}-${said.mid}` });
|
|
1278
|
+
} else {
|
|
1279
|
+
const said = { by: "sender", text: words, at: now().toISOString(), via, ...(mid ? { mid } : {}) };
|
|
1280
|
+
const was = await kept.talk(found.id, found.n, "say", said);
|
|
1281
|
+
if (was && maker?.confirmed) await writeToMaker(kept, found.id, was, record, link, { env, fetcher, kind: "reply", said, origin, maker, once: `maker-${found.id}-${found.n}-${mail.email_id}` });
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
1285
|
+
return reply(200, {});
|
|
1286
|
+
};
|
|
628
1287
|
}
|
|
629
1288
|
|
|
630
1289
|
/** A shared wish for the page and its card, or null when there's none by that id (or it can't be read now). */
|
|
@@ -640,8 +1299,9 @@ export async function takeDown(id, { env = process.env, fetcher = fetch } = {})
|
|
|
640
1299
|
const kept = cloudStore(env, fetcher);
|
|
641
1300
|
const record = await kept.get(id);
|
|
642
1301
|
if (!record) return false;
|
|
1302
|
+
const ideaShots = ideaPictures(await kept.ideas(id).catch(() => []));
|
|
643
1303
|
await kept.remove(id);
|
|
644
|
-
await kept.removeImages(blobsOf(record));
|
|
1304
|
+
await kept.removeImages([...blobsOf(record), ...ideaShots]);
|
|
645
1305
|
await kept.forget(id);
|
|
646
1306
|
return true;
|
|
647
1307
|
}
|
|
@@ -652,6 +1312,9 @@ export async function takeDown(id, { env = process.env, fetcher = fetch } = {})
|
|
|
652
1312
|
// A page is sent packed within `FILE_LIMITS.total`; what it unpacks to may be more, since a big 3D game's data packs small.
|
|
653
1313
|
export const PAGE_LIMITS = { files: 3000, total: 128_000_000 };
|
|
654
1314
|
|
|
1315
|
+
/** How many of a page's files go to the blob store at once as it's put together. */
|
|
1316
|
+
const PAGE_UPLOADS = 12;
|
|
1317
|
+
|
|
655
1318
|
/**
|
|
656
1319
|
* A zip's files, read from its central directory: stored or deflated, never a link, a folder, an absolute path or one
|
|
657
1320
|
* that steps up. Null for anything else, or past the limits, counting what they unpack to.
|
|
@@ -727,16 +1390,54 @@ export function playOrigin(base, id) {
|
|
|
727
1390
|
*/
|
|
728
1391
|
/**
|
|
729
1392
|
* A page's file this big or bigger is played straight from the store instead of through the play host: the CDN keeps
|
|
730
|
-
* nothing over
|
|
1393
|
+
* nothing over 10 MB from a function, so every visit would pass it through again. Only for a page kept with each
|
|
731
1394
|
* file's own type (`page.sizes`), since a browser won't run a script the store calls anything else. Never a page's
|
|
732
1395
|
* HTML, which the store hands out as a download.
|
|
733
1396
|
*/
|
|
734
|
-
export const DIRECT_FILE =
|
|
1397
|
+
export const DIRECT_FILE = 9_000_000;
|
|
1398
|
+
/**
|
|
1399
|
+
* A file of data a game reads with fetch (map tiles, models, sound, JSON) is played straight from the store, whatever
|
|
1400
|
+
* its size: the store's CDN serves it to any page (CORS for all), keeps it whatever the site deploys, and the play host
|
|
1401
|
+
* reads nothing for it. A game may have hundreds of small ones (a map's tiles), all asked for again after each deploy.
|
|
1402
|
+
* From 1 byte, so only for a page kept with each file's size and type (`page.sizes`). The play host reading a game's hundreds of files from the store in bursts is what the store
|
|
1403
|
+
* turned away (403) until the game didn't load. Not a script or a style sheet, whose own imports would then resolve
|
|
1404
|
+
* at the store, nor a picture, which a game may draw from without asking for it cross-origin. Only when the page
|
|
1405
|
+
* fetched it (`Sec-Fetch-Mode: cors`): a video or sound an element of the page loads (no-cors), drawn into WebGL or
|
|
1406
|
+
* played through Web Audio, works only from the page's own origin.
|
|
1407
|
+
*/
|
|
1408
|
+
export const DIRECT_DATA = 1;
|
|
1409
|
+
const DATA_FILE = /\.(bin|gz|br|zst|json|geojson|csv|txt|glb|gltf|ktx2|basis|drc|wasm|mp3|ogg|m4a|wav|aac|flac|opus|mp4|webm|mov)$/i;
|
|
735
1410
|
|
|
736
1411
|
/** How long a link's record is remembered for serving its page's files, and how many links at once. */
|
|
737
1412
|
export const PLAY_MEMORY = { ms: 60_000, links: 200 };
|
|
738
1413
|
|
|
739
|
-
|
|
1414
|
+
/**
|
|
1415
|
+
* What a page played on its link is given at the top of its HTML: when the link's page asks for a picture of the app
|
|
1416
|
+
* (as someone opens the box to send its maker an idea), the app's biggest canvas, drawn after the app has drawn its
|
|
1417
|
+
* next frame, scaled to at most 1280 wide, as a JPEG small enough to go with an idea. Only the site's own page may ask,
|
|
1418
|
+
* and only it is answered. Nothing for an app without a canvas, one that can't be read, or one with nothing drawn in it
|
|
1419
|
+
* (see-through all over). A JPEG has no see-through, so what shows through the canvas on the page is put behind it: the
|
|
1420
|
+
* first color behind it there, else white as the browser's own. A scene that's dark (night, space) is a picture.
|
|
1421
|
+
*
|
|
1422
|
+
* A 3D canvas's picture is gone once its frame is on screen, and an app that draws only when something changes (a
|
|
1423
|
+
* builder, a viewer) draws no next frame to catch: so before the app's own code runs, every WebGL canvas made here
|
|
1424
|
+
* keeps its last picture (`preserveDrawingBuffer`), which costs a game little and lets any of them be read.
|
|
1425
|
+
*/
|
|
1426
|
+
export function pictureScript(site) {
|
|
1427
|
+
// The most a picture may be as a data: URL to go with an idea (`IDEA_LIMITS.picture` bytes, as base64).
|
|
1428
|
+
const limit = 23 + Math.floor(IDEA_LIMITS.picture / 3) * 4;
|
|
1429
|
+
const keep = `try{const get=HTMLCanvasElement.prototype.getContext;HTMLCanvasElement.prototype.getContext=function(type,options){if(/^(webgl2?|experimental-webgl)$/.test(type))options=Object.assign({},options,{preserveDrawingBuffer:true});return get.call(this,type,options)}}catch(_){}`;
|
|
1430
|
+
// Nothing drawn (a canvas read when it held nothing) is no picture: every point of the copy see-through. Read before
|
|
1431
|
+
// anything is put behind it, and by alpha alone, so a dark scene is kept.
|
|
1432
|
+
const blank = `const blank=x=>{const d=x.getImageData(0,0,x.canvas.width,x.canvas.height).data;for(let i=3;i<d.length;i+=4)if(d[i])return false;return true};`;
|
|
1433
|
+
// The color that shows through the canvas on the page: its own background or the first of the elements around it that
|
|
1434
|
+
// has one. None (the page left as the browser draws it) is white. Only an alpha of 0 is none: the fourth of rgba()'s
|
|
1435
|
+
// values or what follows a slash, never a color's last channel, so black, red and green count.
|
|
1436
|
+
const behind = `const behind=c=>{for(let e=c;e;e=e.parentElement){const b=getComputedStyle(e).backgroundColor;if(b&&b!=="transparent"&&!/^rgba\\(([^,]*,){3}\\s*0(\\.0*)?%?\\s*\\)$|\\/\\s*0(\\.0*)?%?\\s*\\)$/.test(b))return b}return"#fff"};`;
|
|
1437
|
+
return `<script>(()=>{${keep}const site=${JSON.stringify(site)};${blank}${behind}addEventListener("message",e=>{if(e.origin!==site||e.source!==parent||!e.data||e.data.lamp!=="picture")return;const n=e.data.n;requestAnimationFrame(()=>{let picture=null;try{const all=[...document.querySelectorAll("canvas")].filter(c=>c.width&&c.height&&c.getClientRects().length);const c=all.sort((a,b)=>b.clientWidth*b.clientHeight-a.clientWidth*a.clientHeight)[0];if(c){const k=Math.min(1,1280/c.width),o=document.createElement("canvas");o.width=Math.round(c.width*k);o.height=Math.round(c.height*k);const x=o.getContext("2d");x.drawImage(c,0,0,o.width,o.height);if(!blank(x)){x.globalCompositeOperation="destination-over";x.fillStyle=behind(c);x.fillRect(0,0,o.width,o.height);for(const q of [0.8,0.6,0.4]){picture=o.toDataURL("image/jpeg",q);if(picture.length<=${limit})break;picture=null}}}}catch(_){}parent.postMessage({lamp:"picture",n,picture},site)})})})()</script>`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
export function createPlayHandler({ env = process.env, fetcher = fetch, store, anyHost = false, direct = DIRECT_FILE, directData = DIRECT_DATA, now = () => Date.now() } = {}) {
|
|
740
1441
|
// A link's record, remembered a minute per instance: a game asks for hundreds of files as it's played, and reading
|
|
741
1442
|
// the store for each used up its allowance of requests in a day, which took every link down. A version the memory
|
|
742
1443
|
// doesn't have (a release since) is read afresh; a link taken down plays on here for that minute at most.
|
|
@@ -796,18 +1497,23 @@ export function createPlayHandler({ env = process.env, fetcher = fetch, store, a
|
|
|
796
1497
|
// Not for a worker's own script, which a browser only takes from the page's own origin, nor from a store a browser
|
|
797
1498
|
// can't reach (the dev server's memory). Kept apart by what asked (`Vary`), so a worker never gets the redirect.
|
|
798
1499
|
const worker = /worker$/i.test(request.headers.get("sec-fetch-dest") ?? "");
|
|
799
|
-
|
|
800
|
-
|
|
1500
|
+
const size = served.sizes?.[name] ?? 0;
|
|
1501
|
+
if (!rebased && !worker && at.startsWith("https://") && !/\.html?$/i.test(name) && (size >= direct || (DATA_FILE.test(name) && size >= directData && request.headers.get("sec-fetch-mode") === "cors"))) {
|
|
1502
|
+
return new Response(null, { status: 302, headers: { Location: at, Vary: "Sec-Fetch-Dest, Sec-Fetch-Mode", "Cache-Control": "public, max-age=300, s-maxage=31536000", ...cacheTags(id) } });
|
|
801
1503
|
}
|
|
802
|
-
// Passed on as it comes from the store; only a
|
|
1504
|
+
// Passed on as it comes from the store; only a page's HTML is read whole, to be given what the link's page asks of
|
|
1505
|
+
// it (`pictureScript`), and a folder's index.html its base.
|
|
803
1506
|
const kept = store ?? cloudStore(env, fetcher);
|
|
1507
|
+
const site = env.LAMP_SITE_ORIGIN || (anyHost ? url.origin : "https://lamp.dev");
|
|
804
1508
|
let bytes, length;
|
|
805
1509
|
try {
|
|
806
|
-
if (
|
|
807
|
-
const base = `<base href="/w/${id}/play/${version}/${folder.split("/").map(encodeURIComponent).join("/")}/"
|
|
1510
|
+
if (/\.html?$/i.test(name) && request.method !== "HEAD") {
|
|
1511
|
+
const base = rebased ? `<base href="/w/${id}/play/${version}/${folder.split("/").map(encodeURIComponent).join("/")}/">` : "";
|
|
1512
|
+
const added = base + pictureScript(site);
|
|
808
1513
|
const html = (await kept.readFile(at)).toString("utf8");
|
|
809
|
-
|
|
810
|
-
|
|
1514
|
+
// In its head, or after its doctype where it has no head of its own: before a doctype, a page is drawn in quirks mode.
|
|
1515
|
+
const head = /<head[^>]*>/i.exec(html) ?? /^(?:\s|<!--[\s\S]*?-->)*<!doctype[^>]*>/i.exec(html);
|
|
1516
|
+
bytes = Buffer.from(head ? html.slice(0, head.index + head[0].length) + added + html.slice(head.index + head[0].length) : added + html);
|
|
811
1517
|
} else if (request.method !== "HEAD") {
|
|
812
1518
|
({ body: bytes, length } = await kept.openFile(at));
|
|
813
1519
|
}
|
|
@@ -815,7 +1521,6 @@ export function createPlayHandler({ env = process.env, fetcher = fetch, store, a
|
|
|
815
1521
|
console.error("wish play: a file couldn't be read:", error?.name, error?.message);
|
|
816
1522
|
return new Response(null, { status: 503 });
|
|
817
1523
|
}
|
|
818
|
-
const site = env.LAMP_SITE_ORIGIN || (anyHost ? url.origin : "https://lamp.dev");
|
|
819
1524
|
return new Response(request.method === "HEAD" ? null : bytes, {
|
|
820
1525
|
headers: {
|
|
821
1526
|
"Content-Type": pageType(name), "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
|
|
@@ -858,7 +1563,17 @@ export const fileName = (name) => `${(name.normalize("NFKD").replace(/[^\w\s-]/g
|
|
|
858
1563
|
* within `FILE_LIMITS.total`, and gives it to the link in place of any it had; DELETE ?id= takes it away. The parts
|
|
859
1564
|
* are taken away once they're together, or with the link.
|
|
860
1565
|
*/
|
|
861
|
-
|
|
1566
|
+
/**
|
|
1567
|
+
* Work a request goes on with once it has answered (`waitUntil`, on Vercel), which the function is kept alive for up
|
|
1568
|
+
* to its whole time; elsewhere (a test, `npm run dev`) it's done before the answer.
|
|
1569
|
+
*/
|
|
1570
|
+
async function afterAnswering(work, env) {
|
|
1571
|
+
if (!env.VERCEL) return await work();
|
|
1572
|
+
const { waitUntil } = await import("@vercel/functions");
|
|
1573
|
+
waitUntil(work());
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
export function createFileHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date(), later = (work) => afterAnswering(work, env) } = {}) {
|
|
862
1577
|
return async (request) => {
|
|
863
1578
|
const method = request.method;
|
|
864
1579
|
if (!["PUT", "POST", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "PUT, POST, DELETE" });
|
|
@@ -944,32 +1659,48 @@ export function createFileHandler({ env = process.env, fetcher = fetch, store, l
|
|
|
944
1659
|
if (page) {
|
|
945
1660
|
const files = readPage(bytes);
|
|
946
1661
|
if (!files) return reply(400, { error: "invalid_page" });
|
|
947
|
-
// Each version in a folder of its own, under a random part, so an old one never mixes with a new one.
|
|
948
|
-
const token = randomBytes(12).toString("base64url");
|
|
949
|
-
const urls = {}, sizes = {};
|
|
950
|
-
// A few at a time: a page can be hundreds of files.
|
|
951
|
-
for (let n = 0; n < files.length; n += 8) {
|
|
952
|
-
await Promise.all(files.slice(n, n + 8).map(async (file) => {
|
|
953
|
-
urls[file.path] = await kept.putPageFile(id, token, file.path, file.bytes);
|
|
954
|
-
sizes[file.path] = file.bytes.length;
|
|
955
|
-
uploaded.push(urls[file.path]);
|
|
956
|
-
}));
|
|
957
|
-
}
|
|
958
1662
|
// The count is the link's (`pageVersion`), so a page taken away and sent again never reuses a play address.
|
|
959
1663
|
const version = (record.page?.version ?? record.pageVersion ?? 0) + 1;
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1664
|
+
// Checked, it's answered at once, and its files are stored after: a big game's page is hundreds of files, which
|
|
1665
|
+
// take longer than a Lamp waits for an answer, and a Lamp that heard nothing took the link's page down. The
|
|
1666
|
+
// link plays the page it had until this one is in; if this one never gets in, it goes on playing that one.
|
|
1667
|
+
await later(async () => {
|
|
1668
|
+
const stored = [];
|
|
1669
|
+
try {
|
|
1670
|
+
// Each version in a folder of its own, under a random part, so an old one never mixes with a new one.
|
|
1671
|
+
const token = randomBytes(12).toString("base64url");
|
|
1672
|
+
const urls = {}, sizes = {};
|
|
1673
|
+
// Many at a time, within what the blob store takes. Said in the log as it goes, so a page that doesn't get in
|
|
1674
|
+
// shows how far it got.
|
|
1675
|
+
const began = Date.now();
|
|
1676
|
+
console.log(`Page ${version} of ${id}: storing ${files.length} files`);
|
|
1677
|
+
for (let n = 0; n < files.length; n += PAGE_UPLOADS) {
|
|
1678
|
+
await Promise.all(files.slice(n, n + PAGE_UPLOADS).map(async (file) => {
|
|
1679
|
+
// Each file named as it starts, so a page that stops shows on which.
|
|
1680
|
+
console.log(`Page ${version} of ${id}: storing ${file.path} (${file.bytes.length} bytes)`);
|
|
1681
|
+
urls[file.path] = await kept.putPageFile(id, token, file.path, file.bytes);
|
|
1682
|
+
sizes[file.path] = file.bytes.length;
|
|
1683
|
+
stored.push(urls[file.path]);
|
|
1684
|
+
}));
|
|
1685
|
+
if ((n / PAGE_UPLOADS) % 10 === 9) console.log(`Page ${version} of ${id}: ${stored.length} of ${files.length} stored, ${Math.round((Date.now() - began) / 1000)}s`);
|
|
1686
|
+
}
|
|
1687
|
+
console.log(`Page ${version} of ${id}: all ${files.length} stored in ${Math.round((Date.now() - began) / 1000)}s`);
|
|
1688
|
+
// As the record is now, not as it was when the parts were read: the link may have changed meanwhile.
|
|
1689
|
+
const current = await kept.get(id);
|
|
1690
|
+
if (!current || !secretMatches(bearer(request), current.secret)) throw new Error("Gone");
|
|
1691
|
+
// The page it replaces stays playable as the version before, so a game open on it goes on finding its
|
|
1692
|
+
// files (a game loads its map as the player moves); the one before that goes now.
|
|
1693
|
+
const { pageParts: _pageParts, ...rest } = current;
|
|
1694
|
+
const previousPage = current.page ? { version: current.page.version, files: current.page.files, sizes: current.page.sizes } : undefined;
|
|
1695
|
+
if (!(await kept.replace({ ...rest, page: { version, files: urls, sizes }, ...(previousPage ? { previousPage } : {}),
|
|
1696
|
+
pageVersion: Math.max(version, current.pageVersion ?? 0), updated: now().toISOString() }))) throw new Error("Gone");
|
|
1697
|
+
await kept.removeImages([...Object.values(current.previousPage?.files ?? {}), ...partBlobs(parts)]);
|
|
1698
|
+
await kept.forget(id);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
console.error(`Page ${version} of ${id} didn't get in, ${stored.length} of ${files.length} stored: ${error?.message ?? error}${error?.cause ? ` (${error.cause.message ?? error.cause})` : ""}`);
|
|
1701
|
+
try { await kept.removeImages(stored); } catch {}
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
973
1704
|
return reply(200, { version, files: files.length });
|
|
974
1705
|
}
|
|
975
1706
|
if (!isWishFile(bytes)) return reply(400, { error: "invalid_file" });
|