@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,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared object-delete cascade.
|
|
3
|
+
*
|
|
4
|
+
* Migrations declare `ON DELETE CASCADE` on the object's interaction/edge
|
|
5
|
+
* tables (likes, announces, bookmarks, object_recipients, story_views,
|
|
6
|
+
* story_votes, story_shares), but SQLite enforces foreign keys only when
|
|
7
|
+
* `PRAGMA foreign_keys = ON` is set on the connection — which is NOT reliably
|
|
8
|
+
* the case on every runtime/connection (D1 does not honour it, and the libsql
|
|
9
|
+
* connection is not guaranteed to have it). Deleting an object row therefore
|
|
10
|
+
* orphans those child rows on at least some runtimes.
|
|
11
|
+
*
|
|
12
|
+
* This helper deletes every child row keyed by `objectApId` deterministically,
|
|
13
|
+
* independent of FK enforcement, so the data stays consistent on all runtimes.
|
|
14
|
+
* It does NOT delete the `objects` row itself — callers do that (and own any
|
|
15
|
+
* counter/fanout side effects) — and it intentionally leaves `activities`
|
|
16
|
+
* alone, whose `object_ap_id` is `ON DELETE SET NULL`, not CASCADE.
|
|
17
|
+
*
|
|
18
|
+
* Used by BOTH the local post-delete path (routes.ts `DELETE /posts/:id`) and
|
|
19
|
+
* the remote `handleDelete` inbox path so neither can orphan rows.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
|
23
|
+
import type { Database } from "../../../db/index.ts";
|
|
24
|
+
import type { IObjectStorage } from "../../runtime/types.ts";
|
|
25
|
+
import {
|
|
26
|
+
activities,
|
|
27
|
+
actors,
|
|
28
|
+
announces,
|
|
29
|
+
bookmarks,
|
|
30
|
+
communities,
|
|
31
|
+
inbox as inboxTable,
|
|
32
|
+
likes,
|
|
33
|
+
mediaUploads,
|
|
34
|
+
objectRecipients,
|
|
35
|
+
objects,
|
|
36
|
+
storyShares,
|
|
37
|
+
storyViews,
|
|
38
|
+
storyVotes,
|
|
39
|
+
} from "../../../db/index.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reap the `media_uploads` rows attached to a single object.
|
|
43
|
+
*
|
|
44
|
+
* `media_uploads` has no FK column to `objects` — the link is the same one the
|
|
45
|
+
* media auth path uses (`routes/media.ts`): an object references its uploads by
|
|
46
|
+
* embedding the media URL / `r2_key` in `attachments_json`, and each upload is
|
|
47
|
+
* the unique, indexed `r2_key` (`media_uploads_r2_key_idx`) owned by the
|
|
48
|
+
* object's author (`uploader_ap_id`, `media_uploads_uploader_idx`). So we scan
|
|
49
|
+
* the author's uploads (indexed equality) and delete the ones whose `r2_key` is
|
|
50
|
+
* substring-referenced in this object's `attachments_json`, mirroring the
|
|
51
|
+
* `attachmentMatches` semantics. There is no engine-level CASCADE for this edge,
|
|
52
|
+
* so without this the upload rows orphan on every runtime.
|
|
53
|
+
*
|
|
54
|
+
* Returns silently when the object row is already gone (caller may delete the
|
|
55
|
+
* object before or after calling this) or has no attachments.
|
|
56
|
+
*
|
|
57
|
+
* When a `media` object-store binding is provided, the backing R2 blobs for the
|
|
58
|
+
* reaped uploads are best-effort deleted by `r2_key` (mirroring the
|
|
59
|
+
* account-delete teardown in `routes/actors.ts`). R2 errors never fail the DB
|
|
60
|
+
* delete; without this the blobs leak forever (there is no orphaned-key GC).
|
|
61
|
+
*
|
|
62
|
+
* A blob is only purged when its `r2_key` is no longer referenced by any OTHER
|
|
63
|
+
* still-present object of the same author (an `r2_key`/media URL can be embedded
|
|
64
|
+
* in more than one object's `attachments_json` even though the `media_uploads`
|
|
65
|
+
* row is unique). Deleting the blob while another object still shows it would
|
|
66
|
+
* data-loss the shared media, so the R2 delete is gated on the reference count
|
|
67
|
+
* dropping to zero. The DB-row delete is unconditional (the reaped rows belong
|
|
68
|
+
* to this object's reap set regardless).
|
|
69
|
+
*/
|
|
70
|
+
async function deleteAttachedMediaUploads(
|
|
71
|
+
db: Database,
|
|
72
|
+
objectApId: string,
|
|
73
|
+
media?: IObjectStorage,
|
|
74
|
+
): Promise<string[]> {
|
|
75
|
+
const obj = await db
|
|
76
|
+
.select({
|
|
77
|
+
attributedTo: objects.attributedTo,
|
|
78
|
+
attachmentsJson: objects.attachmentsJson,
|
|
79
|
+
})
|
|
80
|
+
.from(objects)
|
|
81
|
+
.where(eq(objects.apId, objectApId))
|
|
82
|
+
.get();
|
|
83
|
+
|
|
84
|
+
// No object row (already deleted) or no attachment payload: nothing to reap.
|
|
85
|
+
if (!obj || !obj.attachmentsJson || obj.attachmentsJson === "[]") return [];
|
|
86
|
+
|
|
87
|
+
const attachmentsJson = obj.attachmentsJson;
|
|
88
|
+
|
|
89
|
+
// An attachment may reference an upload by EITHER its `r2_key`
|
|
90
|
+
// (`uploads/<id>.<ext>`) OR its served `/media/<id>.<ext>` URL — the auth-path
|
|
91
|
+
// matcher (media.ts attachmentMatches) accepts both, but the GC historically
|
|
92
|
+
// matched only `r2_key`. A stored attachment carrying only the `/media/` URL
|
|
93
|
+
// (any client that omits `r2_key`) therefore slipped the reap and leaked its
|
|
94
|
+
// blob forever. Match BOTH forms here so the GC is symmetric with the auth path.
|
|
95
|
+
const mediaUrlForKey = (r2Key: string): string =>
|
|
96
|
+
r2Key.startsWith("uploads/")
|
|
97
|
+
? `/media/${r2Key.slice("uploads/".length)}`
|
|
98
|
+
: r2Key;
|
|
99
|
+
|
|
100
|
+
// Indexed scan over the author's own uploads, then substring-match the upload
|
|
101
|
+
// identity (r2_key OR /media URL) against the object's attachment payload.
|
|
102
|
+
const candidates = await db
|
|
103
|
+
.select({ id: mediaUploads.id, r2Key: mediaUploads.r2Key })
|
|
104
|
+
.from(mediaUploads)
|
|
105
|
+
.where(eq(mediaUploads.uploaderApId, obj.attributedTo));
|
|
106
|
+
|
|
107
|
+
const orphaned = candidates.filter(
|
|
108
|
+
(m) =>
|
|
109
|
+
attachmentsJson.includes(m.r2Key) ||
|
|
110
|
+
attachmentsJson.includes(mediaUrlForKey(m.r2Key)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (orphaned.length === 0) return [];
|
|
114
|
+
|
|
115
|
+
// Before any R2 purge, find which of these `r2_key`s are still referenced by
|
|
116
|
+
// ANOTHER (different `ap_id`), still-present object of the same author. Such
|
|
117
|
+
// a key must NOT have its blob deleted — another object still shows it. We
|
|
118
|
+
// run this read while the rows are still present so an OTHER object whose
|
|
119
|
+
// `attachments_json` happens to point back at this object's reap set is
|
|
120
|
+
// honoured; the lookup is scoped to the author's own objects (indexed) and
|
|
121
|
+
// excludes the object being reaped.
|
|
122
|
+
const stillReferencedKeys = new Set<string>();
|
|
123
|
+
if (media) {
|
|
124
|
+
// For each orphaned key, ask SQL (indexed by attributedTo) whether ANOTHER
|
|
125
|
+
// present object of this author still references it — instead of loading
|
|
126
|
+
// EVERY other object's attachmentsJson into memory and substring-scanning
|
|
127
|
+
// them all (O(objects × keys), unbounded for a prolific author). `orphaned`
|
|
128
|
+
// is only this object's own attachments (a handful), so this is a small,
|
|
129
|
+
// bounded set of indexed lookups. Uses instr() (literal substring), NOT
|
|
130
|
+
// `LIKE '%<key>%'`: a 73-char `uploads/<64-hex>.png` key in a `%...%` pattern
|
|
131
|
+
// exceeds D1's LIKE pattern-complexity limit (SQLITE_ERROR 7500, ~50 chars).
|
|
132
|
+
// Mirrors media.ts findReferencingObject.
|
|
133
|
+
for (const m of orphaned) {
|
|
134
|
+
const ref = await db
|
|
135
|
+
.select({ apId: objects.apId })
|
|
136
|
+
.from(objects)
|
|
137
|
+
.where(
|
|
138
|
+
and(
|
|
139
|
+
eq(objects.attributedTo, obj.attributedTo),
|
|
140
|
+
ne(objects.apId, objectApId),
|
|
141
|
+
or(
|
|
142
|
+
sql`instr(${objects.attachmentsJson}, ${m.r2Key}) > 0`,
|
|
143
|
+
sql`instr(${objects.attachmentsJson}, ${mediaUrlForKey(m.r2Key)}) > 0`,
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
.get();
|
|
148
|
+
if (ref) stillReferencedKeys.add(m.r2Key);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Delete the media_uploads rows whose blob we are actually purging. Rows
|
|
153
|
+
// whose `r2_key` is still referenced by another present object are KEPT (row
|
|
154
|
+
// AND blob) so that, when that final referencer is later deleted, this same
|
|
155
|
+
// candidates scan still finds the row and can GC the now-orphaned blob —
|
|
156
|
+
// otherwise the shared blob would leak permanently once its DB row vanished.
|
|
157
|
+
// Without a `media` binding there is no R2 to GC, so all rows are removed.
|
|
158
|
+
const idsToDelete = media
|
|
159
|
+
? orphaned.filter((m) => !stillReferencedKeys.has(m.r2Key)).map((m) => m.id)
|
|
160
|
+
: orphaned.map((m) => m.id);
|
|
161
|
+
if (idsToDelete.length > 0) {
|
|
162
|
+
await db.delete(mediaUploads).where(inArray(mediaUploads.id, idsToDelete));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Return the keys whose reference count has now dropped to zero. The caller
|
|
166
|
+
// purges them via purgeMediaBlobs AFTER it deletes the objects row, so the
|
|
167
|
+
// IRREVERSIBLE R2 delete is the trailing step: if the objects-row delete fails
|
|
168
|
+
// the blob is still present and the post is recoverable, rather than the post
|
|
169
|
+
// surviving with a permanently-deleted blob (a broken image with no recovery).
|
|
170
|
+
// Keys still embedded in another present object's `attachments_json` are kept
|
|
171
|
+
// (blob + media_uploads row) so shared media isn't lost.
|
|
172
|
+
return media
|
|
173
|
+
? orphaned.map((m) => m.r2Key).filter((k) => !stillReferencedKeys.has(k))
|
|
174
|
+
: [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Best-effort purge of unreferenced R2 blobs, intended as the TRAILING step
|
|
179
|
+
* after the objects row has been deleted (see deleteObjectCascade's return).
|
|
180
|
+
* R2 errors never propagate — a failed purge degrades to a leaked blob, the
|
|
181
|
+
* system's already-accepted media failure mode.
|
|
182
|
+
*/
|
|
183
|
+
export async function purgeMediaBlobs(
|
|
184
|
+
media: IObjectStorage | undefined,
|
|
185
|
+
keys: string[],
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
if (!media || keys.length === 0) return;
|
|
188
|
+
try {
|
|
189
|
+
await media.delete(keys);
|
|
190
|
+
} catch {
|
|
191
|
+
// Swallow: storage purge is best-effort and must not fail the delete flow.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reap a profile / community image blob that was just REPLACED.
|
|
197
|
+
*
|
|
198
|
+
* Avatar / header / community-icon media is attached to no object, so neither
|
|
199
|
+
* the object-delete GC nor the expired-story reap ever touches it. Replacing the
|
|
200
|
+
* image — a normal, repeatable user action — would otherwise orphan the prior
|
|
201
|
+
* blob + `media_uploads` row in R2 forever (there is no orphaned-key sweep).
|
|
202
|
+
*
|
|
203
|
+
* Call this AFTER the new URL is persisted: it reaps the OLD `/media/...` URL's
|
|
204
|
+
* upload iff that URL is no longer referenced by ANY actor avatar/header, any
|
|
205
|
+
* non-deleted community icon, or any of the uploader's objects' attachments
|
|
206
|
+
* (URL or `r2_key` form). No-op for empty/external URLs or an upload owned by a
|
|
207
|
+
* different actor. Best-effort: never throws into the caller's response path.
|
|
208
|
+
*/
|
|
209
|
+
export async function reapReplacedMediaUrl(
|
|
210
|
+
db: Database,
|
|
211
|
+
oldUrl: string | null | undefined,
|
|
212
|
+
uploaderApId: string,
|
|
213
|
+
media?: IObjectStorage,
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
try {
|
|
216
|
+
if (!oldUrl || !oldUrl.startsWith("/media/")) return;
|
|
217
|
+
const filename = oldUrl.slice("/media/".length);
|
|
218
|
+
if (!filename || filename.includes("/") || filename.includes("..")) return;
|
|
219
|
+
const r2Key = `uploads/${filename}`;
|
|
220
|
+
|
|
221
|
+
// Only ever reap a blob THIS actor uploaded.
|
|
222
|
+
const owned = await db
|
|
223
|
+
.select({ id: mediaUploads.id })
|
|
224
|
+
.from(mediaUploads)
|
|
225
|
+
.where(
|
|
226
|
+
and(
|
|
227
|
+
eq(mediaUploads.r2Key, r2Key),
|
|
228
|
+
eq(mediaUploads.uploaderApId, uploaderApId),
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
.get();
|
|
232
|
+
if (!owned) return;
|
|
233
|
+
|
|
234
|
+
// Still an actor avatar/header somewhere (e.g. set as both icon and header)?
|
|
235
|
+
const actorRef = await db
|
|
236
|
+
.select({ apId: actors.apId })
|
|
237
|
+
.from(actors)
|
|
238
|
+
.where(or(eq(actors.iconUrl, oldUrl), eq(actors.headerUrl, oldUrl)))
|
|
239
|
+
.get();
|
|
240
|
+
if (actorRef) return;
|
|
241
|
+
|
|
242
|
+
// Still a (non-deleted) community icon?
|
|
243
|
+
const communityRef = await db
|
|
244
|
+
.select({ apId: communities.apId })
|
|
245
|
+
.from(communities)
|
|
246
|
+
.where(
|
|
247
|
+
and(eq(communities.iconUrl, oldUrl), isNull(communities.deletedAt)),
|
|
248
|
+
)
|
|
249
|
+
.get();
|
|
250
|
+
if (communityRef) return;
|
|
251
|
+
|
|
252
|
+
// Still embedded in one of the uploader's objects' attachments (URL or key)?
|
|
253
|
+
const objectRef = await db
|
|
254
|
+
.select({ apId: objects.apId })
|
|
255
|
+
.from(objects)
|
|
256
|
+
.where(
|
|
257
|
+
and(
|
|
258
|
+
eq(objects.attributedTo, uploaderApId),
|
|
259
|
+
or(
|
|
260
|
+
sql`instr(${objects.attachmentsJson}, ${oldUrl}) > 0`,
|
|
261
|
+
sql`instr(${objects.attachmentsJson}, ${r2Key}) > 0`,
|
|
262
|
+
),
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
.get();
|
|
266
|
+
if (objectRef) return;
|
|
267
|
+
|
|
268
|
+
// Unreferenced: drop the DB row, then best-effort purge the blob.
|
|
269
|
+
await db.delete(mediaUploads).where(eq(mediaUploads.id, owned.id));
|
|
270
|
+
await purgeMediaBlobs(media, [r2Key]);
|
|
271
|
+
} catch {
|
|
272
|
+
// Best-effort hygiene: a failure just leaves the prior blob (the existing
|
|
273
|
+
// accepted media failure mode), never breaks the profile/community update.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Delete all child rows that reference `objectApId` (the object's `ap_id`).
|
|
279
|
+
*
|
|
280
|
+
* Mirrors the `ON DELETE CASCADE` edges declared in the migrations:
|
|
281
|
+
* likes, announces, bookmarks, object_recipients,
|
|
282
|
+
* story_views, story_votes, story_shares.
|
|
283
|
+
*
|
|
284
|
+
* Also reaps the object-attached `media_uploads` rows, which have no FK to
|
|
285
|
+
* `objects` and would otherwise orphan (see `deleteAttachedMediaUploads`). When
|
|
286
|
+
* a `media` binding is passed, the backing R2 blobs are best-effort deleted too
|
|
287
|
+
* so storage does not leak; pass `c.env.MEDIA` from the request context.
|
|
288
|
+
*
|
|
289
|
+
* Does not touch the `objects` row or `activities` (SET NULL, not CASCADE).
|
|
290
|
+
*/
|
|
291
|
+
export async function deleteObjectCascade(
|
|
292
|
+
db: Database,
|
|
293
|
+
objectApId: string,
|
|
294
|
+
media?: IObjectStorage,
|
|
295
|
+
): Promise<string[]> {
|
|
296
|
+
// Reap the media_uploads rows + child rows while the object row (and its
|
|
297
|
+
// attachments_json) is still readable. Returns the R2 keys whose blobs are now
|
|
298
|
+
// unreferenced — the caller MUST purge them via purgeMediaBlobs AFTER it has
|
|
299
|
+
// deleted the objects row, so the irreversible R2 delete is the trailing step.
|
|
300
|
+
const mediaKeys = await deleteAttachedMediaUploads(db, objectApId, media);
|
|
301
|
+
await db.delete(likes).where(eq(likes.objectApId, objectApId));
|
|
302
|
+
await db.delete(announces).where(eq(announces.objectApId, objectApId));
|
|
303
|
+
await db.delete(bookmarks).where(eq(bookmarks.objectApId, objectApId));
|
|
304
|
+
await db
|
|
305
|
+
.delete(objectRecipients)
|
|
306
|
+
.where(eq(objectRecipients.objectApId, objectApId));
|
|
307
|
+
await db.delete(storyViews).where(eq(storyViews.storyApId, objectApId));
|
|
308
|
+
await db.delete(storyVotes).where(eq(storyVotes.storyApId, objectApId));
|
|
309
|
+
await db.delete(storyShares).where(eq(storyShares.storyApId, objectApId));
|
|
310
|
+
|
|
311
|
+
// Reap NOTIFICATION inbox rows that pointed at this object (a Like / Announce /
|
|
312
|
+
// reply-Create that notified a local user). After the object row is gone the
|
|
313
|
+
// notifications query's leftJoin(objects) yields NULL, so these would otherwise
|
|
314
|
+
// render as dangling notifications (blank content, dead link) AND keep inflating
|
|
315
|
+
// the unread badge (the inbox row stays read=0). Delete the INBOX rows only, via
|
|
316
|
+
// a subquery (D1-param-safe) — NOT the `activities` rows, which may include the
|
|
317
|
+
// outbound federation Delete that must survive to be delivered.
|
|
318
|
+
await db
|
|
319
|
+
.delete(inboxTable)
|
|
320
|
+
.where(
|
|
321
|
+
inArray(
|
|
322
|
+
inboxTable.activityApId,
|
|
323
|
+
db
|
|
324
|
+
.select({ id: activities.apId })
|
|
325
|
+
.from(activities)
|
|
326
|
+
.where(eq(activities.objectApId, objectApId)),
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
return mediaKeys;
|
|
330
|
+
}
|