-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.tsx
More file actions
3878 lines (3662 loc) · 153 KB
/
Copy pathserver.tsx
File metadata and controls
3878 lines (3662 loc) · 153 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//// IMPORTS ///////////////////////////////////////////////////////////////////
import { Context, Hono } from "@hono/hono";
import { Fragment } from "@hono/hono/jsx";
import { HTTPException } from "@hono/hono/http-exception";
import { some } from "@hono/hono/combine";
import { createMiddleware } from "@hono/hono/factory";
import { logger } from "@hono/hono/logger";
import { basicAuth } from "@hono/hono/basic-auth";
import { html, raw } from "@hono/hono/html";
import { deleteCookie, getSignedCookie, setSignedCookie } from "@hono/hono/cookie";
import { serveStatic, upgradeWebSocket } from "@hono/hono/deno";
import type { HtmlEscapedString } from "@hono/hono/utils/html";
import pg from "postgres";
import { Resend } from "resend";
export const resend = new Resend(Deno.env.get("RESEND_API_KEY") ?? "");
import Stripe from "stripe";
import { type Api, R2Conflict, uploadToR2 } from "./bots.ts";
import { BOTS } from "./bots/mod.ts";
export const r2 = { uploadToR2 };
import { runCheckmark } from "./bots/checkmark.ts";
import {
buildMsg,
DhtReject,
ensureCustodialKey,
hex,
idOf,
type Kind,
KINDS,
type Labels,
nowSec,
parseLabels,
PFX,
type Row,
signRow,
unwrapSecret,
verifyBytes,
verifyRow,
} from "./dht.ts";
export { parseLabels };
export type { Labels };
declare module "@hono/hono" {
interface ContextRenderer {
(
content: string | HtmlEscapedString | Promise<string | HtmlEscapedString>,
props?: { title?: string },
): Response | Promise<Response>;
}
}
//// TYPES /////////////////////////////////////////////////////////////////////
export type Usr = {
name: string;
email: string;
password: string | null;
bio: string;
email_verified_at: Date | null;
invited_by: string;
orgs_r: string[];
orgs_w: string[];
last_seen_at: Date;
created_at: Date;
ok?: boolean;
post_count?: number;
};
export type TagStat = { tag: string; posts: number; ups: number };
// A profile's follow state. `vote` is the VIEWER's ▲/▼ on this profile (null = no vote,
// and -1 only ever reaches the viewer's own render — a mute is private). `followers`
// counts ▲ only, so a downvote is invisible in every count.
export type Follow = { vote: number | null; followers: number; following: number; follows_me: boolean };
export type ChildCom = {
cid: number;
parent_cid: number | null;
body: string;
created_by: string | null; // null for foreign (dht) authors — render by short hash
hash?: string | null;
created_at: string;
tags?: string[];
orgs?: string[];
usrs?: string[];
c_flags: number;
comments: number;
reaction_counts: Record<string, number>;
user_reactions: string[];
child_comments?: ChildCom[];
};
export type Com = {
cid: number;
parent_cid: number | null;
created_by: string;
author_id?: string | null;
hash?: string | null;
checked?: boolean;
tags: string[];
orgs: string[];
usrs: string[];
mentions: string[];
body: string;
links: number[];
thumb: string | null;
created_at: string;
c_comments: number;
c_reactions: Record<string, string>;
c_flags: number;
flaggers: string[];
domains: string[];
score: string;
comments?: number;
reaction_count?: number;
reaction_counts?: Record<string, number>;
user_reactions?: string[];
child_comments?: ChildCom[];
unread?: boolean;
kind?: "mention" | "reply";
};
//// CONSTANTS & HELPERS ///////////////////////////////////////////////////////
const escapeXml = (s: string) =>
s.replace(/[&<>"']/g, (m) => `&${({ "&": "amp", "<": "lt", ">": "gt", '"': "quot", "'": "apos" })[m]};`);
const extractFirstUrl = (b: string) => b.match(/https?:\/\/[^\s]+/)?.[0] || null;
export const extractLinks = (b: string) => [...b.matchAll(/https:\/\/ding\.bar\/c\/(\d+)/g)].map((m) => parseInt(m[1]));
export const extractMentions = (b: string) => [
...new Set(
[...b.matchAll(/@([0-9a-zA-Z_]{4,32})/g)].map((m) => m[1].toLowerCase()),
),
];
export const extractImageUrl = (b: string) =>
b.match(/https?:\/\/[^\s]+\.(?:jpe?g|png|gif|webp|svg)(?:\?[^\s]*)?/i)?.[0] ||
null;
// `com.domains` holds bare lowercase hostnames with `www.` stripped, so every path that
// compares against it — the ?www= filter, a ~domain pref — must speak the same form or it
// silently matches nothing. normHost is that single definition; HOST_RE is what it accepts.
export const normHost = (h: string) => h.trim().toLowerCase().replace(/^www\./, "");
export const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
export const extractDomains = (b: string): string[] => {
const out = new Set<string>();
for (const m of b.matchAll(/https?:\/\/[^\s]+/g)) {
try {
out.add(normHost(new URL(m[0]).hostname));
} catch {
/**/
}
}
return [...out];
};
// `links @> array[..]` (not `= any(links)`) so the gin index on links can serve it.
// Never throws: callers run it AFTER the write has committed, so a failure here (statement
// timeout as `com` grows, say) must not report the post as failed. Stale ranking is the
// right degradation; the next write on the same cid refreshes it.
const refreshScores = (pid: string | number) =>
sql`select refresh_score(array(
select cid from com where cid = ${pid} or links @> array[${pid}::int]
))`.then(() => {}, (err) => console.error(`refresh_score failed for cid=${pid}:`, err));
// stat_tag is materialized, so it needs a hand. CONCURRENTLY keeps readers on the old
// snapshot for the duration instead of locking them out — it needs the unique index on
// (tag) and cannot run inside a transaction. Exported so tests refresh the way the cron
// does rather than reaching for their own SQL.
export const refreshStats = async () => {
await sql`refresh materialized view concurrently stat_tag`;
await sql`refresh materialized view concurrently stat_domain`;
};
const FLAG_THRESHOLD = 3;
const resolveThumbnail = async (url: string) => {
if (/\.(?:jpe?g|png|gif|webp|svg)(?:\?|$)/i.test(url)) return url;
// never fetch video files as text — fall straight to the favicon
if (/\.(?:mp4|webm)(?:\?|$)/i.test(url))
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
try {
const res = await fetch(url, {
headers: { "User-Agent": "ding/1.0" },
signal: AbortSignal.timeout(3000),
});
const og = (await res.text()).match(
/<meta[^>]+(?:property="og:image"|name="twitter:image")[^>]+content="([^"]+)"/i,
)?.[1];
if (og) return new URL(og, url).href;
} catch {
/* ignore */
}
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
};
// Everything com derives from a body: mentions/links/domains, plus (root posts only)
// a thumbnail — which may fetch the first URL, so call OUTSIDE any transaction.
const deriveBody = async (body: string, isRoot: boolean) => ({
mentions: extractMentions(body),
links: extractLinks(body),
domains: extractDomains(body),
thumb: !isRoot ? null : extractImageUrl(body) ||
(extractFirstUrl(body) ? await resolveThumbnail(extractFirstUrl(body)!) : null),
});
//// LABEL PARSING /////////////////////////////////////////////////////////////
// parseLabels / Labels / PFX live in dht.ts (shared with the CLI); re-exported above.
const SYM: Record<string, string> = { tag: "#", org: "*", usr: "@", www: "~" };
export const encodeLabels = (l: Labels) => {
const p = new URLSearchParams();
Object.entries(l).forEach(([k, v]) => Array.isArray(v) ? v.forEach((x) => p.append(k, x)) : v && p.set("q", v));
return p;
};
export const decodeLabels = (p: URLSearchParams) => {
const res: string[] = [];
Object.entries(PFX).forEach(([sym, k]) => p.getAll(k).forEach((v) => res.push(sym + v)));
p.getAll("mention").forEach((v) => res.push(`mention:${v}`));
["replies_to", "reactions", "comments", "q"].forEach((k) => {
const v = p.get(k);
if (v) {
res.push(
k === "q" ? v : k === "reactions" || k === "comments" ? v === "1" ? k : "" : `${k}:${v}`,
);
}
});
return res.filter(Boolean).join(" ");
};
export const formatLabels = (c: {
tags?: string[];
orgs?: string[];
usrs?: string[];
domains?: string[];
}) => [
...(c.tags || []).map((t) => `#${t}`),
...(c.orgs || []).map((t) => `*${t}`),
...(c.usrs || []).map((t) => `@${t}`),
...(c.domains || []).map((t) => `~${t}`),
];
const buildFilterTitle = (p: URLSearchParams) =>
Object.entries(PFX)
.filter(([_, k]) => k !== "www")
.flatMap(([sym, k]) => p.getAll(k).map((v) => sym + v))
.join(" ");
const buildAdditiveLink = (
p: URLSearchParams | undefined,
k: string,
v: string,
) => {
const n = new URLSearchParams(p);
if (!n.getAll(k).includes(v)) n.append(k, v);
n.delete("p");
return `/?${n}`;
};
//// EMAIL TOKEN ///////////////////////////////////////////////////////////////
const SECRET = Deno.env.get("EMAIL_TOKEN_SECRET") ??
(() => {
throw new Error("EMAIL_TOKEN_SECRET required");
})();
export const emailToken = async (ts: Date, email: string) => {
const epoch = Math.floor(ts.getTime() / 1000);
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`${epoch}:${email}`),
);
return `${epoch}:${
Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.slice(0, 32)
}`;
};
const validateEmailToken = async (
token: string,
email: string,
maxAge = 172800000,
) => {
const [epoch] = token.split(":"),
ts = parseInt(epoch) * 1000;
return (
ts &&
Date.now() - ts < maxAge &&
token === (await emailToken(new Date(ts), email))
);
};
//// POSTGRES //////////////////////////////////////////////////////////////////
type Sql = ReturnType<typeof pg>;
export let sql: Sql = pg(
Deno.env.get(`DATABASE_URL`)?.replace(/flycast/, "internal")!,
// Every Deno Deploy isolate opens its own pool, so keep it small and let idle
// connections go. statement_timeout stops one pathological query pinning a slot.
// prepare:false is REQUIRED — DATABASE_URL points at Neon's `-pooler` endpoint, which is
// transaction-mode. There, named prepared statements outlive the client that made them and
// are handed to the next one, so any DDL that changes a result type ("alter table com drop
// column ...") makes every reused plan fail with `cached plan must not change result type`
// until the pooled backends recycle. That took the whole site down once; do not re-enable.
{
database: "ding",
max: 3,
idle_timeout: 20,
connect_timeout: 10,
prepare: false,
connection: { statement_timeout: 15_000 },
},
);
export const setSql = (s: Sql) => (sql = s);
//// DHT ////////////////////////////////////////////////////////////////////////
const KEY_WRAP_SECRET = Deno.env.get("KEY_WRAP_SECRET") ?? (() => {
throw new Error("KEY_WRAP_SECRET required (stable; custodial private keys are encrypted under it)");
})();
// The default trust root: marks signed by this pubkey render a verified ✓. Optional —
// without it, no checkmarks show (the network still works). The matching secret key
// lives ONLY on the checkmark cron, never here.
const DING_ORG_PK = Deno.env.get("DING_ORG_PK") ?? null;
// Verified handles (local names with a live trust-root identity mark), cached ~60s so a ✓ can
// render beside a username anywhere without a per-render query. Refreshed in the "*" middleware.
export const verified = { at: 0, names: new Set<string>() };
// Runs in the middleware on every HTML request, so it is on the critical path for the whole
// site. Two rules follow from that, and both were learned the hard way:
// - Stamp `at` BEFORE the query, not after. On failure the old code left `at` untouched, so
// every subsequent request retried immediately — a thundering herd against a database
// that was already struggling, with each request blocking on its own attempt.
// - Fail OPEN. A stale (or empty) checkmark set is a cosmetic loss; a throwing or hanging
// middleware is the entire site down, including routes that need no database at all.
const refreshVerified = async () => {
if (!DING_ORG_PK || Date.now() - verified.at < 60_000) return;
verified.at = Date.now();
try {
const rows = await sql<{ name: string }[]>`
select u.name from usr u where exists(
select 1 from dht m where m.kind = 'mark' and m.target = u.id and m.pubkey = ${DING_ORG_PK}
and m.val->'mark'->>'v' in ('email','payment','human')
and (m.val->'mark'->>'exp')::bigint > extract(epoch from now()))`;
verified.names = new Set(rows.map((r) => r.name.toLowerCase())); // names are citext
} catch (e) {
console.error(`refreshVerified failed; serving the previous set: ${e instanceof Error ? e.message : e}`);
}
};
// Custodial signer for a local user (see ensureCustodialKey in dht.ts).
const ensureKey = async (name: string): Promise<{ priv: CryptoKey; pub: string }> => {
const key = await ensureCustodialKey(sql, name, KEY_WRAP_SECRET);
if (!key) {
throw new HTTPException(409, {
message: `@${name} is self-custody (server holds no key). Post with the ding CLI instead.`,
});
}
return key;
};
// A new child bumps its parent's denormalized counters: reactions land in the
// c_reactions hstore, real comments in c_comments.
const bumpCounts = (db: pg.ISql, cid: number | string, body: string) =>
isReaction(body)
? db`update com set c_reactions = c_reactions || hstore(${body}, (coalesce((c_reactions->${body})::int,0)+1)::text) where cid = ${cid}`
: db`update com set c_comments = c_comments + 1 where cid = ${cid}`;
// Store a signed row in the dht log and project it: msg -> com; flag -> the target's
// distinct-flagger count. dht is the source of truth, com the rebuildable projection —
// so the log insert + projection share ONE transaction (a partial failure rolls back
// the dht row, so replay re-ingests cleanly). on-conflict-do-nothing makes a genuine
// replay a no-op. PUBLIC posts only: msg rows scoped to *org or @usr are rejected so
// private bodies never enter the log.
export const ingestMsg = async (
row: Row,
opts: { verify?: boolean; parentCid?: number | null; gate?: (pubkey: string) => void; comTags?: string[] } = {},
): Promise<{ cid: number | null; isNew: boolean }> => {
if (opts.verify) {
try {
await verifyRow(row);
if (row.ts > nowSec() + 3600) {
throw new DhtReject(
`row ${String(row.k).slice(0, 8)}…: ts ${row.ts} is more than 1h in the future — clock skew or forgery.`,
);
}
} catch (e) {
throw e instanceof DhtReject ? e : new DhtReject(e instanceof Error ? e.message : String(e));
}
}
opts.gate?.(row.pubkey); // post-verify policy hook (rate-limit); throws DhtReject to drop the row
const { k, kind, pubkey, ts, sig, ...payload } = row;
const tags = (payload.tags as string[]) ?? [];
const orgs = (payload.orgs as string[]) ?? [];
const usrs = (payload.usrs as string[]) ?? [];
const target = (payload.target as string) ?? (payload.subject as string) ?? null;
if (kind === "msg") {
// Private *org / @recipients are ids (names aren't key-bound, so couldn't be auth-gated).
if (orgs.some((o) => !/^[0-9a-f]{64}$/.test(o)))
throw new DhtReject(`row ${String(k).slice(0, 8)}…: *org recipients must be 64-hex ids, not names. refusing.`);
if (usrs.some((u) => !/^[0-9a-f]{64}$/.test(u))) {
throw new DhtReject(
`row ${String(k).slice(0, 8)}…: private @recipients must be 64-hex ids, not names. refusing.`,
);
}
}
if (kind === "mark") {
// exp/v must be well-typed or the feed's `(val->'mark'->>'exp')::bigint` cast would throw.
const m = payload.mark as { v?: unknown; exp?: unknown } | undefined;
if (typeof payload.subject !== "string" || !m || typeof m.v !== "string" || !Number.isSafeInteger(m.exp))
throw new DhtReject(`row ${String(k).slice(0, 8)}…: mark needs {subject, mark:{v:string, exp:int}}. refusing.`);
}
// Derivations (incl. the network thumbnail fetch) happen OUTSIDE the transaction.
const body = kind === "msg" ? (payload.body as string) ?? "" : "";
// dht.tags stays sorted (canonical); com.tags keeps submission order so the rendered feed
// is byte-for-byte unchanged for existing users (the local /c path passes the original order).
const comTags = opts.comTags ?? tags;
const parentHash = (payload.parent as string) ?? null;
// Independent lookups — serialized, this was four round trips before the transaction.
const [author, usrNames, parentCid, author_id] = await Promise.all([
kind === "msg" ? sql`select name from usr where pubkey = ${pubkey}`.then((r) => r[0]) : null,
// dht.usrs stays id-scoped (for auth-gated delivery); com.usrs resolves to local names
// (for the existing name-based feed ACL + rendering). CRITICAL: a DM (usrs non-empty)
// must NEVER project to com.usrs='{}', or the feed ACL would render it PUBLICLY — so when
// no recipient is local, fall back to the raw ids (non-empty, matches no local viewer).
kind === "msg" && usrs.length
? sql<{ name: string }[]>`select name from usr where id = any(${usrs})`.then((r) => r.map((x) => x.name))
: [],
kind !== "msg"
? null
: opts.parentCid !== undefined
? opts.parentCid
: parentHash
? sql`select cid from com where hash = ${parentHash}`.then((r) => r[0]?.cid ?? null)
: null,
kind === "msg" ? idOf(pubkey) : null,
]);
const comUsrs = usrNames.length ? usrNames : usrs;
const { mentions, links, domains, thumb } = await deriveBody(body, kind === "msg" && parentCid == null);
// delivery scope (orgs/usrs) is meaningful only on msg rows; force '{}' elsewhere so a
// signed non-msg row can't craft a value that games the drain's visibility gate.
const dhtOrgs = kind === "msg" ? orgs : [];
const dhtUsrs = kind === "msg" ? usrs : [];
const rowId = kind === "usr" || kind === "org" || kind === "peer" ? await idOf(pubkey) : null;
const members = kind === "org" ? (payload.members as string[]) ?? [] : []; // org register: member ids
const res = await sql.begin(async (tx) => {
const [stored] = await tx`
insert into dht (k, kind, pubkey, id, ts, sig, val, tags, orgs, usrs, members, target)
values (${k}, ${kind}, ${pubkey}, ${rowId}, ${ts}, ${sig}, ${
sql.json(payload as pg.JSONValue)
}, ${tags}, ${dhtOrgs}, ${dhtUsrs}, ${members}, ${target})
on conflict (k) do nothing returning k`;
if (!stored) {
const [c] = kind === "msg" ? await tx`select cid from com where hash = ${k}` : [];
return { cid: c?.cid ?? null, isNew: false, scoreTarget: null as number | null };
}
await tx`select pg_notify('dht', ${k})`; // wake any live WS subscriber (fires on commit)
if (kind === "flag" && target) {
// count DISTINCT flagger pubkeys (replay-/sybil-resistant), mirror onto the
// projected com row's c_flags, and mark the target row at the threshold.
const [{ n }] =
await tx`select count(distinct pubkey)::int as n from dht where kind = 'flag' and target = ${target}`;
await tx`update com set c_flags = ${n} where hash = ${target}`;
if (n >= FLAG_THRESHOLD) await tx`update dht set flagged = true where k = ${target}`;
}
if (kind !== "msg") return { cid: null, isNew: true, scoreTarget: null as number | null };
// *org content is dht-only for now (the web org UI stays on the local name-based ACL).
if (orgs.length) return { cid: null, isNew: true, scoreTarget: null as number | null };
const [cm] = await tx`
insert into com (parent_cid, created_by, hash, author_id, sig, parent_hash, t, body, tags, orgs, usrs, mentions, links, thumb, domains)
values (${parentCid}, ${
author?.name ?? null
}, ${k}, ${author_id}, ${sig}, ${parentHash}, ${ts}, ${body}, ${comTags}, ${orgs}, ${comUsrs}, ${mentions}, ${links}, ${thumb}, ${domains})
returning cid`;
// Backfill suppression from any flag rows that arrived before this msg (out-of-order ingest).
const [{ fn }] = await tx`select count(distinct pubkey)::int as fn from dht where kind = 'flag' and target = ${k}`;
if (fn > 0) await tx`update com set c_flags = ${fn} where cid = ${cm.cid}`;
if (fn >= FLAG_THRESHOLD) await tx`update dht set flagged = true where k = ${k}`;
if (parentCid != null) await bumpCounts(tx, parentCid, body);
return { cid: cm.cid, isNew: true, scoreTarget: parentCid ?? cm.cid };
});
if (res.isNew && res.scoreTarget != null) await refreshScores(res.scoreTarget);
return { cid: res.cid, isNew: res.isNew };
};
// ?t=YYYYMMDDhhmmss is a coarse "since this UTC time" filter on seen_at (human/manual
// drains). The precise, resumable replication cursor is ?after=<seq> (a strictly
// increasing local arrival counter) — immune to clock skew and same-second collisions.
const parseT = (t: string | null): string =>
t && /^\d{14}$/.test(t)
? `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)} ${t.slice(8, 10)}:${t.slice(10, 12)}:${t.slice(12, 14)}`
: "1970-01-01 00:00:00";
// q = "$msg #lol" | "mark @gwern" | "$peer" → { kind, tags, orgs, usrs }. Free text rejected.
const parseQ = (q: string) => {
const parts = q.trim().split(/\s+/).filter(Boolean);
const kind = (parts[0] ?? "").replace(/^\$/, "");
if (!KINDS.includes(kind as Kind)) {
throw new HTTPException(400, {
message: `bad q "${q}": first token must be a kind (${KINDS.map((k) => "$" + k).join(", ")}).`,
});
}
const l = parseLabels(parts.slice(1).join(" "));
if (l.text) throw new HTTPException(400, { message: `bad q "${q}": free text not allowed; use #tag/*org/@usr.` });
return { kind, tags: l.tag, orgs: l.org, usrs: l.usr };
};
const dhtWhere = (qs: ReturnType<typeof parseQ>[]) =>
qs.length
? qs
.map((q) =>
sql`(kind = ${q.kind}${q.tags.length ? sql` and tags @> ${q.tags}::text[]` : sql``}${
q.orgs.length ? sql` and orgs @> ${q.orgs}::text[]` : sql``
}${q.usrs.length ? sql` and usrs @> ${q.usrs}::text[]` : sql``})`
)
.reduce((a, b) => sql`${a} or ${b}`)
: sql`true`;
// Shared per-isolate live-tail: ONE sql.listen('dht') for the whole isolate, fanned out in
// memory to every WS subscriber — instead of one DB connection per subscriber. On NOTIFY the
// row is fetched ONCE, then matched against each subscriber's q-filters in memory (matchesQ
// mirrors dhtWhere's `kind = … and tags/orgs/usrs @> …` containment). Public rows only.
type DhtFull = {
k: string;
seq: string;
kind: Kind;
pubkey: string;
ts: number;
sig: string;
val: Record<string, unknown>;
tags: string[];
orgs: string[];
usrs: string[];
};
type WsSub = { qs: ReturnType<typeof parseQ>[]; onRow: (r: DhtFull) => void };
const wsSubs = new Set<WsSub>();
// The on-the-wire NDJSON row shape. The WS live-tail and the HTTP drain MUST emit
// byte-identical rows (subscribers dedup by k across both paths).
const wireRow = (r: Pick<DhtFull, "k" | "kind" | "pubkey" | "ts" | "sig" | "val">) =>
JSON.stringify({ k: r.k, kind: r.kind, pubkey: r.pubkey, ts: Number(r.ts), sig: r.sig, ...r.val });
// postgres.js infers bigint as int8 at runtime, but its Serializable type omits it.
const int8 = (n: bigint) => n as unknown as number;
// One page of the PUBLIC live-tail drain (WS history sweep + post-subscribe catch-up).
const drainPage = (after: bigint, qs: ReturnType<typeof parseQ>[]) =>
sql<DhtFull[]>`
select k, seq, kind, pubkey, ts, sig, val, tags, orgs, usrs from dht
where seq > ${int8(after)} and orgs = '{}' and usrs = '{}' and (${dhtWhere(qs)})
order by seq asc limit 1000`;
const supersetOf = (have: string[], need: string[]) => need.every((n) => have.includes(n));
export const matchesQ = (r: Pick<DhtFull, "kind" | "tags" | "orgs" | "usrs">, qs: WsSub["qs"]) =>
qs.length === 0 ||
qs.some((q) =>
r.kind === q.kind && supersetOf(r.tags, q.tags) && supersetOf(r.orgs, q.orgs) && supersetOf(r.usrs, q.usrs)
);
let listenerHandle: { unlisten: () => Promise<void> } | null = null;
let listenerStarting: Promise<{ unlisten: () => Promise<void> }> | null = null;
const startDhtListener = async () => {
if (listenerHandle) return;
if (!listenerStarting) {
listenerStarting = sql.listen("dht", async (k: string) => {
if (wsSubs.size === 0) return;
const [r] = await sql`
select k, seq, kind, pubkey, ts, sig, val, tags, orgs, usrs from dht
where k = ${k} and orgs = '{}' and usrs = '{}'` as unknown as DhtFull[];
if (!r) return; // private/missing → never fans out over WS
for (const sub of wsSubs) if (matchesQ(r, sub.qs)) sub.onRow(r);
// A rejected promise must not be cached — otherwise one DB blip kills live-tail for
// the whole isolate, since listenerHandle never becomes truthy to clear it.
}).catch((e) => {
listenerStarting = null;
throw e;
});
}
listenerHandle = await listenerStarting;
};
const stopDhtListenerIfIdle = async () => {
if (wsSubs.size === 0 && listenerHandle) {
const h = listenerHandle;
listenerHandle = null;
listenerStarting = null;
await h.unlisten();
}
};
// Stateless node-auth challenge: nonce = "<exp>:<salt>:<hmac>". The salt makes every
// nonce unique (even within a second), so single-use doesn't collide. A subscriber
// proves an identity by signing the nonce; the drain then ALSO serves that id's private rows.
const nonceHmac = async (body: string) => {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return hex(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`nonce:${body}`))));
};
const nodeChallenge = async () => {
const now = nowSec();
await sql`delete from used_nonce where exp < ${now}`; // GC spent nonces
const body = `${now + 60}:${hex(crypto.getRandomValues(new Uint8Array(8)))}`;
return `${body}:${await nonceHmac(body)}`;
};
const nonceValid = async (nonce: string) => {
const [expStr, salt, h] = nonce.split(":");
const exp = parseInt(expStr);
return !!exp && !!salt && !!h && exp > nowSec() && h === await nonceHmac(`${expStr}:${salt}`);
};
// Authorization: `Ding <pubkey> <nonce> <sig>` (sig = Ed25519(pubkey, nonce)). Returns
// the authenticated id, or "" (which sees only public rows). Nonces are single-use.
const drainAuthId = async (c: Context): Promise<string> => {
const a = c.req.header("authorization");
if (!a?.startsWith("Ding ")) return "";
const [pubkey, nonce, sig] = a.slice(5).split(" ");
if (!/^[0-9a-f]{64}$/.test(pubkey ?? "") || !nonce || !sig || !(await nonceValid(nonce))) return "";
if (!(await verifyBytes(pubkey, sig, nonce))) return "";
const [claimed] = await sql`insert into used_nonce (nonce, exp) values (${nonce}, ${
parseInt(nonce.split(":")[0])
}) on conflict do nothing returning nonce`;
return claimed ? await idOf(pubkey) : ""; // already used → reject
};
// Pull-based replication: drain a bootstrap node's log from `cursor` onward, verify +
// store each row locally, and return the advanced cursor. The dull, Deno-Deploy-friendly
// mirror of the WS live-tail; a node polls this on an interval.
export const replicate = async (bootstrap: string, queries: string[], cursor: string): Promise<string> => {
const qp = queries.map((q) => `q=${encodeURIComponent(q)}`).join("&");
const res = await fetch(`${bootstrap}/?after=${cursor}&${qp}`);
if (!res.ok) throw new Error(`replicate: GET ${bootstrap} → ${res.status} ${await res.text()}`);
for (const line of (await res.text()).split("\n").map((l) => l.trim()).filter(Boolean)) {
let row: Row;
try {
row = JSON.parse(line) as Row;
} catch {
console.error(`replicate drop: not valid JSON from ${bootstrap}`);
continue;
}
try {
await ingestMsg(row, { verify: true });
} catch (e) {
if (!(e instanceof DhtReject)) throw e; // infra error → keep the old cursor, retry next tick
console.error(`replicate drop: ${e.message}`);
}
}
return res.headers.get("x-ding-cursor") ?? cursor;
};
// Gossip discovery: read peer rows from a node to learn other nodes' dialable origins.
export const discoverPeers = async (bootstrap: string): Promise<{ ips: string[]; serves: string[] }[]> => {
const res = await fetch(`${bootstrap}/?q=$peer`);
if (!res.ok) return [];
return (await res.text()).split("\n").map((l) => l.trim()).filter(Boolean).map((l) => {
const r = JSON.parse(l);
return { ips: (r.ips ?? []) as string[], serves: (r.serves ?? []) as string[] };
});
};
// Announce this node's dialable origins + the queries it serves, so others can find us.
export const publishPeer = async (bootstrap: string, ips: string[], serves: string[], priv: CryptoKey, pub: string) => {
const row = await signRow("peer", nowSec(), { ips, serves }, priv, pub);
await fetch(bootstrap, {
method: "POST",
headers: { "content-type": "application/x-ndjson" },
body: JSON.stringify(row),
});
};
// Resolve a self-asserted @name to a canonical id when multiple usr registers claim it:
// prefer the one with the most live trust-root marks, then the earliest seen (first-come).
// Returns null if no register claims the name. (Names aren't key-bound; marks break ties.)
export const resolveName = async (name: string): Promise<string | null> => {
const [best] = await sql`
select u.id from (
select distinct on (pubkey) id, seq from dht where kind = 'usr' and val->>'name' = ${name} order by pubkey, seq asc
) u
order by (
select count(*) from dht m
where m.kind = 'mark' and m.target = u.id and m.pubkey = ${DING_ORG_PK ?? ""}
and (m.val->'mark'->>'exp')::bigint > extract(epoch from now())
) desc, u.seq asc
limit 1`;
return best?.id ?? null;
};
//// RESEND ///////////////////////////////////////////////////////////////////
if (!Deno.env.get(`RESEND_API_KEY`)) {
console.warn(
"RESEND_API_KEY is missing. Verification + password reset emails will fail.",
);
}
const logEmailFailure = (where: string, email: string, err: unknown) =>
console.error(
`${where} email_failed for ${email}:`,
(err as { response?: { body?: unknown } })?.response?.body ?? err,
);
const VERIFY_COOLDOWN = `5 minutes`;
const sendVerify = async (email: string) => {
if (!Deno.env.get(`RESEND_API_KEY`)) {
throw new Error(
`RESEND_API_KEY missing — cannot send verification email to ${email}`,
);
}
const claimed = await sql`
update usr set verify_sent_at = now()
where email = ${email}
and (verify_sent_at is null or verify_sent_at < now() - ${VERIFY_COOLDOWN}::interval)
returning email
`;
if (claimed.length === 0) {
console.log(`sendVerify skipped (cooldown) for ${email}`);
return;
}
const token = await emailToken(new Date(), email);
const { error } = await resend.emails.send({
to: email,
from: Deno.env.get("RESEND_FROM_EMAIL") ?? "noreply@ding.bar",
subject: "Verify your email",
text: `Welcome to ding.\n\nPlease verify your email: https://ding.bar/password?email=${
encodeURIComponent(
email,
)
}&token=${encodeURIComponent(token)}`,
});
if (error) {
console.error(`Could not send verification email to ${email}:`, error);
throw new Error(`resend send failed: ${error.message}`);
}
};
// Known throwaway/temp-mail domains, vendored (deploys with the code; readable on Deno Deploy).
export const disposableDomains = new Set(
Deno.readTextFileSync(new URL("./disposable-domains.txt", import.meta.url))
.split("\n").map((l) => l.trim().toLowerCase())
.filter((l) => l && !l.startsWith("#")),
);
// A domain can receive mail if it has an MX record, or (SMTP fallback) an A record. Deno throws
// NotFound for both NXDOMAIN and "exists but no such record"; any OTHER error is transient → fail
// open so a flaky resolver never blocks real signups.
const hasMailExchange = async (domain: string): Promise<boolean> => {
for (const kind of ["MX", "A"] as const) {
try {
if ((await Deno.resolveDns(domain, kind)).length > 0) return true;
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) return true;
}
}
return false;
};
// Signup email gate: reject known disposable domains and domains that can't receive mail.
// Returns a user-facing reason when the email should be rejected, else null.
export const badSignupEmail = async (email: string): Promise<string | null> => {
const domain = email.split("@")[1]?.toLowerCase();
if (!domain) return "that email address looks malformed.";
if (disposableDomains.has(domain)) return "please sign up with a different email provider.";
if (!(await hasMailExchange(domain))) return "that email domain can't receive mail.";
return null;
};
//// STRIPE ////////////////////////////////////////////////////////////////////
const stripeKey = Deno.env.get("STRIPE_SECRET_KEY") ?? "";
const isStripeConfigured = stripeKey.startsWith("sk_");
if (!isStripeConfigured) {
console.warn(
"STRIPE_SECRET_KEY is missing, invalid, or still a placeholder. org features will fail.",
);
}
export const stripe = new Stripe(
isStripeConfigured ? stripeKey : "sk_test_placeholder",
{
httpClient: Stripe.createFetchHttpClient(),
},
);
//// COMPONENTS ////////////////////////////////////////////////////////////////
// A gray ✓ rendered BEFORE a handle when that name is verified (live trust-root mark).
const isVerified = (name?: string | null | false) => !!name && verified.names.has(name.toLowerCase());
const checkSpan = () => <span class="check" title="verified">✓</span>;
const Check = (name?: string | null) => isVerified(name) ? checkSpan() : null;
const User = (u: Usr, viewerName?: string, tags: TagStat[] = [], follow?: Follow) => {
const isOwner = viewerName && viewerName == u.name;
const mutual = follow && follow.vote === 1 && follow.follows_me;
return (
<section class="user">
<h2>{Check(u.name)}@{u.name}</h2>
{
/* LabelVote is a <form>, so it can live in neither the <h2> nor a <p>. It is gated on
a viewer the way /c's InfoBlocks are — an anonymous click would bounce through
login and silently discard the vote. */
}
{follow && (
<div class="follow-line">
{viewerName && !isOwner && <LabelVote label={`@${u.name}`} vote={follow.vote} ups={follow.followers} />}
<span class="note-sm">
{follow.followers} follower{follow.followers === 1 ? "" : "s"} · {follow.following} following
{mutual ? " · mutual" : follow.follows_me ? " · follows you" : ""}
</span>
</div>
)}
<div class="user-links">
{u.name !== u.invited_by || <a href={`/u/${u.invited_by}`}>invited by {Check(u.invited_by)}@{u.invited_by}</a>}
<a href={`/c?usr=${u.name}`}>posts</a>
<a href={`/c?usr=${u.name}&comments=1`}>comments</a>
{isOwner && (
<>
<a href={`/c?mention=${u.name}`}>mentions</a>
<a href={`/c?replies_to=${u.name}`}>replies</a>
<a href={`/c?usr=${u.name}&reactions=1`}>reactions</a>
</>
)}
</div>
{tags.length > 0 && (
<div class="tag-presets">
{tags.map((t) => (
<a key={t.tag} href={`/c?tag=${encodeURIComponent(t.tag)}`} class="tag-preset">
#{t.tag}
{t.ups > 0 && <span class="tag-preset__count">▲{t.ups}</span>}
</a>
))}
</div>
)}
<pre>{u.bio}</pre>
</section>
);
};
const isReaction = (body: string): boolean => !!body && [...body].length === 1; // Single grapheme (handles emoji)
const SortToggle = ({
sort,
baseHref,
title,
}: {
sort: string;
baseHref: string;
title: string;
}) => {
const base = new URL(baseHref, "http://x");
const href = (s: string) => {
const p = new URLSearchParams(base.search);
s === "hot" ? p.delete("sort") : p.set("sort", s);
p.delete("p");
return `${base.pathname}?${p}`;
};
return (
<nav class="sort-toggle" aria-label="sort">
<span>{title}</span>
<span class="sort-toggle__options">
{["hot", "new", "top"].map((s, i) => (
<Fragment key={s}>
{i > 0 && " • "}
{sort === s ? s : <a href={href(s)}>{s}</a>}
</Fragment>
))}
</span>
</nav>
);
};
// The #tag / *org / @user / ~domain header above a filtered feed: same skeleton, different
// subject. `vote` is its own slot rather than part of `head`/`note` because LabelVote is a
// <form>, which is valid in neither an <h2> nor a <p>.
const InfoBlock = (
{ head, note, postTo, vote }: { head: BodyNode; note: BodyNode; postTo: BodyNode; vote?: BodyNode },
) => (
<div class="info-block">
<div class="follow-line">
<h2>{head}</h2>
{vote}
</div>
<p class="note">{note}</p>
<p class="note-sm">{postTo}</p>
</div>
);
// The draw + attach pair, identical in the frontpage compose form and the reply form.
const ComposeTools = () => (
<>
<button type="button" class="upload-btn" data-draw>draw</button>
<label class="upload-btn">
attach
<input type="file" multiple accept="image/*,video/mp4,video/webm,.pdf" data-upload hidden />
</label>
</>
);
const Pagination = ({ base, cur, p, more }: { base: string; cur: URLSearchParams; p: number; more: boolean }) => {
// set, not append: the current URL already carries p past page 0, and c.req.query reads the
// FIRST value — appending makes every prev/next a self-link.
const href = (to: number) => {
const n = new URLSearchParams(cur);
n.set("p", String(to));
return `${base}?${n}`;
};
return (
<section>
<div class="pagination">
{p > 0 ? <a href={href(p - 1)}>prev</a> : <span />}
{more && <a href={href(p + 1)}>next</a>}
</div>
</section>
);
};
const ActiveFilters = ({
params,
basePath = "/c",
}: {
params: URLSearchParams;
basePath?: string;
}) => {
const f: { label: string; param: string; value: string }[] = [];
["tag", "org", "usr", "www", "mention"].forEach((k) =>
params
.getAll(k)
.forEach((v) => f.push({ label: (SYM[k] ?? `${k}:`) + v, param: k, value: v }))
);
["replies_to", "reactions", "comments"].forEach(
(k) =>
params.get(k) &&
f.push({
label: k === "reactions" || k === "comments" ? k : `${k}:${params.get(k)}`,
param: k,
value: params.get(k)!,
}),
);
return f.length > 0
? (
<div class="active-filters">
{f.map((x) => {
const n = new URLSearchParams(params);
n.delete(x.param);
params
.getAll(x.param)
.filter((v) => v !== x.value)
.forEach((v) => n.append(x.param, v));
n.delete("p");
return (
<a
key={`${x.param}:${x.value}`}
href={`${basePath}?${n}`}
class="filter-pill"
>
{x.param === "usr" ? Check(x.value) : null}
{x.label} x
</a>
);
})}
</div>
)
: <div class="active-filters" />;
};
const Reactions = (c: Com | ChildCom, votesOnly?: boolean) =>
Object.entries({
"▲": 0,
"▼": 0,
...(votesOnly ? {} : c.reaction_counts || {}),
}).map(([k, v]) => (
<form
key={k}
method="post"
action={`/c/${c.cid}`}
class={`reaction${(c.user_reactions || []).includes(k) ? " reacted" : ""}`}
>
<input type="hidden" name="body" value={k} />
<button type="submit" aria-label={k === "▲" ? "upvote" : k === "▼" ? "downvote" : `react ${k}`}>
{k} {v}
</button>
</form>
));