More work on converting to the Lysand protocol

This commit is contained in:
Jesse Wierzbinski 2023-11-03 17:34:31 -10:00
parent 02b56f8fde
commit 77a675afe6
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
25 changed files with 1181 additions and 807 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -128,6 +128,14 @@ remove_media = [] # NOT IMPLEMENTED
# significantly depending on processing power) # significantly depending on processing power)
authorized_fetch = false authorized_fetch = false
[instance]
name = "Lysand"
description = "A test instance of Lysand"
# URL to your instance logo (jpg files should be renamed to jpeg)
logo = ""
# URL to your instance banner (jpg files should be renamed to jpeg)
banner = ""
[filters] [filters]
# Drop notes with these regex filters (only applies to new activities) # Drop notes with these regex filters (only applies to new activities)

View file

@ -2,11 +2,13 @@ import {
BaseEntity, BaseEntity,
Column, Column,
Entity, Entity,
IsNull,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from "typeorm"; } from "typeorm";
import { APIEmoji } from "~types/entities/emoji"; import { APIEmoji } from "~types/entities/emoji";
import { Instance } from "./Instance"; import { Instance } from "./Instance";
import { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis";
/** /**
* Represents an emoji entity in the database. * Represents an emoji entity in the database.
@ -42,12 +44,69 @@ export class Emoji extends BaseEntity {
@Column("varchar") @Column("varchar")
url!: string; url!: string;
/**
* The alt text for the emoji.
*/
@Column("varchar", {
nullable: true,
})
alt!: string | null;
/**
* The content type of the emoji.
*/
@Column("varchar")
content_type!: string;
/** /**
* Whether the emoji is visible in the picker. * Whether the emoji is visible in the picker.
*/ */
@Column("boolean") @Column("boolean")
visible_in_picker!: boolean; visible_in_picker!: boolean;
/**
* Used for parsing emojis from local text
* @param text The text to parse
* @returns An array of emojis
*/
static async parseEmojis(text: string): Promise<Emoji[]> {
const regex = /:[a-zA-Z0-9_]+:/g;
const matches = text.match(regex);
if (!matches) return [];
return (
await Promise.all(
matches.map(match =>
Emoji.findOne({
where: {
shortcode: match.slice(1, -1),
instance: IsNull(),
},
relations: ["instance"],
})
)
)
).filter(emoji => emoji !== null) as Emoji[];
}
static async addIfNotExists(emoji: LysandEmoji) {
const existingEmoji = await Emoji.findOne({
where: {
shortcode: emoji.name,
instance: IsNull(),
},
});
if (existingEmoji) return existingEmoji;
const newEmoji = new Emoji();
newEmoji.shortcode = emoji.name;
// TODO: Content types
newEmoji.url = emoji.url[0].content;
newEmoji.alt = emoji.alt || null;
newEmoji.content_type = emoji.url[0].content_type;
newEmoji.visible_in_picker = true;
await newEmoji.save();
return newEmoji;
}
/** /**
* Converts the emoji to an APIEmoji object. * Converts the emoji to an APIEmoji object.
* @returns The APIEmoji object. * @returns The APIEmoji object.
@ -62,4 +121,17 @@ export class Emoji extends BaseEntity {
category: undefined, category: undefined,
}; };
} }
toLysand(): LysandEmoji {
return {
name: this.shortcode,
url: [
{
content: this.url,
content_type: this.content_type,
},
],
alt: this.alt || undefined,
};
}
} }

View file

@ -1,62 +1,5 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { APIInstance } from "~types/entities/instance"; import { ContentFormat, ServerMetadata } from "~types/lysand/Object";
import { APIAccount } from "~types/entities/account";
export interface NodeInfo {
software: {
name: string;
version: string;
};
protocols: string[];
version: string;
services: {
inbound: string[];
outbound: string[];
};
openRegistrations: boolean;
usage: {
users: {
total: number;
activeHalfyear: number;
activeMonth: number;
};
localPosts: number;
localComments?: number;
remotePosts?: number;
remoteComments?: number;
};
metadata: Partial<{
nodeName: string;
nodeDescription: string;
maintainer: {
name: string;
email: string;
};
langs: string[];
tosUrl: string;
repositoryUrl: string;
feedbackUrl: string;
disableRegistration: boolean;
disableLocalTimeline: boolean;
disableRecommendedTimeline: boolean;
disableGlobalTimeline: boolean;
emailRequiredForSignup: boolean;
searchFilters: boolean;
postEditing: boolean;
postImports: boolean;
enableHcaptcha: boolean;
enableRecaptcha: boolean;
maxNoteTextLength: number;
maxCaptionTextLength: number;
enableTwitterIntegration: boolean;
enableGithubIntegration: boolean;
enableDiscordIntegration: boolean;
enableEmail: boolean;
enableServiceWorker: boolean;
proxyAccountName: string | null;
themeColor: string;
}>;
}
/** /**
* Represents an instance in the database. * Represents an instance in the database.
@ -79,18 +22,27 @@ export class Instance extends BaseEntity {
base_url!: string; base_url!: string;
/** /**
* The configuration of the instance. * The name of the instance.
*/ */
@Column("jsonb", { @Column("varchar")
nullable: true, name!: string;
})
instance_data?: APIInstance;
/** /**
* Instance nodeinfo data * The description of the instance.
*/
@Column("varchar")
version!: string;
/**
* The logo of the instance.
*/ */
@Column("jsonb") @Column("jsonb")
nodeinfo!: NodeInfo; logo?: ContentFormat[];
/**
* The banner of the instance.
*/
banner?: ContentFormat[];
/** /**
* Adds an instance to the database if it doesn't already exist. * Adds an instance to the database if it doesn't already exist.
@ -114,80 +66,25 @@ export class Instance extends BaseEntity {
instance.base_url = hostname; instance.base_url = hostname;
// Fetch the instance configuration // Fetch the instance configuration
const nodeinfo: NodeInfo = await fetch(`${origin}/nodeinfo/2.0`).then( const metadata = (await fetch(`${origin}/.well-known/lysand`).then(
res => res.json() res => res.json()
); )) as Partial<ServerMetadata>;
// Try to fetch configuration from Mastodon-compatible instances if (metadata.type !== "ServerMetadata") {
if ( throw new Error("Invalid instance metadata");
["firefish", "iceshrimp", "mastodon", "akkoma", "pleroma"].includes(
nodeinfo.software.name
)
) {
const instanceData: APIInstance = await fetch(
`${origin}/api/v1/instance`
).then(res => res.json());
instance.instance_data = instanceData;
} }
instance.nodeinfo = nodeinfo; if (!(metadata.name && metadata.version)) {
throw new Error("Invalid instance metadata");
}
instance.name = metadata.name;
instance.version = metadata.version;
instance.logo = metadata.logo;
instance.banner = metadata.banner;
await instance.save(); await instance.save();
return instance; return instance;
} }
/**
* Converts the instance to an API instance.
* @returns The API instance.
*/
// eslint-disable-next-line @typescript-eslint/require-await
async toAPI(): Promise<APIInstance> {
return {
uri: this.instance_data?.uri || this.base_url,
approval_required: this.instance_data?.approval_required || false,
email: this.instance_data?.email || "",
thumbnail: this.instance_data?.thumbnail || "",
title: this.instance_data?.title || "",
version: this.instance_data?.version || "",
configuration: this.instance_data?.configuration || {
media_attachments: {
image_matrix_limit: 0,
image_size_limit: 0,
supported_mime_types: [],
video_frame_limit: 0,
video_matrix_limit: 0,
video_size_limit: 0,
},
polls: {
max_characters_per_option: 0,
max_expiration: 0,
max_options: 0,
min_expiration: 0,
},
statuses: {
characters_reserved_per_url: 0,
max_characters: 0,
max_media_attachments: 0,
},
},
contact_account:
this.instance_data?.contact_account || ({} as APIAccount),
description: this.instance_data?.description || "",
invites_enabled: this.instance_data?.invites_enabled || false,
languages: this.instance_data?.languages || [],
registrations: this.instance_data?.registrations || false,
rules: this.instance_data?.rules || [],
stats: {
domain_count: this.instance_data?.stats.domain_count || 0,
status_count: this.instance_data?.stats.status_count || 0,
user_count: this.instance_data?.stats.user_count || 0,
},
urls: {
streaming_api: this.instance_data?.urls.streaming_api || "",
},
max_toot_chars: this.instance_data?.max_toot_chars || 0,
};
}
} }

View file

@ -7,6 +7,8 @@ import {
} from "typeorm"; } from "typeorm";
import { User } from "./User"; import { User } from "./User";
import { Status } from "./Status"; import { Status } from "./Status";
import { Like as LysandLike } from "~types/lysand/Object";
import { getConfig } from "@config";
/** /**
* Represents a Like entity in the database. * Represents a Like entity in the database.
@ -29,4 +31,15 @@ export class Like extends BaseEntity {
@CreateDateColumn() @CreateDateColumn()
created_at!: Date; created_at!: Date;
toLysand(): LysandLike {
return {
id: this.id,
author: this.liker.uri,
type: "Like",
created_at: new Date(this.created_at).toISOString(),
object: this.liked.toLysand().uri,
uri: `${getConfig().http.base_url}/actions/${this.id}`,
};
}
} }

View file

@ -0,0 +1,92 @@
import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { LysandObjectType } from "~types/lysand/Object";
/**
* Represents a Lysand object in the database.
*/
@Entity({
name: "objects",
})
export class LysandObject extends BaseEntity {
/**
* The unique identifier for the object. If local, same as `remote_id`
*/
@PrimaryGeneratedColumn("uuid")
id!: string;
/**
* UUID of the object across the network. If the object is local, same as `id`
*/
remote_id!: string;
/**
* Any valid Lysand type, such as `Note`, `Like`, `Follow`, etc.
*/
@Column("varchar")
type!: string;
/**
* Remote URI for the object
* Example: `https://example.com/publications/ef235cc6-d68c-4756-b0df-4e6623c4d51c`
*/
@Column("varchar")
uri!: string;
@Column("timestamp")
created_at!: string;
/**
* References an Actor object by URI
*/
@ManyToOne(() => LysandObject, object => object.uri, {
nullable: true,
})
author!: LysandObject;
@Column("jsonb")
extra_data!: Omit<
Omit<Omit<Omit<LysandObjectType, "created_at">, "id">, "uri">,
"type"
>;
@Column("jsonb")
extensions!: Record<string, any>;
static new(type: string, uri: string): LysandObject {
const object = new LysandObject();
object.type = type;
object.uri = uri;
object.created_at = new Date().toISOString();
return object;
}
isPublication(): boolean {
return this.type === "Note" || this.type === "Patch";
}
isAction(): boolean {
return [
"Like",
"Follow",
"Dislike",
"FollowAccept",
"FollowReject",
"Undo",
"Announce",
].includes(this.type);
}
isActor(): boolean {
return this.type === "User";
}
isExtension(): boolean {
return this.type === "Extension";
}
}

View file

