mirror of
https://github.com/versia-pub/server.git
synced 2026-04-27 20:59:15 +02:00
feat(federation): ✨ Port to Versia 0.6
This commit is contained in:
parent
de69f27877
commit
fca30b4dad
62 changed files with 1614 additions and 2008 deletions
|
|
@ -1,10 +1,12 @@
|
|||
import { sign } from "./crypto.ts";
|
||||
import { Collection, URICollection } from "./entities/collection.ts";
|
||||
import type { Entity } from "./entities/entity.ts";
|
||||
import type { Entity, Reference } from "./entities/entity.ts";
|
||||
import { InstanceMetadata } from "./entities/instancemetadata.ts";
|
||||
import { homepage, version } from "./package.json" with { type: "json" };
|
||||
import { WebFingerSchema } from "./schemas/webfinger.ts";
|
||||
|
||||
const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
|
||||
const CONTENT_TYPE = "application/vnd.versia+json";
|
||||
|
||||
/**
|
||||
* A class that handles fetching Versia entities
|
||||
|
|
@ -22,22 +24,22 @@ const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
|
|||
export class FederationRequester {
|
||||
public constructor(
|
||||
private readonly privateKey: CryptoKey,
|
||||
private readonly authorUrl: URL,
|
||||
private readonly instance: URL,
|
||||
) {}
|
||||
|
||||
public async fetchEntity<T extends typeof Entity>(
|
||||
public async fetchSigned<T extends typeof Entity>(
|
||||
url: URL,
|
||||
expectedType: T,
|
||||
entityType: T,
|
||||
): Promise<InstanceType<T>> {
|
||||
const req = new Request(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Accept: CONTENT_TYPE,
|
||||
"User-Agent": DEFAULT_UA,
|
||||
},
|
||||
});
|
||||
|
||||
const finalReq = await sign(this.privateKey, this.authorUrl, req);
|
||||
const finalReq = await sign(this.privateKey, this.instance, req);
|
||||
|
||||
const res = await fetch(finalReq);
|
||||
|
||||
|
|
@ -49,79 +51,116 @@ export class FederationRequester {
|
|||
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
|
||||
if (!contentType?.includes("application/json")) {
|
||||
if (
|
||||
!(
|
||||
contentType?.includes("application/vnd.versia+json") &&
|
||||
contentType?.includes("charset=utf-8")
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected JSON response from ${url.toString()}, got "${contentType}"`,
|
||||
`Expected application/vnd.versia+json; charset=utf-8 response from ${url.toString()}, got "${contentType}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const jsonData = await res.json();
|
||||
const type = jsonData.type;
|
||||
|
||||
if (type && type !== expectedType.name) {
|
||||
if (
|
||||
(!type || type !== entityType.name) &&
|
||||
// (URI)Collections don't have a type field
|
||||
![Collection, URICollection].some((et) => et === entityType)
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected entity type "${expectedType.name}", got "${type}"`,
|
||||
`Expected entity type "${entityType.name}", got "${type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const entity = await expectedType.fromJSON(jsonData);
|
||||
const entity = await entityType.fromJSON(jsonData);
|
||||
|
||||
return entity as InstanceType<T>;
|
||||
}
|
||||
|
||||
public async postEntity(url: URL, entity: Entity): Promise<Response> {
|
||||
public fetchEntity<T extends typeof Entity>(
|
||||
reference: Reference,
|
||||
entityType: T,
|
||||
): Promise<InstanceType<T>> {
|
||||
const url = new URL(
|
||||
`/.versia/v0.6/entities/${encodeURIComponent(
|
||||
entityType.name,
|
||||
)}/${encodeURIComponent(reference.id)}`,
|
||||
`https://${reference.domain}`,
|
||||
);
|
||||
|
||||
return this.fetchSigned(url, entityType);
|
||||
}
|
||||
|
||||
public async postEntity(domain: string, entity: Entity): Promise<Response> {
|
||||
const url = new URL("/.versia/v0.6/inbox", `https://${domain}`);
|
||||
|
||||
const req = new Request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Accept: CONTENT_TYPE,
|
||||
"User-Agent": DEFAULT_UA,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Type": "application/vnd.versia+json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(entity.toJSON()),
|
||||
});
|
||||
|
||||
const finalReq = await sign(this.privateKey, this.authorUrl, req);
|
||||
const finalReq = await sign(this.privateKey, this.instance, req);
|
||||
|
||||
return fetch(finalReq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively go through a Collection of entities until reaching the end
|
||||
* @param url URL to reach the Collection
|
||||
* @param expectedType
|
||||
* @param reference Entity Reference
|
||||
* @param entityType
|
||||
* @param collectionItemType
|
||||
* @param options.limit Limit the number of entities to fetch
|
||||
*/
|
||||
public async resolveCollection<T extends typeof Entity>(
|
||||
url: URL,
|
||||
expectedType: T,
|
||||
public async resolveCollection<
|
||||
E extends typeof Entity,
|
||||
T extends typeof Entity,
|
||||
>(
|
||||
reference: Reference,
|
||||
collectionName: string,
|
||||
entityType: E,
|
||||
collectionItemType: T,
|
||||
options?: {
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<InstanceType<T>[]> {
|
||||
const url = new URL(
|
||||
`/.versia/v0.6/entities/${encodeURIComponent(
|
||||
entityType.name,
|
||||
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
|
||||
collectionName,
|
||||
)}`,
|
||||
`https://${reference.domain}`,
|
||||
);
|
||||
|
||||
const entities: InstanceType<T>[] = [];
|
||||
let nextUrl: URL | null = url;
|
||||
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
while (nextUrl && limit > 0) {
|
||||
const collection: Collection = await this.fetchEntity(
|
||||
nextUrl,
|
||||
Collection,
|
||||
);
|
||||
let collection = await this.fetchSigned(url, Collection);
|
||||
const total = collection.data.total;
|
||||
|
||||
for (const entity of collection.data.items) {
|
||||
if (entity.type === expectedType.name) {
|
||||
entities.push(
|
||||
(await expectedType.fromJSON(
|
||||
entity,
|
||||
)) as InstanceType<T>,
|
||||
);
|
||||
}
|
||||
while (collection && limit > 0) {
|
||||
entities.push(
|
||||
...collection.data.items.map(
|
||||
(item) =>
|
||||
collectionItemType.fromJSON(item) as InstanceType<T>,
|
||||
),
|
||||
);
|
||||
limit -= collection.data.items.length;
|
||||
|
||||
if (entities.length >= total) {
|
||||
break;
|
||||
}
|
||||
|
||||
nextUrl = collection.data.next
|
||||
? new URL(collection.data.next)
|
||||
: null;
|
||||
limit -= collection.data.items.length;
|
||||
url.searchParams.set("offset", entities.length.toString());
|
||||
collection = await this.fetchSigned(url, Collection);
|
||||
}
|
||||
|
||||
return entities;
|
||||
|
|
@ -129,33 +168,46 @@ export class FederationRequester {
|
|||
|
||||
/**
|
||||
* Recursively go through a URICollection of entities until reaching the end
|
||||
* @param url URL to reach the Collection
|
||||
* @param reference Entity Reference
|
||||
* @param entityType
|
||||
* @param options.limit Limit the number of entities to fetch
|
||||
*/
|
||||
public async resolveURICollection(
|
||||
url: URL,
|
||||
public async resolveURICollection<E extends typeof Entity>(
|
||||
reference: Reference,
|
||||
collectionName: string,
|
||||
entityType: E,
|
||||
options?: {
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<URL[]> {
|
||||
const entities: string[] = [];
|
||||
let nextUrl: URL | null = url;
|
||||
): Promise<string[]> {
|
||||
const url = new URL(
|
||||
`/.versia/v0.6/entities/${encodeURIComponent(
|
||||
entityType.name,
|
||||
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
|
||||
collectionName,
|
||||
)}`,
|
||||
`https://${reference.domain}`,
|
||||
);
|
||||
|
||||
const uris: string[] = [];
|
||||
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
while (nextUrl && limit > 0) {
|
||||
const collection: URICollection = await this.fetchEntity(
|
||||
nextUrl,
|
||||
URICollection,
|
||||
);
|
||||
let collection = await this.fetchSigned(url, URICollection);
|
||||
const total = collection.data.total;
|
||||
|
||||
entities.push(...collection.data.items);
|
||||
nextUrl = collection.data.next
|
||||
? new URL(collection.data.next)
|
||||
: null;
|
||||
while (collection && limit > 0) {
|
||||
uris.push(...collection.data.items);
|
||||
limit -= collection.data.items.length;
|
||||
|
||||
if (uris.length >= total) {
|
||||
break;
|
||||
}
|
||||
|
||||
url.searchParams.set("offset", uris.length.toString());
|
||||
collection = await this.fetchSigned(url, URICollection);
|
||||
}
|
||||
|
||||
return entities.map((u) => new URL(u));
|
||||
return uris;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -164,21 +216,21 @@ export class FederationRequester {
|
|||
*/
|
||||
public static async resolveWebFinger(
|
||||
username: string,
|
||||
hostname: string,
|
||||
contentType = "application/json",
|
||||
serverUrl = `https://${hostname}`,
|
||||
domain: string,
|
||||
contentType = "application/vnd.versia+json",
|
||||
serverUrl = `https://${domain}`,
|
||||
): Promise<URL | null> {
|
||||
const res = await fetch(
|
||||
new URL(
|
||||
`/.well-known/webfinger?${new URLSearchParams({
|
||||
resource: `acct:${username}@${hostname}`,
|
||||
resource: `acct:${username}@${domain}`,
|
||||
})}`,
|
||||
serverUrl,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Accept: "application/jrd+json, application/json",
|
||||
"User-Agent": DEFAULT_UA,
|
||||
},
|
||||
},
|
||||
|
|
@ -204,4 +256,57 @@ export class FederationRequester {
|
|||
|
||||
return new URL(selfLink.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve instance metadata from a domain
|
||||
*
|
||||
* Fetches well-known for version discovery, and if versia is supported, fetches the instance metadata
|
||||
* @param domain
|
||||
*/
|
||||
public async resolveInstance(domain: string): Promise<InstanceMetadata> {
|
||||
const wellKnownUrl = new URL(
|
||||
"/.well-known/versia",
|
||||
`https://${domain}`,
|
||||
);
|
||||
|
||||
const wellKnownRes = await fetch(wellKnownUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": DEFAULT_UA,
|
||||
},
|
||||
});
|
||||
|
||||
if (!wellKnownRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch well-known from ${wellKnownUrl.toString()}: got HTTP code ${wellKnownRes.status} with body "${await wellKnownRes.text()}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const wellKnownData = await wellKnownRes.json();
|
||||
|
||||
if (
|
||||
!(
|
||||
wellKnownData.versions &&
|
||||
Array.isArray(wellKnownData.versions) &&
|
||||
wellKnownData.versions.includes("0.6.0")
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Instance at ${domain} does not support Versia v0.6`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadataUrl = new URL(
|
||||
"/.versia/v0.6/instance",
|
||||
`https://${domain}`,
|
||||
);
|
||||
|
||||
const metadataRes = await this.fetchSigned(
|
||||
metadataUrl,
|
||||
InstanceMetadata,
|
||||
);
|
||||
|
||||
return metadataRes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue