@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,139 @@
|
|
|
1
|
+
export const DELIVERY_QUEUE_MESSAGE_VERSION = 1 as const;
|
|
2
|
+
|
|
3
|
+
export type DeliveryFanoutFollowersMessageV1 = {
|
|
4
|
+
version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
|
|
5
|
+
type: "fanout_followers";
|
|
6
|
+
activityId: string;
|
|
7
|
+
followeeApId: string;
|
|
8
|
+
scheduledAt: string; // ISO8601 UTC
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fan-out of an activity to a community's audience (members + community
|
|
13
|
+
* followers) rather than the author's personal follower graph. Used for
|
|
14
|
+
* community-scoped posts so reach == community, not author-followers.
|
|
15
|
+
*/
|
|
16
|
+
export type DeliveryFanoutCommunityMessageV1 = {
|
|
17
|
+
version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
|
|
18
|
+
type: "fanout_community";
|
|
19
|
+
activityId: string;
|
|
20
|
+
communityApId: string;
|
|
21
|
+
// The Group's Announce of the post (Announce-relay): delivered to REMOTE
|
|
22
|
+
// community followers in place of the raw author activity, so the post is
|
|
23
|
+
// attributed to the group (Lemmy/Mobilizon convention). Local members still
|
|
24
|
+
// receive `activityId`. Absent for non-Create activities (edit/delete relay
|
|
25
|
+
// the activity directly).
|
|
26
|
+
announceActivityId?: string;
|
|
27
|
+
scheduledAt: string; // ISO8601 UTC
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type DeliveryResolveActorMessageV1 = {
|
|
31
|
+
version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
|
|
32
|
+
type: "resolve_actor";
|
|
33
|
+
activityId: string;
|
|
34
|
+
recipientActorApId: string;
|
|
35
|
+
// How many resolve attempts this message represents. Each failed actor fetch
|
|
36
|
+
// re-enqueues with attempts+1; the handler gives up at a cap so a permanently
|
|
37
|
+
// unresolvable recipient does not generate a queue message forever. Absent on
|
|
38
|
+
// the initial enqueue (treated as 0).
|
|
39
|
+
attempts?: number;
|
|
40
|
+
scheduledAt: string; // ISO8601 UTC
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type DeliveryDeliverEndpointMessageV1 = {
|
|
44
|
+
version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
|
|
45
|
+
type: "deliver_endpoint";
|
|
46
|
+
jobId: string;
|
|
47
|
+
// How many reconcile cycles this job has already been through. Carried in-band
|
|
48
|
+
// (deliver_endpoint -> dlq -> reconcile_job -> deliver_endpoint) so the
|
|
49
|
+
// reconcile loop has a DURABLE-across-attempts terminal condition without a DB
|
|
50
|
+
// column. Absent on the initial delivery (treated as 0).
|
|
51
|
+
reconcileAttempt?: number;
|
|
52
|
+
scheduledAt: string; // ISO8601 UTC
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type DeliveryReconcileJobMessageV1 = {
|
|
56
|
+
version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
|
|
57
|
+
type: "reconcile_job";
|
|
58
|
+
jobId: string;
|
|
59
|
+
reconcileAttempt: number;
|
|
60
|
+
scheduledAt: string; // ISO8601 UTC
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type DeliveryQueueMessageV1 =
|
|
64
|
+
| DeliveryFanoutFollowersMessageV1
|
|
65
|
+
| DeliveryFanoutCommunityMessageV1
|
|
66
|
+
| DeliveryResolveActorMessageV1
|
|
67
|
+
| DeliveryDeliverEndpointMessageV1
|
|
68
|
+
| DeliveryReconcileJobMessageV1;
|
|
69
|
+
|
|
70
|
+
export type DeliveryDlqMessageV1 = {
|
|
71
|
+
version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
|
|
72
|
+
type: "dlq";
|
|
73
|
+
jobId: string;
|
|
74
|
+
activityId: string;
|
|
75
|
+
endpoint: string;
|
|
76
|
+
attempts: number;
|
|
77
|
+
lastError: string | null;
|
|
78
|
+
// The reconcile-cycle count carried from the deliver_endpoint that
|
|
79
|
+
// dead-lettered, so the DLQ consumer can stop reconciling once it hits the cap.
|
|
80
|
+
reconcileAttempt?: number;
|
|
81
|
+
deadLetteredAt: string; // ISO8601 UTC
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function isDeliveryQueueMessageV1(
|
|
85
|
+
value: unknown,
|
|
86
|
+
): value is DeliveryQueueMessageV1 {
|
|
87
|
+
if (!value || typeof value !== "object") return false;
|
|
88
|
+
const v = value as Record<string, unknown>;
|
|
89
|
+
if (v.version !== DELIVERY_QUEUE_MESSAGE_VERSION) return false;
|
|
90
|
+
if (typeof v.type !== "string") return false;
|
|
91
|
+
|
|
92
|
+
switch (v.type) {
|
|
93
|
+
case "fanout_followers":
|
|
94
|
+
return (
|
|
95
|
+
typeof v.activityId === "string" &&
|
|
96
|
+
typeof v.followeeApId === "string" &&
|
|
97
|
+
typeof v.scheduledAt === "string"
|
|
98
|
+
);
|
|
99
|
+
case "fanout_community":
|
|
100
|
+
return (
|
|
101
|
+
typeof v.activityId === "string" &&
|
|
102
|
+
typeof v.communityApId === "string" &&
|
|
103
|
+
typeof v.scheduledAt === "string"
|
|
104
|
+
);
|
|
105
|
+
case "resolve_actor":
|
|
106
|
+
return (
|
|
107
|
+
typeof v.activityId === "string" &&
|
|
108
|
+
typeof v.recipientActorApId === "string" &&
|
|
109
|
+
typeof v.scheduledAt === "string"
|
|
110
|
+
);
|
|
111
|
+
case "deliver_endpoint":
|
|
112
|
+
return typeof v.jobId === "string" && typeof v.scheduledAt === "string";
|
|
113
|
+
case "reconcile_job":
|
|
114
|
+
return (
|
|
115
|
+
typeof v.jobId === "string" &&
|
|
116
|
+
typeof v.reconcileAttempt === "number" &&
|
|
117
|
+
typeof v.scheduledAt === "string"
|
|
118
|
+
);
|
|
119
|
+
default:
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isDeliveryDlqMessageV1(
|
|
125
|
+
value: unknown,
|
|
126
|
+
): value is DeliveryDlqMessageV1 {
|
|
127
|
+
if (!value || typeof value !== "object") return false;
|
|
128
|
+
const v = value as Record<string, unknown>;
|
|
129
|
+
return (
|
|
130
|
+
v.version === DELIVERY_QUEUE_MESSAGE_VERSION &&
|
|
131
|
+
v.type === "dlq" &&
|
|
132
|
+
typeof v.jobId === "string" &&
|
|
133
|
+
typeof v.activityId === "string" &&
|
|
134
|
+
typeof v.endpoint === "string" &&
|
|
135
|
+
typeof v.attempts === "number" &&
|
|
136
|
+
(v.lastError === null || typeof v.lastError === "string") &&
|
|
137
|
+
typeof v.deadLetteredAt === "string"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standardized error handling for Yurucommu.
|
|
3
|
+
* All errors extend AppError with code, message, statusCode, and optional details.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from "./logger.ts";
|
|
7
|
+
import { maskSensitiveData, maskSensitiveString } from "./log-mask.ts";
|
|
8
|
+
|
|
9
|
+
const log = logger.child({ component: "errors" });
|
|
10
|
+
|
|
11
|
+
const ErrorCodes = {
|
|
12
|
+
INTERNAL_ERROR: "INTERNAL_ERROR",
|
|
13
|
+
BAD_REQUEST: "BAD_REQUEST",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
|
17
|
+
|
|
18
|
+
// FLAT error envelope: `error` is the human-readable message STRING, matching
|
|
19
|
+
// the ~357 per-route handlers (which all return `{ error: "..." }`) and the web
|
|
20
|
+
// client parser (extractErrorMessage reads `data.error` as a string). A nested
|
|
21
|
+
// `{ error: { code, message } }` here would render as "[object Object]" client-
|
|
22
|
+
// side on every 500 / malformed-body 400. `code`/`correlation_id` ride alongside.
|
|
23
|
+
interface ErrorResponse {
|
|
24
|
+
error: string;
|
|
25
|
+
code: string;
|
|
26
|
+
correlation_id?: string;
|
|
27
|
+
details?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class AppError extends Error {
|
|
31
|
+
public readonly code: ErrorCode;
|
|
32
|
+
public readonly statusCode: number;
|
|
33
|
+
public readonly details?: unknown;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
message: string,
|
|
37
|
+
code: ErrorCode = ErrorCodes.INTERNAL_ERROR,
|
|
38
|
+
statusCode = 500,
|
|
39
|
+
details?: unknown,
|
|
40
|
+
) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = this.constructor.name;
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.statusCode = statusCode;
|
|
45
|
+
this.details = details;
|
|
46
|
+
Error.captureStackTrace?.(this, this.constructor);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toResponse(): ErrorResponse {
|
|
50
|
+
return {
|
|
51
|
+
error: this.message,
|
|
52
|
+
code: this.code,
|
|
53
|
+
...(this.details !== undefined && { details: this.details }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Concrete error classes ---
|
|
59
|
+
// Each binds a fixed error code and HTTP status to AppError.
|
|
60
|
+
// InternalError is the only subclass with an actual `new` site
|
|
61
|
+
// (error-handler.ts resolveAppError fallback).
|
|
62
|
+
|
|
63
|
+
export class InternalError extends AppError {
|
|
64
|
+
constructor(message = "Internal server error", details?: unknown) {
|
|
65
|
+
super(message, ErrorCodes.INTERNAL_ERROR, 500, details);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class BadRequestError extends AppError {
|
|
70
|
+
constructor(message = "Bad request", details?: unknown) {
|
|
71
|
+
super(message, ErrorCodes.BAD_REQUEST, 400, details);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Utility functions ---
|
|
76
|
+
|
|
77
|
+
export function isAppError(error: unknown): error is AppError {
|
|
78
|
+
return error instanceof AppError;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function logError(
|
|
82
|
+
error: unknown,
|
|
83
|
+
context?: Record<string, unknown>,
|
|
84
|
+
): void {
|
|
85
|
+
// Pass message / stack / details through the PII masker before
|
|
86
|
+
// emitting. The logger applies the same masker again as a safety net,
|
|
87
|
+
// but masking here keeps the structured `details` shape (which may be
|
|
88
|
+
// a nested object) honest even if logger transports change.
|
|
89
|
+
const errorInfo = isAppError(error)
|
|
90
|
+
? {
|
|
91
|
+
name: error.name,
|
|
92
|
+
code: error.code,
|
|
93
|
+
message: maskSensitiveString(error.message),
|
|
94
|
+
statusCode: error.statusCode,
|
|
95
|
+
details: maskSensitiveData(error.details),
|
|
96
|
+
stack: error.stack ? maskSensitiveString(error.stack) : undefined,
|
|
97
|
+
}
|
|
98
|
+
: {
|
|
99
|
+
message:
|
|
100
|
+
error instanceof Error
|
|
101
|
+
? maskSensitiveString(error.message)
|
|
102
|
+
: maskSensitiveString(String(error)),
|
|
103
|
+
stack:
|
|
104
|
+
error instanceof Error && error.stack
|
|
105
|
+
? maskSensitiveString(error.stack)
|
|
106
|
+
: undefined,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
log.error("AppError", {
|
|
110
|
+
event: "app.error",
|
|
111
|
+
...errorInfo,
|
|
112
|
+
context: context ? maskSensitiveData(context) : undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertSafeRemoteUrlResolved,
|
|
3
|
+
isTakosTestHostname,
|
|
4
|
+
localSubstrateRemoteFetchesEnabled,
|
|
5
|
+
nodeLookupAll,
|
|
6
|
+
normalizeHostname,
|
|
7
|
+
resolveRemoteHostnameIPs,
|
|
8
|
+
} from "./ssrf.ts";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
// Upper bound on the body of any remote federation fetch (actor / object /
|
|
13
|
+
// WebFinger documents). These are small JSON documents in practice, so a
|
|
14
|
+
// couple of MiB is generous; the cap exists to stop a malicious or buggy
|
|
15
|
+
// remote from streaming a multi-GB / never-ending body into memory on the
|
|
16
|
+
// attacker-reachable, pre-auth fetchActorPublicKey hot path. Env-overridable
|
|
17
|
+
// for operators that federate with peers shipping unusually large actor docs.
|
|
18
|
+
const DEFAULT_MAX_FEDERATION_BODY_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const MAX_FEDERATION_BODY_BYTES_ENV = "YURUCOMMU_MAX_FEDERATION_BODY_BYTES";
|
|
20
|
+
|
|
21
|
+
function maxFederationBodyBytes(): number {
|
|
22
|
+
const processEnv = (
|
|
23
|
+
globalThis as {
|
|
24
|
+
process?: { env?: Record<string, string | undefined> };
|
|
25
|
+
}
|
|
26
|
+
).process?.env;
|
|
27
|
+
const raw = processEnv?.[MAX_FEDERATION_BODY_BYTES_ENV];
|
|
28
|
+
if (!raw) return DEFAULT_MAX_FEDERATION_BODY_BYTES;
|
|
29
|
+
const parsed = Number(raw);
|
|
30
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
31
|
+
return DEFAULT_MAX_FEDERATION_BODY_BYTES;
|
|
32
|
+
}
|
|
33
|
+
return Math.floor(parsed);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class FederationBodyTooLargeError extends Error {
|
|
37
|
+
constructor(
|
|
38
|
+
public readonly url: string,
|
|
39
|
+
public readonly limit: number,
|
|
40
|
+
) {
|
|
41
|
+
super(`Remote federation response body exceeded ${limit} bytes: ${url}`);
|
|
42
|
+
this.name = "FederationBodyTooLargeError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read `response`'s body fully into a byte buffer while enforcing a hard byte
|
|
48
|
+
* cap and keeping the read under `signal` (so the request timeout still bounds
|
|
49
|
+
* the body, not just the headers). Rejects early on an oversized Content-Length
|
|
50
|
+
* and otherwise streams the body counting bytes, aborting once the cap is
|
|
51
|
+
* exceeded.
|
|
52
|
+
*
|
|
53
|
+
* Mirrors the capped-reader discipline used elsewhere for tenant-influenced
|
|
54
|
+
* fetches (e.g. the inbox MAX_PAYLOAD_BYTES guard and the node-postgres /
|
|
55
|
+
* git-fetch capped readers).
|
|
56
|
+
*/
|
|
57
|
+
async function readBodyBytesWithCap(
|
|
58
|
+
response: Response,
|
|
59
|
+
url: string,
|
|
60
|
+
limit: number,
|
|
61
|
+
signal: AbortSignal,
|
|
62
|
+
): Promise<Uint8Array> {
|
|
63
|
+
// Short-circuit on an honest, oversized Content-Length before reading.
|
|
64
|
+
const contentLengthHeader = response.headers.get("content-length");
|
|
65
|
+
if (contentLengthHeader) {
|
|
66
|
+
const declared = Number(contentLengthHeader);
|
|
67
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
68
|
+
// Drain/cancel so the connection isn't left half-read.
|
|
69
|
+
try {
|
|
70
|
+
await response.body?.cancel();
|
|
71
|
+
} catch {
|
|
72
|
+
/* ignore cancel failures */
|
|
73
|
+
}
|
|
74
|
+
throw new FederationBodyTooLargeError(url, limit);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A consumed/absent body (e.g. 204) reads as empty bytes.
|
|
79
|
+
if (!response.body) {
|
|
80
|
+
return new Uint8Array(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (signal.aborted) {
|
|
84
|
+
try {
|
|
85
|
+
await response.body.cancel();
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("Aborted");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reader = response.body.getReader();
|
|
93
|
+
const chunks: Uint8Array[] = [];
|
|
94
|
+
let total = 0;
|
|
95
|
+
|
|
96
|
+
const onAbort = () => {
|
|
97
|
+
void reader.cancel(signal.reason).catch(() => {});
|
|
98
|
+
};
|
|
99
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
if (value) {
|
|
106
|
+
total += value.byteLength;
|
|
107
|
+
if (total > limit) {
|
|
108
|
+
await reader.cancel().catch(() => {});
|
|
109
|
+
throw new FederationBodyTooLargeError(url, limit);
|
|
110
|
+
}
|
|
111
|
+
chunks.push(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (signal.aborted && !(err instanceof FederationBodyTooLargeError)) {
|
|
116
|
+
throw signal.reason instanceof Error ? signal.reason : err;
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
} finally {
|
|
120
|
+
signal.removeEventListener("abort", onAbort);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const merged = new Uint8Array(total);
|
|
124
|
+
let offset = 0;
|
|
125
|
+
for (const chunk of chunks) {
|
|
126
|
+
merged.set(chunk, offset);
|
|
127
|
+
offset += chunk.byteLength;
|
|
128
|
+
}
|
|
129
|
+
return merged;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wrap a `Response` so `.json()` / `.text()` (and the other body accessors)
|
|
134
|
+
* read the body under a LIVE timeout + a hard size cap, instead of the raw
|
|
135
|
+
* `Response` whose body read is unbounded once the headers arrive. The wrapper
|
|
136
|
+
* delegates every non-body member (`ok`, `status`, `headers`, `url`, ...) to
|
|
137
|
+
* the underlying response, so all callers keep their existing `res.ok` /
|
|
138
|
+
* `res.status` / `res.json()` / `res.text()` usage with no signature churn.
|
|
139
|
+
*/
|
|
140
|
+
function wrapResponseWithCap(
|
|
141
|
+
response: Response,
|
|
142
|
+
url: string,
|
|
143
|
+
timeout: number,
|
|
144
|
+
): Response {
|
|
145
|
+
const limit = maxFederationBodyBytes();
|
|
146
|
+
let bodyConsumed = false;
|
|
147
|
+
|
|
148
|
+
const readBytes = async (): Promise<Uint8Array> => {
|
|
149
|
+
if (bodyConsumed) {
|
|
150
|
+
// Surface the native "Body already consumed" failure mode by reading the
|
|
151
|
+
// (already-locked) underlying body.
|
|
152
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
153
|
+
}
|
|
154
|
+
bodyConsumed = true;
|
|
155
|
+
// Fresh timeout covering ONLY the body read, so a slow/never-ending body
|
|
156
|
+
// is bounded even though the headers already arrived.
|
|
157
|
+
const bodyAbort = AbortSignal.timeout(timeout);
|
|
158
|
+
try {
|
|
159
|
+
return await readBodyBytesWithCap(response, url, limit, bodyAbort);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Response body read timed out after ${
|
|
164
|
+
timeout / 1000
|
|
165
|
+
} seconds: ${url}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const readText = async (): Promise<string> =>
|
|
173
|
+
new TextDecoder().decode(await readBytes());
|
|
174
|
+
|
|
175
|
+
return new Proxy(response, {
|
|
176
|
+
get(target, prop, _receiver) {
|
|
177
|
+
if (prop === "text") {
|
|
178
|
+
return () => readText();
|
|
179
|
+
}
|
|
180
|
+
if (prop === "json") {
|
|
181
|
+
return async () => JSON.parse(await readText());
|
|
182
|
+
}
|
|
183
|
+
if (prop === "arrayBuffer") {
|
|
184
|
+
return async () => {
|
|
185
|
+
const bytes = await readBytes();
|
|
186
|
+
// Return a standalone ArrayBuffer copy (the merged buffer may be a
|
|
187
|
+
// view into a larger allocation in some runtimes).
|
|
188
|
+
return bytes.slice().buffer;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const value = Reflect.get(target, prop, target);
|
|
192
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
193
|
+
},
|
|
194
|
+
}) as Response;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve a remote hostname using the SAME resolver `fetch` will use on this
|
|
199
|
+
* runtime, so the SSRF validation in `assertSafeRemoteUrlResolved` is not
|
|
200
|
+
* split from the resolution the actual connection performs.
|
|
201
|
+
*
|
|
202
|
+
* DNS-rebinding TOCTOU has two distinct vectors here:
|
|
203
|
+
*
|
|
204
|
+
* 1. Resolver split — validating via Cloudflare DoH while `fetch` connects
|
|
205
|
+
* using the host OS resolver. An attacker who controls authoritative DNS
|
|
206
|
+
* can deterministically serve a public IP to DoH and a private IP to the
|
|
207
|
+
* OS resolver. On a Bun/Node host, we close this deterministic split by
|
|
208
|
+
* validating with the host resolver instead of DoH, so validation and the
|
|
209
|
+
* connection both go through the
|
|
210
|
+
* host's configured DNS rather than two different trust domains. (This
|
|
211
|
+
* removes the resolver-split exploit; it does not by itself guarantee
|
|
212
|
+
* fetch reuses the identical IP — see vector 2.) On Workers `fetch`
|
|
213
|
+
* resolves at the edge with no host-OS resolver to diverge from, so DoH
|
|
214
|
+
* and the connection resolve in the same trust domain.
|
|
215
|
+
* 2. Low-TTL flip — the record changes between validation and connection.
|
|
216
|
+
* Neither Workers' nor host `fetch` exposes a hook to pin the
|
|
217
|
+
* connection to an already-resolved IP, so we cannot eliminate this
|
|
218
|
+
* sub-resolution window through `fetch`; we minimize it by resolving
|
|
219
|
+
* immediately before the request with no other awaited work in between.
|
|
220
|
+
*/
|
|
221
|
+
async function resolveConnectionResolverIPs(
|
|
222
|
+
hostname: string,
|
|
223
|
+
): Promise<string[]> {
|
|
224
|
+
const processLike = (globalThis as { process?: unknown }).process;
|
|
225
|
+
if (processLike) return await nodeLookupAll(hostname);
|
|
226
|
+
return resolveRemoteHostnameIPs(hostname);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function fetchWithTimeout(
|
|
230
|
+
url: string,
|
|
231
|
+
options: RequestInit & { timeout?: number; skipSafetyCheck?: boolean } = {},
|
|
232
|
+
): Promise<Response> {
|
|
233
|
+
const {
|
|
234
|
+
timeout = DEFAULT_FETCH_TIMEOUT_MS,
|
|
235
|
+
skipSafetyCheck = false,
|
|
236
|
+
...fetchOptions
|
|
237
|
+
} = options;
|
|
238
|
+
|
|
239
|
+
if (!skipSafetyCheck) {
|
|
240
|
+
// Validate using the resolver that `fetch` itself will use on this
|
|
241
|
+
// runtime (closes the resolver-split rebinding vector — see
|
|
242
|
+
// resolveConnectionResolverIPs). The local-substrate path keeps its own
|
|
243
|
+
// resolver logic, so only override the remote-resolver default here.
|
|
244
|
+
const parsed = new URL(url);
|
|
245
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
246
|
+
const allowLocalSubstrate = localSubstrateRemoteFetchesEnabled();
|
|
247
|
+
const useConnectionResolver = !(
|
|
248
|
+
allowLocalSubstrate && isTakosTestHostname(hostname)
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await assertSafeRemoteUrlResolved(
|
|
252
|
+
url,
|
|
253
|
+
useConnectionResolver
|
|
254
|
+
? { remoteResolver: resolveConnectionResolverIPs }
|
|
255
|
+
: {},
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const controller = new AbortController();
|
|
260
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const response = await fetch(url, {
|
|
264
|
+
...fetchOptions,
|
|
265
|
+
signal: controller.signal,
|
|
266
|
+
redirect: "manual", // Prevent redirect-based SSRF bypassing DNS safety checks
|
|
267
|
+
});
|
|
268
|
+
// Reject redirects to prevent SSRF via open redirects on remote servers
|
|
269
|
+
if (response.status >= 300 && response.status < 400) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Redirect not allowed from remote URL: ${url} -> ${response.headers.get(
|
|
272
|
+
"location",
|
|
273
|
+
)}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
// The headers-phase timer is cleared in `finally` below, but a raw
|
|
277
|
+
// `Response` then reads its body with NO time bound and NO size bound — on
|
|
278
|
+
// the attacker-reachable, pre-auth federation ingress (fetchActorPublicKey
|
|
279
|
+
// runs on every inbound activity) a malicious remote can stream a
|
|
280
|
+
// multi-GB / never-ending body and exhaust memory. Wrap the response so
|
|
281
|
+
// `.json()` / `.text()` read the body under a fresh timeout and a hard
|
|
282
|
+
// byte cap. Callers keep their `res.ok` / `res.status` / `res.json()` /
|
|
283
|
+
// `res.text()` usage unchanged; paths that never touch the body (outbound
|
|
284
|
+
// POST delivery) simply never trigger the capped read.
|
|
285
|
+
return wrapResponseWithCap(response, url, timeout);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Request timed out after ${timeout / 1000} seconds: ${url}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
throw err;
|
|
293
|
+
} finally {
|
|
294
|
+
clearTimeout(timeoutId);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Shared keyset cursor for `(published, apId)`-ordered feeds.
|
|
2
|
+
//
|
|
3
|
+
// Several list endpoints (replies, bookmarks, profile posts, DM + community
|
|
4
|
+
// chat, the MCP timeline) page back through rows ordered `desc(published),
|
|
5
|
+
// desc(apId)`. `published`/`created_at` is NOT unique (many rows can share a
|
|
6
|
+
// millisecond — local writes collide, and federated inbound timestamps are
|
|
7
|
+
// remote-controlled), so a cursor on the timestamp alone would skip the rows on
|
|
8
|
+
// either side of a page boundary that share the boundary's millisecond. The
|
|
9
|
+
// unique `apId` is the tiebreaker that makes the ordering total.
|
|
10
|
+
//
|
|
11
|
+
// The cursor is encoded as "<published> <apId>". A SPACE separator is used (NOT
|
|
12
|
+
// a raw NUL — typing a single-char separator in a string literal has repeatedly
|
|
13
|
+
// landed as a raw 0x00 byte, which corrupts the source file): a space sorts
|
|
14
|
+
// strictly below every character that can appear in an ISO-8601 timestamp or an
|
|
15
|
+
// https:// ap_id, so the concatenated lexical order matches the (published,
|
|
16
|
+
// apId) tuple order, and neither field can contain a space to break the split.
|
|
17
|
+
// The actual SQL comparison is a real column tuple predicate, so even a
|
|
18
|
+
// variable-width `published` is compared correctly.
|
|
19
|
+
|
|
20
|
+
import { and, eq, lt, or } from "drizzle-orm";
|
|
21
|
+
import type { SQL } from "drizzle-orm";
|
|
22
|
+
import type { AnyColumn } from "drizzle-orm";
|
|
23
|
+
|
|
24
|
+
export const FEED_CURSOR_SEP = " ";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encode a row's `(sortKey, tiebreaker)` into an opaque `before` cursor string.
|
|
28
|
+
* `sortKey` is the published/created timestamp; `tiebreaker` is the unique id
|
|
29
|
+
* (apId / objectApId) that makes the ordering total.
|
|
30
|
+
*/
|
|
31
|
+
export function encodeFeedCursor(sortKey: string, tiebreaker: string): string {
|
|
32
|
+
return `${sortKey}${FEED_CURSOR_SEP}${tiebreaker}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the WHERE predicate for "rows strictly older than `before`" against the
|
|
37
|
+
* given published/apId columns. A composite `before` ("<published> <apId>")
|
|
38
|
+
* yields the tuple predicate `published < p OR (published = p AND apId < a)`; a
|
|
39
|
+
* legacy bare-published `before` (no separator) falls back to `published < before`
|
|
40
|
+
* so older clients/cursors keep working. Returns `undefined` when `before` is
|
|
41
|
+
* absent (no cursor → no predicate).
|
|
42
|
+
*/
|
|
43
|
+
export function feedCursorWhere(
|
|
44
|
+
publishedCol: AnyColumn,
|
|
45
|
+
apIdCol: AnyColumn,
|
|
46
|
+
before: string | undefined | null,
|
|
47
|
+
): SQL | undefined {
|
|
48
|
+
if (!before) return undefined;
|
|
49
|
+
const sepIdx = before.indexOf(FEED_CURSOR_SEP);
|
|
50
|
+
if (sepIdx < 0) return lt(publishedCol, before);
|
|
51
|
+
const cPublished = before.slice(0, sepIdx);
|
|
52
|
+
const cApId = before.slice(sepIdx + FEED_CURSOR_SEP.length);
|
|
53
|
+
return or(
|
|
54
|
+
lt(publishedCol, cPublished),
|
|
55
|
+
and(eq(publishedCol, cPublished), lt(apIdCol, cApId)),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { and, type AnyColumn, eq, notInArray, type SQL } from "drizzle-orm";
|
|
2
|
+
import type { Database } from "../../db/index.ts";
|
|
3
|
+
import { blocks, mutes, objects } from "../../db/index.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Predicate excluding posts authored by anyone the viewer has blocked or muted.
|
|
7
|
+
*
|
|
8
|
+
* Expressed as `objects.attributed_to NOT IN (SELECT blocked) AND ... NOT IN
|
|
9
|
+
* (SELECT muted)` — i.e. `NOT IN (blocked ∪ muted)` — using db.select
|
|
10
|
+
* SUBQUERIES rather than materialising the id lists into an `inArray`. Each
|
|
11
|
+
* subquery binds a single bound parameter (the viewer) regardless of how many
|
|
12
|
+
* accounts the viewer has blocked/muted, so it never approaches Cloudflare D1's
|
|
13
|
+
* 100-bound-parameter-per-query ceiling. (The previous `notInArray(attributedTo,
|
|
14
|
+
* [...blocked, ...muted])` materialised up to ~2000 ids — fine on the libsql the
|
|
15
|
+
* tests run on, but a "too many SQL variables" 500 on production D1 for a heavy
|
|
16
|
+
* moderator.)
|
|
17
|
+
*
|
|
18
|
+
* Returns `undefined` for an empty viewer (anonymous) so callers can skip it.
|
|
19
|
+
* Applying it unconditionally for a logged-in viewer is correct even with zero
|
|
20
|
+
* blocks/mutes: `NOT IN (empty set)` is true for every row.
|
|
21
|
+
*
|
|
22
|
+
* `column` defaults to the post author (`objects.attributedTo`) for feeds, but
|
|
23
|
+
* can be any actor-id column — e.g. `activities.actorApId` so the notifications
|
|
24
|
+
* list/count can suppress activity from blocked/muted accounts the same way.
|
|
25
|
+
*/
|
|
26
|
+
export function excludeBlockedMutedAuthors(
|
|
27
|
+
db: Database,
|
|
28
|
+
viewerApId: string,
|
|
29
|
+
column: AnyColumn = objects.attributedTo,
|
|
30
|
+
): SQL | undefined {
|
|
31
|
+
if (!viewerApId) return undefined;
|
|
32
|
+
return and(
|
|
33
|
+
notInArray(
|
|
34
|
+
column,
|
|
35
|
+
db
|
|
36
|
+
.select({ id: blocks.blockedApId })
|
|
37
|
+
.from(blocks)
|
|
38
|
+
.where(eq(blocks.blockerApId, viewerApId)),
|
|
39
|
+
),
|
|
40
|
+
notInArray(
|
|
41
|
+
column,
|
|
42
|
+
db
|
|
43
|
+
.select({ id: mutes.mutedApId })
|
|
44
|
+
.from(mutes)
|
|
45
|
+
.where(eq(mutes.muterApId, viewerApId)),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}
|