@ -21,6 +21,8 @@ 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 { htmlToText } from "html-to-text";
const config = getConfig(); const config = getConfig();
@ -95,6 +97,14 @@ export class Status extends BaseEntity {
}) })
content!: string; content!: string;
/**
* The content type of this status.
*/
@Column("varchar", {
default: "text/plain",
})
content_type!: string;
/** /**
* The visibility of this status. * The visibility of this status.
*/ */
@ -289,6 +299,8 @@ export class Status extends BaseEntity {
sensitive: boolean; sensitive: boolean;
spoiler_text: string; spoiler_text: string;
emojis: Emoji[]; emojis: Emoji[];
content_type?: string;
mentions?: User[];
reply?: { reply?: {
status: Status; status: Status;
user: User; user: User;
@ -299,6 +311,7 @@ export class Status extends BaseEntity {
newStatus.account = data.account; newStatus.account = data.account;
newStatus.application = data.application ?? null; newStatus.application = data.application ?? null;
newStatus.content = data.content; newStatus.content = data.content;
newStatus.content_type = data.content_type ?? "text/plain";
newStatus.visibility = data.visibility; newStatus.visibility = data.visibility;
newStatus.sensitive = data.sensitive; newStatus.sensitive = data.sensitive;
newStatus.spoiler_text = data.spoiler_text; newStatus.spoiler_text = data.spoiler_text;
@ -318,40 +331,44 @@ export class Status extends BaseEntity {
}); });
// Get list of mentioned users // Get list of mentioned users
await Promise.all( if (!data.mentions) {
mentionedPeople.map(async person => { await Promise.all(
// Check if post is in format @username or @username@instance.com mentionedPeople.map(async person => {
// If is @username, the user is a local user // Check if post is in format @username or @username@instance.com
const instanceUrl = // If is @username, the user is a local user
person.split("@").length === 3 const instanceUrl =
? person.split("@")[2] person.split("@").length === 3
: null; ? person.split("@")[2]
: null;
if (instanceUrl) { if (instanceUrl) {
const user = await User.findOne({ const user = await User.findOne({
where: { where: {
username: person.split("@")[1], username: person.split("@")[1],
// If contains instanceUrl // If contains instanceUrl
instance: { instance: {
base_url: instanceUrl, base_url: instanceUrl,
},
}, },
}, relations: userRelations,
relations: userRelations, });
});
newStatus.mentions.push(user as User); newStatus.mentions.push(user as User);
} else { } else {
const user = await User.findOne({ const user = await User.findOne({
where: { where: {
username: person.split("@")[1], username: person.split("@")[1],
}, },
relations: userRelations, relations: userRelations,
}); });
newStatus.mentions.push(user as User); newStatus.mentions.push(user as User);
} }
}) })
); );
} else {
newStatus.mentions = data.mentions;
}
await newStatus.save(); await newStatus.save();
return newStatus; return newStatus;
@ -442,4 +459,41 @@ export class Status extends BaseEntity {
quote_id: undefined, quote_id: undefined,
}; };
} }
toLysand(): Note {
return {
type: "Note",
created_at: new Date(this.created_at).toISOString(),
id: this.id,
author: this.account.uri,
uri: `${config.http.base_url}/users/${this.account.id}/statuses/${this.id}`,
contents: [
{
content: this.content,
content_type: "text/html",
},
{
// Content converted to plaintext
content: htmlToText(this.content),
content_type: "text/plain",
},
],
// TODO: Add attachments
attachments: [],
is_sensitive: this.sensitive,
mentions: this.mentions.map(mention => mention.id),
// TODO: Add quotes
quotes: [],
replies_to: this.in_reply_to_post?.id
? [this.in_reply_to_post.id]
: [],
subject: this.spoiler_text,
extensions: {
"org.lysand:custom_emojis": {
emojis: this.emojis.map(emoji => emoji.toLysand()),
},
// TODO: Add polls and reactions
},
};
}
} }

View file

