1
0
mirror of https://github.com/spacebarchat/server.git synced 2024-11-22 02:12:40 +01:00

ap guild endpoint

fetching invites from remote instances
This commit is contained in:
Madeline 2023-09-27 12:17:18 +00:00
parent 29f1c36527
commit 904618e0a7
13 changed files with 430 additions and 82 deletions

View File

@ -118,6 +118,7 @@ Is a [Collection](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collecti
Base url: `/federation`
- `/.well-known/webfinger?resource=acct@domain` - Returns webfinger response i.e. https://docs.joinmastodon.org/spec/webfinger/
- - Webfinger resources include users, channels, guilds, as well as invite codes which returns a the corresponding guild
- `/.well-known/host-meta` - Returns location of webfinger? Why is this neccessary?
- `/channels/:channel_id` - Returns specified Channel as AP object ( Group )

View File

@ -0,0 +1,125 @@
import { Config, FederationKey, OrmUtils } from "@spacebar/util";
import { APActivity } from "activitypub-types";
import crypto from "crypto";
import { IncomingHttpHeaders } from "http";
import { APError, fetchFederatedUser, fetchOpts } from "./utils";
export class HttpSig {
private static getSignString<T extends IncomingHttpHeaders>(
target: string,
method: string,
headers: T,
names: string[],
) {
const requestTarget = `${method.toLowerCase()} ${target}`;
headers = {
...headers,
"(request-target)": requestTarget,
};
return names
.map((header) => `${header.toLowerCase()}: ${headers[header]}`)
.join("\n");
}
public static async validate(
target: string,
activity: APActivity,
requestHeaders: IncomingHttpHeaders,
) {
const sigheader = requestHeaders["signature"] as string;
const sigopts: { [key: string]: string } = Object.assign(
{},
...sigheader.split(",").flatMap((keyval) => {
const split = keyval.split("=");
return {
[split[0]]: split[1].replaceAll('"', ""),
};
}),
);
const { signature, headers, keyId, algorithm } = sigopts;
if (!signature || !headers || !keyId)
throw new APError("Invalid signature");
// If it's provided, check it. otherwise just assume it's sha256
if (algorithm && algorithm != "rsa-sha256")
throw new APError(`Unsupported encryption algorithm ${algorithm}`);
const url = new URL(keyId);
const actorId = `${url.origin}${url.pathname}`; // likely wrong
const remoteUser = await fetchFederatedUser(actorId);
const expected = this.getSignString(
target,
"post",
requestHeaders,
headers.split(/\s+/),
);
const verifier = crypto.createVerify(algorithm.toUpperCase());
verifier.write(expected);
verifier.end();
return verifier.verify(
remoteUser.keys.publicKey,
Buffer.from(signature, "base64"),
);
}
/**
* Returns a signed request that can be passed to fetch
* ```
* const signed = await signActivity(receiver.inbox, sender, activity);
* await fetch(receiver.inbox, signed);
* ```
*/
public static sign(
inbox: string,
sender: FederationKey,
message: APActivity,
) {
if (!sender.privateKey)
throw new APError("cannot sign without private key");
const digest = crypto
.createHash("sha256")
.update(JSON.stringify(message))
.digest("base64");
const signer = crypto.createSign("sha256");
const now = new Date();
const url = new URL(inbox);
const inboxFrag = url.pathname;
const toSign =
`(request-target): post ${inboxFrag}\n` +
`host: ${url.hostname}\n` +
`date: ${now.toUTCString()}\n` +
`digest: SHA-256=${digest}`;
signer.update(toSign);
signer.end();
const signature = signer.sign(sender.privateKey);
const sig_b64 = signature.toString("base64");
const { host } = Config.get().federation;
const header =
`keyId="https://${host}/federation/${sender.type}/${sender.actorId}",` +
`headers="(request-target) host date digest",` +
`signature=${sig_b64}`;
return OrmUtils.mergeDeep(fetchOpts, {
method: "POST",
body: message,
headers: {
Host: url.hostname,
Date: now.toUTCString(),
Digest: `SHA-256=${digest}`,
Signature: header,
},
});
}
}

View File

@ -1,10 +1,15 @@
/**
* To be injected into API
* Responsible for dispatching activitypub events to external instances
*/
import { MessageCreateEvent, emitEvent } from "@spacebar/util";
import { APActivity } from "activitypub-types";
import { Request } from "express";
import { HttpSig } from "./HttpSig";
import { federationQueue } from "./queue";
import { transformNoteToMessage } from "./transforms";
import {
APActivityIsCreate,
APError,
APObjectIsNote,
hasAPContext,
} from "./utils";
export * from "./OrderedCollection";
export * from "./transforms";
@ -14,4 +19,37 @@ export class Federation {
static async distribute(activity: APActivity) {
await federationQueue.distribute(activity);
}
static async genericInboxHandler(req: Request) {
const activity = req.body;
if (!hasAPContext(activity))
throw new APError("Activity does not have @context");
if (!(await HttpSig.validate(req.originalUrl, activity, req.headers))) {
throw new APError("Invalid signature");
}
if (!APActivityIsCreate(activity)) throw new APError("not implemented");
const object = Array.isArray(activity.object)
? activity.object[0]
: activity.object;
if (!object || typeof object == "string" || !APObjectIsNote(object))
throw new APError("not implemented");
const message = await transformNoteToMessage(object);
await Promise.all([
emitEvent({
event: "MESSAGE_CREATE",
channel_id: message.channel_id,
data: message,
} as MessageCreateEvent),
message.save(),
]);
return;
}
}

View File

@ -1,7 +1,8 @@
import { Config, FederationKey } from "@spacebar/util";
import fetch from "node-fetch";
import { APError, signActivity, splitQualifiedMention } from "./utils";
import { APActivity } from "activitypub-types";
import fetch from "node-fetch";
import { HttpSig } from "./HttpSig";
import { APError, splitQualifiedMention } from "./utils";
//
type Instance = string;
@ -38,12 +39,12 @@ class FederationQueue {
}
for (const receiver of to) {
if (!(receiver instanceof URL)) {
if (typeof receiver != "string") {
console.error(receiver);
continue;
}
const signedActivity = await signActivity(
const signedActivity = await HttpSig.sign(
receiver.toString(),
sender,
activity,

View File

@ -4,12 +4,21 @@ import {
Config,
DmChannelDTO,
FederationKey,
Guild,
Invite,
Member,
Message,
Snowflake,
User,
UserSettings,
} from "@spacebar/util";
import {
APAnnounce,
APGroup,
APNote,
APOrganization,
APPerson,
} from "activitypub-types";
import TurndownService from "turndown";
import { In } from "typeorm";
import {
@ -18,7 +27,6 @@ import {
APObjectIsPerson,
resolveAPObject,
} from "./utils";
import { APAnnounce, APGroup, APNote, APPerson } from "activitypub-types";
export const transformMessageToAnnounceNoce = async (
message: Message,
@ -32,9 +40,8 @@ export const transformMessageToAnnounceNoce = async (
},
});
let to = [
`https://${host}/federation/channels/${message.channel_id}/followers`,
];
// let to = `https://${host}/federation/channels/${message.channel_id}/followers`;
let to = ["https://www.w3.org/ns/activitystreams#Public"]; // TODO
if (channel.isDm()) {
const otherUsers = channel.recipients?.filter(
@ -83,7 +90,7 @@ export const transformMessageToNote = async (
published: message.timestamp,
attributedTo: `https://${host}/federation/users/${message.author_id}`,
to: [`https://${host}/federation/channels/${message.channel_id}`],
to: `https://${host}/federation/channels/${message.channel_id}/followers`,
tag: message.mentions?.map(
(x) => `https://${host}/federation/users/${x.id}`,
),
@ -250,6 +257,7 @@ export const transformPersonToUser = async (person: APPerson) => {
const keys = await FederationKey.create({
actorId: Snowflake.generate(),
federatedId: url.toString(),
username: person.preferredUsername,
domain: url.hostname,
publicKey: person.publicKey?.publicKeyPem,
type: ActorType.USER,
@ -280,3 +288,44 @@ export const transformPersonToUser = async (person: APPerson) => {
created_at: new Date(),
}).save();
};
export const transformOrganisationToInvite = (guild: APOrganization) => {
return Invite.create({
code: guild.id,
temporary: false,
uses: -1,
max_uses: 0,
max_age: 0,
created_at: new Date(0),
flags: 0,
});
};
export const transformGuildToOrganisation = async (
guild: Guild,
): Promise<APOrganization> => {
const { host, accountDomain } = Config.get().federation;
const keys = await FederationKey.findOneOrFail({
where: { actorId: guild.id, domain: accountDomain },
});
return {
"@context": ACTIVITYSTREAMS_CONTEXT,
type: "Organization",
id: `https://${host}/federation/guilds/${guild.id}`,
name: guild.name,
preferredUsername: guild.id,
icon: undefined,
inbox: `https://${host}/federation/guilds/${guild.id}/inbox`,
outbox: `https://${host}/federation/guilds/${guild.id}/outbox`,
followers: `https://${host}/federation/guilds/${guild.id}/followers`,
publicKey: {
id: `https://${host}/federation/guilds/${guild.id}#main-key`,
owner: `https://${host}/federation/guilds/${guild.id}`,
publicKeyPem: keys.publicKey,
},
};
};

View File

@ -1,21 +1,26 @@
import { DEFAULT_FETCH_OPTIONS } from "@spacebar/api";
import {
ActorType,
Config,
FederationKey,
OrmUtils,
Snowflake,
User,
UserSettings,
WebfingerResponse,
} from "@spacebar/util";
import {
APActivity,
APActor,
APObject,
APAnnounce,
APCreate,
APNote,
APPerson,
AnyAPObject,
} from "activitypub-types";
import crypto from "crypto";
import { HTTPError } from "lambert-server";
import fetch from "node-fetch";
import { ProxyAgent } from "proxy-agent";
import TurndownService from "turndown";
export const ACTIVITYSTREAMS_CONTEXT = "https://www.w3.org/ns/activitystreams";
@ -30,10 +35,9 @@ export class APError extends HTTPError {}
export const hasAPContext = (data: object) => {
if (!("@context" in data)) return false;
const context = data["@context"];
const activitystreams = "https://www.w3.org/ns/activitystreams";
if (Array.isArray(context))
return context.find((x) => x == activitystreams);
return context == activitystreams;
return !!context.find((x) => x == ACTIVITYSTREAMS_CONTEXT);
return context == ACTIVITYSTREAMS_CONTEXT;
};
export const resolveAPObject = async <T extends AnyAPObject>(
@ -97,64 +101,91 @@ export const resolveWebfinger = async (
},
).then((x) => x.json())) as WebfingerResponse;
if (!("links" in wellknown))
throw new APError(
`webfinger did not return any links for actor ${lookup}`,
);
const link = wellknown.links.find((x) => x.rel == "self");
if (!link) throw new APError(".well-known did not contain rel=self link");
return await resolveAPObject<AnyAPObject>(link.href);
};
/**
* Returns a signed request that can be passed to fetch
* ```
* const signed = await signActivity(receiver.inbox, sender, activity);
* await fetch(receiver.inbox, signed);
* ```
*/
export const signActivity = async (
inbox: string,
sender: FederationKey,
message: APActivity,
) => {
if (!sender.privateKey)
throw new APError("cannot sign without private key");
const digest = crypto
.createHash("sha256")
.update(JSON.stringify(message))
.digest("base64");
const signer = crypto.createSign("sha256");
const now = new Date();
const url = new URL(inbox);
const inboxFrag = url.pathname;
const toSign =
`(request-target): post ${inboxFrag}\n` +
`host: ${url.hostname}\n` +
`date: ${now.toUTCString()}\n` +
`digest: SHA-256=${digest}`;
signer.update(toSign);
signer.end();
const signature = signer.sign(sender.privateKey);
const sig_b64 = signature.toString("base64");
const { host } = Config.get().federation;
const header =
`keyId="${host}/${sender.type}/${sender.actorId}#main-key",` +
`headers="(request-target) host date digest",` +
`signature=${sig_b64}`;
return OrmUtils.mergeDeep(fetchOpts, {
method: "POST",
body: message,
headers: {
Host: url.hostname,
Date: now.toUTCString(),
Digest: `SHA-256=${digest}`,
Signature: header,
},
/** Fetch from local db, if not found fetch from remote instance and save */
export const fetchFederatedUser = async (actorId: string) => {
// if we were given webfinger, resolve that first
const mention = splitQualifiedMention(actorId);
const cache = await FederationKey.findOne({
where: { username: mention.user, domain: mention.domain },
});
if (cache) {
return {
keys: cache,
user: await User.findOneOrFail({ where: { id: cache.actorId } }),
};
}
// if we don't already have it, resolve webfinger
const remoteActor = await resolveWebfinger(actorId);
let type: ActorType;
if (APObjectIsPerson(remoteActor)) type = ActorType.USER;
else if (APObjectIsGroup(remoteActor)) type = ActorType.CHANNEL;
else if (APObjectIsOrganisation(remoteActor)) type = ActorType.GUILD;
else
throw new APError(
`The remote actor '${actorId}' is not a Person, Group, or Organisation`,
);
if (
typeof remoteActor.inbox != "string" ||
typeof remoteActor.outbox != "string"
)
throw new APError("Actor inbox/outbox must be string");
const keys = FederationKey.create({
actorId: Snowflake.generate(),
federatedId: actorId,
username: remoteActor.preferredUsername,
// this is technically not correct
// but it's slightly more difficult to go from actor url -> handle
// so thats a problem for future me
domain: mention.domain,
publicKey: remoteActor.publicKey?.publicKeyPem,
type,
inbox: remoteActor.inbox,
outbox: remoteActor.outbox,
});
const user = User.create({
id: keys.actorId,
username: remoteActor.preferredUsername,
discriminator: "0",
bio: new TurndownService().turndown(remoteActor.summary), // html -> markdown
email: `${remoteActor.preferredUsername}@${keys.domain}`,
data: {
hash: "#",
valid_tokens_since: new Date(),
},
extended_settings: "{}",
settings: UserSettings.create(),
premium: false,
premium_since: Config.get().defaults.user.premium
? new Date()
: undefined,
rights: Config.get().register.defaultRights,
premium_type: Config.get().defaults.user.premiumType ?? 0,
verified: Config.get().defaults.user.verified ?? true,
created_at: new Date(),
});
await Promise.all([keys.save(), user.save()]);
return {
keys,
user,
};
};
// fetch from remote server?
@ -181,3 +212,15 @@ export const APObjectIsSpacebarActor = (
APObjectIsPerson(object)
);
};
export const APActivityIsCreate = (act: APActivity): act is APCreate => {
return act.type == "Create";
};
export const APActivityIsAnnounce = (act: APActivity): act is APAnnounce => {
return act.type == "Announce";
};
export const APObjectIsNote = (obj: AnyAPObject): obj is APNote => {
return obj.type == "Note";
};

View File

@ -0,0 +1,16 @@
import { transformGuildToOrganisation } from "@spacebar/ap";
import { route } from "@spacebar/api";
import { Guild } from "@spacebar/util";
import { Request, Response, Router } from "express";
const router = Router();
// TODO: auth
router.get("/", route({}), async (req: Request, res: Response) => {
const guild = await Guild.findOneOrFail({
where: { id: req.params.guild_id },
});
return res.json(await transformGuildToOrganisation(guild));
});
export default router;

View File

@ -1,3 +1,4 @@
import { Federation } from "@spacebar/ap";
import { route } from "@spacebar/api";
import { Request, Response, Router } from "express";
const router = Router();
@ -5,6 +6,8 @@ const router = Router();
router.post("/", route({}), async (req: Request, res: Response) => {
// TODO: support lemmy ChatMessage type?
// TODO: check if the activity exists on the remote server
res.json(await Federation.genericInboxHandler(req));
});
export default router;

View File

@ -6,12 +6,13 @@ import {
FederationKey,
FieldErrors,
Guild,
Invite,
User,
WebfingerResponse,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import { splitQualifiedMention } from "./federation";
import { APError, splitQualifiedMention } from "./federation";
const router = Router();
router.get(
@ -41,11 +42,13 @@ router.get(
const { accountDomain, host } = Config.get().federation;
const { user, domain } = splitQualifiedMention(resource);
const mention = splitQualifiedMention(resource);
const domain = mention.domain;
let user = mention.user;
if (domain != accountDomain)
throw new HTTPError("Resource could not be found", 404);
const keys = await FederationKey.findOneOrFail({
let keys = await FederationKey.findOne({
where: {
actorId: user,
domain,
@ -53,6 +56,21 @@ router.get(
select: ["type"],
});
if (!keys) {
// maybe it's an invite?
const invite = await Invite.findOne({ where: { code: user } });
if (!invite) throw new APError("Resource count not be found");
// yippe, it is.
keys = await FederationKey.findOneOrFail({
where: { domain, actorId: invite.guild_id },
});
user = invite.guild_id;
}
let entity: User | Channel | Guild;
switch (keys.type) {
case ActorType.USER:

View File

@ -16,9 +16,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { APError } from "@spacebar/ap";
import { ApiError, FieldError } from "@spacebar/util";
import { NextFunction, Request, Response } from "express";
import { HTTPError } from "lambert-server";
import { ApiError, FieldError } from "@spacebar/util";
const EntityNotFoundErrorRegex = /"(\w+)"/;
export function ErrorHandler(
@ -35,7 +36,10 @@ export function ErrorHandler(
let message = error?.toString();
let errors = undefined;
if (error instanceof HTTPError && error.code)
if (
(error instanceof HTTPError || error instanceof APError) &&
error.code
)
code = httpcode = error.code;
else if (error instanceof ApiError) {
code = error.code;

View File

@ -16,16 +16,24 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
APError,
APObjectIsOrganisation,
resolveWebfinger,
splitQualifiedMention,
transformOrganisationToInvite,
} from "@spacebar/ap";
import { route } from "@spacebar/api";
import {
Config,
DiscordApiErrors,
emitEvent,
getPermission,
Guild,
Invite,
InviteDeleteEvent,
PublicInviteRelation,
User,
emitEvent,
getPermission,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
@ -46,6 +54,31 @@ router.get(
}),
async (req: Request, res: Response) => {
const { code } = req.params;
const { inputValue } = req.query;
if (inputValue && typeof inputValue == "string") {
const mention = splitQualifiedMention(inputValue);
if (mention.user.length && Config.get().federation.enabled) {
// This invite is in the form `invitecode@domain.com` OR `https://domain.com/whatever/invitecode`
// If the domain provided isn't ours, it's a federated invite
// and we should try and fetch that
const { domain } = mention;
const { accountDomain, host } = Config.get().federation;
if (domain != accountDomain && domain != host) {
// The domain isn't ours
const remoteGuild = await resolveWebfinger(inputValue);
if (APObjectIsOrganisation(remoteGuild))
return res.json(
transformOrganisationToInvite(remoteGuild),
);
throw new APError("Remote resource is not a guild");
}
}
}
const invite = await Invite.findOneOrFail({
where: { code },

View File

@ -25,9 +25,9 @@ export class FederationKey extends BaseClassWithoutId {
@Column()
domain: string;
/** The federated preferred username */
@Column()
username: string;
/** The federated preferred username of remote users. Local usernames are null. */
@Column({ nullable: true, type: String })
username: string | null;
/** The remote ID ( actor URL ) of this user */
@Column()

View File

@ -24,7 +24,14 @@ import {
OneToMany,
RelationId,
} from "typeorm";
import { Config, GuildWelcomeScreen, Snowflake, handleFile } from "..";
import {
ActorType,
Config,
FederationKey,
GuildWelcomeScreen,
Snowflake,
handleFile,
} from "..";
import { Ban } from "./Ban";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
@ -388,6 +395,16 @@ export class Guild extends BaseClass {
);
}
// If federation is enabled, generate signing keys for this actor.
setImmediate(
async () =>
Config.get().federation.enabled &&
(await FederationKey.generateSigningKeys(
guild.id,
ActorType.GUILD,
)),
);
return guild;
}