Skip to content

Commit 83b62e2

Browse files
committed
Merge tag '0.7.12'
Hollo 0.7.12
2 parents a01d896 + 5b6d2bc commit 83b62e2

4 files changed

Lines changed: 326 additions & 20 deletions

File tree

CHANGES.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,19 @@ To be released.
140140
[Fedify debugger]: https://fedify.dev/manual/debug
141141

142142

143+
Version 0.7.12
144+
--------------
145+
146+
Released on April 25, 2026.
147+
148+
- Fixed a federation bug where duplicate Announce activities from the same
149+
actor for the same post could fail with a database uniqueness error instead
150+
of being treated idempotently. [[#443], [#444]]
151+
152+
[#443]: https://github.com/fedify-dev/hollo/issues/443
153+
[#444]: https://github.com/fedify-dev/hollo/issues/444
154+
155+
143156
Version 0.7.11
144157
--------------
145158

src/federation/inbox.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ export async function onPostShared(
654654
ctx.origin,
655655
getPersistOptions(ctx),
656656
);
657+
if (post == null || !post.isNew) return;
657658
if (post?.sharingId != null) {
658659
await updatePostStats(db, { id: post.sharingId });
659660
}

src/federation/post.test.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import type { InboxContext } from "@fedify/fedify";
2+
import { Announce, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab";
3+
import { and, eq } from "drizzle-orm";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
import { cleanDatabase } from "../../tests/helpers";
7+
import { createAccount } from "../../tests/helpers/oauth";
8+
import db from "../db";
9+
import { accounts, follows, instances, posts, timelinePosts } from "../schema";
10+
import type { Uuid } from "../uuid";
11+
import { onPostShared } from "./inbox";
12+
import { persistSharingPost } from "./post";
13+
14+
async function seedRemoteAccount(username: string) {
15+
const id = crypto.randomUUID() as Uuid;
16+
const iri = `https://remote.test/@${username}`;
17+
await db
18+
.insert(instances)
19+
.values({
20+
host: "remote.test",
21+
software: "mastodon",
22+
softwareVersion: null,
23+
})
24+
.onConflictDoNothing();
25+
await db.insert(accounts).values({
26+
id,
27+
iri,
28+
type: "Person",
29+
name: username,
30+
handle: `@${username}@remote.test`,
31+
bioHtml: "",
32+
emojis: {},
33+
fieldHtmls: {},
34+
aliases: [],
35+
protected: false,
36+
inboxUrl: `${iri}/inbox`,
37+
followersUrl: `${iri}/followers`,
38+
sharedInboxUrl: "https://remote.test/inbox",
39+
featuredUrl: `${iri}/featured`,
40+
instanceHost: "remote.test",
41+
published: new Date(),
42+
});
43+
const account = await db.query.accounts.findFirst({
44+
where: eq(accounts.id, id),
45+
with: { owner: true },
46+
});
47+
if (account == null) throw new Error("Failed to seed remote account");
48+
return account;
49+
}
50+
51+
function createPerson(account: {
52+
handle: string;
53+
iri: string;
54+
followersUrl: string | null;
55+
}) {
56+
return new Person({
57+
id: new URL(account.iri),
58+
name: account.handle,
59+
inbox: new URL(`${account.iri}/inbox`),
60+
followers:
61+
account.followersUrl == null ? null : new URL(account.followersUrl),
62+
});
63+
}
64+
65+
function createAnnounce(id: string, actor: Person, object: string | Note) {
66+
return new Announce({
67+
id: new URL(id),
68+
actor,
69+
object: typeof object === "string" ? new URL(object) : object,
70+
to: PUBLIC_COLLECTION,
71+
});
72+
}
73+
74+
function createCtx() {
75+
const forwardActivity = vi.fn(async () => undefined);
76+
const ctx = {
77+
origin: "https://hollo.test",
78+
parseUri: () => null,
79+
forwardActivity,
80+
} as unknown as InboxContext<void>;
81+
return { ctx, forwardActivity };
82+
}
83+
84+
async function seedShareScenario() {
85+
const owner = await createAccount({ username: "hollo" });
86+
const author = await seedRemoteAccount("author");
87+
const sharer = await seedRemoteAccount("sharer");
88+
await db.insert(follows).values({
89+
iri: `https://hollo.test/@hollo#follows/${sharer.id}`,
90+
followingId: sharer.id,
91+
followerId: owner.id as Uuid,
92+
approved: new Date(),
93+
shares: true,
94+
notify: false,
95+
});
96+
const originalPostId = crypto.randomUUID() as Uuid;
97+
const originalPostIri = "https://remote.test/@author/posts/1";
98+
await db.insert(posts).values({
99+
id: originalPostId,
100+
iri: originalPostIri,
101+
type: "Note",
102+
accountId: author.id,
103+
visibility: "public",
104+
contentHtml: "<p>Shared once</p>",
105+
content: "Shared once",
106+
tags: {},
107+
emojis: {},
108+
sensitive: false,
109+
published: new Date(),
110+
updated: new Date(),
111+
});
112+
return {
113+
actor: createPerson(sharer),
114+
object: new Note({ id: new URL(originalPostIri) }),
115+
originalPostId,
116+
originalPostIri,
117+
sharer,
118+
};
119+
}
120+
121+
async function seedLocalPostShareScenario() {
122+
const author = await createAccount({ username: "hollo" });
123+
const sharer = await seedRemoteAccount("sharer");
124+
const originalPostId = crypto.randomUUID() as Uuid;
125+
const originalPostIri = `https://hollo.test/@hollo/${originalPostId}`;
126+
await db.insert(posts).values({
127+
id: originalPostId,
128+
iri: originalPostIri,
129+
type: "Note",
130+
accountId: author.id as Uuid,
131+
visibility: "public",
132+
contentHtml: "<p>Local post</p>",
133+
content: "Local post",
134+
tags: {},
135+
emojis: {},
136+
sensitive: false,
137+
published: new Date(),
138+
updated: new Date(),
139+
});
140+
return {
141+
actor: createPerson(sharer),
142+
object: new Note({ id: new URL(originalPostIri) }),
143+
originalPostIri,
144+
};
145+
}
146+
147+
describe("persistSharingPost", () => {
148+
beforeEach(async () => {
149+
await cleanDatabase();
150+
});
151+
152+
it("returns an existing share when the same actor announces the same post with another IRI", async () => {
153+
expect.assertions(5);
154+
const { actor, object, originalPostId, originalPostIri, sharer } =
155+
await seedShareScenario();
156+
157+
const first = await persistSharingPost(
158+
db,
159+
createAnnounce(
160+
"https://remote.test/@sharer/announces/1",
161+
actor,
162+
originalPostIri,
163+
),
164+
object,
165+
"https://hollo.test",
166+
{ account: sharer },
167+
);
168+
const second = await persistSharingPost(
169+
db,
170+
createAnnounce(
171+
"https://remote.test/@sharer/announces/2",
172+
actor,
173+
originalPostIri,
174+
),
175+
object,
176+
"https://hollo.test",
177+
{ account: sharer },
178+
);
179+
180+
const sharingPosts = await db.query.posts.findMany({
181+
where: and(
182+
eq(posts.accountId, sharer.id),
183+
eq(posts.sharingId, originalPostId),
184+
),
185+
});
186+
const timelineRows = await db.query.timelinePosts.findMany({
187+
where: eq(timelinePosts.postId, first!.id),
188+
});
189+
const originalPost = await db.query.posts.findFirst({
190+
where: eq(posts.id, originalPostId),
191+
});
192+
expect(first).not.toBeNull();
193+
expect(second?.id).toBe(first?.id);
194+
expect(sharingPosts).toHaveLength(1);
195+
expect(timelineRows).toHaveLength(1);
196+
expect(originalPost?.sharesCount).toBe(1);
197+
});
198+
199+
it("handles concurrent duplicate announces atomically", async () => {
200+
expect.assertions(5);
201+
const { actor, object, originalPostId, originalPostIri, sharer } =
202+
await seedShareScenario();
203+
204+
const [first, second] = await Promise.all([
205+
persistSharingPost(
206+
db,
207+
createAnnounce(
208+
"https://remote.test/@sharer/announces/1",
209+
actor,
210+
originalPostIri,
211+
),
212+
object,
213+
"https://hollo.test",
214+
{ account: sharer },
215+
),
216+
persistSharingPost(
217+
db,
218+
createAnnounce(
219+
"https://remote.test/@sharer/announces/2",
220+
actor,
221+
originalPostIri,
222+
),
223+
object,
224+
"https://hollo.test",
225+
{ account: sharer },
226+
),
227+
]);
228+
229+
const sharingPosts = await db.query.posts.findMany({
230+
where: and(
231+
eq(posts.accountId, sharer.id),
232+
eq(posts.sharingId, originalPostId),
233+
),
234+
});
235+
const timelineRows = await db.query.timelinePosts.findMany({
236+
where: eq(timelinePosts.postId, first!.id),
237+
});
238+
const originalPost = await db.query.posts.findFirst({
239+
where: eq(posts.id, originalPostId),
240+
});
241+
expect(first).not.toBeNull();
242+
expect(second?.id).toBe(first?.id);
243+
expect(sharingPosts).toHaveLength(1);
244+
expect(timelineRows).toHaveLength(1);
245+
expect(originalPost?.sharesCount).toBe(1);
246+
});
247+
248+
it("does not forward duplicate announces for a local post", async () => {
249+
expect.assertions(1);
250+
const { actor, object } = await seedLocalPostShareScenario();
251+
const { ctx, forwardActivity } = createCtx();
252+
253+
await onPostShared(
254+
ctx,
255+
createAnnounce("https://remote.test/@sharer/announces/1", actor, object),
256+
);
257+
await onPostShared(
258+
ctx,
259+
createAnnounce("https://remote.test/@sharer/announces/2", actor, object),
260+
);
261+
262+
expect(forwardActivity).toHaveBeenCalledOnce();
263+
});
264+
});

0 commit comments

Comments
 (0)