From 74773318b446905b622f0745c0951a536f012f92 Mon Sep 17 00:00:00 2001
From: Laura Hausmann <laura@hausmann.dev>
Date: Wed, 28 Jun 2023 03:43:32 +0200
Subject: [PATCH] Allow follower-only notes to be fetched by properly
 authorized remote users

---
 .../src/remote/activitypub/check-fetch.ts     | 76 +++++++++++++++++++
 packages/backend/src/server/activitypub.ts    | 34 ++++++++-
 2 files changed, 108 insertions(+), 2 deletions(-)

diff --git a/packages/backend/src/remote/activitypub/check-fetch.ts b/packages/backend/src/remote/activitypub/check-fetch.ts
index a8bbe61b8..3e52575a9 100644
--- a/packages/backend/src/remote/activitypub/check-fetch.ts
+++ b/packages/backend/src/remote/activitypub/check-fetch.ts
@@ -95,3 +95,79 @@ export async function checkFetch(req: IncomingMessage): Promise<number> {
 	}
 	return 200;
 }
+
+export async function getSignatureUser(
+	req: IncomingMessage,
+): Promise<CacheableRemoteUser> {
+	let authUser;
+	const meta = await fetchMeta();
+	if (meta.secureMode || meta.privateMode) {
+		let signature;
+
+		try {
+			signature = httpSignature.parseRequest(req, { headers: [] });
+		} catch (e) {
+			return null;
+		}
+
+		const keyId = new URL(signature.keyId);
+		const host = toPuny(keyId.hostname);
+
+		if (await shouldBlockInstance(host, meta)) {
+			return 403;
+		}
+
+		if (
+			meta.privateMode &&
+			host !== config.host &&
+			!meta.allowedHosts.includes(host)
+		) {
+			return null;
+		}
+
+		const keyIdLower = signature.keyId.toLowerCase();
+		if (keyIdLower.startsWith("acct:")) {
+			// Old keyId is no longer supported.
+			return null;
+		}
+
+		const dbResolver = new DbResolver();
+
+		// HTTP-Signature keyIdを元にDBから取得
+		authUser = await dbResolver.getAuthUserFromKeyId(signature.keyId);
+
+		// keyIdでわからなければ、resolveしてみる
+		if (authUser == null) {
+			try {
+				keyId.hash = "";
+				authUser = await dbResolver.getAuthUserFromApId(
+					getApId(keyId.toString()),
+				);
+			} catch (e) {
+				// できなければ駄目
+				return null;
+			}
+		}
+
+		// publicKey がなくても終了
+		if (authUser?.key == null) {
+			return null;
+		}
+
+		// もう一回チェック
+		if (authUser.user.host !== host) {
+			return null;
+		}
+
+		// HTTP-Signatureの検証
+		const httpSignatureValidated = httpSignature.verifySignature(
+			signature,
+			authUser.key.keyPem,
+		);
+
+		if (!httpSignatureValidated) {
+			return null;
+		}
+	}
+	return authUser;
+}
diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts
index 042ab446c..548aafdd2 100644
--- a/packages/backend/src/server/activitypub.ts
+++ b/packages/backend/src/server/activitypub.ts
@@ -20,7 +20,11 @@ import {
 import type { ILocalUser, User } from "@/models/entities/user.js";
 import { renderLike } from "@/remote/activitypub/renderer/like.js";
 import { getUserKeypair } from "@/misc/keypair-store.js";
-import { checkFetch, hasSignature } from "@/remote/activitypub/check-fetch.js";
+import {
+	checkFetch,
+	hasSignature,
+	getSignatureUser,
+} from "@/remote/activitypub/check-fetch.js";
 import { getInstanceActor } from "@/services/instance-actor.js";
 import { fetchMeta } from "@/misc/fetch-meta.js";
 import renderFollow from "@/remote/activitypub/renderer/follow.js";
@@ -28,6 +32,7 @@ import Featured from "./activitypub/featured.js";
 import Following from "./activitypub/following.js";
 import Followers from "./activitypub/followers.js";
 import Outbox, { packActivity } from "./activitypub/outbox.js";
+import { serverLogger } from "./index.js";
 
 // Init router
 const router = new Router();
@@ -84,7 +89,7 @@ router.get("/notes/:note", async (ctx, next) => {
 
 	const note = await Notes.findOneBy({
 		id: ctx.params.note,
-		visibility: In(["public" as const, "home" as const]),
+		visibility: In(["public" as const, "home" as const, "followers" as const]),
 		localOnly: false,
 	});
 
@@ -103,6 +108,31 @@ router.get("/notes/:note", async (ctx, next) => {
 		return;
 	}
 
+	if (note.visibility == "followers") {
+		serverLogger.debug(
+			"Responding to request for follower-only note, validating access...",
+		);
+		let remoteUser = await getSignatureUser(ctx.req);
+		serverLogger.debug("Local note author user:");
+		serverLogger.debug(JSON.stringify(note, null, 2));
+		serverLogger.debug("Authenticated remote user:");
+		serverLogger.debug(JSON.stringify(remoteUser, null, 2));
+
+		let relation = await Users.getRelation(remoteUser.user.id, note.userId);
+		serverLogger.debug("Relation:");
+		serverLogger.debug(JSON.stringify(relation, null, 2));
+
+		if (!relation.isFollowing || relation.isBlocked) {
+			serverLogger.debug(
+				"Rejecting: authenticated user is not following us or was blocked by us",
+			);
+			ctx.status = 403;
+			return;
+		}
+
+		serverLogger.debug("Accepting: access criteria met");
+	}
+
 	ctx.body = renderActivity(await renderNote(note, false));
 
 	const meta = await fetchMeta();