@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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch wrapper with consistent configuration.
|
|
3
|
+
* All API calls should use this wrapper so frontend plugins can override
|
|
4
|
+
* transport behavior (URL resolution, auth headers, credentials mode).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
fetchWithTimeout,
|
|
9
|
+
type FetchWithTimeoutInit,
|
|
10
|
+
} from "../fetch-with-timeout.ts";
|
|
11
|
+
import { getYurucommuApiTransport } from "../transport.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Custom error class for API responses that includes the HTTP status code.
|
|
15
|
+
*/
|
|
16
|
+
export class ApiError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
public readonly status: number,
|
|
19
|
+
message: string,
|
|
20
|
+
) {
|
|
21
|
+
// `message` is the human-facing server error (or a caller fallback); the
|
|
22
|
+
// HTTP status lives on `.status`. Keep `.message` clean — many UI surfaces
|
|
23
|
+
// render `err.message` verbatim in an error box, and a "<status>: " prefix
|
|
24
|
+
// reads as technical noise (e.g. "422: Add this account as an alias…").
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "ApiError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read an error message from a failed API response. Attempts to parse JSON
|
|
32
|
+
* with an `error` field; falls back to `statusText`.
|
|
33
|
+
*/
|
|
34
|
+
export async function extractErrorMessage(
|
|
35
|
+
res: Response,
|
|
36
|
+
fallback: string,
|
|
37
|
+
): Promise<string> {
|
|
38
|
+
try {
|
|
39
|
+
const data = (await res.json()) as {
|
|
40
|
+
error?: string | { message?: string };
|
|
41
|
+
};
|
|
42
|
+
// The server uses a flat `{ error: "message" }` envelope. Defensively also
|
|
43
|
+
// unwrap a nested `{ error: { message } }` so a stray non-flat error never
|
|
44
|
+
// renders as the "[object Object]" stringification.
|
|
45
|
+
const err = data.error;
|
|
46
|
+
const message = typeof err === "string" ? err : err?.message;
|
|
47
|
+
return message || fallback;
|
|
48
|
+
} catch {
|
|
49
|
+
return res.statusText || fallback;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Assert that a response is OK. Throws an `ApiError` with the status code
|
|
55
|
+
* and a message extracted from the response body when it is not.
|
|
56
|
+
*/
|
|
57
|
+
export async function assertOk(res: Response, fallback: string): Promise<void> {
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const message = await extractErrorMessage(res, fallback);
|
|
60
|
+
throw new ApiError(res.status, message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ApiRequestInit extends FetchWithTimeoutInit {}
|
|
65
|
+
|
|
66
|
+
export function apiFetch(
|
|
67
|
+
url: string,
|
|
68
|
+
options: ApiRequestInit = {},
|
|
69
|
+
): Promise<Response> {
|
|
70
|
+
const transport = getYurucommuApiTransport();
|
|
71
|
+
const apiUrl = transport.resolveUrl(url);
|
|
72
|
+
const headers = new Headers(options.headers);
|
|
73
|
+
|
|
74
|
+
const authHeaders = transport.getAuthHeaders(url);
|
|
75
|
+
for (const [key, value] of Object.entries(authHeaders)) {
|
|
76
|
+
if (!headers.has(key)) {
|
|
77
|
+
headers.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return fetchWithTimeout(apiUrl, {
|
|
82
|
+
...options,
|
|
83
|
+
headers,
|
|
84
|
+
credentials: options.credentials ?? transport.credentials,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createApiMethod(method: string) {
|
|
89
|
+
return async (
|
|
90
|
+
url: string,
|
|
91
|
+
body?: unknown,
|
|
92
|
+
options: Omit<ApiRequestInit, "method" | "body"> = {},
|
|
93
|
+
): Promise<Response> => {
|
|
94
|
+
const headers = new Headers(options.headers);
|
|
95
|
+
if (body) {
|
|
96
|
+
headers.set("Content-Type", "application/json");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return await apiFetch(url, {
|
|
100
|
+
method,
|
|
101
|
+
headers,
|
|
102
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
103
|
+
...options,
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const apiPost = createApiMethod("POST");
|
|
109
|
+
export const apiPut = createApiMethod("PUT");
|
|
110
|
+
export const apiPatch = createApiMethod("PATCH");
|
|
111
|
+
export const apiDelete = createApiMethod("DELETE");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { apiDelete, apiPost, assertOk } from "./fetch.ts";
|
|
2
|
+
|
|
3
|
+
export async function follow(targetApId: string): Promise<{ status: string }> {
|
|
4
|
+
const res = await apiPost("/api/follow", { target_ap_id: targetApId });
|
|
5
|
+
await assertOk(res, "Failed to follow");
|
|
6
|
+
return (await res.json()) as { status: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function unfollow(targetApId: string): Promise<void> {
|
|
10
|
+
const res = await apiDelete("/api/follow", { target_ap_id: targetApId });
|
|
11
|
+
await assertOk(res, "Failed to unfollow");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function acceptFollowRequest(
|
|
15
|
+
requesterApId: string,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const res = await apiPost("/api/follow/accept", {
|
|
18
|
+
requester_ap_id: requesterApId,
|
|
19
|
+
});
|
|
20
|
+
await assertOk(res, "Failed to accept");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function rejectFollowRequest(
|
|
24
|
+
requesterApId: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const res = await apiPost("/api/follow/reject", {
|
|
27
|
+
requester_ap_id: requesterApId,
|
|
28
|
+
});
|
|
29
|
+
await assertOk(res, "Failed to reject");
|
|
30
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { apiFetch, assertOk } from "./fetch.ts";
|
|
2
|
+
import { UPLOAD_REQUEST_TIMEOUT_MS } from "../fetch-with-timeout.ts";
|
|
3
|
+
|
|
4
|
+
// Allowed MIME types for media uploads
|
|
5
|
+
export const allowedMimeTypes = [
|
|
6
|
+
// Images
|
|
7
|
+
"image/jpeg",
|
|
8
|
+
"image/png",
|
|
9
|
+
"image/gif",
|
|
10
|
+
"image/webp",
|
|
11
|
+
// Videos
|
|
12
|
+
"video/mp4",
|
|
13
|
+
"video/webm",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type AllowedMimeType = (typeof allowedMimeTypes)[number];
|
|
17
|
+
|
|
18
|
+
// Maximum file sizes MUST match the backend media route (MAX_IMAGE_SIZE /
|
|
19
|
+
// MAX_VIDEO_SIZE in src/backend/routes/media.ts). When the client video cap was
|
|
20
|
+
// 100MB but the server cap was 40MB (and the pre-route body limit 48 MiB), a
|
|
21
|
+
// 48–100MB video passed client validation and was then rejected by the body
|
|
22
|
+
// limit with an opaque "body_too_large" error instead of a friendly up-front
|
|
23
|
+
// message. Keep these in lockstep with the backend.
|
|
24
|
+
export const maxImageFileSize = 20 * 1024 * 1024;
|
|
25
|
+
export const maxVideoFileSize = 40 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
// Filename validation regex: alphanumeric, dots, hyphens, underscores
|
|
28
|
+
const filenameRegex = /^[\w\-. ]+$/;
|
|
29
|
+
|
|
30
|
+
export class FileValidationError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
public code: "INVALID_TYPE" | "FILE_TOO_LARGE" | "INVALID_FILENAME",
|
|
34
|
+
) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "FileValidationError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate a file before upload
|
|
42
|
+
* @throws FileValidationError if validation fails
|
|
43
|
+
*/
|
|
44
|
+
export function validateFile(file: File): void {
|
|
45
|
+
// Check file type
|
|
46
|
+
if (!allowedMimeTypes.includes(file.type as AllowedMimeType)) {
|
|
47
|
+
throw new FileValidationError(
|
|
48
|
+
`Invalid file type: ${file.type}. Allowed types: ${allowedMimeTypes.join(
|
|
49
|
+
", ",
|
|
50
|
+
)}`,
|
|
51
|
+
"INVALID_TYPE",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check file size
|
|
56
|
+
const maxFileSize = file.type.startsWith("video/")
|
|
57
|
+
? maxVideoFileSize
|
|
58
|
+
: maxImageFileSize;
|
|
59
|
+
if (file.size > maxFileSize) {
|
|
60
|
+
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
|
61
|
+
const maxMB = maxFileSize / (1024 * 1024);
|
|
62
|
+
throw new FileValidationError(
|
|
63
|
+
`File too large: ${sizeMB}MB. Maximum size: ${maxMB}MB`,
|
|
64
|
+
"FILE_TOO_LARGE",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check filename
|
|
69
|
+
if (!filenameRegex.test(file.name)) {
|
|
70
|
+
throw new FileValidationError(
|
|
71
|
+
`Invalid filename: ${file.name}. Filename can only contain letters, numbers, dots, hyphens, underscores, and spaces.`,
|
|
72
|
+
"INVALID_FILENAME",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Upload a media file
|
|
79
|
+
* @throws FileValidationError if validation fails
|
|
80
|
+
* @throws Error if upload fails
|
|
81
|
+
*/
|
|
82
|
+
export async function uploadMedia(
|
|
83
|
+
file: File,
|
|
84
|
+
): Promise<{ url: string; r2_key: string; content_type: string }> {
|
|
85
|
+
// Validate file before upload
|
|
86
|
+
validateFile(file);
|
|
87
|
+
|
|
88
|
+
const formData = new FormData();
|
|
89
|
+
formData.append("file", file);
|
|
90
|
+
|
|
91
|
+
const res = await apiFetch("/api/media/upload", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: formData,
|
|
94
|
+
timeoutMs: UPLOAD_REQUEST_TIMEOUT_MS,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await assertOk(res, "Failed to upload");
|
|
98
|
+
|
|
99
|
+
return res.json();
|
|
100
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
2
|
+
|
|
3
|
+
// Owner-only federation moderation surface. Mirrors the backend operator
|
|
4
|
+
// routes mounted at /api/moderation (see src/backend/routes/moderation.ts).
|
|
5
|
+
// Every call is gated server-side to the instance owner (role === "owner")
|
|
6
|
+
// and returns 403 otherwise; the UI also hides the entry from non-owners.
|
|
7
|
+
|
|
8
|
+
export interface BlockedDomain {
|
|
9
|
+
domain: string;
|
|
10
|
+
reason: string | null;
|
|
11
|
+
created_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BlockedActor {
|
|
15
|
+
actor_ap_id: string;
|
|
16
|
+
reason: string | null;
|
|
17
|
+
created_at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ModerationReport {
|
|
21
|
+
id: string;
|
|
22
|
+
reporter_ap_id: string | null;
|
|
23
|
+
target_ap_id: string | null;
|
|
24
|
+
content: string | null;
|
|
25
|
+
instance: string | null;
|
|
26
|
+
created_at: string;
|
|
27
|
+
resolved_at: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function fetchBlockedDomains(): Promise<BlockedDomain[]> {
|
|
31
|
+
const res = await apiFetch("/api/moderation/domains");
|
|
32
|
+
await assertOk(res, "Failed to fetch blocked domains");
|
|
33
|
+
const data = (await res.json()) as { domains?: BlockedDomain[] };
|
|
34
|
+
return data.domains || [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function blockDomain(
|
|
38
|
+
domain: string,
|
|
39
|
+
reason?: string,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const res = await apiPost("/api/moderation/domains", { domain, reason });
|
|
42
|
+
await assertOk(res, "Failed to block domain");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function unblockDomain(domain: string): Promise<void> {
|
|
46
|
+
const res = await apiDelete("/api/moderation/domains", { domain });
|
|
47
|
+
await assertOk(res, "Failed to unblock domain");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function fetchBlockedActors(): Promise<BlockedActor[]> {
|
|
51
|
+
const res = await apiFetch("/api/moderation/actors");
|
|
52
|
+
await assertOk(res, "Failed to fetch blocked actors");
|
|
53
|
+
const data = (await res.json()) as { actors?: BlockedActor[] };
|
|
54
|
+
return data.actors || [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function blockActor(apId: string, reason?: string): Promise<void> {
|
|
58
|
+
const res = await apiPost("/api/moderation/actors", { ap_id: apId, reason });
|
|
59
|
+
await assertOk(res, "Failed to block actor");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function unblockActor(apId: string): Promise<void> {
|
|
63
|
+
const res = await apiDelete("/api/moderation/actors", { ap_id: apId });
|
|
64
|
+
await assertOk(res, "Failed to unblock actor");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function fetchReports(options?: {
|
|
68
|
+
onlyOpen?: boolean;
|
|
69
|
+
}): Promise<ModerationReport[]> {
|
|
70
|
+
const query = options?.onlyOpen ? "?status=open" : "";
|
|
71
|
+
const res = await apiFetch(`/api/moderation/reports${query}`);
|
|
72
|
+
await assertOk(res, "Failed to fetch reports");
|
|
73
|
+
const data = (await res.json()) as { reports?: ModerationReport[] };
|
|
74
|
+
return data.reports || [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function resolveReport(id: string, reopen = false): Promise<void> {
|
|
78
|
+
const res = await apiPost(
|
|
79
|
+
`/api/moderation/reports/${encodeURIComponent(id)}/resolve`,
|
|
80
|
+
{ reopen },
|
|
81
|
+
);
|
|
82
|
+
await assertOk(res, "Failed to resolve report");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// File an outbound abuse Flag against a remote actor (and optionally one of
|
|
86
|
+
// their posts), federated to their instance from this instance's actor.
|
|
87
|
+
export async function reportContent(input: {
|
|
88
|
+
targetActorApId: string;
|
|
89
|
+
postApId?: string;
|
|
90
|
+
reason?: string;
|
|
91
|
+
}): Promise<void> {
|
|
92
|
+
const res = await apiPost("/api/moderation/reports/outbound", {
|
|
93
|
+
target_actor_ap_id: input.targetActorApId,
|
|
94
|
+
post_ap_id: input.postApId,
|
|
95
|
+
reason: input.reason,
|
|
96
|
+
});
|
|
97
|
+
await assertOk(res, "Failed to submit report");
|
|
98
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Actor,
|
|
3
|
+
ActorStories,
|
|
4
|
+
Notification,
|
|
5
|
+
Post,
|
|
6
|
+
Story,
|
|
7
|
+
} from "../../types/index.ts";
|
|
8
|
+
|
|
9
|
+
type ActorLike = {
|
|
10
|
+
ap_id: string;
|
|
11
|
+
username?: string;
|
|
12
|
+
preferred_username?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function formatUsernameFromApId(
|
|
16
|
+
apId: string,
|
|
17
|
+
preferred?: string,
|
|
18
|
+
): string | null {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(apId);
|
|
21
|
+
const match = apId.match(/\/(users|groups)\/([^/]+)$/);
|
|
22
|
+
if (match) return `${match[2]}@${url.host}`;
|
|
23
|
+
if (preferred) return `${preferred}@${url.host}`;
|
|
24
|
+
} catch {
|
|
25
|
+
// Ignore malformed URLs and fallback to existing fields.
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeActor<T extends ActorLike>(actor: T): T {
|
|
31
|
+
if (!actor || !actor.ap_id) return actor;
|
|
32
|
+
const rawUsername = actor.username?.trim();
|
|
33
|
+
const formatted =
|
|
34
|
+
rawUsername ||
|
|
35
|
+
formatUsernameFromApId(actor.ap_id, actor.preferred_username) ||
|
|
36
|
+
actor.preferred_username ||
|
|
37
|
+
actor.username ||
|
|
38
|
+
actor.ap_id;
|
|
39
|
+
const preferred =
|
|
40
|
+
actor.preferred_username?.trim() ||
|
|
41
|
+
(formatted.includes("@") ? formatted.split("@")[0] : formatted);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
...actor,
|
|
45
|
+
username: formatted,
|
|
46
|
+
preferred_username: preferred,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const normalizePost = (post: Post): Post => ({
|
|
51
|
+
...post,
|
|
52
|
+
author: normalizeActor(post.author),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const normalizeStory = (story: Story): Story => ({
|
|
56
|
+
...story,
|
|
57
|
+
author: normalizeActor(story.author),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const normalizeActorStories = (stories: ActorStories): ActorStories => ({
|
|
61
|
+
...stories,
|
|
62
|
+
actor: normalizeActor(stories.actor),
|
|
63
|
+
stories: (stories.stories || []).map(normalizeStory),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const normalizeNotification = (
|
|
67
|
+
notification: Notification,
|
|
68
|
+
): Notification => ({
|
|
69
|
+
...notification,
|
|
70
|
+
actor: normalizeActor(notification.actor),
|
|
71
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { clearYurucommuApiTransport } from "../transport.ts";
|
|
3
|
+
import type { Notification } from "../../types/index.ts";
|
|
4
|
+
import { fetchNotifications } from "./notifications.ts";
|
|
5
|
+
|
|
6
|
+
function makeNotification(id: string): Notification {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
type: "like",
|
|
10
|
+
actor: {
|
|
11
|
+
ap_id: "https://example.com/ap/users/alice",
|
|
12
|
+
username: "alice@example.com",
|
|
13
|
+
preferred_username: "alice",
|
|
14
|
+
name: "Alice",
|
|
15
|
+
icon_url: null,
|
|
16
|
+
},
|
|
17
|
+
object_ap_id: null,
|
|
18
|
+
read: false,
|
|
19
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
20
|
+
} as unknown as Notification;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function withMockFetch<T>(
|
|
24
|
+
responseBody: unknown,
|
|
25
|
+
fn: () => Promise<T>,
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
const originalFetch = globalThis.fetch;
|
|
28
|
+
clearYurucommuApiTransport();
|
|
29
|
+
globalThis.fetch = ((_input: RequestInfo | URL) =>
|
|
30
|
+
Promise.resolve(
|
|
31
|
+
new Response(JSON.stringify(responseBody), {
|
|
32
|
+
status: 200,
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
}),
|
|
35
|
+
)) as typeof fetch;
|
|
36
|
+
try {
|
|
37
|
+
return await fn();
|
|
38
|
+
} finally {
|
|
39
|
+
globalThis.fetch = originalFetch;
|
|
40
|
+
clearYurucommuApiTransport();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Regression: the notifications "load older" affordance depends on the client
|
|
45
|
+
// surfacing the server's `has_more` (it was previously discarded).
|
|
46
|
+
test("fetchNotifications surfaces has_more as hasMore and maps notifications", async () => {
|
|
47
|
+
const result = await withMockFetch(
|
|
48
|
+
{ notifications: [makeNotification("n1")], has_more: true },
|
|
49
|
+
() => fetchNotifications({ limit: 20 }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result.notifications.map((n) => n.id)).toEqual(["n1"]);
|
|
53
|
+
expect(result.hasMore).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("fetchNotifications defaults hasMore to false when the server omits it", async () => {
|
|
57
|
+
const result = await withMockFetch({ notifications: [] }, () =>
|
|
58
|
+
fetchNotifications({ limit: 20 }),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(result.notifications).toEqual([]);
|
|
62
|
+
expect(result.hasMore).toBe(false);
|
|
63
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Notification } from "../../types/index.ts";
|
|
2
|
+
import { normalizeNotification } from "./normalize.ts";
|
|
3
|
+
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
|
+
|
|
5
|
+
export async function fetchNotifications(options?: {
|
|
6
|
+
limit?: number;
|
|
7
|
+
type?: string;
|
|
8
|
+
before?: string;
|
|
9
|
+
archived?: boolean;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
notifications: Notification[];
|
|
12
|
+
hasMore: boolean;
|
|
13
|
+
nextCursor: string | null;
|
|
14
|
+
}> {
|
|
15
|
+
const params = new URLSearchParams();
|
|
16
|
+
if (options?.limit) params.set("limit", options.limit.toString());
|
|
17
|
+
if (options?.type && options.type !== "all") params.set("type", options.type);
|
|
18
|
+
if (options?.before) params.set("before", options.before);
|
|
19
|
+
if (options?.archived) params.set("archived", "true");
|
|
20
|
+
const query = params.toString() ? `?${params}` : "";
|
|
21
|
+
const res = await apiFetch(`/api/notifications${query}`);
|
|
22
|
+
await assertOk(res, "Failed to load notifications");
|
|
23
|
+
const data = (await res.json()) as {
|
|
24
|
+
notifications?: Notification[];
|
|
25
|
+
has_more?: boolean;
|
|
26
|
+
next_cursor?: string | null;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
notifications: (data.notifications || []).map(normalizeNotification),
|
|
30
|
+
hasMore: data.has_more ?? false,
|
|
31
|
+
nextCursor: data.next_cursor ?? null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function fetchUnreadCount(): Promise<number> {
|
|
36
|
+
const res = await apiFetch("/api/notifications/unread/count");
|
|
37
|
+
const data = (await res.json()) as { count?: number };
|
|
38
|
+
return data.count || 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function markNotificationsRead(ids?: string[]): Promise<void> {
|
|
42
|
+
const res = await apiPost("/api/notifications/read", { ids });
|
|
43
|
+
await assertOk(res, "Failed to mark as read");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function archiveNotifications(ids: string[]): Promise<void> {
|
|
47
|
+
const res = await apiPost("/api/notifications/archive", { ids });
|
|
48
|
+
await assertOk(res, "Failed to archive");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function unarchiveNotifications(ids: string[]): Promise<void> {
|
|
52
|
+
const res = await apiDelete("/api/notifications/archive", { ids });
|
|
53
|
+
await assertOk(res, "Failed to unarchive");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function archiveAllNotifications(): Promise<number> {
|
|
57
|
+
const res = await apiPost("/api/notifications/archive/all", {});
|
|
58
|
+
await assertOk(res, "Failed to archive all");
|
|
59
|
+
const data = (await res.json()) as { archived_count?: number };
|
|
60
|
+
return data.archived_count ?? 0;
|
|
61
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { clearYurucommuApiTransport } from "../transport.ts";
|
|
3
|
+
import type { Post } from "../../types/index.ts";
|
|
4
|
+
import { createPost, fetchBookmarks, fetchTimeline } from "./posts.ts";
|
|
5
|
+
|
|
6
|
+
function makePost(overrides: Partial<Post> = {}): Post {
|
|
7
|
+
return {
|
|
8
|
+
ap_id: "https://example.com/ap/objects/post-1",
|
|
9
|
+
type: "Note",
|
|
10
|
+
author: {
|
|
11
|
+
ap_id: "https://example.com/ap/users/alice",
|
|
12
|
+
username: "alice@example.com",
|
|
13
|
+
preferred_username: "alice",
|
|
14
|
+
name: "Alice",
|
|
15
|
+
icon_url: null,
|
|
16
|
+
},
|
|
17
|
+
content: "hello",
|
|
18
|
+
summary: null,
|
|
19
|
+
attachments: [],
|
|
20
|
+
in_reply_to: null,
|
|
21
|
+
visibility: "public",
|
|
22
|
+
community_ap_id: null,
|
|
23
|
+
like_count: 0,
|
|
24
|
+
reply_count: 0,
|
|
25
|
+
announce_count: 0,
|
|
26
|
+
published: "2026-01-01T00:00:00.000Z",
|
|
27
|
+
edited_at: null,
|
|
28
|
+
liked: false,
|
|
29
|
+
bookmarked: false,
|
|
30
|
+
reposted: false,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function withMockFetch<T>(
|
|
36
|
+
responseBody: unknown,
|
|
37
|
+
fn: () => Promise<T>,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const originalFetch = globalThis.fetch;
|
|
40
|
+
clearYurucommuApiTransport();
|
|
41
|
+
globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => {
|
|
42
|
+
return Promise.resolve(
|
|
43
|
+
new Response(JSON.stringify(responseBody), {
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}) as typeof fetch;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await fn();
|
|
52
|
+
} finally {
|
|
53
|
+
globalThis.fetch = originalFetch;
|
|
54
|
+
clearYurucommuApiTransport();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("createPost reads the current wrapped post response", async () => {
|
|
59
|
+
const post = makePost();
|
|
60
|
+
|
|
61
|
+
const result = await withMockFetch({ post }, () =>
|
|
62
|
+
createPost({ content: "hello" }),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(result.ap_id).toBe(post.ap_id);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("fetchBookmarks reads the posts + pagination fields", async () => {
|
|
69
|
+
const post = makePost({ bookmarked: true });
|
|
70
|
+
|
|
71
|
+
const result = await withMockFetch(
|
|
72
|
+
{ posts: [post], has_more: true, next_cursor: "c1" },
|
|
73
|
+
() => fetchBookmarks(),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(result.posts.map((p) => p.ap_id)).toEqual([post.ap_id]);
|
|
77
|
+
expect(result.hasMore).toBe(true);
|
|
78
|
+
expect(result.nextCursor).toBe("c1");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Regression: the timeline client must SURFACE the server's composite cursor (so
|
|
82
|
+
// loadMore can echo it back as `before`). It was discarding `next_cursor` and
|
|
83
|
+
// paginating with a post's ap_id instead, which the server decodes as a
|
|
84
|
+
// published-only cursor whose string compare matches every row → the feed
|
|
85
|
+
// re-serves page 1 forever and never advances.
|
|
86
|
+
test("fetchTimeline surfaces the server next_cursor and has_more", async () => {
|
|
87
|
+
const post = makePost();
|
|
88
|
+
const result = await withMockFetch(
|
|
89
|
+
{
|
|
90
|
+
posts: [post],
|
|
91
|
+
has_more: true,
|
|
92
|
+
next_cursor: "2026-01-01T00:00:00.000Z\u0000" + post.ap_id,
|
|
93
|
+
},
|
|
94
|
+
() => fetchTimeline({ limit: 20 }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(result.posts.map((p) => p.ap_id)).toEqual([post.ap_id]);
|
|
98
|
+
expect(result.hasMore).toBe(true);
|
|
99
|
+
expect(result.nextCursor).toBe("2026-01-01T00:00:00.000Z\u0000" + post.ap_id);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("fetchTimeline defaults to no cursor / hasMore=false when the server omits them", async () => {
|
|
103
|
+
const result = await withMockFetch({ posts: [] }, () =>
|
|
104
|
+
fetchTimeline({ limit: 20 }),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(result.posts).toEqual([]);
|
|
108
|
+
expect(result.hasMore).toBe(false);
|
|
109
|
+
expect(result.nextCursor).toBe(null);
|
|
110
|
+
});
|