@takosjp/yurucommu-core 3.4.0 → 3.4.3
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/migrations/0022_inbound_dispatch_claims.sql +17 -0
- package/package.json +3 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/notifications.ts +1 -0
- package/packages/api/src/lib/api/posts.ts +1 -0
- package/src/backend/index.ts +62 -12
- package/src/backend/lib/delivery/queue-batching.ts +53 -36
- package/src/backend/lib/delivery/queue-delivery.ts +3 -3
- package/src/backend/lib/delivery/queue.ts +13 -9
- package/src/backend/lib/delivery/types.ts +12 -0
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oauth-providers.ts +9 -0
- package/src/backend/lib/strip-image-metadata.ts +50 -30
- package/src/backend/middleware/bearer-auth.ts +24 -9
- package/src/backend/public.ts +38 -1
- package/src/backend/retention.ts +78 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
- package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
- package/src/backend/routes/activitypub/inbox-types.ts +8 -0
- package/src/backend/routes/activitypub/inbox.ts +410 -205
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/actors.ts +5 -5
- package/src/backend/routes/auth.ts +2 -1
- package/src/backend/routes/posts/post-helpers.ts +42 -23
- package/src/backend/routes/stories/routes.ts +5 -7
- package/src/backend/runtime/cloudflare.ts +63 -2
- package/src/backend/runtime/managed-relational.ts +197 -0
- package/src/backend/runtime/managed-runtime.ts +631 -0
- package/src/backend/runtime/queue.ts +40 -0
- package/src/backend/server.ts +15 -18
- package/src/backend/types.ts +9 -2
- package/src/db/d1-write.ts +270 -0
- package/src/db/index.ts +17 -0
- package/src/db/schema/federation.ts +19 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* re-encoding the pixels, so the visible image is unchanged.
|
|
11
11
|
*
|
|
12
12
|
* Pure byte-surgery (no native image library): it walks the container structure
|
|
13
|
-
* and drops only metadata segments/chunks, copying everything else verbatim.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* and drops only metadata segments/chunks, copying everything else verbatim.
|
|
14
|
+
* Supported containers fail closed on structural surprises: returning their
|
|
15
|
+
* original bytes would silently publish the metadata the parser failed to
|
|
16
|
+
* understand.
|
|
16
17
|
*
|
|
17
18
|
* Covered: JPEG (APP1 EXIF/XMP, APP13 IPTC, COM), PNG (tEXt/zTXt/iTXt/eXIf/tIME),
|
|
18
19
|
* WebP (EXIF / XMP chunks). GIF and video are passed through unchanged (GIF
|
|
@@ -24,36 +25,37 @@ export function stripImageMetadata(
|
|
|
24
25
|
bytes: Uint8Array,
|
|
25
26
|
mimeType: string,
|
|
26
27
|
): Uint8Array {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return bytes; // gif / video / unknown: unchanged
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
// Never let a parsing surprise corrupt or drop the upload.
|
|
40
|
-
return bytes;
|
|
28
|
+
switch (mimeType) {
|
|
29
|
+
case "image/jpeg":
|
|
30
|
+
return stripJpeg(bytes);
|
|
31
|
+
case "image/png":
|
|
32
|
+
return stripPng(bytes);
|
|
33
|
+
case "image/webp":
|
|
34
|
+
return stripWebp(bytes);
|
|
35
|
+
default:
|
|
36
|
+
return bytes; // gif / video / unknown: unchanged
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
39
|
|
|
40
|
+
function malformed(format: "JPEG" | "PNG" | "WebP"): never {
|
|
41
|
+
throw new Error(`Malformed ${format} image`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
// JPEG: a stream of marker segments. Drop APP1 (EXIF + XMP), APP13 (IPTC /
|
|
46
46
|
// Photoshop) and COM (comment); keep APP0 (JFIF), APP2 (ICC), APP14 (Adobe
|
|
47
47
|
// color transform), the quantization/Huffman tables, and the scan data verbatim.
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
49
|
function stripJpeg(bytes: Uint8Array): Uint8Array {
|
|
50
|
-
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
|
50
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
|
51
|
+
return malformed("JPEG");
|
|
52
|
+
}
|
|
51
53
|
|
|
52
54
|
const out: number[] = [0xff, 0xd8]; // SOI
|
|
53
55
|
let i = 2;
|
|
54
56
|
|
|
55
57
|
while (i + 1 < bytes.length) {
|
|
56
|
-
if (bytes[i] !== 0xff) return
|
|
58
|
+
if (bytes[i] !== 0xff) return malformed("JPEG");
|
|
57
59
|
const marker = bytes[i + 1];
|
|
58
60
|
|
|
59
61
|
// Start of Scan: entropy-coded data runs to EOI — copy the rest verbatim.
|
|
@@ -72,9 +74,9 @@ function stripJpeg(bytes: Uint8Array): Uint8Array {
|
|
|
72
74
|
i += 2;
|
|
73
75
|
continue;
|
|
74
76
|
}
|
|
75
|
-
if (i + 3 >= bytes.length) return
|
|
77
|
+
if (i + 3 >= bytes.length) return malformed("JPEG");
|
|
76
78
|
const len = (bytes[i + 2] << 8) | bytes[i + 3]; // includes the 2 length bytes
|
|
77
|
-
if (len < 2 || i + 2 + len > bytes.length) return
|
|
79
|
+
if (len < 2 || i + 2 + len > bytes.length) return malformed("JPEG");
|
|
78
80
|
|
|
79
81
|
const drop =
|
|
80
82
|
marker === 0xe1 || // APP1: EXIF + XMP (the GPS carriers)
|
|
@@ -85,7 +87,7 @@ function stripJpeg(bytes: Uint8Array): Uint8Array {
|
|
|
85
87
|
}
|
|
86
88
|
i += 2 + len;
|
|
87
89
|
}
|
|
88
|
-
return
|
|
90
|
+
return malformed("JPEG");
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// ---------------------------------------------------------------------------
|
|
@@ -96,8 +98,10 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
|
96
98
|
const PNG_DROP_CHUNKS = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME"]);
|
|
97
99
|
|
|
98
100
|
function stripPng(bytes: Uint8Array): Uint8Array {
|
|
99
|
-
if (bytes.length < 8) return
|
|
100
|
-
for (let i = 0; i < 8; i++)
|
|
101
|
+
if (bytes.length < 8) return malformed("PNG");
|
|
102
|
+
for (let i = 0; i < 8; i++) {
|
|
103
|
+
if (bytes[i] !== PNG_SIGNATURE[i]) return malformed("PNG");
|
|
104
|
+
}
|
|
101
105
|
|
|
102
106
|
const out: number[] = [...PNG_SIGNATURE];
|
|
103
107
|
let i = 8;
|
|
@@ -107,7 +111,7 @@ function stripPng(bytes: Uint8Array): Uint8Array {
|
|
|
107
111
|
(bytes[i + 1] << 16) |
|
|
108
112
|
(bytes[i + 2] << 8) |
|
|
109
113
|
bytes[i + 3];
|
|
110
|
-
if (len < 0) return
|
|
114
|
+
if (len < 0) return malformed("PNG");
|
|
111
115
|
const type = String.fromCharCode(
|
|
112
116
|
bytes[i + 4],
|
|
113
117
|
bytes[i + 5],
|
|
@@ -115,7 +119,7 @@ function stripPng(bytes: Uint8Array): Uint8Array {
|
|
|
115
119
|
bytes[i + 7],
|
|
116
120
|
);
|
|
117
121
|
const chunkEnd = i + 12 + len; // length(4) + type(4) + data(len) + crc(4)
|
|
118
|
-
if (chunkEnd > bytes.length) return
|
|
122
|
+
if (chunkEnd > bytes.length) return malformed("PNG");
|
|
119
123
|
|
|
120
124
|
if (!PNG_DROP_CHUNKS.has(type)) {
|
|
121
125
|
for (let k = i; k < chunkEnd; k++) out.push(bytes[k]);
|
|
@@ -123,9 +127,22 @@ function stripPng(bytes: Uint8Array): Uint8Array {
|
|
|
123
127
|
i = chunkEnd;
|
|
124
128
|
if (type === "IEND") break;
|
|
125
129
|
}
|
|
130
|
+
if (i !== bytes.length || !containsPngChunk(out, "IEND")) {
|
|
131
|
+
return malformed("PNG");
|
|
132
|
+
}
|
|
126
133
|
return Uint8Array.from(out);
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
function containsPngChunk(bytes: readonly number[], type: string): boolean {
|
|
137
|
+
const needle = [...type].map((character) => character.charCodeAt(0));
|
|
138
|
+
for (let i = 8; i + needle.length <= bytes.length; i++) {
|
|
139
|
+
if (needle.every((value, offset) => bytes[i + offset] === value)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
129
146
|
// ---------------------------------------------------------------------------
|
|
130
147
|
// WebP: a RIFF container ("RIFF" <size> "WEBP" <chunks>). Drop the "EXIF" and
|
|
131
148
|
// "XMP " chunks, clear the matching VP8X feature-flag bits, and rewrite the RIFF
|
|
@@ -142,8 +159,10 @@ function fourCC(bytes: Uint8Array, off: number): string {
|
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
function stripWebp(bytes: Uint8Array): Uint8Array {
|
|
145
|
-
if (bytes.length < 16) return
|
|
146
|
-
if (fourCC(bytes, 0) !== "RIFF" || fourCC(bytes, 8) !== "WEBP")
|
|
162
|
+
if (bytes.length < 16) return malformed("WebP");
|
|
163
|
+
if (fourCC(bytes, 0) !== "RIFF" || fourCC(bytes, 8) !== "WEBP") {
|
|
164
|
+
return malformed("WebP");
|
|
165
|
+
}
|
|
147
166
|
|
|
148
167
|
const head: number[] = [];
|
|
149
168
|
for (let k = 0; k < 12; k++) head.push(bytes[k]); // RIFF + size + WEBP
|
|
@@ -158,10 +177,10 @@ function stripWebp(bytes: Uint8Array): Uint8Array {
|
|
|
158
177
|
(bytes[i + 5] << 8) |
|
|
159
178
|
(bytes[i + 6] << 16) |
|
|
160
179
|
(bytes[i + 7] << 24);
|
|
161
|
-
if (size < 0) return
|
|
180
|
+
if (size < 0) return malformed("WebP");
|
|
162
181
|
const padded = size + (size & 1); // chunks pad to an even length
|
|
163
182
|
const chunkEnd = i + 8 + padded;
|
|
164
|
-
if (chunkEnd > bytes.length) return
|
|
183
|
+
if (chunkEnd > bytes.length) return malformed("WebP");
|
|
165
184
|
|
|
166
185
|
if (cc === "EXIF" || cc === "XMP ") {
|
|
167
186
|
removed = true; // skip this chunk entirely
|
|
@@ -170,6 +189,7 @@ function stripWebp(bytes: Uint8Array): Uint8Array {
|
|
|
170
189
|
}
|
|
171
190
|
i = chunkEnd;
|
|
172
191
|
}
|
|
192
|
+
if (i !== bytes.length) return malformed("WebP");
|
|
173
193
|
if (!removed) return bytes; // nothing to strip — keep original bytes
|
|
174
194
|
|
|
175
195
|
// Clear the EXIF (bit 3) / XMP (bit 2) feature flags in a VP8X header so the
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
issuerEndpoint,
|
|
7
7
|
} from "../lib/oauth-providers.ts";
|
|
8
8
|
|
|
9
|
+
const INTROSPECTION_TIMEOUT_MS = 5_000;
|
|
10
|
+
|
|
9
11
|
export function requireBearerAuth(
|
|
10
12
|
requiredScope: string,
|
|
11
13
|
): MiddlewareHandler<{ Bindings: Env; Variables: Variables }> {
|
|
@@ -27,15 +29,28 @@ export function requireBearerAuth(
|
|
|
27
29
|
);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
let res: Response;
|
|
33
|
+
try {
|
|
34
|
+
res = await fetch(issuerEndpoint(issuer, "/oauth/introspect"), {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
37
|
+
body: new URLSearchParams({
|
|
38
|
+
token,
|
|
39
|
+
client_id: clientId,
|
|
40
|
+
client_secret: clientSecret,
|
|
41
|
+
}).toString(),
|
|
42
|
+
signal: AbortSignal.timeout(INTROSPECTION_TIMEOUT_MS),
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
c.header("Retry-After", "5");
|
|
46
|
+
return c.json(
|
|
47
|
+
{
|
|
48
|
+
error: "temporarily_unavailable",
|
|
49
|
+
error_description: "Introspection request failed",
|
|
50
|
+
},
|
|
51
|
+
503,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
39
54
|
if (!res.ok) {
|
|
40
55
|
return c.json(
|
|
41
56
|
{
|
package/src/backend/public.ts
CHANGED
|
@@ -9,10 +9,47 @@ export {
|
|
|
9
9
|
type YurucommuBackendDiscoveryOptionsV1,
|
|
10
10
|
type YurucommuBackendPluginV1,
|
|
11
11
|
} from "./index.ts";
|
|
12
|
+
export {
|
|
13
|
+
runYurucommuRetention,
|
|
14
|
+
YurucommuRetentionError,
|
|
15
|
+
type YurucommuRetentionResult,
|
|
16
|
+
type YurucommuRetentionStep,
|
|
17
|
+
} from "./retention.ts";
|
|
12
18
|
export { default } from "./index.ts";
|
|
13
19
|
export { default as app } from "./index.ts";
|
|
14
20
|
export { type Database, getDb, getDbSQLite } from "../db/index.ts";
|
|
15
|
-
export {
|
|
21
|
+
export {
|
|
22
|
+
wrapCloudflareBindings,
|
|
23
|
+
wrapCloudflareMessageBatch,
|
|
24
|
+
wrapCloudflareQueue,
|
|
25
|
+
} from "./runtime/cloudflare.ts";
|
|
26
|
+
export {
|
|
27
|
+
ManagedRuntimeGatewayError,
|
|
28
|
+
createManagedRuntimeKeyValueStore,
|
|
29
|
+
createManagedRuntimeObjectStorage,
|
|
30
|
+
createManagedRuntimeQueueProducer,
|
|
31
|
+
type ManagedRuntimeDataAdapterOptions,
|
|
32
|
+
type ManagedRuntimeGateway,
|
|
33
|
+
type ManagedRuntimeQueueProducerOptions,
|
|
34
|
+
} from "./runtime/managed-runtime.ts";
|
|
35
|
+
export {
|
|
36
|
+
createManagedRelationalDatabase,
|
|
37
|
+
type ManagedRelationalDatabaseOptions,
|
|
38
|
+
} from "./runtime/managed-relational.ts";
|
|
39
|
+
export type {
|
|
40
|
+
IKeyValueStore,
|
|
41
|
+
IObjectStorage,
|
|
42
|
+
ListObjectsResult,
|
|
43
|
+
ObjectMetadata,
|
|
44
|
+
StorageObject,
|
|
45
|
+
} from "./runtime/types.ts";
|
|
46
|
+
export type {
|
|
47
|
+
IQueueBatch,
|
|
48
|
+
IQueueMessage,
|
|
49
|
+
IQueueProducer,
|
|
50
|
+
QueueBatchItem,
|
|
51
|
+
QueueSendOptions,
|
|
52
|
+
} from "./runtime/queue.ts";
|
|
16
53
|
// Call feature: the signaling Durable Object class each product's generated
|
|
17
54
|
// worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
|
|
18
55
|
export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Env } from "./types.ts";
|
|
2
|
+
import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
|
|
3
|
+
import { reapDrainedTombstones } from "./routes/actors.ts";
|
|
4
|
+
import { cleanupExpiredStories } from "./routes/stories/query-helpers.ts";
|
|
5
|
+
|
|
6
|
+
export type YurucommuRetentionStep =
|
|
7
|
+
"expired_stories" | "drained_tombstones" | "notification_push";
|
|
8
|
+
|
|
9
|
+
export interface YurucommuRetentionResult {
|
|
10
|
+
readonly expiredStories: number;
|
|
11
|
+
readonly reapedTombstones: number;
|
|
12
|
+
readonly enqueuedNotificationPushJobs: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Identifies the exact bounded retention step that failed. Scheduled callers
|
|
17
|
+
* must reject the invocation instead of treating a partial or skipped sweep as
|
|
18
|
+
* success; the original database/runtime error remains available as `cause`.
|
|
19
|
+
*/
|
|
20
|
+
export class YurucommuRetentionError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
readonly step: YurucommuRetentionStep,
|
|
23
|
+
cause: unknown,
|
|
24
|
+
) {
|
|
25
|
+
super(`yurucommu retention failed at ${step}`, { cause });
|
|
26
|
+
this.name = "YurucommuRetentionError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run one bounded retention pass against the already-materialized runtime.
|
|
32
|
+
*
|
|
33
|
+
* This deliberately reuses the same race-safe cleanup paths as request/queue
|
|
34
|
+
* handling:
|
|
35
|
+
* - Story expiry uses the canonical object cascade and purges its media last.
|
|
36
|
+
* - Tombstones remain until every Delete delivery has drained.
|
|
37
|
+
* - Notification push performs bounded pusher/job retention, stale-job
|
|
38
|
+
* recovery, and enqueues due durable outbox rows when a queue is available.
|
|
39
|
+
*
|
|
40
|
+
* Steps are awaited sequentially because D1 is the shared authority. Any
|
|
41
|
+
* failure rejects with its exact step; callers must retry the cron invocation
|
|
42
|
+
* rather than silently acknowledging incomplete retention.
|
|
43
|
+
*/
|
|
44
|
+
export async function runYurucommuRetention(
|
|
45
|
+
env: Env,
|
|
46
|
+
): Promise<YurucommuRetentionResult> {
|
|
47
|
+
if (!env?.DB_INSTANCE) {
|
|
48
|
+
throw new TypeError("Yurucommu retention requires DB_INSTANCE");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const expiredStories = await retentionStep("expired_stories", () =>
|
|
52
|
+
cleanupExpiredStories(env.DB_INSTANCE, env.MEDIA),
|
|
53
|
+
);
|
|
54
|
+
const reapedTombstones = await retentionStep("drained_tombstones", () =>
|
|
55
|
+
reapDrainedTombstones(env.DB_INSTANCE),
|
|
56
|
+
);
|
|
57
|
+
const enqueuedNotificationPushJobs = await retentionStep(
|
|
58
|
+
"notification_push",
|
|
59
|
+
() => enqueuePendingNotificationPushJobs(env),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
expiredStories,
|
|
64
|
+
reapedTombstones,
|
|
65
|
+
enqueuedNotificationPushJobs,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function retentionStep<T>(
|
|
70
|
+
step: YurucommuRetentionStep,
|
|
71
|
+
run: () => Promise<T>,
|
|
72
|
+
): Promise<T> {
|
|
73
|
+
try {
|
|
74
|
+
return await run();
|
|
75
|
+
} catch (cause) {
|
|
76
|
+
throw new YurucommuRetentionError(step, cause);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -59,6 +59,7 @@ export async function handleGroupFollow(
|
|
|
59
59
|
actorApIdStr: string,
|
|
60
60
|
baseUrl: string,
|
|
61
61
|
activityId: string,
|
|
62
|
+
sourceActivityId: string = activityId,
|
|
62
63
|
) {
|
|
63
64
|
const db = c.get("db");
|
|
64
65
|
const followerKey = {
|
|
@@ -130,7 +131,7 @@ export async function handleGroupFollow(
|
|
|
130
131
|
where: and(
|
|
131
132
|
eq(activities.type, responseType),
|
|
132
133
|
eq(activities.actorApId, group.apId),
|
|
133
|
-
eq(activities.objectApId,
|
|
134
|
+
eq(activities.objectApId, sourceActivityId),
|
|
134
135
|
),
|
|
135
136
|
});
|
|
136
137
|
if (existingResponse) {
|
|
@@ -144,14 +145,14 @@ export async function handleGroupFollow(
|
|
|
144
145
|
id: responseId,
|
|
145
146
|
type: responseType,
|
|
146
147
|
actor: group.apId,
|
|
147
|
-
object:
|
|
148
|
+
object: sourceActivityId,
|
|
148
149
|
};
|
|
149
150
|
|
|
150
151
|
await db.insert(activities).values({
|
|
151
152
|
apId: responseId,
|
|
152
153
|
type: responseType,
|
|
153
154
|
actorApId: group.apId,
|
|
154
|
-
objectApId:
|
|
155
|
+
objectApId: sourceActivityId,
|
|
155
156
|
rawJson: JSON.stringify(responseActivity),
|
|
156
157
|
direction: "outbound",
|
|
157
158
|
});
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Database } from "../../../../db/index.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
and,
|
|
4
|
+
count,
|
|
5
|
+
eq,
|
|
6
|
+
gt,
|
|
7
|
+
inArray,
|
|
8
|
+
isNotNull,
|
|
9
|
+
isNull,
|
|
10
|
+
or,
|
|
11
|
+
sql,
|
|
12
|
+
} from "drizzle-orm";
|
|
3
13
|
import type { BatchItem } from "drizzle-orm/batch";
|
|
4
14
|
import {
|
|
5
15
|
activities,
|
|
@@ -8,6 +18,7 @@ import {
|
|
|
8
18
|
announces,
|
|
9
19
|
blocks,
|
|
10
20
|
bookmarks,
|
|
21
|
+
communities,
|
|
11
22
|
follows,
|
|
12
23
|
inbox as inboxTable,
|
|
13
24
|
likes,
|
|
@@ -45,6 +56,8 @@ import {
|
|
|
45
56
|
fetchAndUpsertActorCache,
|
|
46
57
|
getInstanceFetchSignerByDb,
|
|
47
58
|
} from "../../../lib/activitypub-actor-cache.ts";
|
|
59
|
+
import { fetchWithTimeout } from "../../../lib/federation-fetch.ts";
|
|
60
|
+
import { signRequest } from "../../../lib/ap-signing.ts";
|
|
48
61
|
import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
|
|
49
62
|
import { destinationDeclaresAlias } from "../../../lib/account-migration.ts";
|
|
50
63
|
import { chunkForInClause } from "../../../lib/chunk.ts";
|
|
@@ -132,13 +145,13 @@ function isActorTypeUpdate(type: string | string[] | undefined): boolean {
|
|
|
132
145
|
|
|
133
146
|
// The ActivityStreams public-collection magic value, including the legacy
|
|
134
147
|
// short forms some implementations still emit.
|
|
135
|
-
const PUBLIC_COLLECTION = new Set([
|
|
148
|
+
export const PUBLIC_COLLECTION = new Set([
|
|
136
149
|
"https://www.w3.org/ns/activitystreams#Public",
|
|
137
150
|
"as:Public",
|
|
138
151
|
"Public",
|
|
139
152
|
]);
|
|
140
153
|
|
|
141
|
-
function addressesPublic(addresses: string[]): boolean {
|
|
154
|
+
export function addressesPublic(addresses: string[]): boolean {
|
|
142
155
|
return addresses.some((a) => PUBLIC_COLLECTION.has(a));
|
|
143
156
|
}
|
|
144
157
|
|
|
@@ -146,7 +159,7 @@ function addressesPublic(addresses: string[]): boolean {
|
|
|
146
159
|
// and NOT to Public is a followers-only post. We match any `/followers`
|
|
147
160
|
// collection by suffix (mirrors isDirectNote), which covers the author's
|
|
148
161
|
// collection without needing to resolve it.
|
|
149
|
-
function addressesFollowers(addresses: string[]): boolean {
|
|
162
|
+
export function addressesFollowers(addresses: string[]): boolean {
|
|
150
163
|
return addresses.some((a) => a.endsWith("/followers"));
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -194,7 +207,7 @@ function isDirectShapedNote(object: { to?: string[]; cc?: string[] }): boolean {
|
|
|
194
207
|
// Cap persisted addressing arrays so a remote cannot bloat a row with a huge
|
|
195
208
|
// to/cc list; 64 entries is far beyond any real audience and keeps the explicit-
|
|
196
209
|
// recipient (mention) gate working.
|
|
197
|
-
const MAX_ADDRESS_ENTRIES = 64;
|
|
210
|
+
export const MAX_ADDRESS_ENTRIES = 64;
|
|
198
211
|
function boundAddressJson(addresses: string[] | undefined): string {
|
|
199
212
|
if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
|
|
200
213
|
return JSON.stringify(
|
|
@@ -204,6 +217,21 @@ function boundAddressJson(addresses: string[] | undefined): string {
|
|
|
204
217
|
);
|
|
205
218
|
}
|
|
206
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Normalize an AS2 addressing field (`to` / `cc` / `audience`) to a bounded list
|
|
222
|
+
* of strings. The field may be absent, a bare string, or an array mixing
|
|
223
|
+
* strings and embedded objects; only the string forms are usable as a
|
|
224
|
+
* collection id, and the same 64-entry cap applies so a remote cannot force a
|
|
225
|
+
* huge `IN (...)` lookup.
|
|
226
|
+
*/
|
|
227
|
+
function addressList(value: unknown): string[] {
|
|
228
|
+
if (typeof value === "string") return [value];
|
|
229
|
+
if (!Array.isArray(value)) return [];
|
|
230
|
+
return value
|
|
231
|
+
.filter((a): a is string => typeof a === "string")
|
|
232
|
+
.slice(0, MAX_ADDRESS_ENTRIES);
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
/**
|
|
208
236
|
* Reject an inbound object whose `object.id` is asserted under a host the
|
|
209
237
|
* delivering actor does not control (object-ID squatting / cross-origin
|
|
@@ -888,6 +916,35 @@ export async function handleCreateStory(
|
|
|
888
916
|
storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
|
|
889
917
|
}
|
|
890
918
|
|
|
919
|
+
// Carry the community scope across the federation boundary. A story that
|
|
920
|
+
// arrived through community fanout is addressed to the community's followers
|
|
921
|
+
// collection, not the author's; storing it with no scope made the local read
|
|
922
|
+
// gate treat it as a personal story and serve it to every local follower of
|
|
923
|
+
// the author, member or not. Resolve the addressed collection against the
|
|
924
|
+
// communities this instance actually knows and only then mark the scope —
|
|
925
|
+
// an unresolvable audience is left unscoped rather than trusted, and the
|
|
926
|
+
// membership gate is still evaluated locally against `community_members`.
|
|
927
|
+
const addressedCollections = [
|
|
928
|
+
...addressList((activity as { audience?: unknown }).audience),
|
|
929
|
+
...addressList(object.to),
|
|
930
|
+
...addressList((object as { audience?: unknown }).audience),
|
|
931
|
+
];
|
|
932
|
+
const community = addressedCollections.length
|
|
933
|
+
? await db
|
|
934
|
+
.select({ apId: communities.apId })
|
|
935
|
+
.from(communities)
|
|
936
|
+
.where(
|
|
937
|
+
and(
|
|
938
|
+
or(
|
|
939
|
+
inArray(communities.followersUrl, addressedCollections),
|
|
940
|
+
inArray(communities.apId, addressedCollections),
|
|
941
|
+
),
|
|
942
|
+
isNull(communities.deletedAt),
|
|
943
|
+
),
|
|
944
|
+
)
|
|
945
|
+
.get()
|
|
946
|
+
: undefined;
|
|
947
|
+
|
|
891
948
|
const inserted = await db
|
|
892
949
|
.insert(objects)
|
|
893
950
|
.values({
|
|
@@ -896,6 +953,12 @@ export async function handleCreateStory(
|
|
|
896
953
|
attributedTo: actor,
|
|
897
954
|
content: "",
|
|
898
955
|
attachmentsJson: storyDataJson,
|
|
956
|
+
...(community
|
|
957
|
+
? {
|
|
958
|
+
communityApId: community.apId,
|
|
959
|
+
audienceJson: JSON.stringify([community.apId]),
|
|
960
|
+
}
|
|
961
|
+
: {}),
|
|
899
962
|
endTime,
|
|
900
963
|
published: publishedAt,
|
|
901
964
|
isLocal: 0,
|
|
@@ -907,6 +970,142 @@ export async function handleCreateStory(
|
|
|
907
970
|
if (!inserted) return; // duplicate
|
|
908
971
|
}
|
|
909
972
|
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
// Announce target resolution (fetch-and-store a boosted remote Note)
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
// Upper bound on the fetch of a boosted remote object. Mirrors the actor-cache
|
|
978
|
+
// fetch timeout; the body size is already capped by fetchWithTimeout's wrapper.
|
|
979
|
+
const ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS = 15_000;
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Resolve an inbound Announce whose target this instance has never seen: fetch
|
|
983
|
+
* the boosted object from its origin, validate it, and persist it as a remote
|
|
984
|
+
* Note so the announce edge recorded by handleAnnounce surfaces in feeds
|
|
985
|
+
* ("reposted by X") instead of dangling on an unknown ap_id.
|
|
986
|
+
*
|
|
987
|
+
* Reuses the SSRF-guarded federation fetch discipline end to end:
|
|
988
|
+
* `fetchWithTimeout` (resolver-pinned DNS validation, no redirects, capped +
|
|
989
|
+
* time-bounded body) with the GET signed as the instance actor so a
|
|
990
|
+
* secure-mode remote serves the document (mirrors fetchAndUpsertActorCache).
|
|
991
|
+
*
|
|
992
|
+
* Validation mirrors the inbound Create(Note) gates:
|
|
993
|
+
* - the document's `id` must equal the fetched URL (no id squatting);
|
|
994
|
+
* - `attributedTo` must be same-origin with the object id (a remote author
|
|
995
|
+
* may only own objects under its own host — mirrors
|
|
996
|
+
* isObjectIdOriginMismatch, and a local-origin object is never fetched);
|
|
997
|
+
* - type must include "Note"; direct/DM-shaped addressing is refused;
|
|
998
|
+
* - only world-readable classifications (public / unlisted) are persisted —
|
|
999
|
+
* a boost must never widen a followers-only/direct object's audience, and
|
|
1000
|
+
* this instance cannot verify a remote author's follower audience.
|
|
1001
|
+
*
|
|
1002
|
+
* Depth cap: the object's `inReplyTo` is stored verbatim but NEVER resolved —
|
|
1003
|
+
* a single Announce triggers at most one object fetch (plus a best-effort
|
|
1004
|
+
* author-profile cache fill), not a thread walk.
|
|
1005
|
+
*
|
|
1006
|
+
* Best-effort by contract: every failure returns false and the Announce is
|
|
1007
|
+
* dropped exactly as it was before this path existed.
|
|
1008
|
+
*/
|
|
1009
|
+
export async function fetchAndPersistAnnouncedNote(
|
|
1010
|
+
db: Database,
|
|
1011
|
+
objectId: string,
|
|
1012
|
+
baseUrl: string,
|
|
1013
|
+
): Promise<boolean> {
|
|
1014
|
+
// Never fetch a local id (a local object that does not exist is just gone)
|
|
1015
|
+
// and never fetch an unsafe URL (non-http(s), credentials, blocked host…).
|
|
1016
|
+
if (isLocal(objectId, baseUrl) || !isSafeRemoteUrl(objectId)) return false;
|
|
1017
|
+
|
|
1018
|
+
let note: ActivityObject & { attributedTo?: unknown };
|
|
1019
|
+
try {
|
|
1020
|
+
const headers: Record<string, string> = {
|
|
1021
|
+
Accept: "application/activity+json, application/ld+json",
|
|
1022
|
+
};
|
|
1023
|
+
const signer = await getInstanceFetchSignerByDb(db);
|
|
1024
|
+
if (signer) {
|
|
1025
|
+
Object.assign(
|
|
1026
|
+
headers,
|
|
1027
|
+
await signRequest(signer.privateKeyPem, signer.keyId, "GET", objectId),
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
const res = await fetchWithTimeout(objectId, {
|
|
1031
|
+
headers,
|
|
1032
|
+
timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
|
|
1033
|
+
});
|
|
1034
|
+
if (!res.ok) return false;
|
|
1035
|
+
const raw: unknown = await res.json();
|
|
1036
|
+
if (!raw || typeof raw !== "object") return false;
|
|
1037
|
+
note = raw as ActivityObject & { attributedTo?: unknown };
|
|
1038
|
+
} catch {
|
|
1039
|
+
// Unresolvable / oversized / timed-out fetch: drop silently (the caller
|
|
1040
|
+
// logs at debug level), matching the pre-existing unknown-object behavior.
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (note.id !== objectId) return false;
|
|
1045
|
+
if (!typeIncludes(note.type, "Note")) return false;
|
|
1046
|
+
|
|
1047
|
+
const attributedTo =
|
|
1048
|
+
typeof note.attributedTo === "string" ? note.attributedTo : null;
|
|
1049
|
+
if (!attributedTo || !isSafeRemoteUrl(attributedTo)) return false;
|
|
1050
|
+
try {
|
|
1051
|
+
if (getDomain(attributedTo) !== getDomain(objectId)) return false;
|
|
1052
|
+
} catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Addressing gates: a DM-shaped object must never be stored world-readable,
|
|
1057
|
+
// and a non-public classification is refused outright (see doc comment).
|
|
1058
|
+
if (isDirectShapedNote(note)) return false;
|
|
1059
|
+
const visibility = classifyInboundNoteVisibility(note);
|
|
1060
|
+
if (visibility !== "public" && visibility !== "unlisted") return false;
|
|
1061
|
+
|
|
1062
|
+
// Best-effort author profile fill so the surfaced boost renders with the
|
|
1063
|
+
// author's name/icon. Cache-when-absent; a failure never blocks the persist.
|
|
1064
|
+
const cachedAuthor = await db
|
|
1065
|
+
.select({ apId: actorCache.apId })
|
|
1066
|
+
.from(actorCache)
|
|
1067
|
+
.where(eq(actorCache.apId, attributedTo))
|
|
1068
|
+
.get();
|
|
1069
|
+
if (!cachedAuthor) {
|
|
1070
|
+
try {
|
|
1071
|
+
await fetchAndUpsertActorCache(db, attributedTo, {
|
|
1072
|
+
timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
|
|
1073
|
+
mode: "insert",
|
|
1074
|
+
signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
|
|
1075
|
+
});
|
|
1076
|
+
} catch {
|
|
1077
|
+
/* best-effort */
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const attachments = note.attachment ? JSON.stringify(note.attachment) : "[]";
|
|
1082
|
+
await db
|
|
1083
|
+
.insert(objects)
|
|
1084
|
+
.values({
|
|
1085
|
+
apId: objectId,
|
|
1086
|
+
type: "Note",
|
|
1087
|
+
attributedTo,
|
|
1088
|
+
content: boundInboundContent(note.content),
|
|
1089
|
+
summary: boundInboundSummary(note.summary),
|
|
1090
|
+
attachmentsJson: boundAttachmentsJson(attachments),
|
|
1091
|
+
// Stored verbatim, never resolved (depth cap): a boosted reply keeps its
|
|
1092
|
+
// honest thread link even though the parent may stay unknown here.
|
|
1093
|
+
inReplyTo: note.inReplyTo || null,
|
|
1094
|
+
visibility,
|
|
1095
|
+
toJson: boundAddressJson(note.to),
|
|
1096
|
+
ccJson: boundAddressJson(note.cc),
|
|
1097
|
+
communityApId: null,
|
|
1098
|
+
published: normalizeInboundTimestamp(
|
|
1099
|
+
note.published,
|
|
1100
|
+
new Date().toISOString(),
|
|
1101
|
+
),
|
|
1102
|
+
isLocal: 0,
|
|
1103
|
+
})
|
|
1104
|
+
.onConflictDoNothing();
|
|
1105
|
+
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
910
1109
|
// ---------------------------------------------------------------------------
|
|
911
1110
|
// Delete handler
|
|
912
1111
|
// ---------------------------------------------------------------------------
|