@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,159 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
type MobilePushHostRegistration,
|
|
5
|
+
type MobilePushHostUnregistrationResponse,
|
|
6
|
+
parseMobilePushHostRegistrationRequest,
|
|
7
|
+
type ParsedMobilePushHostRegistrationRequest,
|
|
8
|
+
} from "../lib/mobile-contract.ts";
|
|
9
|
+
|
|
10
|
+
import { mobilePushRegistrations, type Database } from "../../db/index.ts";
|
|
11
|
+
import type { Actor, Env, Variables } from "../types.ts";
|
|
12
|
+
import { requireActor } from "./actors-helpers.ts";
|
|
13
|
+
import { parseJsonObject } from "../lib/parse-helpers.ts";
|
|
14
|
+
import { generateId } from "../lib/oauth-utils.ts";
|
|
15
|
+
|
|
16
|
+
const mobile = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
17
|
+
|
|
18
|
+
type MobilePushRegistrationBody = ParsedMobilePushHostRegistrationRequest;
|
|
19
|
+
|
|
20
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
21
|
+
const bytes = new TextEncoder().encode(value);
|
|
22
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
23
|
+
return Array.from(new Uint8Array(digest))
|
|
24
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
25
|
+
.join("");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parsePushRegistrationBody(
|
|
29
|
+
body: Record<string, unknown>,
|
|
30
|
+
): MobilePushRegistrationBody | Response {
|
|
31
|
+
const parsed = parseMobilePushHostRegistrationRequest(body);
|
|
32
|
+
if (!parsed.ok) return Response.json(parsed.error, { status: 400 });
|
|
33
|
+
return parsed.value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function registerMobilePushRegistration(
|
|
37
|
+
db: Database,
|
|
38
|
+
actor: Actor,
|
|
39
|
+
body: MobilePushRegistrationBody,
|
|
40
|
+
): Promise<MobilePushHostRegistration> {
|
|
41
|
+
const now = new Date().toISOString();
|
|
42
|
+
const tokenHash = await sha256Hex(body.token);
|
|
43
|
+
|
|
44
|
+
await db
|
|
45
|
+
.insert(mobilePushRegistrations)
|
|
46
|
+
.values({
|
|
47
|
+
id: generateId(16),
|
|
48
|
+
actorApId: actor.ap_id,
|
|
49
|
+
product: body.product,
|
|
50
|
+
token: body.token,
|
|
51
|
+
tokenHash,
|
|
52
|
+
environment: body.environment,
|
|
53
|
+
hostUrl: body.hostUrl,
|
|
54
|
+
createdAt: now,
|
|
55
|
+
updatedAt: now,
|
|
56
|
+
lastSeenAt: now,
|
|
57
|
+
})
|
|
58
|
+
.onConflictDoUpdate({
|
|
59
|
+
target: [
|
|
60
|
+
mobilePushRegistrations.actorApId,
|
|
61
|
+
mobilePushRegistrations.product,
|
|
62
|
+
mobilePushRegistrations.tokenHash,
|
|
63
|
+
],
|
|
64
|
+
set: {
|
|
65
|
+
token: body.token,
|
|
66
|
+
environment: body.environment,
|
|
67
|
+
hostUrl: body.hostUrl,
|
|
68
|
+
updatedAt: now,
|
|
69
|
+
lastSeenAt: now,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const row = await db
|
|
74
|
+
.select({
|
|
75
|
+
id: mobilePushRegistrations.id,
|
|
76
|
+
product: mobilePushRegistrations.product,
|
|
77
|
+
environment: mobilePushRegistrations.environment,
|
|
78
|
+
hostUrl: mobilePushRegistrations.hostUrl,
|
|
79
|
+
createdAt: mobilePushRegistrations.createdAt,
|
|
80
|
+
lastSeenAt: mobilePushRegistrations.lastSeenAt,
|
|
81
|
+
})
|
|
82
|
+
.from(mobilePushRegistrations)
|
|
83
|
+
.where(
|
|
84
|
+
and(
|
|
85
|
+
eq(mobilePushRegistrations.actorApId, actor.ap_id),
|
|
86
|
+
eq(mobilePushRegistrations.product, body.product),
|
|
87
|
+
eq(mobilePushRegistrations.tokenHash, tokenHash),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
.get();
|
|
91
|
+
|
|
92
|
+
if (!row) throw new Error("Failed to register mobile push token");
|
|
93
|
+
return {
|
|
94
|
+
id: row.id,
|
|
95
|
+
product: body.product,
|
|
96
|
+
environment: row.environment,
|
|
97
|
+
host_url: row.hostUrl,
|
|
98
|
+
registered_at: row.createdAt,
|
|
99
|
+
last_seen_at: row.lastSeenAt,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function unregisterMobilePushRegistration(
|
|
104
|
+
db: Database,
|
|
105
|
+
actor: Actor,
|
|
106
|
+
body: MobilePushRegistrationBody,
|
|
107
|
+
): Promise<MobilePushHostUnregistrationResponse> {
|
|
108
|
+
const tokenHash = await sha256Hex(body.token);
|
|
109
|
+
await db
|
|
110
|
+
.delete(mobilePushRegistrations)
|
|
111
|
+
.where(
|
|
112
|
+
and(
|
|
113
|
+
eq(mobilePushRegistrations.actorApId, actor.ap_id),
|
|
114
|
+
eq(mobilePushRegistrations.product, body.product),
|
|
115
|
+
eq(mobilePushRegistrations.environment, body.environment),
|
|
116
|
+
eq(mobilePushRegistrations.tokenHash, tokenHash),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
return { unregistered: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
mobile.post("/push-registrations", async (c) => {
|
|
123
|
+
const actor = requireActor(c);
|
|
124
|
+
if (actor instanceof Response) return actor;
|
|
125
|
+
|
|
126
|
+
const rawBody = await parseJsonObject(c);
|
|
127
|
+
if (!rawBody) {
|
|
128
|
+
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = parsePushRegistrationBody(rawBody);
|
|
132
|
+
if (body instanceof Response) return body;
|
|
133
|
+
|
|
134
|
+
const registration = await registerMobilePushRegistration(
|
|
135
|
+
c.get("db"),
|
|
136
|
+
actor,
|
|
137
|
+
body,
|
|
138
|
+
);
|
|
139
|
+
return c.json({ registration });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
mobile.delete("/push-registrations", async (c) => {
|
|
143
|
+
const actor = requireActor(c);
|
|
144
|
+
if (actor instanceof Response) return actor;
|
|
145
|
+
|
|
146
|
+
const rawBody = await parseJsonObject(c);
|
|
147
|
+
if (!rawBody) {
|
|
148
|
+
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const body = parsePushRegistrationBody(rawBody);
|
|
152
|
+
if (body instanceof Response) return body;
|
|
153
|
+
|
|
154
|
+
return c.json(
|
|
155
|
+
await unregisterMobilePushRegistration(c.get("db"), actor, body),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export default mobile;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator moderation routes.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the federation blocklist (blocked domains / actors) and the inbound
|
|
5
|
+
* abuse-report queue to the INSTANCE OWNER only. Without these endpoints the
|
|
6
|
+
* blocklist helpers in `lib/blocklist.ts` have no callers, so an operator has
|
|
7
|
+
* no way to defederate a misbehaving domain or actor.
|
|
8
|
+
*
|
|
9
|
+
* Authorization: every route requires a logged-in actor whose `role` is
|
|
10
|
+
* `"owner"` (the single-operator account, assigned to the first actor created
|
|
11
|
+
* on the instance — see auth-helpers.ts). Arbitrary logged-in users are
|
|
12
|
+
* rejected with 403 so moderation is never exposed to members.
|
|
13
|
+
*
|
|
14
|
+
* Mounted at `/api/moderation` by the index.ts wiring cluster.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
import { desc, eq, isNull } from "drizzle-orm";
|
|
19
|
+
|
|
20
|
+
import type { Env, Variables } from "../types.ts";
|
|
21
|
+
import {
|
|
22
|
+
activityApId,
|
|
23
|
+
generateId,
|
|
24
|
+
isLocal,
|
|
25
|
+
parseLimit,
|
|
26
|
+
parseOffset,
|
|
27
|
+
} from "../federation-helpers.ts";
|
|
28
|
+
import {
|
|
29
|
+
blockActor,
|
|
30
|
+
blockDomain,
|
|
31
|
+
unblockActor,
|
|
32
|
+
unblockDomain,
|
|
33
|
+
} from "../lib/blocklist.ts";
|
|
34
|
+
import {
|
|
35
|
+
purgeActorContent,
|
|
36
|
+
purgeDomainContent,
|
|
37
|
+
} from "../lib/blocklist-purge.ts";
|
|
38
|
+
import {
|
|
39
|
+
activities,
|
|
40
|
+
blockedActors,
|
|
41
|
+
blockedDomains,
|
|
42
|
+
nowIso,
|
|
43
|
+
reports,
|
|
44
|
+
} from "../../db/index.ts";
|
|
45
|
+
import { requireActor } from "./actors-helpers.ts";
|
|
46
|
+
import { getInstanceActor } from "./activitypub/query-helpers.ts";
|
|
47
|
+
import { enqueueDeliveryToActor } from "../lib/delivery/queue.ts";
|
|
48
|
+
|
|
49
|
+
const MAX_REPORT_REASON_LEN = 1000;
|
|
50
|
+
|
|
51
|
+
export const moderationRoutes = new Hono<{
|
|
52
|
+
Bindings: Env;
|
|
53
|
+
Variables: Variables;
|
|
54
|
+
}>();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Require the current actor AND that it is the instance owner. Returns the
|
|
58
|
+
* actor on success, or a 401/403 Response that the caller should return.
|
|
59
|
+
*/
|
|
60
|
+
function requireOwner(c: Parameters<typeof requireActor>[0]) {
|
|
61
|
+
const result = requireActor(c);
|
|
62
|
+
if (result instanceof Response) return result;
|
|
63
|
+
if (result.role !== "owner") {
|
|
64
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readApIdBody(
|
|
70
|
+
c: Parameters<typeof requireActor>[0],
|
|
71
|
+
field: "domain" | "ap_id",
|
|
72
|
+
): Promise<
|
|
73
|
+
{ value: string; body: Record<string, unknown> } | { error: Response }
|
|
74
|
+
> {
|
|
75
|
+
let body: Record<string, unknown>;
|
|
76
|
+
try {
|
|
77
|
+
body = (await c.req.json()) as Record<string, unknown>;
|
|
78
|
+
} catch {
|
|
79
|
+
return { error: c.json({ error: "Invalid JSON body" }, 400) };
|
|
80
|
+
}
|
|
81
|
+
const raw = body[field];
|
|
82
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
83
|
+
return { error: c.json({ error: `${field} required` }, 400) };
|
|
84
|
+
}
|
|
85
|
+
return { value: raw.trim(), body };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readReason(body: unknown): string | null {
|
|
89
|
+
if (typeof body !== "object" || body === null) return null;
|
|
90
|
+
const reason = (body as { reason?: unknown }).reason;
|
|
91
|
+
return typeof reason === "string" && reason.length > 0 ? reason : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Blocked domains
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
moderationRoutes.get("/domains", async (c) => {
|
|
99
|
+
const owner = requireOwner(c);
|
|
100
|
+
if (owner instanceof Response) return owner;
|
|
101
|
+
|
|
102
|
+
const db = c.get("db");
|
|
103
|
+
const limit = parseLimit(c.req.query("limit"), 100, 500);
|
|
104
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
105
|
+
|
|
106
|
+
const rows = await db
|
|
107
|
+
.select()
|
|
108
|
+
.from(blockedDomains)
|
|
109
|
+
.orderBy(desc(blockedDomains.createdAt))
|
|
110
|
+
.limit(limit)
|
|
111
|
+
.offset(offset);
|
|
112
|
+
|
|
113
|
+
return c.json({
|
|
114
|
+
domains: rows.map((r) => ({
|
|
115
|
+
domain: r.domain,
|
|
116
|
+
reason: r.reason,
|
|
117
|
+
created_at: r.createdAt,
|
|
118
|
+
})),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
moderationRoutes.post("/domains", async (c) => {
|
|
123
|
+
const owner = requireOwner(c);
|
|
124
|
+
if (owner instanceof Response) return owner;
|
|
125
|
+
|
|
126
|
+
const parsed = await readApIdBody(c, "domain");
|
|
127
|
+
if ("error" in parsed) return parsed.error;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await blockDomain(c.get("db"), parsed.value, readReason(parsed.body));
|
|
131
|
+
} catch {
|
|
132
|
+
return c.json({ error: "Invalid domain" }, 400);
|
|
133
|
+
}
|
|
134
|
+
// Defederation also purges the domain's already-ingested content so it stops
|
|
135
|
+
// being served (the blocklist is otherwise ingest/delivery-only).
|
|
136
|
+
await purgeDomainContent(c.get("db"), parsed.value, c.env.MEDIA);
|
|
137
|
+
return c.json({ success: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
moderationRoutes.delete("/domains", async (c) => {
|
|
141
|
+
const owner = requireOwner(c);
|
|
142
|
+
if (owner instanceof Response) return owner;
|
|
143
|
+
|
|
144
|
+
const parsed = await readApIdBody(c, "domain");
|
|
145
|
+
if ("error" in parsed) return parsed.error;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await unblockDomain(c.get("db"), parsed.value);
|
|
149
|
+
} catch {
|
|
150
|
+
return c.json({ error: "Invalid domain" }, 400);
|
|
151
|
+
}
|
|
152
|
+
return c.json({ success: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Blocked actors
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
moderationRoutes.get("/actors", async (c) => {
|
|
160
|
+
const owner = requireOwner(c);
|
|
161
|
+
if (owner instanceof Response) return owner;
|
|
162
|
+
|
|
163
|
+
const db = c.get("db");
|
|
164
|
+
const limit = parseLimit(c.req.query("limit"), 100, 500);
|
|
165
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
166
|
+
|
|
167
|
+
const rows = await db
|
|
168
|
+
.select()
|
|
169
|
+
.from(blockedActors)
|
|
170
|
+
.orderBy(desc(blockedActors.createdAt))
|
|
171
|
+
.limit(limit)
|
|
172
|
+
.offset(offset);
|
|
173
|
+
|
|
174
|
+
return c.json({
|
|
175
|
+
actors: rows.map((r) => ({
|
|
176
|
+
actor_ap_id: r.actorApId,
|
|
177
|
+
reason: r.reason,
|
|
178
|
+
created_at: r.createdAt,
|
|
179
|
+
})),
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
moderationRoutes.post("/actors", async (c) => {
|
|
184
|
+
const owner = requireOwner(c);
|
|
185
|
+
if (owner instanceof Response) return owner;
|
|
186
|
+
|
|
187
|
+
const parsed = await readApIdBody(c, "ap_id");
|
|
188
|
+
if ("error" in parsed) return parsed.error;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await blockActor(c.get("db"), parsed.value, readReason(parsed.body));
|
|
192
|
+
} catch {
|
|
193
|
+
return c.json({ error: "Invalid actor" }, 400);
|
|
194
|
+
}
|
|
195
|
+
// Purge the blocked actor's already-ingested content so it stops being served.
|
|
196
|
+
await purgeActorContent(c.get("db"), parsed.value, c.env.MEDIA);
|
|
197
|
+
return c.json({ success: true });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
moderationRoutes.delete("/actors", async (c) => {
|
|
201
|
+
const owner = requireOwner(c);
|
|
202
|
+
if (owner instanceof Response) return owner;
|
|
203
|
+
|
|
204
|
+
const parsed = await readApIdBody(c, "ap_id");
|
|
205
|
+
if ("error" in parsed) return parsed.error;
|
|
206
|
+
|
|
207
|
+
await unblockActor(c.get("db"), parsed.value);
|
|
208
|
+
return c.json({ success: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Reports (inbound Flag queue)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
moderationRoutes.get("/reports", async (c) => {
|
|
216
|
+
const owner = requireOwner(c);
|
|
217
|
+
if (owner instanceof Response) return owner;
|
|
218
|
+
|
|
219
|
+
const db = c.get("db");
|
|
220
|
+
const limit = parseLimit(c.req.query("limit"), 100, 500);
|
|
221
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
222
|
+
// `?status=open` filters to unresolved reports; default returns all.
|
|
223
|
+
const onlyOpen = c.req.query("status") === "open";
|
|
224
|
+
|
|
225
|
+
const base = db.select().from(reports);
|
|
226
|
+
const filtered = onlyOpen ? base.where(isNull(reports.resolvedAt)) : base;
|
|
227
|
+
|
|
228
|
+
const rows = await filtered
|
|
229
|
+
.orderBy(desc(reports.createdAt))
|
|
230
|
+
.limit(limit)
|
|
231
|
+
.offset(offset);
|
|
232
|
+
|
|
233
|
+
return c.json({
|
|
234
|
+
reports: rows.map((r) => ({
|
|
235
|
+
id: r.id,
|
|
236
|
+
reporter_ap_id: r.reporterApId,
|
|
237
|
+
target_ap_id: r.targetApId,
|
|
238
|
+
content: r.content,
|
|
239
|
+
instance: r.instance,
|
|
240
|
+
created_at: r.createdAt,
|
|
241
|
+
resolved_at: r.resolvedAt,
|
|
242
|
+
})),
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Mark a report handled (or reopen it).
|
|
248
|
+
*
|
|
249
|
+
* `POST /reports/:id/resolve` stamps `reports.resolvedAt = nowIso()` so the row
|
|
250
|
+
* drops out of the `?status=open` queue. Sending `{ "reopen": true }` clears
|
|
251
|
+
* `resolvedAt` instead, putting the report back on the open queue. Without this
|
|
252
|
+
* route `resolvedAt` is never written, so `?status=open` would be dead and the
|
|
253
|
+
* queue would grow forever.
|
|
254
|
+
*
|
|
255
|
+
* Owner-gated like every other moderation mutation; CSRF + rate-limit are
|
|
256
|
+
* applied at the `/api/*` mount in index.ts.
|
|
257
|
+
*/
|
|
258
|
+
moderationRoutes.post("/reports/:id/resolve", async (c) => {
|
|
259
|
+
const owner = requireOwner(c);
|
|
260
|
+
if (owner instanceof Response) return owner;
|
|
261
|
+
|
|
262
|
+
const id = c.req.param("id");
|
|
263
|
+
if (typeof id !== "string" || id.trim().length === 0) {
|
|
264
|
+
return c.json({ error: "id required" }, 400);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let reopen = false;
|
|
268
|
+
try {
|
|
269
|
+
const body = (await c.req.json()) as { reopen?: unknown };
|
|
270
|
+
reopen = body?.reopen === true;
|
|
271
|
+
} catch {
|
|
272
|
+
// No body / invalid JSON => default resolve.
|
|
273
|
+
reopen = false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const db = c.get("db");
|
|
277
|
+
const resolvedAt = reopen ? null : nowIso();
|
|
278
|
+
const updated = await db
|
|
279
|
+
.update(reports)
|
|
280
|
+
.set({ resolvedAt })
|
|
281
|
+
.where(eq(reports.id, id))
|
|
282
|
+
.returning({ id: reports.id });
|
|
283
|
+
|
|
284
|
+
if (updated.length === 0) {
|
|
285
|
+
return c.json({ error: "Report not found" }, 404);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return c.json({ success: true, resolved_at: resolvedAt });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Outbound reports (file an abuse Flag to a REMOTE actor's instance)
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* `POST /reports/outbound` — file an AS2 `Flag` against a remote actor (and,
|
|
297
|
+
* optionally, one of their posts) and federate it to their instance, so a
|
|
298
|
+
* yurucommu owner can report abusive remote content the same way they can
|
|
299
|
+
* triage inbound Flags. The Flag is sent FROM the instance actor (Mastodon's
|
|
300
|
+
* convention — the individual reporter stays anonymous to the remote moderators)
|
|
301
|
+
* and signed with the instance actor's key (the delivery worker resolves it).
|
|
302
|
+
*
|
|
303
|
+
* Owner-gated. Local targets are rejected: there is no remote instance to
|
|
304
|
+
* notify — the owner should block/mute/defederate instead (stronger local
|
|
305
|
+
* tools). The same CSRF + rate-limit apply at the `/api/*` mount.
|
|
306
|
+
*/
|
|
307
|
+
moderationRoutes.post("/reports/outbound", async (c) => {
|
|
308
|
+
const owner = requireOwner(c);
|
|
309
|
+
if (owner instanceof Response) return owner;
|
|
310
|
+
|
|
311
|
+
const baseUrl = c.env.APP_URL;
|
|
312
|
+
let body: Record<string, unknown>;
|
|
313
|
+
try {
|
|
314
|
+
body = (await c.req.json()) as Record<string, unknown>;
|
|
315
|
+
} catch {
|
|
316
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const targetActorApId =
|
|
320
|
+
typeof body.target_actor_ap_id === "string"
|
|
321
|
+
? body.target_actor_ap_id.trim()
|
|
322
|
+
: "";
|
|
323
|
+
const postApId =
|
|
324
|
+
typeof body.post_ap_id === "string" ? body.post_ap_id.trim() : "";
|
|
325
|
+
const reason = readReason(body) ?? "";
|
|
326
|
+
|
|
327
|
+
if (!targetActorApId) {
|
|
328
|
+
return c.json({ error: "target_actor_ap_id required" }, 400);
|
|
329
|
+
}
|
|
330
|
+
if (isLocal(targetActorApId, baseUrl)) {
|
|
331
|
+
return c.json(
|
|
332
|
+
{ error: "Cannot report a local actor; use block/mute instead" },
|
|
333
|
+
400,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (reason.length > MAX_REPORT_REASON_LEN) {
|
|
337
|
+
return c.json({ error: "reason too long" }, 400);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const db = c.get("db");
|
|
341
|
+
const instance = await getInstanceActor(c);
|
|
342
|
+
const flagId = activityApId(baseUrl, generateId());
|
|
343
|
+
|
|
344
|
+
// AS2/Mastodon `Flag`: `object` lists the reported entity(ies) — the post (if
|
|
345
|
+
// given) plus the actor, so the remote moderator can locate the content.
|
|
346
|
+
const object = postApId ? [postApId, targetActorApId] : [targetActorApId];
|
|
347
|
+
const flag = {
|
|
348
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
349
|
+
id: flagId,
|
|
350
|
+
type: "Flag",
|
|
351
|
+
actor: instance.apId,
|
|
352
|
+
object,
|
|
353
|
+
...(reason ? { content: reason } : {}),
|
|
354
|
+
to: [targetActorApId],
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await db.insert(activities).values({
|
|
358
|
+
apId: flagId,
|
|
359
|
+
type: "Flag",
|
|
360
|
+
actorApId: instance.apId,
|
|
361
|
+
objectApId: targetActorApId,
|
|
362
|
+
rawJson: JSON.stringify(flag),
|
|
363
|
+
direction: "outbound",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Async federation (no remote POST in the request path); the worker signs as
|
|
367
|
+
// the instance actor and delivers to the reported actor's inbox.
|
|
368
|
+
await enqueueDeliveryToActor(c.env, flagId, targetActorApId);
|
|
369
|
+
|
|
370
|
+
return c.json({ success: true });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
export default moderationRoutes;
|