fedipod 1.49.1 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46164,6 +46164,15 @@ var PodStore = class {
46164
46164
  getStatuses() {
46165
46165
  return this.read("statuses.json", []);
46166
46166
  }
46167
+ // When each open poll closes, read in place: asked every 30 seconds, where
46168
+ // a copy of the whole timeline each time was the whole cost of asking.
46169
+ openPollEnds() {
46170
+ const out = [];
46171
+ for (const s of this.cache.get("statuses.json") || []) {
46172
+ if (s?.kind === "post" && s.poll && !s.poll.closed && s.poll.expiresAt) out.push(Date.parse(s.poll.expiresAt));
46173
+ }
46174
+ return out;
46175
+ }
46167
46176
  // A count must not pay for a clone: nodeinfo and the instance document are
46168
46177
  // public and answer strangers, so they read the cache directly.
46169
46178
  countStatuses(kind = null) {
@@ -46205,6 +46214,9 @@ var PodStore = class {
46205
46214
  const all = this.getStatuses();
46206
46215
  const i = all.findIndex((x) => x.noteId === noteId);
46207
46216
  if (i < 0) return null;
46217
+ if (typeof patch?.content === "string" && patch.content.length > MAX_CONTENT) {
46218
+ patch = { ...patch, content: clamp(patch.content, MAX_CONTENT), truncated: true };
46219
+ }
46208
46220
  all[i] = { ...all[i], ...patch };
46209
46221
  this.write("statuses.json", all);
46210
46222
  return all[i];
@@ -57691,7 +57703,9 @@ async function publishNote(publisher, content, { inReplyTo, attachments, visibil
57691
57703
  reply?.inbox,
57692
57704
  ...addressedInboxes
57693
57705
  ].filter(Boolean))];
57694
- await publisher.deliverer.deliverToAll(inboxes, create);
57706
+ const fanout = publisher.deliverer.deliverToAll(inboxes, create);
57707
+ if (publisher.background) fanout.catch((e) => publisher.log(`delivering ${note.id}: ${e.message}`));
57708
+ else await fanout;
57695
57709
  publisher.log(`note published: ${note.id} \u2192 ${inboxes.length} inbox(es)`);
57696
57710
  if (request) {
57697
57711
  const [inbox] = await inboxesFor(publisher, [quote.actor]);
@@ -58502,7 +58516,8 @@ var Publisher = class {
58502
58516
  probeFetch = null,
58503
58517
  resolveMention = null,
58504
58518
  resolveActor = null,
58505
- clientOrigin = null
58519
+ clientOrigin = null,
58520
+ background = false
58506
58521
  }) {
58507
58522
  this.config = config;
58508
58523
  this.remote = remote;
@@ -58512,6 +58527,7 @@ var Publisher = class {
58512
58527
  this.publicKeyPem = publicKeyPem;
58513
58528
  this.assertionKey = assertionKey;
58514
58529
  this.clientOrigin = clientOrigin;
58530
+ this.background = background;
58515
58531
  const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/, "") : null;
58516
58532
  this.urls = apUrls2(config.remotePod, config.root, { publicBase });
58517
58533
  if (this.urls.toPod && this.remote?.setUrlMap) this.remote.setUrlMap(this.urls.toPod);
@@ -65361,6 +65377,7 @@ function describeShapeFailure(failure) {
65361
65377
  // lib/core/intake/index.mjs
65362
65378
  var POLL_MS = 2 * 6e4;
65363
65379
  var POLL_PUSH_OK_MS = 10 * 6e4;
65380
+ var POLL_IN_PROCESS_MS = 60 * 6e4;
65364
65381
  var DRAIN_COOLDOWN_MIN_MS = 2 * 6e4;
65365
65382
  var DRAIN_COOLDOWN_MAX_MS = 30 * 6e4;
65366
65383
  var DELETE_GAP_MS = 150;
@@ -65417,13 +65434,15 @@ var Intake = class {
65417
65434
  // what makes the fallback poll a slow one.
65418
65435
  _pollMs() {
65419
65436
  if (this.pollSeconds) return this.pollSeconds * 1e3;
65420
- return this.wsState === "open" || this.wsState === "in-process" ? POLL_PUSH_OK_MS : POLL_MS;
65437
+ if (this.wsState === "in-process") return POLL_IN_PROCESS_MS;
65438
+ return this.wsState === "open" ? POLL_PUSH_OK_MS : POLL_MS;
65421
65439
  }
65422
65440
  // `drainNow: false` and `subscribe: false` are for a browser worker the
65423
65441
  // browser restarted: it drained moments ago, and a socket would die with it.
65424
65442
  async start({ drainNow = true, subscribe: subscribe2 = true } = {}) {
65425
65443
  this.stopped = false;
65426
65444
  if (drainNow) await this.drain().catch((e) => this.log(`drain: ${e.message}`));
65445
+ if (!this.push) this.wsState = "in-process";
65427
65446
  const tick = () => {
65428
65447
  this.pollTimer = setTimeout(() => {
65429
65448
  this.drain().catch((e) => this.log(`drain: ${e.message}`)).finally(() => {
@@ -65433,8 +65452,7 @@ var Intake = class {
65433
65452
  this.pollTimer.unref?.();
65434
65453
  };
65435
65454
  tick();
65436
- if (!this.push) this.wsState = "in-process";
65437
- else if (subscribe2) this.subscribe().catch((e) => this.log(`subscribe: ${e.message}`));
65455
+ if (this.push && subscribe2) this.subscribe().catch((e) => this.log(`subscribe: ${e.message}`));
65438
65456
  }
65439
65457
  stop() {
65440
65458
  this.stopped = true;
@@ -67324,10 +67342,11 @@ var web_push_default = { generateVAPIDKeys, setVapidDetails, sendNotification };
67324
67342
  // lib/client/webpush.mjs
67325
67343
  var Push = class {
67326
67344
  constructor({ store, subject, log: log2 = () => {
67327
- } }) {
67345
+ }, liveKeys = null }) {
67328
67346
  this.store = store;
67329
67347
  this.subject = subject;
67330
67348
  this.log = log2;
67349
+ this.liveKeys = liveKeys;
67331
67350
  }
67332
67351
  state() {
67333
67352
  return this.store.read("webpush.json", { subs: {} });
@@ -67387,14 +67406,27 @@ var Push = class {
67387
67406
  }
67388
67407
  // Push one notification to every subscription whose alerts allow the type.
67389
67408
  // A push service answering 404/410 means the browser dropped the
67390
- // subscription — it is removed rather than retried forever.
67409
+ // subscription — it is removed rather than retried forever. With
67410
+ // `liveKeys`, every subscription whose sign-in is no longer in force is
67411
+ // forgotten first: an app signed out, expired or pushed off the list hears
67412
+ // nothing more.
67391
67413
  async notify(n, payload) {
67392
67414
  const st2 = this.state();
67415
+ let dropped = false;
67416
+ const live = this.liveKeys?.() || null;
67417
+ if (live) {
67418
+ for (const key of Object.keys(st2.subs)) if (!live.has(key)) {
67419
+ delete st2.subs[key];
67420
+ dropped = true;
67421
+ }
67422
+ }
67393
67423
  const entries = Object.entries(st2.subs);
67394
- if (!entries.length) return;
67424
+ if (!entries.length) {
67425
+ if (dropped) this.save(st2);
67426
+ return;
67427
+ }
67395
67428
  const { publicKey, privateKey } = this.vapid();
67396
67429
  const details = { subject: this.subject(), publicKey, privateKey };
67397
- let dropped = false;
67398
67430
  for (const [key, sub] of entries) {
67399
67431
  if (sub.alerts && sub.alerts[n.type] === false) continue;
67400
67432
  try {
@@ -67545,103 +67577,54 @@ function readMultipart(req, limit = 12e6) {
67545
67577
 
67546
67578
  // lib/client/masto/oauth.mjs
67547
67579
  var TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
67548
- var AUTHZ_WINDOW_MS = 6e4;
67549
- var AUTHZ_MAX_ATTEMPTS = 5;
67550
67580
  var CODE_TTL_MS = 5 * 6e4;
67551
67581
  var MAX_APPS = 200;
67582
+ var MAX_REDIRECTS = 10;
67583
+ var MAX_REDIRECT_CHARS = 2048;
67584
+ var REGISTRATION_WINDOW_MS = 6e4;
67585
+ var MAX_REGISTRATIONS = 10;
67552
67586
  var CLIENT_DOC_TTL_MS = 10 * 6e4;
67553
67587
  var CLIENT_DOC_MAX_CACHED = 200;
67554
67588
  var CLIENT_DOC_WINDOW_MS = 6e4;
67555
67589
  var CLIENT_DOC_MAX_FETCHES = 20;
67556
67590
  var CLIENT_DOC_MAX = 64 * 1024;
67557
- function hashPassword(password) {
67558
- const salt = node_crypto_default.randomBytes(16);
67559
- const hash = node_crypto_default.scryptSync(String(password), salt, 32);
67560
- return { saltHex: salt.toString("hex"), hashHex: hash.toString("hex") };
67561
- }
67562
- function checkPassword(rec, password) {
67563
- try {
67564
- const hash = node_crypto_default.scryptSync(String(password), Buffer.from(rec.saltHex, "hex"), 32);
67565
- return node_crypto_default.timingSafeEqual(hash, Buffer.from(rec.hashHex, "hex"));
67566
- } catch {
67567
- return false;
67568
- }
67569
- }
67570
67591
  var escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
67571
67592
  var parseRedirects = (v) => (Array.isArray(v) ? v : String(v || "").split(/\s+/)).map((s) => s.trim()).filter(Boolean);
67572
- function sendPodSigninPage(res, client, webId, mount = "") {
67573
- let asking = "";
67574
- if (client && (client.name || client.redirect)) {
67575
- let where = "";
67576
- try {
67577
- where = client.redirect ? new URL(client.redirect).host : "";
67578
- } catch {
67579
- }
67580
- const who = client.name ? escapeHtml(client.name) : where ? escapeHtml(where) : "A client";
67581
- asking = `<p><strong>${who}</strong> is asking to use your account${where ? `, and you will be sent back to <code>${escapeHtml(where)}</code>` : ""}.</p>`;
67593
+ var ownAddress = (api) => {
67594
+ const handle7 = api.store.getConfig()?.handle;
67595
+ return handle7 && api.host ? `@${handle7}@${api.host}` : null;
67596
+ };
67597
+ function sendPodSigninPage(res, client, webId, mount = "", address = null) {
67598
+ let where = "";
67599
+ try {
67600
+ where = client?.redirect ? new URL(client.redirect).host : "";
67601
+ } catch {
67582
67602
  }
67603
+ const app = escapeHtml(client?.name || where || "this app");
67604
+ const question = `Allow ${app} to access your ${address ? `${escapeHtml(address)} ` : ""}account?`;
67583
67605
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
67584
67606
  res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
67585
- <title>Sign in with your pod</title>
67607
+ <title>${question}</title>
67586
67608
  <style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
67587
67609
  body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
67588
67610
  h1{color:var(--heading);font-size:1.5rem}
67589
- code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
67611
+ h1,p{overflow-wrap:anywhere}
67590
67612
  button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
67591
67613
  button[hidden]{display:none}
67614
+ #cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
67592
67615
  #signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
67593
- @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#5a8a4f}}</style></head><body>
67616
+ #signin-status:empty{min-height:0;padding:0;border:0;margin:0}
67617
+ @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
67594
67618
  <main>
67595
- <h1>Sign in with your pod</h1>
67596
- ${asking}
67597
- <p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
67598
- <p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
67619
+ <h1>${question}</h1>
67620
+ <a id="webid" href="${escapeHtml(webId)}" hidden></a>
67621
+ <p><button type="button" id="signin" hidden>Allow</button><button type="button" id="cancel" hidden>Cancel</button></p>
67599
67622
  <p id="signin-status" role="status" aria-live="polite"></p>
67600
67623
  </main>
67601
67624
  <script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"><\/script>
67602
67625
  </body></html>`);
67603
67626
  return true;
67604
67627
  }
67605
- function sendLoginForm(res, params, error2 = "", client = null, status2 = null, headers = {}) {
67606
- const hidden = [...params.entries()].filter(([k]) => k !== "password").map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join("\n");
67607
- let asking = "<p>Enter the agent password to authorize this client.</p>";
67608
- if (client && (client.name || client.redirect)) {
67609
- let where = "";
67610
- try {
67611
- where = client.redirect ? new URL(client.redirect).host : "";
67612
- } catch {
67613
- }
67614
- const who = client.name ? escapeHtml(client.name) : where ? escapeHtml(where) : "A client";
67615
- asking = `<p><strong>${who}</strong> is asking to access your account${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ""}.</p><p>Scope: <code>${escapeHtml(client.scope || "read")}</code>. Enter the agent password to allow it.</p>`;
67616
- }
67617
- res.writeHead(
67618
- status2 || (error2 ? 401 : 200),
67619
- { "content-type": "text/html; charset=utf-8", ...headers }
67620
- );
67621
- res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
67622
- <title>FediPod \u2014 authorize</title>
67623
- <style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
67624
- body{font:1rem system-ui,sans-serif;max-width:22rem;margin:15vh auto;padding:0 1rem}
67625
- h1{color:var(--heading)}
67626
- code{word-break:break-all}
67627
- @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8}}
67628
- input,button{font:inherit;width:100%;padding:.5rem;margin:.3rem 0;box-sizing:border-box}
67629
- .err{color:#b00020}
67630
- @media (prefers-color-scheme:dark){.err{color:#ff8a8a}}</style></head><body>
67631
- <main>
67632
- <h1>FediPod</h1>
67633
- ${asking}
67634
- ${error2 ? `<p class="err" id="login-err" role="alert">${escapeHtml(error2)}</p>` : ""}
67635
- <form method="POST" action="/oauth/authorize">
67636
- ${hidden}
67637
- <label for="password">Agent password</label>
67638
- <input type="password" id="password" name="password" autofocus autocomplete="current-password"
67639
- ${error2 ? 'aria-invalid="true" aria-describedby="login-err"' : ""}>
67640
- <button type="submit">Authorize</button>
67641
- </form>
67642
- </main></body></html>`);
67643
- return true;
67644
- }
67645
67628
  function scopeFor(method, pathname) {
67646
67629
  if (/^\/api\/v\d\/push\//u.test(pathname)) return "push";
67647
67630
  const relationship2 = /^\/api\/v1\/(accounts\/[a-f0-9]+\/(follow|unfollow|block|unblock|mute|unmute|remove_from_followers)|follow_requests\/)/u;
@@ -67780,12 +67763,43 @@ function registerApp(api, { name, website, redirectUris, scopes }) {
67780
67763
  name: String(name || "client").slice(0, 200),
67781
67764
  website: String(website || "").slice(0, 500),
67782
67765
  redirectUris,
67783
- scopes: String(scopes || "read"),
67784
- createdAt: Date.now()
67766
+ scopes: String(scopes || "read").slice(0, 500),
67767
+ createdAt: Date.now(),
67768
+ // Set when the owner first lets it sign in (touchApp): a client nobody
67769
+ // has used is the first to go when the list is full.
67770
+ usedAt: null
67785
67771
  };
67786
- api.store.write("oauth-apps.json", [...api.apps(), app].slice(-MAX_APPS));
67772
+ const kept = api.apps();
67773
+ while (kept.length >= MAX_APPS) kept.splice(kept.indexOf(leastUsed(kept)), 1);
67774
+ api.store.write("oauth-apps.json", [...kept, app]);
67787
67775
  return app;
67788
67776
  }
67777
+ function leastUsed(list3) {
67778
+ const never = list3.filter((a) => a.usedAt === null);
67779
+ const when = (a) => a.usedAt ?? a.createdAt ?? 0;
67780
+ return (never.length ? never : list3).reduce((a, b) => when(b) < when(a) ? b : a);
67781
+ }
67782
+ function touchApp(api, clientId) {
67783
+ const all = api.apps();
67784
+ const app = all.find((a) => a.clientId === clientId);
67785
+ if (!app) return;
67786
+ app.usedAt = Date.now();
67787
+ api.store.write("oauth-apps.json", all);
67788
+ }
67789
+ function redirectsProblem(raw) {
67790
+ if (Array.isArray(raw) && raw.some((v) => typeof v !== "string")) return "redirect_uris must be text";
67791
+ const list3 = parseRedirects(raw);
67792
+ if (list3.length > MAX_REDIRECTS) return `at most ${MAX_REDIRECTS} redirect_uris`;
67793
+ if (list3.some((u) => u.length > MAX_REDIRECT_CHARS)) return `a redirect_uri is at most ${MAX_REDIRECT_CHARS} characters`;
67794
+ return null;
67795
+ }
67796
+ function registrationLimited(api) {
67797
+ const now = Date.now();
67798
+ api.appRegistrations = (api.appRegistrations || []).filter((t) => now - t < REGISTRATION_WINDOW_MS);
67799
+ if (api.appRegistrations.length >= MAX_REGISTRATIONS) return true;
67800
+ api.appRegistrations.push(now);
67801
+ return false;
67802
+ }
67789
67803
  function mintCode(api, { clientId, redirectUri, scope, challenge = null, challengeMethod = null }) {
67790
67804
  const code = node_crypto_default.randomBytes(24).toString("hex");
67791
67805
  const now = Date.now();
@@ -67804,6 +67818,10 @@ function mintCode(api, { clientId, redirectUri, scope, challenge = null, challen
67804
67818
  }].slice(-50));
67805
67819
  return code;
67806
67820
  }
67821
+ function peekCode(api, code) {
67822
+ const now = Date.now();
67823
+ return api.store.read("oauth-codes.json", []).find((c) => c.code === code && now - c.createdAt < CODE_TTL_MS) || null;
67824
+ }
67807
67825
  function consumeCode(api, code) {
67808
67826
  const now = Date.now();
67809
67827
  const all = api.store.read("oauth-codes.json", []);
@@ -67828,22 +67846,27 @@ function redirectAllowed(api, redirect) {
67828
67846
  try {
67829
67847
  const u = new URL(redirect);
67830
67848
  if (u.protocol !== "http:" && u.protocol !== "https:") return false;
67831
- return api.allowed.has(u.host.toLowerCase());
67849
+ if (!api.allowed.has(u.host.toLowerCase())) return false;
67850
+ const mount = api.mount || "";
67851
+ return !mount || u.pathname === mount || u.pathname.startsWith(`${mount}/`);
67832
67852
  } catch {
67833
67853
  return false;
67834
67854
  }
67835
67855
  }
67836
- function rateLimited(api) {
67837
- const now = Date.now();
67838
- api.authzAttempts = api.authzAttempts.filter((t) => now - t < AUTHZ_WINDOW_MS);
67839
- if (api.authzAttempts.length >= AUTHZ_MAX_ATTEMPTS) return true;
67840
- api.authzAttempts.push(now);
67841
- return false;
67842
- }
67843
67856
  async function handle(api, ctx) {
67844
67857
  const { req, res, pathname, url, send } = ctx;
67845
67858
  if (pathname === "/api/v1/apps" && req.method === "POST") {
67846
67859
  const body = await readBody2(req);
67860
+ const problem = redirectsProblem(body.redirect_uris);
67861
+ if (problem) return send(422, { error: problem });
67862
+ if (registrationLimited(api)) {
67863
+ api.log("app registration refused: too many this minute");
67864
+ return send(
67865
+ 429,
67866
+ { error: "too many apps registered just now \u2014 try again in a minute" },
67867
+ { "retry-after": String(REGISTRATION_WINDOW_MS / 1e3) }
67868
+ );
67869
+ }
67847
67870
  const redirectUris = parseRedirects(body.redirect_uris);
67848
67871
  const app = api.registerApp({
67849
67872
  name: body.client_name,
@@ -67862,7 +67885,6 @@ async function handle(api, ctx) {
67862
67885
  });
67863
67886
  }
67864
67887
  if (pathname === "/oauth/authorize" && (req.method === "GET" || req.method === "POST")) {
67865
- const pw = api.store.getConfig()?.uiPassword;
67866
67888
  let params = url.searchParams;
67867
67889
  let body = null;
67868
67890
  if (req.method === "POST") {
@@ -67878,7 +67900,7 @@ async function handle(api, ctx) {
67878
67900
  }
67879
67901
  const ownerWebId = api.ownerWebId?.() || null;
67880
67902
  if (req.method === "GET" && !params.get("client_id") && params.get("code") && params.get("state") && ownerWebId) {
67881
- return sendPodSigninPage(res, null, ownerWebId, api.mount || "");
67903
+ return sendPodSigninPage(res, null, ownerWebId, api.mount || "", ownAddress(api));
67882
67904
  }
67883
67905
  const app = api.findApp(params.get("client_id") || "");
67884
67906
  const doc = app ? null : await api.resolveClientDocument(params.get("client_id") || "");
@@ -67888,11 +67910,15 @@ async function handle(api, ctx) {
67888
67910
  return send(403, { error: "a cross-site navigation may not authorize a client" });
67889
67911
  }
67890
67912
  const client = { name: app?.name || doc?.name || null, redirect, scope: params.get("scope") || "read" };
67891
- if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
67892
- if (req.method === "GET" && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || "");
67893
- api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
67913
+ const challengeMethod = params.get("code_challenge_method");
67914
+ if (params.get("code_challenge") && challengeMethod && challengeMethod !== "S256" && challengeMethod !== "plain") {
67915
+ return send(400, { error: "code_challenge_method must be S256 or plain" });
67916
+ }
67917
+ if (external && !proved && !api.redirectAllowed(redirect)) {
67918
+ if (req.method === "GET" && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || "", ownAddress(api));
67919
+ api.log(`authorize refused: "${redirect}" is not an address of this agent, and nothing approved it`);
67894
67920
  return send(403, {
67895
- error: "this client asks to be sent somewhere other than this agent, and no password is set to approve that with. Run `fedipod passwd` and try again."
67921
+ error: "this client asks to be sent somewhere other than this account; approve it by signing in at your pod"
67896
67922
  });
67897
67923
  }
67898
67924
  if (app) {
@@ -67913,28 +67939,11 @@ async function handle(api, ctx) {
67913
67939
  api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
67914
67940
  return send(400, { error: "redirect_uri must be an address of this agent" });
67915
67941
  }
67916
- if (req.method === "POST" && !proved) {
67917
- if (api.rateLimited()) {
67918
- api.log("authorize rate limited");
67919
- return sendLoginForm(
67920
- res,
67921
- params,
67922
- "too many attempts \u2014 wait a minute",
67923
- client,
67924
- 429,
67925
- { "retry-after": String(Math.ceil(AUTHZ_WINDOW_MS / 1e3)) }
67926
- );
67927
- }
67928
- if (!pw || !checkPassword(pw, body.password || "")) {
67929
- return sendLoginForm(res, params, "wrong password \u2014 try again", client);
67930
- }
67931
- } else if (pw && !proved) {
67932
- return sendLoginForm(res, params, "", client);
67933
- } else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
67934
- if (req.method === "GET" && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || "");
67935
- api.log(`authorize refused: no UI password, and "${req.headers.host}" is not this machine`);
67942
+ if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
67943
+ if (req.method === "GET" && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || "", ownAddress(api));
67944
+ api.log(`authorize refused: not approved at the pod, and "${req.headers.host}" is not this machine`);
67936
67945
  return send(403, {
67937
- error: api.embedded ? "Open your account's management page first, then sign in from there." : "Signing in from another machine needs a password. On the machine that runs your account, run: fedipod passwd"
67946
+ error: api.embedded ? "Open your account's management page first, then sign in from there." : "Signing in from another machine goes through your pod: sign in there to approve this app."
67938
67947
  });
67939
67948
  }
67940
67949
  const code = external ? api.mintCode({
@@ -67944,6 +67953,7 @@ async function handle(api, ctx) {
67944
67953
  challenge: params.get("code_challenge") || null,
67945
67954
  challengeMethod: params.get("code_challenge_method") || null
67946
67955
  }) : api.mintToken(client.scope);
67956
+ if (app) touchApp(api, app.clientId);
67947
67957
  if (!redirect || redirect === "urn:ietf:wg:oauth:2.0:oob") return send(200, { code });
67948
67958
  const target = new URL(redirect);
67949
67959
  target.searchParams.set("code", code);
@@ -67957,7 +67967,7 @@ async function handle(api, ctx) {
67957
67967
  const body = await readBody2(req);
67958
67968
  const app = api.findApp(body.client_id || "");
67959
67969
  if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ""))) {
67960
- const rec = api.consumeCode(body.code || "");
67970
+ const rec = api.peekCode(body.code || "");
67961
67971
  if (!rec || rec.clientId !== body.client_id || body.redirect_uri && rec.redirectUri !== body.redirect_uri) {
67962
67972
  api.log("token refused: code is not a live authorization for that client document");
67963
67973
  return send(400, { error: "invalid_grant" });
@@ -67966,6 +67976,7 @@ async function handle(api, ctx) {
67966
67976
  api.log("token refused: the verifier does not answer the challenge this code was made with");
67967
67977
  return send(400, { error: "invalid_grant" });
67968
67978
  }
67979
+ if (!api.consumeCode(body.code || "")) return send(400, { error: "invalid_grant" });
67969
67980
  return send(200, {
67970
67981
  access_token: api.mintToken(rec.scope || "read"),
67971
67982
  token_type: "Bearer",
@@ -67975,7 +67986,7 @@ async function handle(api, ctx) {
67975
67986
  });
67976
67987
  }
67977
67988
  if (app && body.code_verifier) {
67978
- const rec = api.consumeCode(body.code || "");
67989
+ const rec = api.peekCode(body.code || "");
67979
67990
  if (!rec || rec.clientId !== app.clientId || body.redirect_uri && rec.redirectUri !== body.redirect_uri) {
67980
67991
  api.log("token refused: code is not a live authorization for this client");
67981
67992
  return send(400, { error: "invalid_grant" });
@@ -67984,6 +67995,7 @@ async function handle(api, ctx) {
67984
67995
  api.log("token refused: the verifier does not answer the challenge this code was made with");
67985
67996
  return send(400, { error: "invalid_grant" });
67986
67997
  }
67998
+ if (!api.consumeCode(body.code || "")) return send(400, { error: "invalid_grant" });
67987
67999
  return send(200, {
67988
68000
  access_token: api.mintToken(rec.scope || "read"),
67989
68001
  token_type: "Bearer",
@@ -68000,7 +68012,7 @@ async function handle(api, ctx) {
68000
68012
  api.log("token refused: client secret mismatch");
68001
68013
  return send(401, { error: "invalid_client" });
68002
68014
  }
68003
- const rec = api.consumeCode(body.code || "");
68015
+ const rec = api.peekCode(body.code || "");
68004
68016
  if (!rec || rec.clientId !== app.clientId || body.redirect_uri && rec.redirectUri !== body.redirect_uri) {
68005
68017
  api.log("token refused: code is not a live authorization for this client");
68006
68018
  return send(400, { error: "invalid_grant" });
@@ -68009,6 +68021,7 @@ async function handle(api, ctx) {
68009
68021
  api.log("token refused: this code was made with a challenge and the verifier does not answer it");
68010
68022
  return send(400, { error: "invalid_grant" });
68011
68023
  }
68024
+ if (!api.consumeCode(body.code || "")) return send(400, { error: "invalid_grant" });
68012
68025
  return send(200, {
68013
68026
  access_token: api.mintToken(rec.scope || "read"),
68014
68027
  token_type: "Bearer",
@@ -68039,6 +68052,7 @@ async function handle(api, ctx) {
68039
68052
  if (gone) {
68040
68053
  const kept = api.tokenRecords().filter((r) => r.token !== gone);
68041
68054
  api.store.write("masto-tokens.json", kept);
68055
+ if (api.push.get(gone)) api.push.drop(gone);
68042
68056
  api.log("client token revoked");
68043
68057
  }
68044
68058
  return send(200, {});
@@ -68878,6 +68892,11 @@ init_node_crypto();
68878
68892
  var write6 = (pod, url, bytes, contentType) => pod.put(url, bytes, contentType);
68879
68893
 
68880
68894
  // lib/client/masto/accounts.mjs
68895
+ var PROFILE_IMAGE_EXT = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif" };
68896
+ var profileImageType = (f) => {
68897
+ const t = String(f?.contentType || "").split(";")[0].trim().toLowerCase();
68898
+ return PROFILE_IMAGE_EXT[t] ? t : null;
68899
+ };
68881
68900
  async function handle3(api, ctx) {
68882
68901
  const { req, res, pathname, url, send } = ctx;
68883
68902
  if (pathname === "/api/v1/accounts/verify_credentials") {
@@ -68902,12 +68921,17 @@ async function handle3(api, ctx) {
68902
68921
  if (ct.includes("multipart/form-data")) ({ fields: form, files } = await readMultipart(req));
68903
68922
  else form = await readBody2(req);
68904
68923
  const cfg = { ...api.store.getConfig() };
68924
+ for (const f of [files.avatar, files.header]) {
68925
+ if (f?.data?.length && !profileImageType(f)) {
68926
+ return send(422, { error: "a profile picture or header must be a PNG, JPEG, GIF, WebP or AVIF image" });
68927
+ }
68928
+ }
68905
68929
  const putImage = async (f) => {
68906
- const ext = (f.filename || "").includes(".") ? f.filename.split(".").pop().replace(/[^\w]/g, "") : "bin";
68907
- const slug = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10) + "-" + node_crypto_default.randomBytes(4).toString("hex") + "." + ext;
68930
+ const type = profileImageType(f);
68931
+ const slug = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10) + "-" + node_crypto_default.randomBytes(4).toString("hex") + "." + PROFILE_IMAGE_EXT[type];
68908
68932
  const url2 = api.urls.media + slug;
68909
68933
  await api.agent.publisher.ensureMediaContainer();
68910
- await write6(api.agent.remote, url2, f.data, f.contentType);
68934
+ await write6(api.agent.remote, url2, f.data, type);
68911
68935
  return url2;
68912
68936
  };
68913
68937
  if ("display_name" in form) cfg.name = String(form.display_name).trim() || cfg.handle;
@@ -69886,7 +69910,6 @@ var MastoApi = class _MastoApi {
69886
69910
  this.streaming = streaming;
69887
69911
  this.webPush = webPush;
69888
69912
  this.scheduling = scheduling;
69889
- this.authzAttempts = [];
69890
69913
  }
69891
69914
  get store() {
69892
69915
  return this.agent.store;
@@ -69898,7 +69921,8 @@ var MastoApi = class _MastoApi {
69898
69921
  this._push ||= new Push({
69899
69922
  store: this.store,
69900
69923
  subject: () => this.urls?.actor || "https://localhost/",
69901
- log: this.log
69924
+ log: this.log,
69925
+ liveKeys: () => new Set(this.tokens().map((t) => this._push.keyOf(t)))
69902
69926
  });
69903
69927
  return this._push;
69904
69928
  }
@@ -69997,6 +70021,9 @@ var MastoApi = class _MastoApi {
69997
70021
  consumeCode(...a) {
69998
70022
  return consumeCode(this, ...a);
69999
70023
  }
70024
+ peekCode(...a) {
70025
+ return peekCode(this, ...a);
70026
+ }
70000
70027
  tokenOf(...a) {
70001
70028
  return tokenOf(this, ...a);
70002
70029
  }
@@ -70006,9 +70033,6 @@ var MastoApi = class _MastoApi {
70006
70033
  redirectAllowed(...a) {
70007
70034
  return redirectAllowed(this, ...a);
70008
70035
  }
70009
- rateLimited(...a) {
70010
- return rateLimited(this, ...a);
70011
- }
70012
70036
  // render.mjs
70013
70037
  selfAccount(...a) {
70014
70038
  return selfAccount(this, ...a);
@@ -70106,6 +70130,20 @@ var DEFAULTS2 = {
70106
70130
  intervalMin: 15
70107
70131
  };
70108
70132
  var PER_TAG = 20;
70133
+ var SHARED_TTL_MS = 12 * 6e4;
70134
+ var sharedTimelines = /* @__PURE__ */ new Map();
70135
+ function timelineOnce(key, load2) {
70136
+ const now = Date.now();
70137
+ const held = sharedTimelines.get(key);
70138
+ if (held && now - held.at < SHARED_TTL_MS) return held.answer;
70139
+ const answer = load2().catch((e) => {
70140
+ sharedTimelines.delete(key);
70141
+ throw e;
70142
+ });
70143
+ sharedTimelines.set(key, { at: now, answer });
70144
+ if (sharedTimelines.size > 500) sharedTimelines.delete(sharedTimelines.keys().next().value);
70145
+ return answer;
70146
+ }
70109
70147
  var MAX_NEW_PER_SWEEP = 8;
70110
70148
  var MAX_NEW_PER_TAG = 2;
70111
70149
  var TAGS_PER_SWEEP = 4;
@@ -70197,12 +70235,17 @@ var TagFeed = class {
70197
70235
  try {
70198
70236
  const { safeFetch: safeFetch2, retryAfterMs: retryAfterMs3, readCapped: readCapped3 } = await Promise.resolve().then(() => (init_safefetch(), safefetch_exports));
70199
70237
  const url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${PER_TAG}`;
70200
- const res = this.fetcher === globalThis.fetch ? await safeFetch2(url, { headers: { accept: "application/json" } }) : await this.fetcher(url, { headers: { accept: "application/json" } });
70201
- if (res.status >= 400) {
70202
- this._backOff(res.status, retryAfterMs3(res));
70238
+ const load2 = async () => {
70239
+ const res = this.fetcher === globalThis.fetch ? await safeFetch2(url, { headers: { accept: "application/json" } }) : await this.fetcher(url, { headers: { accept: "application/json" } });
70240
+ if (res.status >= 400) return { status: res.status, retryAfter: retryAfterMs3(res), list: null };
70241
+ return { status: res.status, list: JSON.parse(await readCapped3(res, MAX_TIMELINE_BYTES)) };
70242
+ };
70243
+ const got = this.fetcher === globalThis.fetch ? await timelineOnce(`${instance}|${tag}`, load2) : await load2();
70244
+ if (got.status >= 400) {
70245
+ this._backOff(got.status, got.retryAfter);
70203
70246
  return;
70204
70247
  }
70205
- list3 = JSON.parse(await readCapped3(res, MAX_TIMELINE_BYTES));
70248
+ list3 = got.list;
70206
70249
  } catch (e) {
70207
70250
  this.log(`tagfeed #${tag}: ${e.message}`);
70208
70251
  this._backOff(0, null);
@@ -70480,7 +70523,7 @@ async function attachProof(activity, { privateKey, verificationMethod, created =
70480
70523
 
70481
70524
  // lib/core/deliver.mjs
70482
70525
  init_safefetch();
70483
- var MAX_REDIRECTS = 3;
70526
+ var MAX_REDIRECTS2 = 3;
70484
70527
  async function withQueryInTarget(signed, privateKey) {
70485
70528
  const url = new URL(signed.url);
70486
70529
  if (!url.search) return signed;
@@ -70537,7 +70580,8 @@ var Deliverer = class {
70537
70580
  log: log2 = console.log,
70538
70581
  passive = false,
70539
70582
  defer = false,
70540
- onGone = null
70583
+ onGone = null,
70584
+ parallel = 1
70541
70585
  }) {
70542
70586
  this.store = store;
70543
70587
  this.defer = defer;
@@ -70548,7 +70592,8 @@ var Deliverer = class {
70548
70592
  this.edPrivate = edPrivate;
70549
70593
  this.proofKeyId = proofKeyId;
70550
70594
  this.log = log2;
70551
- this.batchSize = 1;
70595
+ this.batchSize = parallel;
70596
+ this.parallel = parallel;
70552
70597
  if (!passive) this.startQueue();
70553
70598
  }
70554
70599
  startQueue() {
@@ -70568,7 +70613,7 @@ var Deliverer = class {
70568
70613
  signal: init.signal || AbortSignal.timeout(HTTP_TIMEOUT_MS),
70569
70614
  headers: { "user-agent": USER_AGENT, ...init.headers || {} }
70570
70615
  };
70571
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
70616
+ for (let hop = 0; hop <= MAX_REDIRECTS2; hop++) {
70572
70617
  const dispatcher = await pinnedFor(current);
70573
70618
  const req = new Request(current, withUa);
70574
70619
  const signed = await withQueryInTarget(
@@ -70629,6 +70674,9 @@ var Deliverer = class {
70629
70674
  // with the error deliverNow would have thrown. Here one at a time; the
70630
70675
  // relay deliverer sends the whole list in one call.
70631
70676
  async deliverManyNow(targets) {
70677
+ if (this.parallel > 1) {
70678
+ return Promise.all(targets.map((t) => this.deliverNow(t.inbox, t.activity).then(() => ({ ok: true }), (error2) => ({ error: error2 }))));
70679
+ }
70632
70680
  const out = [];
70633
70681
  for (const t of targets) {
70634
70682
  try {
@@ -71454,7 +71502,6 @@ var AdminFacade = class {
71454
71502
  fields: cfg.fields || [],
71455
71503
  aliases: cfg.aliases || [],
71456
71504
  autoAcceptFollows: cfg.autoAcceptFollows !== false,
71457
- hasUiPassword: !!cfg.uiPassword,
71458
71505
  quiescedAt: cfg.quiescedAt || null,
71459
71506
  movedTo: cfg.movedTo || null,
71460
71507
  mode: a.status().mode,
@@ -71570,10 +71617,6 @@ var AdminFacade = class {
71570
71617
  cfg.fields = (Array.isArray(body.fields) ? body.fields : []).filter((f) => f?.name?.trim()).map((f) => ({ name: String(f.name).trim(), value: String(f.value ?? "").trim() }));
71571
71618
  }
71572
71619
  if ("autoAcceptFollows" in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
71573
- if ("password" in body) {
71574
- if (body.password) cfg.uiPassword = hashPassword(body.password);
71575
- else delete cfg.uiPassword;
71576
- }
71577
71620
  const republish = WIRE_CONFIG.some((k) => k in body);
71578
71621
  a.store.setConfig(cfg);
71579
71622
  if ("autoAcceptFollows" in body && a.publisher) a.publisher.config.autoAcceptFollows = cfg.autoAcceptFollows;