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
11 changes: 8 additions & 3 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ To be released.
origin. This sidesteps CORS configurations on remote object stores
and prevents the visitor's browser from talking directly to the
source server. Controlled by a new `MEDIA_PROXY` environment
variable with three levels: [[#481], [#483]]
variable with three levels: [[#481], [#483], [#493]]

- `off` (default): the Mastodon API and web UI hand the original
remote URL to clients, matching the historical behaviour.
Expand All @@ -113,8 +113,12 @@ To be released.
- `cache`: same URL rewriting, but the streamed body is persisted
to the configured storage backend as `proxy/<sha256>.bin`, with
a content-type sidecar alongside it at `proxy/<sha256>.json`.
Subsequent requests skip the upstream fetch. The admin
dashboard at */thumbnail\_cleanup* can purge the cache on demand.
Subsequent requests skip the upstream fetch. Remote actor avatars
for accounts with an approved follow relationship to the local
account are also prefetched into this same cache when the actor is
stored or refreshed, so stale upstream avatar files can keep
rendering after Hollo has seen them once. The admin dashboard at
*/thumbnail\_cleanup* can purge the cache on demand.

`MEDIA_PROXY` also accepts the Boolean synonyms `true`/`on`/`1`
(as aliases for `proxy`) and `false`/`off`/`0` (as aliases for
Expand Down Expand Up @@ -401,6 +405,7 @@ To be released.
[#489]: https://github.com/fedify-dev/hollo/issues/489
[#490]: https://github.com/fedify-dev/hollo/pull/490
[#491]: https://github.com/fedify-dev/hollo/pull/491
[#493]: https://github.com/fedify-dev/hollo/pull/493


Version 0.8.4
Expand Down
7 changes: 5 additions & 2 deletions docs/src/content/docs/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,11 @@ are:
- `cache`: same URL rewriting as `proxy`, but the streamed body is
persisted to the configured storage backend as `proxy/<sha256>.bin`
alongside a content-type sidecar at `proxy/<sha256>.json`.
Subsequent requests skip the upstream fetch. The admin dashboard
at */thumbnail_cleanup* can purge the cache on demand.
Subsequent requests skip the upstream fetch. Remote actor avatars
for accounts with an approved follow relationship to the local account
are also prefetched into this cache when the actor is stored or
refreshed. The admin dashboard at */thumbnail_cleanup* can purge
the cache on demand.

The boolean synonyms `true` / `on` / `1` are accepted as aliases for
`proxy`, and `false` / `off` / `0` as aliases for `off`. Disk caching
Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/ja/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,10 @@ Holloは起動に失敗します。**最初のアカウントを作成する前
本文を設定済みのストレージバックエンドの`proxy/<sha256>.bin`に
保存し、Content-Type情報を持つサイドカーを`proxy/<sha256>.json`に
一緒に保存します。以降のリクエストはアップストリームを再取得
しません。管理ダッシュボードの */thumbnail_cleanup* から必要に
応じてキャッシュを消去できます。
しません。ローカルアカウントと承認済みのフォロー関係がある
リモートactorのアバターも、actorが保存または更新されるときに
このキャッシュへ事前取得されます。管理ダッシュボードの
*/thumbnail_cleanup* から必要に応じてキャッシュを消去できます。

真偽値の同義語として`true` / `on` / `1`は`proxy`の別名、
`false` / `off` / `0`は`off`の別名として受け付けます。ディスク
Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/ko/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야
- `cache`: `proxy`와 동일한 URL 재작성에 더해, 스트리밍한 본문을 설정된
저장소 백엔드의 `proxy/<sha256>.bin`에 저장하고, 콘텐츠 타입 정보를
담은 사이드카 파일을 `proxy/<sha256>.json`에 함께 저장합니다.
이후 요청은 원본을 다시 가져오지 않습니다. */thumbnail_cleanup*
관리자 페이지에서 필요할 때 캐시를 비울 수 있습니다.
이후 요청은 원본을 다시 가져오지 않습니다. 로컬 계정과 승인된
팔로우 관계가 있는 리모트 액터의 아바타도 액터가 저장되거나 갱신될
때 이 캐시에 미리 저장됩니다. */thumbnail_cleanup* 관리자
페이지에서 필요할 때 캐시를 비울 수 있습니다.

불리언 동의어로 `true` / `on` / `1`은 `proxy`의 별칭으로,
`false` / `off` / `0`은 `off`의 별칭으로 받아들입니다. 디스크 캐싱은
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/zh-cn/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ openssl rand -hex 32
避免远程 CORS 配置的影响,也避免访问者的 IP 被泄露。
- `cache`:URL 改写与 `proxy` 相同,但会把流式获取的响应主体保存到
所配置存储后端的 `proxy/<sha256>.bin`,并把记录内容类型的旁路文件
保存到 `proxy/<sha256>.json`。后续请求会跳过上游请求。管理面板的
*/thumbnail_cleanup* 页面可以按需清空缓存。
保存到 `proxy/<sha256>.json`。后续请求会跳过上游请求。与本地账号
存在已批准关注关系的远程 actor 头像,也会在 actor 被保存或刷新时
预取到此缓存中。管理面板的 */thumbnail_cleanup* 页面可以按需清空缓存。

布尔同义值:`true` / `on` / `1` 作为 `proxy` 的别名,
`false` / `off` / `0` 作为 `off` 的别名。磁盘缓存必须用 `cache` 显式
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/zh-tw/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ openssl rand -hex 32
避免遠端 CORS 設定的影響,也避免訪客的 IP 被洩露。
- `cache`:URL 改寫與 `proxy` 相同,但會把串流取得的回應主體儲存到
所設定儲存後端的 `proxy/<sha256>.bin`,並把記錄內容類型的旁路檔案
儲存到 `proxy/<sha256>.json`。後續請求會跳過上游請求。管理面板的
*/thumbnail_cleanup* 頁面可以按需清空快取。
儲存到 `proxy/<sha256>.json`。後續請求會跳過上游請求。與本機帳號
存在已核准追蹤關係的遠端 actor 頭像,也會在 actor 被儲存或重新整理時
預先擷取到此快取中。管理面板的 */thumbnail_cleanup* 頁面可以按需清空快取。

布林同義值:`true` / `on` / `1` 作為 `proxy` 的別名,
`false` / `off` / `0` 作為 `off` 的別名。磁碟快取必須用 `cache` 明確
Expand Down
165 changes: 162 additions & 3 deletions src/federation/account.persistence.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
import { Person } from "@fedify/vocab";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Image, Person } from "@fedify/vocab";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { cleanDatabase } from "../../tests/helpers";
import { createAccount } from "../../tests/helpers/oauth";
import db from "../db";
import { proxyCacheKeyForUrl } from "../proxy-cache";
import * as Schema from "../schema";
import { drive } from "../storage";
import type { Uuid } from "../uuid";
import {
AccountHandleConflictError,
persistAccount,
updateAccountStats,
} from "./account";

async function waitFor(condition: () => Promise<boolean>): Promise<void> {
for (let i = 0; i < 50; i++) {
if (await condition()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}

function getFetchMockCalls(): Parameters<typeof fetch>[] {
return (
globalThis.fetch as unknown as {
mock: { calls: Parameters<typeof fetch>[] };
}
).mock.calls;
}

function isFetchCallForUrl(
[input]: Parameters<typeof fetch>,
url: string,
): boolean {
const requestUrl =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
return requestUrl === url;
}

async function createRemoteAccount(params: {
iri: string;
handle: string;
Expand Down Expand Up @@ -78,13 +108,19 @@ async function createLocalPost(accountId: Uuid): Promise<Schema.Post> {
return post;
}

function createRemotePerson(iri: string, username: string): Person {
function createRemotePerson(
iri: string,
username: string,
avatarUrl?: string,
): Person {
return new Person({
id: new URL(iri),
preferredUsername: username,
name: "Michael Foster",
inbox: new URL(`${iri}/inbox`),
url: new URL(`https://${new URL(iri).host}/@${username}`),
icon:
avatarUrl == null ? undefined : new Image({ url: new URL(avatarUrl) }),
});
}

Expand Down Expand Up @@ -298,3 +334,126 @@ describe.sequential("persistAccount canonical handle reassignment", () => {
}
});
});

describe.sequential("persistAccount remote avatar cache", () => {
beforeEach(async () => {
await cleanDatabase();
drive.fake();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

afterEach(() => {
drive.restore();
});

it("does not prefetch unrelated remote avatars in cache mode", async () => {
expect.assertions(3);

const avatarUrl = "https://remote.test/users/michael/avatar.webp";
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 404 })),
);

const account = await persistAccount(
db,
createRemotePerson(
"https://remote.test/users/michael",
"michael",
avatarUrl,
),
"https://hollo.test",
{ mediaProxyMode: "cache" },
);

const key = proxyCacheKeyForUrl(avatarUrl);

expect(account?.avatarUrl).toBe(avatarUrl);
expect(
getFetchMockCalls().some((call) => isFetchCallForUrl(call, avatarUrl)),
).toBe(false);
expect(await drive.use().exists(`${key}.bin`)).toBe(false);
});

it("prefetches a related remote avatar into the proxy cache in cache mode", async () => {
expect.assertions(6);

const avatarUrl = "https://remote.test/users/michael/avatar.webp";
const avatar = new Uint8Array([10, 20, 30, 40]);
let resolveAvatarFetch: (response: Response) => void;
const avatarFetch = new Promise<Response>((resolve) => {
resolveAvatarFetch = resolve;
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
const requestUrl =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (requestUrl === avatarUrl) {
return await avatarFetch;
}
return new Response(null, { status: 404 });
}),
);

const initialAccount = await persistAccount(
db,
createRemotePerson(
"https://remote.test/users/michael",
"michael",
avatarUrl,
),
"https://hollo.test",
{ mediaProxyMode: "cache" },
);
if (initialAccount == null) throw new Error("Expected remote account");

const localAccount = await createAccount();
await db.insert(Schema.follows).values({
iri: "https://hollo.test/#follows/remote-michael",
followingId: initialAccount.id,
followerId: localAccount.id,
approved: new Date(),
});

const account = await persistAccount(
db,
createRemotePerson(
"https://remote.test/users/michael",
"michael",
avatarUrl,
),
"https://hollo.test",
{ mediaProxyMode: "cache" },
);

const disk = drive.use();
const key = proxyCacheKeyForUrl(avatarUrl);

expect(account?.avatarUrl).toBe(avatarUrl);
expect(await disk.exists(`${key}.bin`)).toBe(false);
await waitFor(async () =>
getFetchMockCalls().some((call) => isFetchCallForUrl(call, avatarUrl)),
);
expect(globalThis.fetch).toHaveBeenCalledWith(
avatarUrl,
expect.any(Object),
);

resolveAvatarFetch!(
new Response(avatar.buffer as ArrayBuffer, {
status: 200,
headers: { "Content-Type": "image/webp" },
}),
);
await waitFor(async () => await disk.exists(`${key}.bin`));
expect(await disk.exists(`${key}.bin`)).toBe(true);
expect(await disk.exists(`${key}.json`)).toBe(true);
expect(await disk.getBytes(`${key}.bin`)).toEqual(avatar);
});
});
69 changes: 69 additions & 0 deletions src/federation/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { getLogger } from "@logtape/logtape";
import { and, count, eq, inArray, isNotNull, sql } from "drizzle-orm";

import type { DatabaseLike } from "../db";
import { MEDIA_PROXY, type MediaProxyMode } from "../media-proxy";
import { scheduleProxyCachePrefetchForMode } from "../proxy-cache";
import type { NewPinnedPost, Post } from "../schema";
import * as schema from "../schema";
import { type Uuid, uuidv7 } from "../uuid";
Expand Down Expand Up @@ -55,6 +57,7 @@ export const REFRESH_ACTORS_ON_INTERACTION =
refreshOnInteractionEnv === "yes";

export type PersistAccountHandleConflictPolicy = "throw" | "skip";
export type AvatarCachePrefetchPolicy = "always" | "related" | "never";

export class AccountHandleConflictError extends Error {
readonly actorIri: string;
Expand Down Expand Up @@ -90,6 +93,8 @@ export type PersistAccountOptions = {
documentLoader?: DocumentLoader;
skipUpdate?: boolean;
handleConflictPolicy?: PersistAccountHandleConflictPolicy;
mediaProxyMode?: MediaProxyMode;
avatarCachePrefetch?: AvatarCachePrefetchPolicy;
};

function getAcctUri(handle: string): string {
Expand Down Expand Up @@ -165,6 +170,56 @@ function skipAccountConflict(error: AccountHandleConflictError): void {
);
}

async function hasApprovedLocalFollowRelationship(
db: DatabaseLike,
accountId: Uuid,
): Promise<boolean> {
const followedByLocal = await db
.select({ iri: schema.follows.iri })
.from(schema.follows)
.innerJoin(
schema.accountOwners,
eq(schema.follows.followerId, schema.accountOwners.id),
)
.where(
and(
eq(schema.follows.followingId, accountId),
isNotNull(schema.follows.approved),
),
)
.limit(1);
if (followedByLocal.length > 0) return true;

const followingLocal = await db
.select({ iri: schema.follows.iri })
.from(schema.follows)
.innerJoin(
schema.accountOwners,
eq(schema.follows.followingId, schema.accountOwners.id),
)
.where(
and(
eq(schema.follows.followerId, accountId),
isNotNull(schema.follows.approved),
),
)
.limit(1);
return followingLocal.length > 0;
}

async function shouldPrefetchAvatar(
db: DatabaseLike,
account: schema.Account & { owner: schema.AccountOwner | null },
mode: MediaProxyMode,
avatarUrl: string | null | undefined,
policy: AvatarCachePrefetchPolicy,
): Promise<boolean> {
if (mode !== "cache" || avatarUrl == null) return false;
if (account.owner != null || policy === "never") return false;
if (policy === "always") return true;
return await hasApprovedLocalFollowRelationship(db, account.id);
}

export function getFollowOrderingKey(
followerIri: string,
followingIri: string,
Expand Down Expand Up @@ -347,6 +402,20 @@ export async function persistAccount(
where: { iri: { eq: actorId.href } },
});
if (account == null) return null;
const mediaProxyMode = options.mediaProxyMode ?? MEDIA_PROXY;
if (
await shouldPrefetchAvatar(
db,
account,
mediaProxyMode,
values.avatarUrl,
options.avatarCachePrefetch ?? "related",
)
) {
// This is cache warming only. Account persistence should not wait for a
// slow remote avatar CDN after the database row has already been stored.
scheduleProxyCachePrefetchForMode(mediaProxyMode, values.avatarUrl);
}
const [{ posts }] = await db
.select({ posts: count() })
.from(schema.posts)
Expand Down
Loading
Loading