From 029f1f134b5f7f38773f4a37bff1245204fa9fed Mon Sep 17 00:00:00 2001 From: Robert Clarke Date: Sat, 31 Jan 2026 22:04:56 +0000 Subject: [PATCH] Return 401s when logged out of more activitypub api endpoints (#750) Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> --- db/federation/actor.go | 7 ++-- db/main.go | 5 +-- web/src/lib/util/activitypub_server_util.ts | 35 +++++++++++++++++++ web/src/lib/util/activitypub_util.ts | 25 +++++++++++-- web/src/routes/api/v1/follow/+server.ts | 5 +-- .../routes/api/v1/profile/[handle]/+server.ts | 11 ++---- .../api/v1/profile/[handle]/feed/+server.ts | 3 +- .../api/v1/profile/[handle]/lists/+server.ts | 3 +- .../api/v1/profile/[handle]/stats/+server.ts | 7 ++-- .../api/v1/profile/[handle]/trails/+server.ts | 3 +- 10 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 web/src/lib/util/activitypub_server_util.ts diff --git a/db/federation/actor.go b/db/federation/actor.go index aeb81df7..59d03fac 100644 --- a/db/federation/actor.go +++ b/db/federation/actor.go @@ -4,6 +4,7 @@ import ( "crypto/x509" "database/sql" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -19,6 +20,8 @@ import ( "github.com/pocketbase/pocketbase/tools/security" ) +var ErrProfilePrivate = errors.New("profile is private") + type WebfingerResponse struct { Subject string `json:"subject"` Links []struct { @@ -220,7 +223,7 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu } if private { - return dbActor, fmt.Errorf("profile is private") + return dbActor, ErrProfilePrivate } return dbActor, nil @@ -361,7 +364,7 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er } if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("profile is private") + return nil, ErrProfilePrivate } return nil, fmt.Errorf("collection fetch %s returned: %v", url, resp.StatusCode) } diff --git a/db/main.go b/db/main.go index 67b5957f..5e07d066 100644 --- a/db/main.go +++ b/db/main.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -1166,7 +1167,7 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { } return err } else if err != nil && actor != nil { - if err.Error() == "profile is private" { + if errors.Is(err, federation.ErrProfilePrivate) { // this is our own profile if e.Auth != nil && actor.GetString("user") == e.Auth.Id { return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil}) @@ -1214,7 +1215,7 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { } collection, err := federation.FetchCollection(userActor, fmt.Sprintf("%s?page=%d", url, intPage)) if err != nil { - if err.Error() == "profile is private" { + if errors.Is(err, federation.ErrProfilePrivate) { return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"}) } return err diff --git a/web/src/lib/util/activitypub_server_util.ts b/web/src/lib/util/activitypub_server_util.ts new file mode 100644 index 00000000..27337d58 --- /dev/null +++ b/web/src/lib/util/activitypub_server_util.ts @@ -0,0 +1,35 @@ +import { env } from '$env/dynamic/private'; +import type { RequestEvent } from '@sveltejs/kit'; +import { ClientResponseError } from 'pocketbase'; + +import type { Actor } from '$lib/models/activitypub/actor'; +import { isRemoteHandle } from '$lib/util/activitypub_util'; + +type GetActorOptions = { + follows?: boolean; +}; + +type ActorResponse = { + actor: Actor; + error?: string | null; +}; + +export async function getActorResponseForHandle(event: RequestEvent, handle: string, options: GetActorOptions = {}): Promise { + const origin = env.ORIGIN!; + const remoteHandle = isRemoteHandle(handle, origin); + if (remoteHandle && !event.locals.user) { + throw new ClientResponseError({ status: 401, response: { message: "Unauthorized" } }); + } + const searchParams = new URLSearchParams(); + if (options.follows) { + searchParams.set("follows", "true"); + } + const query = searchParams.toString(); + + const encodedHandle = encodeURIComponent(handle); + const response: ActorResponse = await event.locals.pb.send( + `/activitypub/actor?resource=acct:${encodedHandle}${query ? `&${query}` : ""}`, + { method: "GET", fetch: event.fetch }, + ); + return response; +} diff --git a/web/src/lib/util/activitypub_util.ts b/web/src/lib/util/activitypub_util.ts index 20bf1b83..f72cabab 100644 --- a/web/src/lib/util/activitypub_util.ts +++ b/web/src/lib/util/activitypub_util.ts @@ -3,8 +3,13 @@ export function splitUsername(handle: string, localDomain?: string) { const cleaned = handle.replace(/^@/, "").trim(); + let normalizedLocalDomain = localDomain; + if (normalizedLocalDomain && normalizedLocalDomain.includes("://")) { + normalizedLocalDomain = new URL(normalizedLocalDomain).hostname; + } + if (!cleaned.includes("@")) { - return [cleaned, localDomain]; + return [cleaned, normalizedLocalDomain]; } let [user, domain] = cleaned.split("@"); @@ -12,6 +17,22 @@ export function splitUsername(handle: string, localDomain?: string) { return [user, domain] } +export function isRemoteHandle(handle: string, origin: string) { + const [, domain] = splitUsername(handle, origin); + if (!domain) { + return false; + } + let normalizedDomain = domain; + try { + normalizedDomain = new URL(`http://${domain}`).hostname; + } catch { + normalizedDomain = domain.split(":")[0]; + } + normalizedDomain = normalizedDomain.replace(/^www\./, ""); + const localHost = new URL(origin).hostname.replace(/^www\./, ""); + return normalizedDomain.toLowerCase() !== localHost.toLowerCase(); +} + export function handleFromRecordWithIRI(record: any) { if (!record.expand?.author) { throw new Error("object has no author info") @@ -23,4 +44,4 @@ export function handleFromRecordWithIRI(record: any) { const url = new URL(record.iri ?? "") return `@${record.expand.author.preferred_username}@${url.hostname}` -} \ No newline at end of file +} diff --git a/web/src/routes/api/v1/follow/+server.ts b/web/src/routes/api/v1/follow/+server.ts index 271d1c9c..bdd59e4c 100644 --- a/web/src/routes/api/v1/follow/+server.ts +++ b/web/src/routes/api/v1/follow/+server.ts @@ -1,6 +1,7 @@ import type { Actor } from '$lib/models/activitypub/actor'; import { FollowCreateSchema } from '$lib/models/api/follow_schema'; import type { Follow } from '$lib/models/follow'; +import { getActorResponseForHandle } from '$lib/util/activitypub_server_util'; import { APIError, Collection, handleError, list } from '$lib/util/api_util'; import { json, type RequestEvent } from '@sveltejs/kit'; import type { APOrderedCollectionPage } from 'activitypub-types'; @@ -19,7 +20,7 @@ export async function GET(event: RequestEvent) { throw new APIError(400, "invalid params") } - const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, }); + const { actor } = await getActorResponseForHandle(event, handle); const page = event.url.searchParams.get("page") ?? "1" @@ -81,4 +82,4 @@ export async function PUT(event: RequestEvent) { } catch (e) { return handleError(e) } -} \ No newline at end of file +} diff --git a/web/src/routes/api/v1/profile/[handle]/+server.ts b/web/src/routes/api/v1/profile/[handle]/+server.ts index d75bbe3c..13be4ffd 100644 --- a/web/src/routes/api/v1/profile/[handle]/+server.ts +++ b/web/src/routes/api/v1/profile/[handle]/+server.ts @@ -1,6 +1,5 @@ -import { env } from '$env/dynamic/private'; import type { Profile } from '$lib/models/profile'; -import { splitUsername } from '$lib/util/activitypub_util'; +import { getActorResponseForHandle } from '$lib/util/activitypub_server_util'; import { handleError } from '$lib/util/api_util'; import { error, json, type RequestEvent } from '@sveltejs/kit'; @@ -10,12 +9,8 @@ export async function GET(event: RequestEvent) { return error(400, { message: "Bad request" }) } - if(splitUsername(handle)[1] !== undefined && !event.locals.user) { - return error(401, { message: "Unauthorized" }) - } - try { - const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}&follows=true`, { method: "GET", fetch: event.fetch, }); + const { actor, error: actorError } = await getActorResponseForHandle(event, handle, { follows: true }); const profile: Profile = { id: actor.id!, @@ -28,7 +23,7 @@ export async function GET(event: RequestEvent) { followers: actor.followerCount ?? 0, following: actor.followingCount ?? 0, icon: actor.icon ?? "", - error + error: actorError ?? undefined } return json({ profile, actor: actor }) diff --git a/web/src/routes/api/v1/profile/[handle]/feed/+server.ts b/web/src/routes/api/v1/profile/[handle]/feed/+server.ts index 6f859825..093ff6d3 100644 --- a/web/src/routes/api/v1/profile/[handle]/feed/+server.ts +++ b/web/src/routes/api/v1/profile/[handle]/feed/+server.ts @@ -1,6 +1,7 @@ import { RecordListOptionsSchema } from '$lib/models/api/base_schema'; import { type FeedItem } from '$lib/models/feed'; import type { Trail } from '$lib/models/trail'; +import { getActorResponseForHandle } from '$lib/util/activitypub_server_util'; import { Collection, handleError } from '$lib/util/api_util'; import { error, json, type RequestEvent } from '@sveltejs/kit'; import { ClientResponseError, type ListResult } from 'pocketbase'; @@ -12,7 +13,7 @@ export async function GET(event: RequestEvent) { } try { - const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, }); + const { actor } = await getActorResponseForHandle(event, handle); const searchParams = Object.fromEntries(event.url.searchParams); const safeSearchParams = RecordListOptionsSchema.parse(searchParams); diff --git a/web/src/routes/api/v1/profile/[handle]/lists/+server.ts b/web/src/routes/api/v1/profile/[handle]/lists/+server.ts index 14f9d37c..a9d49f41 100644 --- a/web/src/routes/api/v1/profile/[handle]/lists/+server.ts +++ b/web/src/routes/api/v1/profile/[handle]/lists/+server.ts @@ -1,5 +1,6 @@ import type { TrailSearchResult } from '$lib/models/trail'; import type { ListSearchResult } from '$lib/stores/search_store'; +import { getActorResponseForHandle } from '$lib/util/activitypub_server_util'; import { handleError } from '$lib/util/api_util'; import { error, json, type RequestEvent } from '@sveltejs/kit'; import type { SearchResponse } from 'meilisearch'; @@ -12,7 +13,7 @@ export async function POST(event: RequestEvent) { } try { - const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, }); + const { actor } = await getActorResponseForHandle(event, handle); const data = await event.request.json() diff --git a/web/src/routes/api/v1/profile/[handle]/stats/+server.ts b/web/src/routes/api/v1/profile/[handle]/stats/+server.ts index 0e238860..02d7f679 100644 --- a/web/src/routes/api/v1/profile/[handle]/stats/+server.ts +++ b/web/src/routes/api/v1/profile/[handle]/stats/+server.ts @@ -1,5 +1,6 @@ import { RecordListOptionsSchema } from '$lib/models/api/base_schema'; import type { SummitLog } from '$lib/models/summit_log'; +import { getActorResponseForHandle } from '$lib/util/activitypub_server_util'; import { Collection, handleError } from '$lib/util/api_util'; import { error, json, type RequestEvent } from '@sveltejs/kit'; import { ClientResponseError, type ListResult } from 'pocketbase'; @@ -9,9 +10,9 @@ export async function GET(event: RequestEvent) { if (!handle) { return error(400, { message: "Bad request" }) } - + try { - const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, }); + const { actor } = await getActorResponseForHandle(event, handle); const searchParams = Object.fromEntries(event.url.searchParams); const safeSearchParams = RecordListOptionsSchema.parse(searchParams); @@ -20,7 +21,7 @@ export async function GET(event: RequestEvent) { safeSearchParams.filter = safeSearchParams.filter + `&&author='${actor.id}'` }else { safeSearchParams.filter = `author='${actor.id}'` - } + } let summitLogs: SummitLog[]; if (actor.isLocal) { diff --git a/web/src/routes/api/v1/profile/[handle]/trails/+server.ts b/web/src/routes/api/v1/profile/[handle]/trails/+server.ts index cf6e569d..2bd6a753 100644 --- a/web/src/routes/api/v1/profile/[handle]/trails/+server.ts +++ b/web/src/routes/api/v1/profile/[handle]/trails/+server.ts @@ -1,4 +1,5 @@ import type { TrailSearchResult } from '$lib/models/trail'; +import { getActorResponseForHandle } from '$lib/util/activitypub_server_util'; import { handleError } from '$lib/util/api_util'; import { error, json, type RequestEvent } from '@sveltejs/kit'; import type { SearchResponse } from 'meilisearch'; @@ -11,7 +12,7 @@ export async function POST(event: RequestEvent) { } try { - const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, }); + const { actor } = await getActorResponseForHandle(event, handle); const data = await event.request.json()