mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
More work on converting to the Lysand protocol
This commit is contained in:
parent
02b56f8fde
commit
77a675afe6
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
database/entities/Object.ts
Normal file
92
database/entities/Object.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
50
server/api/.well-known/lysand.ts
Normal file
50
server/api/.well-known/lysand.ts
Normal 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
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
@ -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());
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(", "),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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({});
|
|
||||||
};
|
|
||||||
|
|
@ -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[],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
224
server/api/users/uuid/inbox/index.ts
Normal file
224
server/api/users/uuid/inbox/index.ts
Normal 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({});
|
||||||
|
};
|
||||||
44
server/api/users/uuid/index.ts
Normal file
44
server/api/users/uuid/index.ts
Normal 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());
|
||||||
|
};
|
||||||
67
server/api/users/uuid/outbox/index.ts
Normal file
67
server/api/users/uuid/outbox/index.ts
Normal 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()),
|
||||||
|
});
|
||||||
|
};
|
||||||
6
types/lysand/Extension.ts
Normal file
6
types/lysand/Extension.ts
Normal 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
164
types/lysand/Object.ts
Normal 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;
|
||||||
|
}
|
||||||
7
types/lysand/extensions/org.lysand/custom_emojis.ts
Normal file
7
types/lysand/extensions/org.lysand/custom_emojis.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { ContentFormat } from "../../Object";
|
||||||
|
|
||||||
|
export interface Emoji {
|
||||||
|
name: string;
|
||||||
|
url: ContentFormat[];
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
14
types/lysand/extensions/org.lysand/polls.ts
Normal file
14
types/lysand/extensions/org.lysand/polls.ts
Normal 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[];
|
||||||
|
}
|
||||||
8
types/lysand/extensions/org.lysand/reactions.ts
Normal file
8
types/lysand/extensions/org.lysand/reactions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue