Return 401s when logged out of more activitypub api endpoints (#750)

Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
This commit is contained in:
Robert Clarke
2026-01-31 22:04:56 +00:00
committed by GitHub
parent 1005aa0a84
commit 029f1f134b
10 changed files with 82 additions and 22 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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<ActorResponse> {
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;
}

View File

@@ -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}`
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 })

View File

@@ -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);

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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()