@ -18,6 +18,9 @@ import { Status, statusRelations } from "./Status";
import { APISource } from "~types/entities/source"; import { APISource } from "~types/entities/source";
import { Relationship } from "./Relationship"; import { Relationship } from "./Relationship";
import { Instance } from "./Instance"; import { Instance } from "./Instance";
import { User as LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
import { Emoji } from "./Emoji";
export const userRelations = ["relationships", "pinned_notes", "instance"]; export const userRelations = ["relationships", "pinned_notes", "instance"];
@ -35,6 +38,12 @@ export class User extends BaseEntity {
@PrimaryGeneratedColumn("uuid") @PrimaryGeneratedColumn("uuid")
id!: string; id!: string;
/**
* The user URI on the global network
*/
@Column("varchar")
uri!: string;
/** /**
* The username for the user. * The username for the user.
*/ */
@ -82,6 +91,19 @@ export class User extends BaseEntity {
}) })
is_admin!: boolean; is_admin!: boolean;
@Column("jsonb", {
nullable: true,
})
endpoints!: {
liked: string;
disliked: string;
featured: string;
followers: string;
following: string;
inbox: string;
outbox: string;
} | null;
/** /**
* The source for the user. * The source for the user.
*/ */
@ -147,6 +169,13 @@ export class User extends BaseEntity {
@JoinTable() @JoinTable()
pinned_notes!: Status[]; pinned_notes!: Status[];
/**
* The emojis for the user.
*/
@ManyToMany(() => Emoji, emoji => emoji.id)
@JoinTable()
emojis!: Emoji[];
/** /**
* Get the user's avatar in raw URL format * Get the user's avatar in raw URL format
* @param config The config to use * @param config The config to use
@ -180,6 +209,76 @@ export class User extends BaseEntity {
return { user: await User.retrieveFromToken(token), token }; return { user: await User.retrieveFromToken(token), token };
} }
static async fetchRemoteUser(uri: string) {
const response = await fetch(uri, {
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
const data = (await response.json()) as Partial<LysandUser>;
const user = new User();
if (
!(
data.id &&
data.username &&
data.uri &&
data.created_at &&
data.disliked &&
data.featured &&
data.liked &&
data.followers &&
data.following &&
data.inbox &&
data.outbox &&
data.public_key
)
) {
throw new Error("Invalid user data");
}
user.id = data.id;
user.username = data.username;
user.uri = data.uri;
user.created_at = new Date(data.created_at);
user.endpoints = {
disliked: data.disliked,
featured: data.featured,
liked: data.liked,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
};
user.avatar = (data.avatar && data.avatar[0].content) || "";
user.header = (data.header && data.header[0].content) || "";
user.display_name = data.display_name ?? "";
// TODO: Add bio content types
user.note = data.bio?.[0].content ?? "";
// Parse emojis and add them to database
const emojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
for (const emoji of emojis) {
user.emojis.push(await Emoji.addIfNotExists(emoji));
}
user.public_key = data.public_key.public_key;
const uriData = new URL(data.uri);
user.instance = await Instance.addIfNotExists(uriData.origin);
await user.save();
return user;
}
/** /**
* Fetches the list of followers associated with the actor and updates the user's followers * Fetches the list of followers associated with the actor and updates the user's followers
*/ */
@ -229,6 +328,7 @@ export class User extends BaseEntity {
user.note = data.bio ?? ""; user.note = data.bio ?? "";
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.relationships = []; user.relationships = [];
user.instance = null; user.instance = null;
@ -348,15 +448,10 @@ export class User extends BaseEntity {
* Generates keys for the user. * Generates keys for the user.
*/ */
async generateKeys(): Promise<void> { async generateKeys(): Promise<void> {
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -outform PEM -pubout -out public.pem
const keys = await crypto.subtle.generateKey( const keys = await crypto.subtle.generateKey(
{ {
name: "RSASSA-PKCS1-v1_5", name: "ed25519",
hash: "SHA-256", namedCurve: "ed25519",
modulusLength: 4096,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
}, },
true, true,
["sign", "verify"] ["sign", "verify"]
@ -366,7 +461,7 @@ export class User extends BaseEntity {
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("pkcs8", keys.privateKey) await crypto.subtle.exportKey("raw", keys.privateKey)
), ),
]) ])
); );
@ -374,7 +469,7 @@ 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("spki", keys.publicKey) await crypto.subtle.exportKey("raw", keys.publicKey)
) )
) )
); );
@ -431,7 +526,7 @@ export class User extends BaseEntity {
followers_count: follower_count, followers_count: follower_count,
following_count: following_count, following_count: following_count,
statuses_count: statusCount, statuses_count: statusCount,
emojis: [], emojis: await Promise.all(this.emojis.map(emoji => emoji.toAPI())),
fields: [], fields: [],
bot: false, bot: false,
source: isOwnAccount ? this.source : undefined, source: isOwnAccount ? this.source : undefined,
@ -451,4 +546,83 @@ export class User extends BaseEntity {
role: undefined, role: undefined,
}; };
} }
/**
* Should only return local users
*/
toLysand(): LysandUser {
if (this.instance !== null) {
throw new Error("Cannot convert remote user to Lysand format");
}
return {
id: this.id,
type: "User",
uri: this.uri,
bio: [
{
content: this.note,
content_type: "text/html",
},
{
content: htmlToText(this.note),
content_type: "text/plain",
},
],
created_at: new Date(this.created_at).toISOString(),
disliked: `${this.uri}/disliked`,
featured: `${this.uri}/featured`,
liked: `${this.uri}/liked`,
followers: `${this.uri}/followers`,
following: `${this.uri}/following`,
inbox: `${this.uri}/inbox`,
outbox: `${this.uri}/outbox`,
indexable: false,
username: this.username,
avatar: [
{
content: this.getAvatarUrl(getConfig()) || "",
content_type: `image/${this.avatar.split(".")[1]}`,
},
],
header: [
{
content: this.getHeaderUrl(getConfig()) || "",
content_type: `image/${this.header.split(".")[1]}`,
},
],
display_name: this.display_name,
fields: this.source.fields.map(field => ({
key: [
{
content: field.name,
content_type: "text/html",
},
{
content: htmlToText(field.name),
content_type: "text/plain",
},
],
value: [
{
content: field.value,
content_type: "text/html",
},
{
content: htmlToText(field.value),
content_type: "text/plain",
},
],
})),
public_key: {
actor: `${getConfig().http.base_url}/users/${this.id}`,
public_key: this.public_key,
},
extensions: {
"org.lysand:custom_emojis": {
emojis: this.emojis.map(emoji => emoji.toLysand()),
},
},
};
}
} }

View file

