@takosjp/yurucommu-core 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Single-object read-gate combining ALL visibility dimensions: the private-
|
|
2
|
+
// community membership gate (delegated to canViewerReadObject) AND the
|
|
3
|
+
// per-post public / unlisted / followers / direct visibility check. This is the
|
|
4
|
+
// canonical "can this viewer read this object" predicate; the inline gates in
|
|
5
|
+
// posts/routes.ts (post-detail + filterVisibleReplies), posts/interactions.ts
|
|
6
|
+
// (bookmarks), and notifications.ts implement the same logic and may migrate to
|
|
7
|
+
// this helper over time.
|
|
8
|
+
|
|
9
|
+
import { and, eq } from "drizzle-orm";
|
|
10
|
+
import type { Database } from "../../db/index.ts";
|
|
11
|
+
import { blocks, follows } from "../../db/index.ts";
|
|
12
|
+
import { canViewerReadObject } from "./community-visibility.ts";
|
|
13
|
+
import { safeJsonParse } from "../federation-helpers.ts";
|
|
14
|
+
|
|
15
|
+
export type ReadGateObject = {
|
|
16
|
+
visibility: string;
|
|
17
|
+
attributedTo: string;
|
|
18
|
+
toJson: string;
|
|
19
|
+
ccJson: string;
|
|
20
|
+
audienceJson: string;
|
|
21
|
+
communityApId: string | null;
|
|
22
|
+
// A Story is stored visibility="public" / audienceJson="[]" but its REAL reach
|
|
23
|
+
// is followers (personal) or members (community), and it is revoked at endTime.
|
|
24
|
+
// When these are supplied the gate applies the Story reach rule; omit them for
|
|
25
|
+
// non-story callers (the branch is then never taken).
|
|
26
|
+
type?: string;
|
|
27
|
+
endTime?: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A viewer the author EXPLICITLY addressed (in `to` or `cc`) — e.g. an
|
|
32
|
+
* @mention — may read the post even without an accepted-follow edge: the author
|
|
33
|
+
* chose to send it to them, and it was delivered to that actor's inbox.
|
|
34
|
+
* Mentions land in `cc` (mergeCc in posts/routes.ts); direct recipients in `to`.
|
|
35
|
+
*/
|
|
36
|
+
export function isExplicitRecipient(
|
|
37
|
+
obj: { toJson: string; ccJson: string },
|
|
38
|
+
viewerApId: string,
|
|
39
|
+
): boolean {
|
|
40
|
+
return (
|
|
41
|
+
safeJsonParse<string[]>(obj.toJson, []).includes(viewerApId) ||
|
|
42
|
+
safeJsonParse<string[]>(obj.ccJson, []).includes(viewerApId)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve whether `viewerApId` has an accepted follow edge to `authorApId`.
|
|
48
|
+
*/
|
|
49
|
+
async function hasAcceptedFollow(
|
|
50
|
+
db: Database,
|
|
51
|
+
viewerApId: string,
|
|
52
|
+
authorApId: string,
|
|
53
|
+
): Promise<boolean> {
|
|
54
|
+
const row = await db
|
|
55
|
+
.select({ followerApId: follows.followerApId })
|
|
56
|
+
.from(follows)
|
|
57
|
+
.where(
|
|
58
|
+
and(
|
|
59
|
+
eq(follows.followerApId, viewerApId),
|
|
60
|
+
eq(follows.followingApId, authorApId),
|
|
61
|
+
eq(follows.status, "accepted"),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
.get();
|
|
65
|
+
return Boolean(row);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-post visibility decision EXCLUDING the private-community membership gate.
|
|
70
|
+
* This is the single source of truth for the public / unlisted / followers /
|
|
71
|
+
* direct rules plus the Story reach + expiry rule, and is shared by the async
|
|
72
|
+
* single-object helper (`canViewerReadObjectFull`) and by batched page gates
|
|
73
|
+
* (bookmarks, etc.) so the to/cc explicit-recipient and Story branches cannot
|
|
74
|
+
* drift per surface. The community membership gate is applied SEPARATELY by the
|
|
75
|
+
* caller (inline async in the single-object helper, batched in page gates), and
|
|
76
|
+
* a follower lookup is injected via `isAcceptedFollower` so both a per-object DB
|
|
77
|
+
* query and a precomputed batched Set satisfy the same rules without an N+1.
|
|
78
|
+
*
|
|
79
|
+
* Returns true/false; for a COMMUNITY story it returns true after the author /
|
|
80
|
+
* expiry shortcuts so the caller's community gate decides membership.
|
|
81
|
+
*/
|
|
82
|
+
export function passesPostVisibilitySync(
|
|
83
|
+
obj: ReadGateObject,
|
|
84
|
+
viewerApId: string | null | undefined,
|
|
85
|
+
isAcceptedFollower: (authorApId: string) => boolean,
|
|
86
|
+
now: string = new Date().toISOString(),
|
|
87
|
+
): boolean {
|
|
88
|
+
// A Story's stored visibility ("public") does NOT encode its reach: a personal
|
|
89
|
+
// story is followers-only and a community story is members-only, and BOTH are
|
|
90
|
+
// revoked at endTime.
|
|
91
|
+
if (obj.type === "Story") {
|
|
92
|
+
if (viewerApId && obj.attributedTo === viewerApId) return true; // own story
|
|
93
|
+
if (obj.endTime && obj.endTime <= now) return false; // expired → revoked
|
|
94
|
+
if (obj.communityApId) return true; // members gate applied by caller
|
|
95
|
+
if (!viewerApId) return false;
|
|
96
|
+
return isAcceptedFollower(obj.attributedTo); // personal → followers reach
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (obj.visibility === "direct") {
|
|
100
|
+
if (!viewerApId) return false;
|
|
101
|
+
if (obj.attributedTo === viewerApId) return true;
|
|
102
|
+
return isExplicitRecipient(obj, viewerApId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (obj.visibility === "followers") {
|
|
106
|
+
if (!viewerApId) return false;
|
|
107
|
+
if (obj.attributedTo === viewerApId) return true;
|
|
108
|
+
if (isExplicitRecipient(obj, viewerApId)) return true;
|
|
109
|
+
return isAcceptedFollower(obj.attributedTo);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return true; // public / unlisted
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether `viewerApId` may read `obj`, honoring BOTH the community membership
|
|
117
|
+
* gate and the per-post visibility:
|
|
118
|
+
* - public / unlisted → readable (subject to the community gate);
|
|
119
|
+
* - followers → author, an accepted follower, OR an explicitly
|
|
120
|
+
* addressed (to/cc) recipient such as a mention;
|
|
121
|
+
* - direct → author or an addressed recipient (to/cc);
|
|
122
|
+
* - Story → author always; else revoked past endTime; community
|
|
123
|
+
* story → members-only; personal story → followers.
|
|
124
|
+
* An anonymous viewer (`null`) can never satisfy followers/direct. Fails closed.
|
|
125
|
+
*/
|
|
126
|
+
export async function canViewerReadObjectFull(
|
|
127
|
+
db: Database,
|
|
128
|
+
obj: ReadGateObject,
|
|
129
|
+
viewerApId: string | null | undefined,
|
|
130
|
+
): Promise<boolean> {
|
|
131
|
+
const now = new Date().toISOString();
|
|
132
|
+
|
|
133
|
+
// Story author + expiry shortcuts need no community/follow query.
|
|
134
|
+
if (obj.type === "Story") {
|
|
135
|
+
if (viewerApId && obj.attributedTo === viewerApId) return true;
|
|
136
|
+
if (obj.endTime && obj.endTime <= now) return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Private-community membership gate first (non-community objects short to true).
|
|
140
|
+
if (
|
|
141
|
+
!(await canViewerReadObject(
|
|
142
|
+
db,
|
|
143
|
+
{ audienceJson: obj.audienceJson, communityApId: obj.communityApId },
|
|
144
|
+
viewerApId,
|
|
145
|
+
))
|
|
146
|
+
) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Resolve the single accepted-follow edge only when a follower-gated branch
|
|
151
|
+
// actually needs it (personal story, or a followers-only post with no explicit
|
|
152
|
+
// to/cc recipient), then defer to the shared per-post predicate.
|
|
153
|
+
const needsFollow =
|
|
154
|
+
!!viewerApId &&
|
|
155
|
+
obj.attributedTo !== viewerApId &&
|
|
156
|
+
((obj.type === "Story" && !obj.communityApId) ||
|
|
157
|
+
(obj.type !== "Story" &&
|
|
158
|
+
obj.visibility === "followers" &&
|
|
159
|
+
!isExplicitRecipient(obj, viewerApId)));
|
|
160
|
+
const following = needsFollow
|
|
161
|
+
? await hasAcceptedFollow(db, viewerApId, obj.attributedTo)
|
|
162
|
+
: false;
|
|
163
|
+
|
|
164
|
+
return passesPostVisibilitySync(obj, viewerApId, () => following, now);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* True if `targetApId` (a post author / follow target) has blocked `actorApId`.
|
|
169
|
+
* Callers reject the interaction (like / repost / follow) with a 404 so a blocked
|
|
170
|
+
* actor cannot bump the target's counts, establish a follow edge, or insert into
|
|
171
|
+
* the target's inbox — and the 404 (not 403) avoids leaking the block. Mirrors
|
|
172
|
+
* the inline guard already used by the story-like and DM-send paths.
|
|
173
|
+
*/
|
|
174
|
+
export async function actorIsBlockedBy(
|
|
175
|
+
db: Database,
|
|
176
|
+
targetApId: string,
|
|
177
|
+
actorApId: string,
|
|
178
|
+
): Promise<boolean> {
|
|
179
|
+
const row = await db
|
|
180
|
+
.select({ blockerApId: blocks.blockerApId })
|
|
181
|
+
.from(blocks)
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(blocks.blockerApId, targetApId),
|
|
185
|
+
eq(blocks.blockedApId, actorApId),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
.get();
|
|
189
|
+
return Boolean(row);
|
|
190
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { getCookie } from "hono/cookie";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import type { Actor, Env, Variables } from "../types.ts";
|
|
5
|
+
import { sessions } from "../../db/index.ts";
|
|
6
|
+
import { hashSessionIdForEnv } from "./crypto.ts";
|
|
7
|
+
|
|
8
|
+
function isExpired(expiresAt: string): boolean {
|
|
9
|
+
const expiresMs = Date.parse(expiresAt);
|
|
10
|
+
return !Number.isFinite(expiresMs) || expiresMs <= Date.now();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Look up the session cookie, load the associated member, and set `c.var.actor`
|
|
15
|
+
* if the session is valid and unexpired.
|
|
16
|
+
*/
|
|
17
|
+
export async function extractActorFromSession(
|
|
18
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const sessionId = getCookie(c, "session");
|
|
21
|
+
if (!sessionId) return;
|
|
22
|
+
|
|
23
|
+
const db = c.get("db");
|
|
24
|
+
const sessionKey = await hashSessionIdForEnv(c.env, sessionId);
|
|
25
|
+
const session = await db.query.sessions.findFirst({
|
|
26
|
+
where: eq(sessions.id, sessionKey),
|
|
27
|
+
with: { member: true },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!session || isExpired(session.expiresAt)) return;
|
|
31
|
+
|
|
32
|
+
const m = session.member;
|
|
33
|
+
// A tombstoned actor (account-deletion soft-delete: `deletedAt` set) must
|
|
34
|
+
// never resolve to a live session actor, even if a stale session row somehow
|
|
35
|
+
// survived teardown. Account deletion deletes the actor's sessions, but this
|
|
36
|
+
// guard fail-closes so a tombstone can never be re-inhabited via a session.
|
|
37
|
+
if (!m || m.deletedAt != null) return;
|
|
38
|
+
const actor: Actor = {
|
|
39
|
+
ap_id: m.apId,
|
|
40
|
+
type: m.type,
|
|
41
|
+
preferred_username: m.preferredUsername,
|
|
42
|
+
name: m.name,
|
|
43
|
+
summary: m.summary,
|
|
44
|
+
icon_url: m.iconUrl,
|
|
45
|
+
header_url: m.headerUrl,
|
|
46
|
+
inbox: m.inbox,
|
|
47
|
+
outbox: m.outbox,
|
|
48
|
+
followers_url: m.followersUrl,
|
|
49
|
+
following_url: m.followingUrl,
|
|
50
|
+
public_key_pem: m.publicKeyPem,
|
|
51
|
+
private_key_pem: m.privateKeyPem,
|
|
52
|
+
takos_user_id: m.takosUserId,
|
|
53
|
+
follower_count: m.followerCount,
|
|
54
|
+
following_count: m.followingCount,
|
|
55
|
+
post_count: m.postCount,
|
|
56
|
+
is_private: m.isPrivate,
|
|
57
|
+
role: m.role as "owner" | "moderator" | "member",
|
|
58
|
+
created_at: m.createdAt,
|
|
59
|
+
};
|
|
60
|
+
c.set("actor", actor);
|
|
61
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/i;
|
|
2
|
+
const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
|
|
3
|
+
// DoH lookups gate every outbound federation request. A stalled DoH call
|
|
4
|
+
// would let an unreachable upstream hang inbox/delivery loops, so cap each
|
|
5
|
+
// lookup at 5s.
|
|
6
|
+
const DOH_TIMEOUT_MS = 5_000;
|
|
7
|
+
const LOCAL_SUBSTRATE_REMOTE_FETCH_ENV =
|
|
8
|
+
"YURUCOMMU_ENABLE_LOCAL_SUBSTRATE_REMOTE_FETCHES";
|
|
9
|
+
|
|
10
|
+
type DnsRecordType = "A" | "AAAA";
|
|
11
|
+
|
|
12
|
+
export type RemoteUrlSafetyOptions = {
|
|
13
|
+
allowLocalSubstrateRemoteFetches?: boolean;
|
|
14
|
+
localResolver?: (
|
|
15
|
+
hostname: string,
|
|
16
|
+
recordType: DnsRecordType,
|
|
17
|
+
) => Promise<string[]>;
|
|
18
|
+
remoteResolver?: (hostname: string) => Promise<string[]>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function parseIPv4(hostname: string): number[] | null {
|
|
22
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return null;
|
|
23
|
+
const parts = hostname.split(".").map((part) => Number(part));
|
|
24
|
+
if (parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return parts;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPrivateIPv4(hostname: string): boolean {
|
|
31
|
+
const parts = parseIPv4(hostname);
|
|
32
|
+
if (!parts) return false;
|
|
33
|
+
const [a, b, c] = parts;
|
|
34
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
35
|
+
if (a === 169 && b === 254) return true;
|
|
36
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
37
|
+
if (a === 192 && b === 168) return true;
|
|
38
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
39
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
40
|
+
if (a === 192 && b === 0 && c === 2) return true;
|
|
41
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
42
|
+
if (a === 198 && b === 51 && c === 100) return true;
|
|
43
|
+
if (a === 203 && b === 0 && c === 113) return true;
|
|
44
|
+
if (a >= 224) return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PRIVATE_IPV6_EXACT = ["::1", "0:0:0:0:0:0:0:1", "::", "0:0:0:0:0:0:0:0"];
|
|
49
|
+
// fc/fd = unique-local; fe8/fe9/fea/feb = link-local; fec/fed/fee/fef =
|
|
50
|
+
// deprecated site-local; ff = multicast. Used ONLY as a textual fallback when
|
|
51
|
+
// the address cannot be expanded (the numeric classifier below is canonical).
|
|
52
|
+
const PRIVATE_IPV6_PREFIXES = [
|
|
53
|
+
"fc",
|
|
54
|
+
"fd",
|
|
55
|
+
"fe8",
|
|
56
|
+
"fe9",
|
|
57
|
+
"fea",
|
|
58
|
+
"feb",
|
|
59
|
+
"fec",
|
|
60
|
+
"fed",
|
|
61
|
+
"fee",
|
|
62
|
+
"fef",
|
|
63
|
+
"ff",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Expand an IPv6 textual address to its 8 numeric hextets, resolving `::`
|
|
68
|
+
* compression and a trailing dotted-IPv4 tail (e.g. ::ffff:127.0.0.1 or
|
|
69
|
+
* 64:ff9b::1.2.3.4). Returns null if `input` is not a well-formed IPv6 literal.
|
|
70
|
+
* This canonicalization is what lets the classifier treat every encoding of an
|
|
71
|
+
* embedded IPv4 (mapped hex/dotted, IPv4-compatible, NAT64, 6to4) uniformly.
|
|
72
|
+
*/
|
|
73
|
+
function expandIPv6(input: string): number[] | null {
|
|
74
|
+
let s = input.toLowerCase().replace(/^\[|\]$/g, "");
|
|
75
|
+
const zone = s.indexOf("%");
|
|
76
|
+
if (zone !== -1) s = s.slice(0, zone);
|
|
77
|
+
if (s.length === 0) return null;
|
|
78
|
+
|
|
79
|
+
// Fold a trailing dotted-IPv4 tail into two hex groups so the rest of the
|
|
80
|
+
// parse is uniform regardless of the surrounding IPv6 prefix.
|
|
81
|
+
if (s.includes(".")) {
|
|
82
|
+
const m = s.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
83
|
+
if (!m) return null;
|
|
84
|
+
const v4 = parseIPv4(m[2]);
|
|
85
|
+
if (!v4) return null;
|
|
86
|
+
const h1 = ((v4[0] << 8) | v4[1]).toString(16);
|
|
87
|
+
const h2 = ((v4[2] << 8) | v4[3]).toString(16);
|
|
88
|
+
s = `${m[1]}${h1}:${h2}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const doubleIdx = s.indexOf("::");
|
|
92
|
+
if (doubleIdx !== s.lastIndexOf("::")) return null; // at most one "::"
|
|
93
|
+
|
|
94
|
+
const parseGroups = (str: string): number[] | null => {
|
|
95
|
+
if (str === "") return [];
|
|
96
|
+
const out: number[] = [];
|
|
97
|
+
for (const g of str.split(":")) {
|
|
98
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
99
|
+
out.push(parseInt(g, 16));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
let groups: number[];
|
|
105
|
+
if (doubleIdx !== -1) {
|
|
106
|
+
const head = parseGroups(s.slice(0, doubleIdx));
|
|
107
|
+
const tail = parseGroups(s.slice(doubleIdx + 2));
|
|
108
|
+
if (head === null || tail === null) return null;
|
|
109
|
+
const missing = 8 - head.length - tail.length;
|
|
110
|
+
if (missing < 1) return null; // "::" must stand for >= 1 zero group
|
|
111
|
+
groups = [...head, ...new Array(missing).fill(0), ...tail];
|
|
112
|
+
} else {
|
|
113
|
+
const all = parseGroups(s);
|
|
114
|
+
if (all === null) return null;
|
|
115
|
+
groups = all;
|
|
116
|
+
}
|
|
117
|
+
return groups.length === 8 ? groups : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function embeddedV4IsPrivate(hi: number, lo: number): boolean {
|
|
121
|
+
return isPrivateIPv4(
|
|
122
|
+
`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isPrivateIPv6(ipv6Raw: string): boolean {
|
|
127
|
+
const g = expandIPv6(ipv6Raw);
|
|
128
|
+
if (!g) {
|
|
129
|
+
// Unparseable: fall back to the conservative textual checks so a form the
|
|
130
|
+
// string matcher caught is never regressed.
|
|
131
|
+
const s = ipv6Raw.toLowerCase().replace(/^\[|\]$/g, "");
|
|
132
|
+
if (PRIVATE_IPV6_EXACT.includes(s)) return true;
|
|
133
|
+
return PRIVATE_IPV6_PREFIXES.some((prefix) => s.startsWith(prefix));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const allZeroHigh =
|
|
137
|
+
g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
138
|
+
// :: (unspecified) and ::1 (loopback)
|
|
139
|
+
if (allZeroHigh && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const hi8 = g[0] >> 8;
|
|
144
|
+
if (hi8 === 0xfc || hi8 === 0xfd) return true; // fc00::/7 unique-local
|
|
145
|
+
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
146
|
+
if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local (deprecated)
|
|
147
|
+
if (hi8 === 0xff) return true; // ff00::/8 multicast
|
|
148
|
+
|
|
149
|
+
// Embedded-IPv4 transition ranges — decode the embedded IPv4 and classify it,
|
|
150
|
+
// so ::ffff:7f00:1, ::127.0.0.1, 64:ff9b::7f00:1 and 2002:7f00:1:: are all
|
|
151
|
+
// recognized as 127.0.0.1 etc.
|
|
152
|
+
if (allZeroHigh && g[5] === 0xffff) return embeddedV4IsPrivate(g[6], g[7]); // IPv4-mapped
|
|
153
|
+
if (allZeroHigh && g[5] === 0) return embeddedV4IsPrivate(g[6], g[7]); // IPv4-compatible (::/96)
|
|
154
|
+
if (g[0] === 0x64 && g[1] === 0xff9b) return embeddedV4IsPrivate(g[6], g[7]); // NAT64 64:ff9b::/96
|
|
155
|
+
if (g[0] === 0x2002) return embeddedV4IsPrivate(g[1], g[2]); // 6to4 2002:V4::/16
|
|
156
|
+
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* True if `host` is a well-formed IPv4 or IPv6 literal. Used to reject
|
|
162
|
+
* unparseable DNS RDATA before the private-IP classifier runs, so a malformed
|
|
163
|
+
* or ambiguous resolved-IP string can never slip past as "not private".
|
|
164
|
+
*/
|
|
165
|
+
export function isWellFormedIp(host: string): boolean {
|
|
166
|
+
if (parseIPv4(host)) return true;
|
|
167
|
+
if (host.includes(":")) return expandIPv6(host) !== null;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isPrivateIpAddress(host: string): boolean {
|
|
172
|
+
if (isPrivateIPv4(host)) return true;
|
|
173
|
+
if (host.includes(":")) return isPrivateIPv6(host);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function normalizeHostname(hostname: string): string {
|
|
178
|
+
const normalized = hostname.trim().toLowerCase();
|
|
179
|
+
return normalized.endsWith(".") ? normalized.slice(0, -1) : normalized;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isTruthyEnv(value: string | undefined): boolean {
|
|
183
|
+
if (!value) return false;
|
|
184
|
+
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function localSubstrateRemoteFetchesEnabled(): boolean {
|
|
188
|
+
const processEnv = (
|
|
189
|
+
globalThis as {
|
|
190
|
+
process?: { env?: Record<string, string | undefined> };
|
|
191
|
+
}
|
|
192
|
+
).process?.env;
|
|
193
|
+
return isTruthyEnv(processEnv?.[LOCAL_SUBSTRATE_REMOTE_FETCH_ENV]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const BLOCKED_HOSTNAME_SUFFIXES = [
|
|
197
|
+
".localhost",
|
|
198
|
+
".local",
|
|
199
|
+
".localdomain",
|
|
200
|
+
".internal",
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
function isBlockedHostname(hostname: string): boolean {
|
|
204
|
+
const lower = normalizeHostname(hostname);
|
|
205
|
+
if (lower === "localhost") return true;
|
|
206
|
+
if (BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => lower.endsWith(suffix))) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
return isPrivateIpAddress(lower);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function isSafeRemoteUrl(url: string): boolean {
|
|
213
|
+
try {
|
|
214
|
+
const parsed = new URL(url);
|
|
215
|
+
if (parsed.username || parsed.password) return false;
|
|
216
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
if (!HOSTNAME_PATTERN.test(parsed.hostname)) return false;
|
|
220
|
+
if (!parsed.hostname.includes(".")) return false;
|
|
221
|
+
if (isBlockedHostname(parsed.hostname)) return false;
|
|
222
|
+
return true;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function normalizeRemoteDomain(domain: string): string | null {
|
|
229
|
+
const trimmed = domain.trim();
|
|
230
|
+
if (!trimmed) return null;
|
|
231
|
+
try {
|
|
232
|
+
const parsed = new URL(`https://${trimmed}`);
|
|
233
|
+
if (parsed.username || parsed.password) return null;
|
|
234
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
|
|
235
|
+
const hostname = parsed.hostname;
|
|
236
|
+
if (!HOSTNAME_PATTERN.test(hostname)) return null;
|
|
237
|
+
if (!hostname.includes(".")) return null;
|
|
238
|
+
if (isBlockedHostname(hostname)) return null;
|
|
239
|
+
return parsed.host;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function dohResolve(
|
|
246
|
+
hostname: string,
|
|
247
|
+
type: "A" | "AAAA" | "CNAME",
|
|
248
|
+
): Promise<Array<{ type: number; data: string }>> {
|
|
249
|
+
const response = await fetch(
|
|
250
|
+
`${DOH_ENDPOINT}?name=${encodeURIComponent(hostname)}&type=${type}`,
|
|
251
|
+
{
|
|
252
|
+
headers: { Accept: "application/dns-json" },
|
|
253
|
+
redirect: "manual",
|
|
254
|
+
signal: AbortSignal.timeout(DOH_TIMEOUT_MS),
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
throw new Error(`DoH lookup failed (${response.status})`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const json = (await response.json()) as {
|
|
263
|
+
Answer?: Array<{ type?: number; data?: string }>;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return (json.Answer ?? []).filter(
|
|
267
|
+
(answer): answer is { type: number; data: string } =>
|
|
268
|
+
typeof answer.type === "number" && typeof answer.data === "string",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function resolveRemoteHostnameIPs(
|
|
273
|
+
hostname: string,
|
|
274
|
+
): Promise<string[]> {
|
|
275
|
+
const visited = new Set<string>();
|
|
276
|
+
const ips = new Set<string>();
|
|
277
|
+
|
|
278
|
+
async function walk(name: string, depth: number): Promise<void> {
|
|
279
|
+
if (depth > 10) {
|
|
280
|
+
throw new Error("DNS resolution exceeded max depth");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const normalized = normalizeHostname(name);
|
|
284
|
+
if (visited.has(normalized)) return;
|
|
285
|
+
visited.add(normalized);
|
|
286
|
+
|
|
287
|
+
const [aAnswers, aaaaAnswers, cnameAnswers] = await Promise.all([
|
|
288
|
+
dohResolve(normalized, "A"),
|
|
289
|
+
dohResolve(normalized, "AAAA"),
|
|
290
|
+
dohResolve(normalized, "CNAME"),
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
// type 1 = A record, type 28 = AAAA record
|
|
294
|
+
for (const answer of [...aAnswers, ...aaaaAnswers]) {
|
|
295
|
+
if (answer.type === 1 || answer.type === 28) ips.add(answer.data);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const answer of cnameAnswers) {
|
|
299
|
+
if (answer.type === 5) await walk(answer.data, depth + 1);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await walk(hostname, 0);
|
|
304
|
+
return Array.from(ips);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function isTakosTestHostname(hostname: string): boolean {
|
|
308
|
+
const normalized = normalizeHostname(hostname);
|
|
309
|
+
return normalized === "takos.test" || normalized.endsWith(".takos.test");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isLocalSubstrateUrlShape(parsed: URL): boolean {
|
|
313
|
+
return (
|
|
314
|
+
parsed.protocol === "https:" &&
|
|
315
|
+
(parsed.port === "" || parsed.port === "443")
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isAllowedLocalSubstrateIp(ip: string): boolean {
|
|
320
|
+
const parts = parseIPv4(ip);
|
|
321
|
+
if (!parts) return false;
|
|
322
|
+
const [a, b, c, d] = parts;
|
|
323
|
+
if (a === 127 && b === 0 && c === 0 && d === 1) return true;
|
|
324
|
+
return a === 172 && b >= 16 && b <= 31;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function resolveLocalSubstrateHostnameIPs(
|
|
328
|
+
hostname: string,
|
|
329
|
+
resolver?: RemoteUrlSafetyOptions["localResolver"],
|
|
330
|
+
): Promise<string[]> {
|
|
331
|
+
const resolve =
|
|
332
|
+
resolver ??
|
|
333
|
+
(async (name: string, recordType: DnsRecordType): Promise<string[]> => {
|
|
334
|
+
return await nodeLookupByRecordType(name, recordType);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const [aRecords, aaaaRecords] = await Promise.all([
|
|
338
|
+
resolve(hostname, "A"),
|
|
339
|
+
resolve(hostname, "AAAA"),
|
|
340
|
+
]);
|
|
341
|
+
return [...aRecords, ...aaaaRecords];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve `url`'s hostname and assert every resolved IP is public (or, in
|
|
346
|
+
* local-substrate mode, inside the local-substrate allowlist). On success
|
|
347
|
+
* returns the validated IP set so callers can PIN the actual connection to
|
|
348
|
+
* one of these exact IPs and avoid a second, independent DNS resolution.
|
|
349
|
+
*/
|
|
350
|
+
export async function assertSafeRemoteUrlResolved(
|
|
351
|
+
url: string,
|
|
352
|
+
options: RemoteUrlSafetyOptions = {},
|
|
353
|
+
): Promise<string[]> {
|
|
354
|
+
if (!isSafeRemoteUrl(url)) {
|
|
355
|
+
throw new Error(`Unsafe remote URL: ${url}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// isSafeRemoteUrl already validated the hostname is not blocked,
|
|
359
|
+
// so we only need to verify resolved IPs are not private.
|
|
360
|
+
const parsed = new URL(url);
|
|
361
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
362
|
+
const allowLocalSubstrate =
|
|
363
|
+
options.allowLocalSubstrateRemoteFetches ??
|
|
364
|
+
localSubstrateRemoteFetchesEnabled();
|
|
365
|
+
|
|
366
|
+
if (allowLocalSubstrate && isTakosTestHostname(hostname)) {
|
|
367
|
+
if (!isLocalSubstrateUrlShape(parsed)) {
|
|
368
|
+
throw new Error(`Unsafe local-substrate remote URL: ${url}`);
|
|
369
|
+
}
|
|
370
|
+
const resolvedIps = await resolveLocalSubstrateHostnameIPs(
|
|
371
|
+
hostname,
|
|
372
|
+
options.localResolver,
|
|
373
|
+
);
|
|
374
|
+
if (resolvedIps.length === 0) {
|
|
375
|
+
throw new Error(`Failed to resolve hostname: ${hostname}`);
|
|
376
|
+
}
|
|
377
|
+
for (const ip of resolvedIps) {
|
|
378
|
+
if (!isAllowedLocalSubstrateIp(ip)) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`Hostname ${hostname} resolved outside local-substrate allowlist: ${ip}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return resolvedIps;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const resolvedIps = await (
|
|
388
|
+
options.remoteResolver ?? resolveRemoteHostnameIPs
|
|
389
|
+
)(hostname);
|
|
390
|
+
if (resolvedIps.length === 0) {
|
|
391
|
+
throw new Error(`Failed to resolve hostname: ${hostname}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const ip of resolvedIps) {
|
|
395
|
+
// Reject unparseable RDATA first: an IP string the classifier cannot parse
|
|
396
|
+
// must NOT be allowed through as "not private" (fail closed).
|
|
397
|
+
if (!isWellFormedIp(ip)) {
|
|
398
|
+
throw new Error(`Hostname ${hostname} resolved to unparseable IP ${ip}`);
|
|
399
|
+
}
|
|
400
|
+
if (isPrivateIpAddress(ip)) {
|
|
401
|
+
throw new Error(`Hostname ${hostname} resolved to private IP ${ip}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return resolvedIps;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function nodeLookupAll(hostname: string): Promise<string[]> {
|
|
409
|
+
const { lookup } = await import("node:dns/promises");
|
|
410
|
+
const records = await lookup(hostname, { all: true });
|
|
411
|
+
return records.map((record) => record.address);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function nodeLookupByRecordType(
|
|
415
|
+
hostname: string,
|
|
416
|
+
recordType: DnsRecordType,
|
|
417
|
+
): Promise<string[]> {
|
|
418
|
+
try {
|
|
419
|
+
const { lookup } = await import("node:dns/promises");
|
|
420
|
+
const records = await lookup(hostname, {
|
|
421
|
+
all: true,
|
|
422
|
+
family: recordType === "A" ? 4 : 6,
|
|
423
|
+
});
|
|
424
|
+
return records.map((record) => record.address);
|
|
425
|
+
} catch {
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
}
|