More Lysand protocol work, refactor keys, small refactoring overall

This commit is contained in:
Jesse Wierzbinski 2023-11-04 13:59:55 -10:00
parent 77a675afe6
commit a1c0164e9d
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
11 changed files with 304 additions and 89 deletions

View file

@ -39,15 +39,15 @@ export class LysandObject extends BaseEntity {
uri!: string; uri!: string;
@Column("timestamp") @Column("timestamp")
created_at!: string; created_at!: Date;
/** /**
* References an Actor object by URI * References an Actor object
*/ */
@ManyToOne(() => LysandObject, object => object.uri, { @ManyToOne(() => LysandObject, object => object.uri, {
nullable: true, nullable: true,
}) })
author!: LysandObject; author!: LysandObject | null;
@Column("jsonb") @Column("jsonb")
extra_data!: Omit< extra_data!: Omit<
@ -62,10 +62,65 @@ export class LysandObject extends BaseEntity {
const object = new LysandObject(); const object = new LysandObject();
object.type = type; object.type = type;
object.uri = uri; object.uri = uri;
object.created_at = new Date().toISOString(); object.created_at = new Date();
return object; return object;
} }
static async createFromObject(object: LysandObjectType) {
let newObject: LysandObject;
const foundObject = await LysandObject.findOne({
where: { remote_id: object.id },
relations: ["author"],
});
if (foundObject) {
newObject = foundObject;
} else {
newObject = new LysandObject();
}
const author = await LysandObject.findOne({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
where: { uri: (object as any).author },
});
newObject.author = author;
newObject.created_at = new Date(object.created_at);
newObject.extensions = object.extensions || {};
newObject.remote_id = object.id;
newObject.type = object.type;
newObject.uri = object.uri;
// Rest of data (remove id, author, created_at, extensions, type, uri)
newObject.extra_data = Object.fromEntries(
Object.entries(object).filter(
([key]) =>
![
"id",
"author",
"created_at",
"extensions",
"type",
"uri",
].includes(key)
)
);
await newObject.save();
return newObject;
}
toLysand(): LysandObjectType {
return {
id: this.remote_id || this.id,
created_at: new Date(this.created_at).toISOString(),
type: this.type,
uri: this.uri,
...this.extra_data,
extensions: this.extensions,
};
}
isPublication(): boolean { isPublication(): boolean {
return this.type === "Note" || this.type === "Patch"; return this.type === "Note" || this.type === "Patch";
} }

View file