@ -40,6 +40,7 @@
], ],
"devDependencies": { "devDependencies": {
"@julr/unocss-preset-forms": "^0.0.5", "@julr/unocss-preset-forms": "^0.0.5",
"@types/html-to-text": "^9.0.3",
"@types/jsonld": "^1.5.9", "@types/jsonld": "^1.5.9",
"@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0", "@typescript-eslint/parser": "^6.6.0",
@ -61,6 +62,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.429.0", "@aws-sdk/client-s3": "^3.429.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"html-to-text": "^9.0.5",
"ip-matching": "^2.1.2", "ip-matching": "^2.1.2",
"isomorphic-dompurify": "^1.9.0", "isomorphic-dompurify": "^1.9.0",
"jsonld": "^8.3.1", "jsonld": "^8.3.1",

View file

@ -0,0 +1,50 @@
import { jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { getConfig } from "@config";
import { applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/lysand",
});
/**
* Lysand instance metadata endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = getConfig();
// In the format acct:name@example.com
return jsonResponse({
type: "ServerMetadata",
name: config.instance.name,
version: "0.0.1",
description: config.instance.description,
logo: config.instance.logo ? [
{
content: config.instance.logo,
content_type: `image/${config.instance.logo.split(".")[1]}`,
}
] : undefined,
banner: config.instance.banner ? [
{
content: config.instance.banner,
content_type: `image/${config.instance.banner.split(".")[1]}`,
}
] : undefined,
supported_extensions: [
"org.lysand:custom_emojis"
],
website: "https://lysand.org",
// TODO: Add admins, moderators field
})
};

View file

@ -59,8 +59,6 @@ export default async (
relationship.note = comment ?? ""; relationship.note = comment ?? "";
// TODO: Implement duration
await relationship.save(); await relationship.save();
return jsonResponse(await relationship.toAPI()); return jsonResponse(await relationship.toAPI());
}; };

View file

@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun"; import { MatchedRoute } from "bun";
import { Status, statusAndUserRelations } from "~database/entities/Status"; import { Status, statusAndUserRelations } from "~database/entities/Status";
import { User, userRelations } from "~database/entities/User"; import { User, userRelations } from "~database/entities/User";
import { applyConfig } from "@api"; import { applyConfig } from "@api";
import { FindManyOptions } from "typeorm";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -25,10 +27,13 @@ export default async (
): Promise<Response> => { ): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// TODO: Add pinned
const { const {
max_id,
min_id,
since_id,
limit, limit,
exclude_reblogs, exclude_reblogs,
pinned,
}: { }: {
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
@ -51,12 +56,8 @@ export default async (
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
if (pinned) { // Get list of boosts for this status
// TODO: Add pinned statuses let query: FindManyOptions<Status> = {
}
// TODO: Check if status can be seen by this user
const statuses = await Status.find({
where: { where: {
account: { account: {
id: user.id, id: user.id,
@ -64,13 +65,81 @@ export default async (
isReblog: exclude_reblogs ? true : undefined, isReblog: exclude_reblogs ? true : undefined,
}, },
relations: statusAndUserRelations, relations: statusAndUserRelations,
order: {
created_at: "DESC",
},
take: limit ?? 20, take: limit ?? 20,
}); order: {
id: "DESC",
},
};
if (max_id) {
const maxStatus = await Status.findOneBy({ id: max_id });
if (maxStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxStatus.created_at,
},
},
};
}
}
if (since_id) {
const sinceStatus = await Status.findOneBy({ id: since_id });
if (sinceStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: sinceStatus.created_at,
},
},
};
}
}
if (min_id) {
const minStatus = await Status.findOneBy({ id: min_id });
if (minStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gte: minStatus.created_at,
},
},
};
}
}
const objects = await Status.find(query);
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`
);
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`
);
}
return jsonResponse( return jsonResponse(
await Promise.all(statuses.map(async status => await status.toAPI())) await Promise.all(objects.map(async status => await status.toAPI())),
200,
{
Link: linkHeader.join(", "),
}
); );
}; };

View file

@ -6,6 +6,7 @@ import { applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import { sanitizeHtml } from "@sanitization"; import { sanitizeHtml } from "@sanitization";
import { uploadFile } from "~classes/media"; import { uploadFile } from "~classes/media";
import { Emoji } from "~database/entities/Emoji";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["PATCH"], allowedMethods: ["PATCH"],
@ -81,6 +82,9 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Display name contains blocked words", 422); return errorResponse("Display name contains blocked words", 422);
} }
// Remove emojis
user.emojis = [];
user.display_name = sanitizedDisplayName; user.display_name = sanitizedDisplayName;
} }
@ -102,6 +106,9 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Bio contains blocked words", 422); return errorResponse("Bio contains blocked words", 422);
} }
// Remove emojis
user.emojis = [];
user.note = sanitizedNote; user.note = sanitizedNote;
} }
@ -193,6 +200,18 @@ export default async (req: Request): Promise<Response> => {
// user.discoverable = discoverable === "true"; // user.discoverable = discoverable === "true";
} }
// Parse emojis
const displaynameEmojis = await Emoji.parseEmojis(sanitizedDisplayName);
const noteEmojis = await Emoji.parseEmojis(sanitizedNote);
user.emojis = [...displaynameEmojis, ...noteEmojis];
// Deduplicate emojis
user.emojis = user.emojis.filter(
(emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index
);
await user.save(); await user.save();
return jsonResponse(await user.toAPI()); return jsonResponse(await user.toAPI());

View file

@ -1,153 +0,0 @@
import { errorResponse, jsonLdResponse } from "@response";
import { MatchedRoute } from "bun";
import { User, userRelations } from "~database/entities/User";
import { getConfig, getHost } from "@config";
import { applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/actor",
});
/**
* ActivityPub user actor endpoinmt
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
// Check for Accept header
const accept = req.headers.get("Accept");
if (!accept || !accept.includes("application/activity+json")) {
return errorResponse("This endpoint requires an Accept header", 406);
}
const config = getConfig();
const username = matchedRoute.params.username;
const user = await User.findOne({
where: { username },
relations: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonLdResponse({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
toot: "http://joinmastodon.org/ns#",
featured: {
"@id": "toot:featured",
"@type": "@id",
},
featuredTags: {
"@id": "toot:featuredTags",
"@type": "@id",
},
alsoKnownAs: {
"@id": "as:alsoKnownAs",
"@type": "@id",
},
movedTo: {
"@id": "as:movedTo",
"@type": "@id",
},
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
discoverable: "toot:discoverable",
Device: "toot:Device",
Ed25519Signature: "toot:Ed25519Signature",
Ed25519Key: "toot:Ed25519Key",
Curve25519Key: "toot:Curve25519Key",
EncryptedMessage: "toot:EncryptedMessage",
publicKeyBase64: "toot:publicKeyBase64",
deviceId: "toot:deviceId",
claim: {
"@type": "@id",
"@id": "toot:claim",
},
fingerprintKey: {
"@type": "@id",
"@id": "toot:fingerprintKey",
},
identityKey: {
"@type": "@id",
"@id": "toot:identityKey",
},
devices: {
"@type": "@id",
"@id": "toot:devices",
},
messageFranking: "toot:messageFranking",
messageType: "toot:messageType",
cipherText: "toot:cipherText",
suspended: "toot:suspended",
Emoji: "toot:Emoji",
focalPoint: {
"@container": "@list",
"@id": "toot:focalPoint",
},
Hashtag: "as:Hashtag",
},
],
id: `${config.http.base_url}/users/${user.username}`,
type: "Person",
preferredUsername: user.username, // TODO: Add user display name
name: user.username,
summary: user.note,
icon: {
type: "Image",
url: user.avatar,
mediaType: "image/png", // TODO: Set user avatar mimetype
},
image: {
type: "Image",
url: user.header,
mediaType: "image/png", // TODO: Set user header mimetype
},
inbox: `${config.http.base_url}/users/${user.username}/inbox`,
outbox: `${config.http.base_url}/users/${user.username}/outbox`,
followers: `${config.http.base_url}/users/${user.username}/followers`,
following: `${config.http.base_url}/users/${user.username}/following`,
liked: `${config.http.base_url}/users/${user.username}/liked`,
discoverable: true,
alsoKnownAs: [
// TODO: Add accounts from which the user migrated
],
manuallyApprovesFollowers: false, // TODO: Change
publicKey: {
id: `${getHost()}${config.http.base_url}/users/${
user.username
}/actor#main-key`,
owner: `${config.http.base_url}/users/${user.username}`,
// Split the public key into PEM format
publicKeyPem: `-----BEGIN PUBLIC KEY-----\n${user.public_key
.match(/.{1,64}/g)
?.join("\n")}\n-----END PUBLIC KEY-----`,
},
tag: [
// TODO: Add emojis here, and hashtags
],
attachment: [
// TODO: Add user attachments (I.E. profile metadata)
],
endpoints: {
sharedInbox: `${config.http.base_url}/inbox`,
},
});
};

View file

@ -1,305 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
import {
APAccept,
APActivity,
APActor,
APCreate,
APDelete,
APFollow,
APObject,
APReject,
APTombstone,
APUpdate,
} from "activitypub-types";
import { MatchedRoute } from "bun";
import { RawActivity } from "~database/entities/RawActivity";
import { RawActor } from "~database/entities/RawActor";
import { User } from "~database/entities/User";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/inbox",
});
/**
* ActivityPub user inbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const username = matchedRoute.params.username;
const config = getConfig();
try {
if (
config.activitypub.reject_activities.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
// Process request body
const body: APActivity = await req.json();
// Verify HTTP signature
if (config.activitypub.authorized_fetch) {
// Check if date is older than 30 seconds
const date = new Date(req.headers.get("Date") ?? "");
if (date.getTime() < Date.now() - 30000) {
return errorResponse("Date is too old (max 30 seconds)", 401);
}
const signature = req.headers.get("Signature") ?? "";
const signatureParams = signature
.split(",")
.reduce<Record<string, string>>((params, param) => {
const [key, value] = param.split("=");
params[key] = value.replace(/"/g, "");
return params;
}, {});
const signedString = `(request-target): post /users/${username}/inbox\nhost: ${
config.http.base_url
}\ndate: ${req.headers.get("Date")}`;
const signatureBuffer = new TextEncoder().encode(
signatureParams.signature
);
const signatureBytes = new Uint8Array(signatureBuffer).buffer;
const publicKeyBuffer = (body.actor as any).publicKey.publicKeyPem;
const publicKey = await crypto.subtle.importKey(
"spki",
publicKeyBuffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"]
);
const verified = await crypto.subtle.verify(
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
publicKey,
signatureBytes,
new TextEncoder().encode(signedString)
);
if (!verified) {
return errorResponse("Invalid signature", 401);
}
}
// Get the object's ActivityPub type
const type = body.type;
switch (type) {
case "Create" as APCreate: {
// Body is an APCreate object
// Store the Create object in database
// TODO: Add authentication
// Check is Activity already exists
const activity = await RawActivity.createIfNotExists(body);
if (activity instanceof Response) {
return activity;
}
break;
}
case "Update" as APUpdate: {
// Body is an APUpdate object
// Replace the object in database with the new provided object
// TODO: Add authentication
try {
if (
config.activitypub.discard_updates.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Update Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
const object = await RawActivity.updateObjectIfExists(
body.object as APObject
);
if (object instanceof Response) {
return object;
}
const activity = await RawActivity.createIfNotExists(body, object);
if (activity instanceof Response) {
return activity;
}
break;
}
case "Delete" as APDelete: {
// Body is an APDelete object
// Delete the object from database
// TODO: Add authentication
try {
if (
config.activitypub.discard_deletes.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Delete Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
const response = await RawActivity.deleteObjectIfExists(
body.object as APObject
);
if (response instanceof Response) {
return response;
}
// Store the Delete event in the database
const activity = await RawActivity.createIfNotExists(body);
if (activity instanceof Response) {
return activity;
}
break;
}
case "Accept" as APAccept: {
// Body is an APAccept object
// Add the actor to the object actor's followers list
if ((body.object as APFollow).type === "Follow") {
const user = await User.getByActorId(
((body.object as APFollow).actor as APActor).id ?? ""
);
if (!user) {
return errorResponse("User not found", 404);
}
const actor = await RawActor.addIfNotExists(
body.actor as APActor
);
if (actor instanceof Response) {
return actor;
}
// TODO: Add follower
await user.save();
}
break;
}
case "Reject" as APReject: {
// Body is an APReject object
// Mark the follow request as not pending
if ((body.object as APFollow).type === "Follow") {
const user = await User.getByActorId(
((body.object as APFollow).actor as APActor).id ?? ""
);
if (!user) {
return errorResponse("User not found", 404);
}
const actor = await RawActor.addIfNotExists(
body.actor as APActor
);
if (actor instanceof Response) {
return actor;
}
// TODO: Remove follower
await user.save();
}
break;
}
case "Follow" as APFollow: {
// Body is an APFollow object
// Add the actor to the object actor's followers list
try {
if (
config.activitypub.discard_follows.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Reject request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Delete Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
const user = await User.getByActorId(
(body.actor as APActor).id ?? ""
);
if (!user) {
return errorResponse("User not found", 404);
}
const actor = await RawActor.addIfNotExists(body.actor as APActor);
if (actor instanceof Response) {
return actor;
}
// TODO: Add follower
await user.save();
break;
}
}
return jsonResponse({});
};

View file

@ -1,163 +0,0 @@
import { errorResponse, jsonLdResponse } from "@response";
import { MatchedRoute } from "bun";
import { User, userRelations } from "~database/entities/User";
import { getHost } from "@config";
import { NodeObject, compact } from "jsonld";
import { RawActivity } from "~database/entities/RawActivity";
import { applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/outbox",
});
/**
* ActivityPub user outbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const username = matchedRoute.params.username.split("@")[0];
const page = Boolean(matchedRoute.query.page || "false");
const min_id = matchedRoute.query.min_id || false;
const max_id = matchedRoute.query.max_id || false;
const user = await User.findOne({
where: { username },
relations: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
// Get the user's corresponding ActivityPub notes
const count = await RawActivity.count({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
},
},
order: {
data: {
published: "DESC",
},
},
});
const lastPost = (
await RawActivity.find({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
},
},
order: {
data: {
published: "ASC",
},
},
take: 1,
})
)[0];
if (!page)
return jsonLdResponse(
await compact({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
id: `${getHost()}/@${user.username}/inbox`,
type: "OrderedCollection",
totalItems: count,
first: `${getHost()}/@${user.username}/outbox?page=true`,
last: `${getHost()}/@${user.username}/outbox?min_id=${
lastPost.id
}&page=true`,
})
);
else {
let posts: RawActivity[] = [];
if (min_id) {
posts = await RawActivity.find({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
id: min_id,
},
},
order: {
data: {
published: "DESC",
},
},
take: 11, // Take one extra to have the ID of the next post
});
} else if (max_id) {
posts = await RawActivity.find({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
id: max_id,
},
},
order: {
data: {
published: "ASC",
},
},
take: 10,
});
}
return jsonLdResponse(
await compact({
"@context": [
"https://www.w3.org/ns/activitystreams",
{
ostatus: "http://ostatus.org#",
atomUri: "ostatus:atomUri",
inReplyToAtomUri: "ostatus:inReplyToAtomUri",
conversation: "ostatus:conversation",
sensitive: "as:sensitive",
toot: "http://joinmastodon.org/ns#",
votersCount: "toot:votersCount",
litepub: "http://litepub.social/ns#",
directMessage: "litepub:directMessage",
Emoji: "toot:Emoji",
focalPoint: {
"@container": "@list",
"@id": "toot:focalPoint",
},
blurhash: "toot:blurhash",
},
],
id: `${getHost()}/@${user.username}/inbox`,
type: "OrderedCollectionPage",
totalItems: count,
partOf: `${getHost()}/@${user.username}/inbox`,
// Next is less recent posts chronologically, uses min_id
next: `${getHost()}/@${user.username}/outbox?min_id=${
posts[posts.length - 1].id
}&page=true`,
// Prev is more recent posts chronologically, uses max_id
prev: `${getHost()}/@${user.username}/outbox?max_id=${
posts[0].id
}&page=true`,
orderedItems: posts
.slice(0, 10)
.map(post => post.data) as NodeObject[],
})
);
}
};

View file

@ -0,0 +1,224 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Status } from "~database/entities/Status";
import { User, userRelations } from "~database/entities/User";
import {
ContentFormat,
LysandAction,
LysandObjectType,
LysandPublication,
} from "~types/lysand/Object";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/inbox",
});
/**
* ActivityPub user inbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const username = matchedRoute.params.username;
const config = getConfig();
try {
if (
config.activitypub.reject_activities.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
// Process request body
const body = (await req.json()) as LysandPublication | LysandAction;
const author = await User.findOne({
where: {
uri: body.author,
},
relations: userRelations,
});
if (!author) {
// TODO: Add new author to database
return errorResponse("Author not found", 404);
}
// Verify HTTP signature
if (config.activitypub.authorized_fetch) {
// Check if date is older than 30 seconds
const origin = req.headers.get("Origin");
if (!origin) {
return errorResponse("Origin header is required", 401);
}
const date = req.headers.get("Date");
if (!date) {
return errorResponse("Date header is required", 401);
}
if (new Date(date).getTime() < Date.now() - 30000) {
return errorResponse("Date is too old (max 30 seconds)", 401);
}
const signatureHeader = req.headers.get("Signature");
if (!signatureHeader) {
return errorResponse("Signature header is required", 401);
}
const signature = signatureHeader
.split("signature=")[1]
.replace(/"/g, "");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(await req.text())
);
const expectedSignedString =
`(request-target): ${req.method.toLowerCase()} ${req.url}\n` +
`host: ${req.url}\n` +
`date: ${date}\n` +
`digest: SHA-256=${Buffer.from(digest).toString("base64")}`;
// author.public_key is base64 encoded raw public key
const publicKey = await crypto.subtle.importKey(
"raw",
Buffer.from(author.public_key, "base64"),
{
name: "ed25519",
},
false,
["verify"]
);
// Check if signed string is valid
const isValid = await crypto.subtle.verify(
{
name: "ed25519",
saltLength: 0,
},
publicKey,
new TextEncoder().encode(signature),
new TextEncoder().encode(expectedSignedString)
);
if (!isValid) {
throw new Error("Invalid signature");
}
}
// Get the object's ActivityPub type
const type = body.type;
switch (type) {
case "Note": {
let content: ContentFormat | null;
// Find the best content and content type
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({
account: author,
content: content?.content || "",
content_type: content?.content_type,
application: null,
// TODO: Add visibility
visibility: "public",
spoiler_text: body.subject || "",
sensitive: body.is_sensitive,
// TODO: Add emojis
emojis: [],
});
break;
}
case "Patch": {
break;
}
case "Like": {
break;
}
case "Dislike": {
break;
}
case "Follow": {
break;
}
case "FollowAccept": {
break;
}
case "FollowReject": {
break;
}
case "Announce": {
break;
}
case "Undo": {
break;
}
default: {
return errorResponse("Invalid type", 400);
}
}
return jsonResponse({});
};

View file

@ -0,0 +1,44 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { User, userRelations } from "~database/entities/User";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid",
});
/**
* ActivityPub user inbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const uuid = matchedRoute.params.uuid;
const config = getConfig();
const user = await User.findOne({
where: {
id: uuid,
},
relations: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(user.toLysand());
};

View file

@ -0,0 +1,67 @@
import { jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { userRelations } from "~database/entities/User";
import { getHost } from "@config";
import { applyConfig } from "@api";
import { Status } from "~database/entities/Status";
import { In } from "typeorm";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid/outbox",
});
/**
* ActivityPub user outbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const uuid = matchedRoute.params.uuid;
const pageNumber = Number(matchedRoute.query.page) || 1;
const statuses = await Status.find({
where: {
account: {
id: uuid,
},
visibility: In(["public", "unlisted"]),
},
relations: userRelations,
take: 20,
skip: 20 * (pageNumber - 1),
});
const totalStatuses = await Status.count({
where: {
account: {
id: uuid,
},
visibility: In(["public", "unlisted"]),
},
relations: userRelations,
});
return jsonResponse({
first: `${getHost()}/users/${uuid}/outbox?page=1`,
last: `${getHost()}/users/${uuid}/outbox?page=1`,
total_items: totalStatuses,
next:
statuses.length === 20
? `${getHost()}/users/${uuid}/outbox?page=${pageNumber + 1}`
: undefined,
prev:
pageNumber > 1
? `${getHost()}/users/${uuid}/outbox?page=${pageNumber - 1}`
: undefined,
items: statuses.map(s => s.toLysand()),
});
};

View file

@ -0,0 +1,6 @@
import { LysandObjectType } from "./Object";
export interface ExtensionType extends LysandObjectType {
type: "Extension";
extension_type: string;
}

164
types/lysand/Object.ts Normal file
View file

@ -0,0 +1,164 @@
import { Emoji } from "./extensions/org.lysand/custom_emojis";
export interface LysandObjectType {
type: string;
id: string; // Either a UUID or some kind of time-based UUID-compatible system
uri: string; // URI to the note
created_at: string;
extensions?: {
// Should be in the format
// "organization:extension_name": value
// Example: "org.joinmastodon:spoiler_text": "This is a spoiler!"
"org.lysand:custom_emojis"?: {
emojis: Emoji[];
};
"org.lysand:reactions"?: {
reactions: string;
};
"org.lysand:polls"?: {
poll: {
options: ContentFormat[][];
votes: number[];
expires_at: string;
multiple_choice: boolean;
};
};
[key: string]: any;
};
}
export interface ActorPublicKeyData {
public_key: string;
actor: string;
}
export interface Collection<T> {
first: string;
last: string;
next?: string;
prev?: string;
items: T[];
}
export interface User extends LysandObjectType {
type: "User";
bio: ContentFormat[];
inbox: string;
outbox: string;
followers: string;
following: string;
liked: string;
disliked: string;
featured: string;
indexable: boolean;
fields?: {
key: ContentFormat[];
value: ContentFormat[];
}[];
display_name?: string;
public_key?: ActorPublicKeyData;
username: string;
avatar?: ContentFormat[];
header?: ContentFormat[];
}
export interface LysandPublication extends LysandObjectType {
type: "Note" | "Patch";
author: string;
contents: ContentFormat[];
mentions: string[];
replies_to: string[];
quotes: string[];
is_sensitive: boolean;
subject: string;
attachments: ContentFormat[][];
}
export interface LysandAction extends LysandObjectType {
type:
| "Like"
| "Dislike"
| "Follow"
| "FollowAccept"
| "FollowReject"
| "Announce"
| "Undo";
author: string;
}
/**
* A Note is a publication on the network, such as a post or comment
*/
export interface Note extends LysandPublication {
type: "Note";
}
/**
* A Patch is an edit to a Note
*/
export interface Patch extends LysandPublication {
type: "Patch";
patched_id: string;
patched_at: string;
}
export interface Like extends LysandAction {
type: "Like";
object: string;
}
export interface Dislike extends LysandAction {
type: "Dislike";
object: string;
}
export interface Announce extends LysandAction {
type: "Announce";
object: string;
}
export interface Undo extends LysandAction {
type: "Undo";
object: string;
}
export interface Follow extends LysandAction {
type: "Follow";
followee: string;
}
export interface FollowAccept extends LysandAction {
type: "FollowAccept";
follower: string;
}
export interface FollowReject extends LysandAction {
type: "FollowReject";
follower: string;
}
export interface ServerMetadata extends LysandObjectType {
type: "ServerMetadata";
name: string;
version?: string;
description?: string;
website?: string;
moderators?: string[];
admins?: string[];
logo?: ContentFormat[];
banner?: ContentFormat[];
supported_extensions?: string[];
}
/**
* Content format is an array of objects that contain the content and the content type.
*/
export interface ContentFormat {
content: string;
content_type: string;
description?: string;
size?: string;
}

View file

@ -0,0 +1,7 @@
import { ContentFormat } from "../../Object";
export interface Emoji {
name: string;
url: ContentFormat[];
alt?: string;
}

View file

@ -0,0 +1,14 @@
import { ExtensionType } from "../../Extension";
export interface OrgLysandPollsVoteType extends ExtensionType {
extension_type: "org.lysand:polls/Vote";
author: string;
poll: string;
option: number;
}
export interface OrgLysandPollsVoteResultType extends ExtensionType {
extension_type: "org.lysand:polls/VoteResult";
poll: string;
votes: number[];
}

View file

@ -0,0 +1,8 @@
import { ExtensionType } from "../../Extension";
export interface OrgLysandReactionsType extends ExtensionType {
extension_type: "org.lysand:reactions/Reaction";
author: string;
object: string;
content: string;
}

View file

@ -16,6 +16,13 @@ export interface ConfigType {
banned_ips: string[]; banned_ips: string[];
}; };
instance: {
name: string;
description: string;
banner: string;
logo: string;
};
smtp: { smtp: {
server: string; server: string;
port: number; port: number;
@ -133,6 +140,12 @@ export const configDefaults: ConfigType = {
password: "postgres", password: "postgres",
database: "lysand", database: "lysand",
}, },
instance: {
banner: "",
description: "",
logo: "",
name: "",
},
smtp: { smtp: {
password: "", password: "",
port: 465, port: 465,