Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ To be released.

- Upgraded Fedify to 2.4.1.

- Quotes awaiting FEP-044f approval now expose `quoteUrl` for compatibility
- Quotes awaiting [FEP-044f] approval now expose `quoteUrl` for compatibility
with older software when the original public or unlisted post explicitly
allows automatic approval for everyone. The `quote` field and generated
fallback still wait for approval. Rejection or revocation removes the
reference through an `Update`; failed rejection updates can be retried
without changing the quote state or counts. [[#602]]

- FEP-044f quote authorization now uses Fedify's
- [FEP-044f] quote authorization now uses Fedify's
*@fedify/interaction-controls* package to build and verify quote
requests and authorizations and to evaluate local quote policies.
Verification is stricter in a few cases: [[#635], [#641]]
Expand All @@ -33,6 +33,14 @@ To be released.
- Outgoing `Accept` and `Reject` responses to quote requests now have
explicit IDs and address the requester in `to`.

- Remote quotes without [FEP-044f] approval are now accepted when the local
target is public or unlisted, allows automatic quotes by everyone
(including the default policy), and neither account blocks the other.
Accepted quotes have a local authorization and appear in quote counts
and notifications. Cached unauthorized quotes are reevaluated on later
updates; there is no backfill. Revoked quotes retain their state on
updates for the same target, including remote targets. [[#640], [#660]]

- Added WebP (`image/webp`) as an accepted format for profile avatar and
banner image uploads. Previously only JPEG, PNG, and GIF were accepted
by both the Mastodon-compatible
Expand Down Expand Up @@ -124,6 +132,7 @@ To be released.
14 adds post-quantum ML-DSA passkey support on runtimes that provide the
algorithms. [[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]]

[FEP-044f]: https://w3id.org/fep/044f
[FEP-c0e0]: https://w3id.org/fep/c0e0
[Gukhanmun]: https://gukhanmun.org/
[RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749#section-3.3
Expand All @@ -139,12 +148,14 @@ To be released.
[#637]: https://github.com/fedify-dev/hollo/issues/637
[#638]: https://github.com/fedify-dev/hollo/issues/638
[#639]: https://github.com/fedify-dev/hollo/issues/639
[#640]: https://github.com/fedify-dev/hollo/issues/640
[#641]: https://github.com/fedify-dev/hollo/pull/641
[#645]: https://github.com/fedify-dev/hollo/pull/645
[#646]: https://github.com/fedify-dev/hollo/issues/646
[#648]: https://github.com/fedify-dev/hollo/pull/648
[#649]: https://github.com/fedify-dev/hollo/pull/649
[#650]: https://github.com/fedify-dev/hollo/pull/650
[#660]: https://github.com/fedify-dev/hollo/pull/660


Version 0.9.22
Expand Down Expand Up @@ -926,7 +937,6 @@ Released on May 20, 2026.
- Added Traditional Chinese (繁體中文; `zh-TW`) documentation.

[Split-domain WebFinger guide]: https://docs.hollo.social/install/split-domain/
[FEP-044f]: https://w3id.org/fep/044f
[logfmt]: https://brandur.org/logfmt
[@hollo@hollo.social]: https://hollo.social/@hollo
[#67]: https://github.com/fedify-dev/hollo/issues/67
Expand Down
115 changes: 115 additions & 0 deletions src/api/v1/statuses.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { InboxContext } from "@fedify/fedify";
import { Create, Note, Person, PUBLIC_COLLECTION, Update } from "@fedify/vocab";
import { eq } from "drizzle-orm";
import {
afterAll,
Expand All @@ -20,6 +22,7 @@ import {
} from "../../../tests/helpers/oauth";
import db from "../../db";
import { federation } from "../../federation";
import { onPostCreated, onPostUpdated } from "../../federation/inbox";
import app from "../../index";
import {
accountOwners,
Expand Down Expand Up @@ -936,6 +939,118 @@ describe("/api/v1/statuses quotes", { concurrent: false }, () => {
});
}

it("exposes an impolite quote and preserves API revocation through a remote Update", async () => {
const targetResponse = await createStatus(authorToken, {
status: "Quote this",
quote_approval_policy: "public",
});
const target = await targetResponse.json();
const remoteId = uuidv7();
const remoteIri = "https://remote.test/users/impolite-quoter";
const quoteIri = "https://remote.test/notes/impolite";
await db
.insert(instances)
.values({ host: "remote.test" })
.onConflictDoNothing();
await db.insert(accounts).values({
id: remoteId,
iri: remoteIri,
instanceHost: "remote.test",
type: "Person",
name: "Impolite quoter",
handle: "@impolite-quoter@remote.test",
bioHtml: "",
protected: false,
inboxUrl: `${remoteIri}/inbox`,
published: new Date(),
});
function note() {
return new Note({
id: new URL(quoteIri),
attribution: new Person({
id: new URL(remoteIri),
preferredUsername: "impolite-quoter",
inbox: new URL(`${remoteIri}/inbox`),
}),
quoteUrl: new URL(target.uri),
to: PUBLIC_COLLECTION,
content: "<p>Remote quote</p>",
});
}
const ctx = federation.createContext(
new URL("https://hollo.test"),
undefined,
) as InboxContext<void>;
await onPostCreated(
ctx,
new Create({ actor: new URL(remoteIri), object: note() }),
);
const quote = await db.query.posts.findFirst({
where: { iri: { eq: quoteIri } },
});
const quoteResponse = await app.request(`/api/v1/statuses/${quote!.id}`, {
headers: { authorization: bearerAuthorization(authorToken) },
});
expect(quoteResponse.status).toBe(200);
expect((await quoteResponse.json()).quote).toMatchObject({
state: "accepted",
quoted_status: { id: target.id },
});
const counted = await app.request(`/api/v1/statuses/${target.id}`);
expect((await counted.json()).quotes_count).toBe(1);
expect(
await db.query.notifications.findMany({
where: { type: { eq: "quote" }, targetPostId: { eq: quote!.id } },
}),
).toHaveLength(1);
const authorizationRequest = () =>
new Request(quote!.quoteAuthorizationIri!, {
headers: { Accept: "application/activity+json" },
});
expect(
(
await federation.fetch(authorizationRequest(), {
contextData: undefined,
})
).status,
).toBe(200);

const fetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 202 }));
try {
const revoked = await app.request(
`/api/v1/statuses/${target.id}/quotes/${quote!.id}/revoke`,
{
method: "POST",
headers: { authorization: bearerAuthorization(authorToken) },
},
);
expect(revoked.status).toBe(200);
expect((await revoked.json()).quote.state).toBe("revoked");
await onPostUpdated(
ctx,
new Update({ actor: new URL(remoteIri), object: note() }),
);
const edited = await app.request(`/api/v1/statuses/${quote!.id}`);
expect((await edited.json()).quote).toMatchObject({
state: "revoked",
quoted_status: null,
});
const countedAgain = await app.request(`/api/v1/statuses/${target.id}`);
expect((await countedAgain.json()).quotes_count).toBe(0);
expect(
(
await federation.fetch(authorizationRequest(), {
contextData: undefined,
})
).status,
).toBe(404);
} finally {
fetch.mockRestore();
}
});

it("allows same-instance quotes regardless of quote policy and emits an authorization IRI", async () => {
expect.assertions(7);

Expand Down
Loading
Loading