130 lines
3.4 KiB
TypeScript
130 lines
3.4 KiB
TypeScript
import { config } from "@/config.js";
|
|
import { getUserKeypair } from "@/misc/keypair-store.js";
|
|
import type { User, ILocalUser } from "@/models/entities/user.js";
|
|
import { StatusError, getResponse } from "@/misc/fetch.js";
|
|
import { createSignedPost, createSignedGet } from "./ap-request.js";
|
|
import type { Response } from "node-fetch";
|
|
import type { IObject } from "./type.js";
|
|
import { apLogger } from "@/remote/activitypub/logger.js";
|
|
import { isSafeUrl } from "backend-rs";
|
|
|
|
export default async (user: { id: User["id"] }, url: string, object: any) => {
|
|
const body = JSON.stringify(object);
|
|
|
|
const keypair = await getUserKeypair(user.id);
|
|
|
|
const req = createSignedPost({
|
|
key: {
|
|
privateKeyPem: keypair.privateKey,
|
|
keyId: `${config.url}/users/${user.id}#main-key`,
|
|
},
|
|
url,
|
|
body,
|
|
additionalHeaders: {
|
|
"User-Agent": config.userAgent,
|
|
},
|
|
});
|
|
|
|
await getResponse({
|
|
url,
|
|
method: req.request.method,
|
|
headers: req.request.headers,
|
|
body,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get ActivityPub object
|
|
* @param url URL to fetch
|
|
* @param user http-signature user
|
|
* @param redirects whether or not to accept redirects
|
|
*/
|
|
export async function apGet(
|
|
url: string,
|
|
user?: ILocalUser,
|
|
redirects: boolean = true,
|
|
): Promise<{ finalUrl: string; content: IObject }> {
|
|
if (!isSafeUrl(url)) {
|
|
throw new StatusError("Invalid URL", 400);
|
|
}
|
|
|
|
let res: Response;
|
|
|
|
if (user != null) {
|
|
const keypair = await getUserKeypair(user.id);
|
|
const req = createSignedGet({
|
|
key: {
|
|
privateKeyPem: keypair.privateKey,
|
|
keyId: `${config.url}/users/${user.id}#main-key`,
|
|
},
|
|
url,
|
|
additionalHeaders: {
|
|
"User-Agent": config.userAgent,
|
|
},
|
|
});
|
|
|
|
res = await getResponse({
|
|
url,
|
|
method: req.request.method,
|
|
headers: req.request.headers,
|
|
redirect: redirects ? "manual" : "error",
|
|
});
|
|
|
|
if (redirects && [301, 302, 307, 308].includes(res.status)) {
|
|
const newUrl = res.headers.get("location");
|
|
if (newUrl == null)
|
|
throw new Error("apGet got redirect but no target location");
|
|
apLogger.debug(`apGet is redirecting to ${newUrl}`);
|
|
return apGet(newUrl, user, false);
|
|
}
|
|
} else {
|
|
res = await getResponse({
|
|
url,
|
|
method: "GET",
|
|
headers: {
|
|
Accept:
|
|
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
|
"User-Agent": config.userAgent,
|
|
},
|
|
redirect: redirects ? "manual" : "error",
|
|
});
|
|
|
|
if (redirects && [301, 302, 307, 308].includes(res.status)) {
|
|
const newUrl = res.headers.get("location");
|
|
if (newUrl == null)
|
|
throw new Error("apGet got redirect but no target location");
|
|
apLogger.debug(`apGet is redirecting to ${newUrl}`);
|
|
return apGet(newUrl, undefined, false);
|
|
}
|
|
}
|
|
|
|
const contentType = res.headers.get("content-type");
|
|
if (contentType == null || !validateContentType(contentType)) {
|
|
throw new Error(
|
|
`apGet response had unexpected content-type: ${contentType}`,
|
|
);
|
|
}
|
|
|
|
if (res.body == null) throw new Error("body is null");
|
|
|
|
const text = await res.text();
|
|
if (text.length > 65536) throw new Error("too big result");
|
|
|
|
return {
|
|
finalUrl: res.url,
|
|
content: JSON.parse(text) as IObject,
|
|
};
|
|
}
|
|
|
|
function validateContentType(contentType: string): boolean {
|
|
const parts = contentType.split(/\s*;\s*/);
|
|
if (parts[0] === "application/activity+json") return true;
|
|
if (parts[0] !== "application/ld+json") return false;
|
|
return parts
|
|
.slice(1)
|
|
.some(
|
|
(part) =>
|
|
part.trim() === 'profile="https://www.w3.org/ns/activitystreams"',
|
|
);
|
|
}
|