@ -21,8 +21,9 @@ import { Emoji } from "./Emoji";
import { Instance } from "./Instance"; import { Instance } from "./Instance";
import { Like } from "./Like"; import { Like } from "./Like";
import { AppDataSource } from "~database/datasource"; import { AppDataSource } from "~database/datasource";
import { Note } from "~types/lysand/Object"; import { LysandPublication, Note } from "~types/lysand/Object";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import { getBestContentType } from "@content_types";
const config = getConfig(); const config = getConfig();
@ -39,7 +40,13 @@ export const statusRelations = [
export const statusAndUserRelations = [ export const statusAndUserRelations = [
...statusRelations, ...statusRelations,
...["account.relationships", "account.pinned_notes", "account.instance"], // Can't directly map to userRelations as variable isnt yet initialized
...[
"account.relationships",
"account.pinned_notes",
"account.instance",
"account.emojis",
],
]; ];
/** /**
@ -56,6 +63,12 @@ export class Status extends BaseEntity {
@PrimaryGeneratedColumn("uuid") @PrimaryGeneratedColumn("uuid")
id!: string; id!: string;
/**
* The URI for this status.
*/
@Column("varchar")
uri!: string;
/** /**
* The user account that created this status. * The user account that created this status.
*/ */
@ -119,6 +132,14 @@ export class Status extends BaseEntity {
}) })
in_reply_to_post!: Status | null; in_reply_to_post!: Status | null;
/**
* The status that this status is quoting, if any
*/
@ManyToOne(() => Status, status => status.id, {
nullable: true,
})
quoting_post!: Status | null;
@TreeChildren() @TreeChildren()
replies!: Status[]; replies!: Status[];
@ -223,6 +244,64 @@ export class Status extends BaseEntity {
} }
} }
static async fetchFromRemote(uri: string) {
// Check if already in database
const existingStatus = await Status.findOne({
where: {
uri: uri,
},
});
if (existingStatus) return existingStatus;
const status = await fetch(uri);
if (status.status === 404) return null;
const body = (await status.json()) as LysandPublication;
const content = getBestContentType(body.contents);
const emojis = await Emoji.parseEmojis(content?.content || "");
const author = await User.fetchRemoteUser(body.author);
let replyStatus: Status | null = null;
let quotingStatus: Status | null = null;
if (body.replies_to.length > 0) {
replyStatus = await Status.fetchFromRemote(body.replies_to[0]);
}
if (body.quotes.length > 0) {
quotingStatus = await Status.fetchFromRemote(body.quotes[0]);
}
const newStatus = await Status.createNew({
account: author,
content: content?.content || "",
content_type: content?.content_type,
application: null,
// TODO: Add visibility
visibility: "public",
spoiler_text: body.subject || "",
uri: body.uri,
sensitive: body.is_sensitive,
emojis: emojis,
mentions: await User.parseMentions(body.mentions),
reply: replyStatus
? {
status: replyStatus,
user: replyStatus.account,
}
: undefined,
quote: quotingStatus || undefined,
});
return await newStatus.save();
}
/** /**
* Return all the ancestors of this post, * Return all the ancestors of this post,
*/ */
@ -300,11 +379,13 @@ export class Status extends BaseEntity {
spoiler_text: string; spoiler_text: string;
emojis: Emoji[]; emojis: Emoji[];
content_type?: string; content_type?: string;
uri?: string;
mentions?: User[]; mentions?: User[];
reply?: { reply?: {
status: Status; status: Status;
user: User; user: User;
}; };
quote?: Status;
}) { }) {
const newStatus = new Status(); const newStatus = new Status();
@ -319,6 +400,9 @@ export class Status extends BaseEntity {
newStatus.isReblog = false; newStatus.isReblog = false;
newStatus.mentions = []; newStatus.mentions = [];
newStatus.instance = data.account.instance; newStatus.instance = data.account.instance;
newStatus.uri =
data.uri || `${config.http.base_url}/statuses/${newStatus.id}`;
newStatus.quoting_post = data.quote || null;
if (data.reply) { if (data.reply) {
newStatus.in_reply_to_post = data.reply.status; newStatus.in_reply_to_post = data.reply.status;

View file

@ -22,7 +22,12 @@ import { User as LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import { Emoji } from "./Emoji"; import { Emoji } from "./Emoji";
export const userRelations = ["relationships", "pinned_notes", "instance"]; export const userRelations = [
"relationships",
"pinned_notes",
"instance",
"emojis",
];
/** /**
* Represents a user in the database. * Represents a user in the database.
@ -210,6 +215,15 @@ export class User extends BaseEntity {
} }
static async fetchRemoteUser(uri: string) { static async fetchRemoteUser(uri: string) {
// Check if user not already in database
const foundUser = await User.findOne({
where: {
uri,
},
});
if (foundUser) return foundUser;
const response = await fetch(uri, { const response = await fetch(uri, {
method: "GET", method: "GET",
headers: { headers: {
@ -329,6 +343,7 @@ export class User extends BaseEntity {
user.avatar = data.avatar ?? config.defaults.avatar; user.avatar = data.avatar ?? config.defaults.avatar;
user.header = data.header ?? config.defaults.avatar; user.header = data.header ?? config.defaults.avatar;
user.uri = `${config.http.base_url}/users/${user.id}`; user.uri = `${config.http.base_url}/users/${user.id}`;
user.emojis = [];
user.relationships = []; user.relationships = [];
user.instance = null; user.instance = null;
@ -349,6 +364,22 @@ export class User extends BaseEntity {
return user; return user;
} }
static async parseMentions(mentions: string[]) {
return await Promise.all(
mentions.map(async mention => {
const user = await User.findOne({
where: {
uri: mention,
},
relations: userRelations,
});
if (user) return user;
else return await User.fetchRemoteUser(mention);
})
);
}
/** /**
* Retrieves a user from a token. * Retrieves a user from a token.
* @param access_token The access token to retrieve the user from. * @param access_token The access token to retrieve the user from.
@ -448,20 +479,16 @@ export class User extends BaseEntity {
* Generates keys for the user. * Generates keys for the user.
*/ */
async generateKeys(): Promise<void> { async generateKeys(): Promise<void> {
const keys = await crypto.subtle.generateKey( const keys = (await crypto.subtle.generateKey("Ed25519", true, [
{ "sign",
name: "ed25519", "verify",
namedCurve: "ed25519", ])) as CryptoKeyPair;
},
true,
["sign", "verify"]
);
const privateKey = btoa( const privateKey = btoa(
String.fromCharCode.apply(null, [ String.fromCharCode.apply(null, [
...new Uint8Array( ...new Uint8Array(
// jesus help me what do these letters mean // jesus help me what do these letters mean
await crypto.subtle.exportKey("raw", keys.privateKey) await crypto.subtle.exportKey("pkcs8", keys.privateKey)
), ),
]) ])
); );
@ -469,13 +496,13 @@ export class User extends BaseEntity {
String.fromCharCode( String.fromCharCode(
...new Uint8Array( ...new Uint8Array(
// why is exporting a key so hard // why is exporting a key so hard
await crypto.subtle.exportKey("raw", keys.publicKey) await crypto.subtle.exportKey("spki", keys.publicKey)
) )
) )
); );
// Add header, footer and newlines later on // Add header, footer and newlines later on
// These keys are PEM encrypted // These keys are base64 encrypted
this.private_key = privateKey; this.private_key = privateKey;
this.public_key = publicKey; this.public_key = publicKey;
} }

View file

@ -2,8 +2,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api"; import { applyConfig } from "@api";
import { getConfig } from "@config"; import { getConfig } from "@config";
import { getBestContentType } from "@content_types";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun"; import { MatchedRoute } from "bun";
import { Emoji } from "~database/entities/Emoji";
import { LysandObject } from "~database/entities/Object";
import { Status } from "~database/entities/Status"; import { Status } from "~database/entities/Status";
import { User, userRelations } from "~database/entities/User"; import { User, userRelations } from "~database/entities/User";
import { import {
@ -11,6 +14,7 @@ import {
LysandAction, LysandAction,
LysandObjectType, LysandObjectType,
LysandPublication, LysandPublication,
Patch,
} from "~types/lysand/Object"; } from "~types/lysand/Object";
export const meta = applyConfig({ export const meta = applyConfig({
@ -111,28 +115,23 @@ export default async (
// author.public_key is base64 encoded raw public key // author.public_key is base64 encoded raw public key
const publicKey = await crypto.subtle.importKey( const publicKey = await crypto.subtle.importKey(
"raw", "spki",
Buffer.from(author.public_key, "base64"), Buffer.from(author.public_key, "base64"),
{ "Ed25519",
name: "ed25519",
},
false, false,
["verify"] ["verify"]
); );
// Check if signed string is valid // Check if signed string is valid
const isValid = await crypto.subtle.verify( const isValid = await crypto.subtle.verify(
{ "Ed25519",
name: "ed25519",
saltLength: 0,
},
publicKey, publicKey,
new TextEncoder().encode(signature), Buffer.from(signature, "base64"),
new TextEncoder().encode(expectedSignedString) new TextEncoder().encode(expectedSignedString)
); );
if (!isValid) { if (!isValid) {
throw new Error("Invalid signature"); return errorResponse("Invalid signature", 401);
} }
} }
@ -141,42 +140,14 @@ export default async (
switch (type) { switch (type) {
case "Note": { case "Note": {
let content: ContentFormat | null; // Store the object in the LysandObject table
await LysandObject.createFromObject(body);
// Find the best content and content type const content = getBestContentType(body.contents);
if (
body.contents.find(
c => c.content_type === "text/x.misskeymarkdown"
)
) {
content =
body.contents.find(
c => c.content_type === "text/x.misskeymarkdown"
) || null;
} else if (
body.contents.find(c => c.content_type === "text/html")
) {
content =
body.contents.find(c => c.content_type === "text/html") ||
null;
} else if (
body.contents.find(c => c.content_type === "text/markdown")
) {
content =
body.contents.find(
c => c.content_type === "text/markdown"
) || null;
} else if (
body.contents.find(c => c.content_type === "text/plain")
) {
content =
body.contents.find(c => c.content_type === "text/plain") ||
null;
} else {
content = body.contents[0] || null;
}
const status = await Status.createNew({ const emojis = await Emoji.parseEmojis(content?.content || "");
const newStatus = await Status.createNew({
account: author, account: author,
content: content?.content || "", content: content?.content || "",
content_type: content?.content_type, content_type: content?.content_type,
@ -185,34 +156,106 @@ export default async (
visibility: "public", visibility: "public",
spoiler_text: body.subject || "", spoiler_text: body.subject || "",
sensitive: body.is_sensitive, sensitive: body.is_sensitive,
// TODO: Add emojis uri: body.uri,
emojis: [], emojis: emojis,
mentions: await User.parseMentions(body.mentions),
}); });
// If there is a reply, fetch all the reply parents and add them to the database
if (body.replies_to.length > 0) {
newStatus.in_reply_to_post = await Status.fetchFromRemote(
body.replies_to[0]
);
}
// Same for quotes
if (body.quotes.length > 0) {
newStatus.quoting_post = await Status.fetchFromRemote(
body.quotes[0]
);
}
await newStatus.save();
break; break;
} }
case "Patch": { case "Patch": {
const patch = body as Patch;
// Store the object in the LysandObject table
await LysandObject.createFromObject(patch);
// Edit the status
const content = getBestContentType(patch.contents);
const emojis = await Emoji.parseEmojis(content?.content || "");
const status = await Status.findOneBy({
id: patch.patched_id,
});
if (!status) {
return errorResponse("Status not found", 404);
}
status.content = content?.content || "";
status.content_type = content?.content_type || "text/plain";
status.spoiler_text = patch.subject || "";
status.sensitive = patch.is_sensitive;
status.emojis = emojis;
// If there is a reply, fetch all the reply parents and add them to the database
if (body.replies_to.length > 0) {
status.in_reply_to_post = await Status.fetchFromRemote(
body.replies_to[0]
);
}
// Same for quotes
if (body.quotes.length > 0) {
status.quoting_post = await Status.fetchFromRemote(
body.quotes[0]
);
}
break; break;
} }
case "Like": { case "Like": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break; break;
} }
case "Dislike": { case "Dislike": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break; break;
} }
case "Follow": { case "Follow": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break; break;
} }
case "FollowAccept": { case "FollowAccept": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break; break;
} }
case "FollowReject": { case "FollowReject": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break; break;
} }
case "Announce": { case "Announce": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break; break;
} }
case "Undo": { case "Undo": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break;
}
case "Extension": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
break; break;
} }
default: { default: {

View file

@ -1,7 +1,7 @@
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { MatchedRoute } from "bun"; import { MatchedRoute } from "bun";
import { userRelations } from "~database/entities/User"; import { userRelations } from "~database/entities/User";
import { getHost } from "@config"; import { getConfig, getHost } from "@config";
import { applyConfig } from "@api"; import { applyConfig } from "@api";
import { Status } from "~database/entities/Status"; import { Status } from "~database/entities/Status";
import { In } from "typeorm"; import { In } from "typeorm";
@ -27,6 +27,7 @@ export default async (
): Promise<Response> => { ): Promise<Response> => {
const uuid = matchedRoute.params.uuid; const uuid = matchedRoute.params.uuid;
const pageNumber = Number(matchedRoute.query.page) || 1; const pageNumber = Number(matchedRoute.query.page) || 1;
const config = getConfig();
const statuses = await Status.find({ const statuses = await Status.find({
where: { where: {
@ -54,6 +55,8 @@ export default async (
first: `${getHost()}/users/${uuid}/outbox?page=1`, first: `${getHost()}/users/${uuid}/outbox?page=1`,
last: `${getHost()}/users/${uuid}/outbox?page=1`, last: `${getHost()}/users/${uuid}/outbox?page=1`,
total_items: totalStatuses, total_items: totalStatuses,
// Server actor
author: `${config.http.base_url}/users/actor`,
next: next:
statuses.length === 20 statuses.length === 20
? `${getHost()}/users/${uuid}/outbox?page=${pageNumber + 1}` ? `${getHost()}/users/${uuid}/outbox?page=${pageNumber + 1}`

View file

@ -109,7 +109,8 @@ describe("API Tests", () => {
const emoji = new Emoji(); const emoji = new Emoji();
emoji.instance = null; emoji.instance = null;
emoji.url = "https://example.com"; emoji.url = "https://example.com/test.png";
emoji.content_type = "image/png";
emoji.shortcode = "test"; emoji.shortcode = "test";
emoji.visible_in_picker = true; emoji.visible_in_picker = true;
@ -135,7 +136,7 @@ describe("API Tests", () => {
expect(emojis.length).toBeGreaterThan(0); expect(emojis.length).toBeGreaterThan(0);
expect(emojis[0].shortcode).toBe("test"); expect(emojis[0].shortcode).toBe("test");
expect(emojis[0].url).toBe("https://example.com"); expect(emojis[0].url).toBe("https://example.com/test.png");
}); });
afterAll(async () => { afterAll(async () => {
await Emoji.delete({ shortcode: "test" }); await Emoji.delete({ shortcode: "test" });

View file

@ -283,7 +283,7 @@ describe("API Tests", () => {
const status1 = statuses[1]; const status1 = statuses[1];
// Basic validation // Basic validation
expect(status1.content).toBe("Hello, world!"); expect(status1.content).toBe("This is a reply!");
expect(status1.visibility).toBe("public"); expect(status1.visibility).toBe("public");
expect(status1.account.id).toBe(user.id); expect(status1.account.id).toBe(user.id);
}); });

View file

@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; /* import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { AppDataSource } from "~database/datasource"; import { AppDataSource } from "~database/datasource";
import { Instance } from "~database/entities/Instance"; import { Instance } from "~database/entities/Instance";
@ -14,25 +14,6 @@ describe("Instance", () => {
instance = await Instance.addIfNotExists(url); instance = await Instance.addIfNotExists(url);
expect(instance.base_url).toBe("mastodon.social"); expect(instance.base_url).toBe("mastodon.social");
}); });
it("should convert the instance to an API instance", async () => {
const apiInstance = await instance.toAPI();
expect(apiInstance.uri).toBe("mastodon.social");
expect(apiInstance.approval_required).toBe(false);
expect(apiInstance.email).toBe("staff@mastodon.social");
expect(apiInstance.thumbnail).toBeDefined();
expect(apiInstance.title).toBeDefined();
expect(apiInstance.configuration).toBeDefined();
expect(apiInstance.contact_account).toBeDefined();
expect(apiInstance.description).toBeDefined();
expect(apiInstance.invites_enabled).toBeDefined();
expect(apiInstance.languages).toBeDefined();
expect(apiInstance.registrations).toBeDefined();
expect(apiInstance.rules).toBeDefined();
expect(apiInstance.stats).toBeDefined();
expect(apiInstance.urls).toBeDefined();
expect(apiInstance.max_toot_chars).toBeDefined();
});
}); });
afterAll(async () => { afterAll(async () => {
@ -40,3 +21,4 @@ afterAll(async () => {
await AppDataSource.destroy(); await AppDataSource.destroy();
}); });
*/

View file

@ -85,7 +85,8 @@ export interface LysandAction extends LysandObjectType {
| "FollowAccept" | "FollowAccept"
| "FollowReject" | "FollowReject"
| "Announce" | "Announce"
| "Undo"; | "Undo"
| "Extension";
author: string; author: string;
} }

19
utils/content_types.ts Normal file
View file

@ -0,0 +1,19 @@
import { ContentFormat } from "~types/lysand/Object";
export const getBestContentType = (contents: ContentFormat[]) => {
// Find the best content and content type
if (contents.find(c => c.content_type === "text/x.misskeymarkdown")) {
return (
contents.find(c => c.content_type === "text/x.misskeymarkdown") ||
null
);
} else if (contents.find(c => c.content_type === "text/html")) {
return contents.find(c => c.content_type === "text/html") || null;
} else if (contents.find(c => c.content_type === "text/markdown")) {
return contents.find(c => c.content_type === "text/markdown") || null;
} else if (contents.find(c => c.content_type === "text/plain")) {
return contents.find(c => c.content_type === "text/plain") || null;
} else {
return contents[0] || null;
}
};