refactor(api): 🎨 Move User methods into their own class similar to Note

This commit is contained in:
Jesse Wierzbinski 2024-04-24 17:40:27 -10:00
parent abc8f1ae16
commit 9d70778abd
No known key found for this signature in database
81 changed files with 2055 additions and 1603 deletions

View file

@ -5,6 +5,7 @@ import {
desc,
eq,
inArray,
isNotNull,
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
import type * as Lysand from "lysand-types";
@ -30,17 +31,7 @@ import {
contentToHtml,
findFirstNote,
findManyNotes,
getStatusUri,
} from "~database/entities/Status";
import {
type User,
type UserWithRelations,
type UserWithRelationsAndRelationships,
findManyUsers,
getUserUri,
userToAPI,
userToMention,
} from "~database/entities/User";
import { db } from "~drizzle/db";
import {
Attachments,
@ -48,13 +39,12 @@ import {
NoteToMentions,
Notes,
Notifications,
UserToPinnedNotes,
Users,
UsersRelations,
} from "~drizzle/schema";
import { config } from "~packages/config-manager";
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
import type { Status as APIStatus } from "~types/mastodon/status";
import { User } from "./user";
/**
* Gives helpers to fetch notes from database in a nice format
@ -101,36 +91,44 @@ export class Note {
return found.map((s) => new Note(s));
}
get id() {
return this.status.id;
}
async getUsersToFederateTo() {
// Mentioned users
const mentionedUsers =
this.getStatus().mentions.length > 0
? await findManyUsers({
where: (user, { and, isNotNull, inArray }) =>
and(
isNotNull(user.instanceId),
inArray(
user.id,
this.getStatus().mentions.map(
(mention) => mention.id,
),
? await User.manyFromSql(
and(
isNotNull(Users.instanceId),
inArray(
Users.id,
this.getStatus().mentions.map(
(mention) => mention.id,
),
),
})
),
)
: [];
const usersThatCanSeePost = await findManyUsers({
where: (user, { isNotNull }) => isNotNull(user.instanceId),
with: {
relationships: {
where: (relationship, { eq, and }) =>
and(
eq(relationship.subjectId, Users.id),
eq(relationship.following, true),
),
const usersThatCanSeePost = await User.manyFromSql(
isNotNull(Users.instanceId),
undefined,
undefined,
undefined,
{
with: {
relationships: {
where: (relationship, { eq, and }) =>
and(
eq(relationship.subjectId, Users.id),
eq(relationship.following, true),
),
},
},
},
});
);
const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost];
@ -159,39 +157,13 @@ export class Note {
}
getAuthor() {
return this.status.author;
return new User(this.status.author);
}
async getReplyChildren() {
return await Note.manyFromSql(eq(Notes.replyId, this.status.id));
}
async pin(pinner: User) {
return (
await db
.insert(UserToPinnedNotes)
.values({
noteId: this.status.id,
userId: pinner.id,
})
.returning()
)[0];
}
async unpin(unpinner: User) {
return (
await db
.delete(UserToPinnedNotes)
.where(
and(
eq(NoteToMentions.noteId, this.status.id),
eq(NoteToMentions.userId, unpinner.id),
),
)
.returning()
)[0];
}
static async insert(values: InferInsertModel<typeof Notes>) {
return (await db.insert(Notes).values(values).returning())[0];
}
@ -204,7 +176,7 @@ export class Note {
spoiler_text: string,
emojis: EmojiWithInstance[],
uri?: string,
mentions?: UserWithRelations[],
mentions?: User[],
/** List of IDs of database Attachment objects */
media_attachments?: string[],
replyId?: string,
@ -216,7 +188,7 @@ export class Note {
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
if (author.instanceId === null) {
if (author.isLocal()) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
@ -277,7 +249,7 @@ export class Note {
// Send notifications for mentioned local users
for (const mention of mentions ?? []) {
if (mention.instanceId === null) {
if (mention.isLocal()) {
await db.insert(Notifications).values({
accountId: author.id,
notifiedId: mention.id,
@ -296,7 +268,7 @@ export class Note {
is_sensitive?: boolean,
spoiler_text?: string,
emojis: EmojiWithInstance[] = [],
mentions: UserWithRelations[] = [],
mentions: User[] = [],
/** List of IDs of database Attachment objects */
media_attachments: string[] = [],
) {
@ -307,7 +279,7 @@ export class Note {
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
if (this.getAuthor().instanceId === null && htmlContent) {
if (this.getAuthor().isLocal() && htmlContent) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
@ -401,7 +373,7 @@ export class Note {
* @param user The user to check.
* @returns Whether this status is viewable by the user.
*/
async isViewableByUser(user: UserWithRelations | null) {
async isViewableByUser(user: User | null) {
if (this.getAuthor().id === user?.id) return true;
if (this.getStatus().visibility === "public") return true;
if (this.getStatus().visibility === "unlisted") return true;
@ -423,7 +395,7 @@ export class Note {
);
}
async toAPI(userFetching?: UserWithRelations | null): Promise<APIStatus> {
async toAPI(userFetching?: User | null): Promise<APIStatus> {
const data = this.getStatus();
const wasPinnedByUser = userFetching
? !!(await db.query.UserToPinnedNotes.findFirst({
@ -480,7 +452,7 @@ export class Note {
id: data.id,
in_reply_to_id: data.replyId || null,
in_reply_to_account_id: data.reply?.authorId || null,
account: userToAPI(data.author),
account: this.getAuthor().toAPI(userFetching?.id === data.authorId),
created_at: new Date(data.createdAt).toISOString(),
application: data.application
? applicationToAPI(data.application)
@ -495,7 +467,16 @@ export class Note {
media_attachments: (data.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment,
),
mentions: data.mentions.map((mention) => userToMention(mention)),
mentions: data.mentions.map((mention) => ({
id: mention.id,
acct: User.getAcct(
mention.instanceId === null,
mention.username,
mention.instance?.baseUrl,
),
url: User.getUri(mention.id, mention.uri, config.http.base_url),
username: mention.username,
})),
language: null,
muted: wasMutedByUser,
pinned: wasPinnedByUser,
@ -531,8 +512,13 @@ export class Note {
return localObjectURI(this.getStatus().id);
}
static getURI(id?: string | null) {
if (!id) return null;
return localObjectURI(id);
}
getMastoURI() {
return `/@${this.getAuthor().username}/${this.getStatus().id}`;
return `/@${this.getAuthor().getUser().username}/${this.id}`;
}
toLysand(): Lysand.Note {
@ -541,7 +527,7 @@ export class Note {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: getUserUri(status.author),
author: this.getAuthor().getUri(),
uri: this.getURI(),
content: {
"text/html": {
@ -556,8 +542,8 @@ export class Note {
),
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mention.uri || ""),
quotes: getStatusUri(status.quote) ?? undefined,
replies_to: getStatusUri(status.reply) ?? undefined,
quotes: Note.getURI(status.quotingId) ?? undefined,
replies_to: Note.getURI(status.replyId) ?? undefined,
subject: status.spoilerText,
visibility: status.visibility as Lysand.Visibility,
extensions: {
@ -572,7 +558,7 @@ export class Note {
/**
* Return all the ancestors of this post,
*/
async getAncestors(fetcher: UserWithRelationsAndRelationships | null) {
async getAncestors(fetcher: User | null) {
const ancestors: Note[] = [];
let currentStatus: Note = this;
@ -599,10 +585,7 @@ export class Note {
* Return all the descendants of this post (recursive)
* Temporary implementation, will be replaced with a recursive SQL query when I get to it
*/
async getDescendants(
fetcher: UserWithRelationsAndRelationships | null,
depth = 0,
) {
async getDescendants(fetcher: User | null, depth = 0) {
const descendants: Note[] = [];
for (const child of await this.getReplyChildren()) {
descendants.push(child);

View file

@ -1,10 +1,12 @@
import { type SQL, gt } from "drizzle-orm";
import { Notes } from "~drizzle/schema";
import { Notes, Users } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import { Note } from "./note";
import { User } from "./user";
enum TimelineType {
NOTE = "Note",
USER = "User",
}
export class Timeline {
@ -15,7 +17,23 @@ export class Timeline {
limit: number,
url: string,
) {
return new Timeline(TimelineType.NOTE).fetchTimeline(sql, limit, url);
return new Timeline(TimelineType.NOTE).fetchTimeline<Note>(
sql,
limit,
url,
);
}
static async getUserTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
) {
return new Timeline(TimelineType.USER).fetchTimeline<User>(
sql,
limit,
url,
);
}
private async fetchTimeline<T>(
@ -23,13 +41,15 @@ export class Timeline {
limit: number,
url: string,
) {
const objects: Note[] = [];
const notes: Note[] = [];
const users: User[] = [];
switch (this.type) {
case TimelineType.NOTE:
objects.push(
...(await Note.manyFromSql(sql, undefined, limit)),
);
notes.push(...(await Note.manyFromSql(sql, undefined, limit)));
break;
case TimelineType.USER:
users.push(...(await User.manyFromSql(sql, undefined, limit)));
break;
}
@ -39,26 +59,26 @@ export class Timeline {
config.http.base_url,
).toString();
if (objects.length > 0) {
if (notes.length > 0) {
switch (this.type) {
case TimelineType.NOTE: {
const objectBefore = await Note.fromSql(
gt(Notes.id, objects[0].getStatus().id),
gt(Notes.id, notes[0].getStatus().id),
);
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${
objects[0].getStatus().id
notes[0].getStatus().id
}>; rel="prev"`,
);
}
if (objects.length >= (limit ?? 20)) {
if (notes.length >= (limit ?? 20)) {
const objectAfter = await Note.fromSql(
gt(
Notes.id,
objects[objects.length - 1].getStatus().id,
notes[notes.length - 1].getStatus().id,
),
);
@ -67,7 +87,37 @@ export class Timeline {
`<${urlWithoutQuery}?limit=${
limit ?? 20
}&max_id=${
objects[objects.length - 1].getStatus().id
notes[notes.length - 1].getStatus().id
}>; rel="next"`,
);
}
}
break;
}
case TimelineType.USER: {
const objectBefore = await User.fromSql(
gt(Users.id, users[0].id),
);
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${
users[0].id
}>; rel="prev"`,
);
}
if (users.length >= (limit ?? 20)) {
const objectAfter = await User.fromSql(
gt(Users.id, users[users.length - 1].id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${
limit ?? 20
}&max_id=${
users[users.length - 1].id
}>; rel="next"`,
);
}
@ -77,9 +127,17 @@ export class Timeline {
}
}
return {
link: linkHeader.join(", "),
objects,
};
switch (this.type) {
case TimelineType.NOTE:
return {
link: linkHeader.join(", "),
objects: notes as T[],
};
case TimelineType.USER:
return {
link: linkHeader.join(", "),
objects: users as T[],
};
}
}
}

View file

@ -0,0 +1,421 @@
import { idValidator } from "@api";
import { getBestContentType, urlToContentFormat } from "@content_types";
import { addUserToMeilisearch } from "@meilisearch";
import { type SQL, and, desc, eq, inArray } from "drizzle-orm";
import { htmlToText } from "html-to-text";
import type * as Lysand from "lysand-types";
import {
emojiToAPI,
emojiToLysand,
fetchEmoji,
} from "~database/entities/Emoji";
import { addInstanceIfNotExists } from "~database/entities/Instance";
import {
type UserWithRelations,
findFirstUser,
findManyUsers,
} from "~database/entities/User";
import { db } from "~drizzle/db";
import {
EmojiToUser,
NoteToMentions,
UserToPinnedNotes,
Users,
} from "~drizzle/schema";
import { type Config, config } from "~packages/config-manager";
import type { Account as APIAccount } from "~types/mastodon/account";
import type { Mention as APIMention } from "~types/mastodon/mention";
import type { Note } from "./note";
/**
* Gives helpers to fetch users from database in a nice format
*/
export class User {
constructor(private user: UserWithRelations) {}
static async fromId(id: string | null): Promise<User | null> {
if (!id) return null;
return await User.fromSql(eq(Users.id, id));
}
static async fromIds(ids: string[]): Promise<User[]> {
return await User.manyFromSql(inArray(Users.id, ids));
}
static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Users.id),
) {
const found = await findFirstUser({
where: sql,
orderBy,
});
if (!found) return null;
return new User(found);
}
static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Users.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Users.findMany>[0],
) {
const found = await findManyUsers({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new User(s));
}
get id() {
return this.user.id;
}
getUser() {
return this.user;
}
isLocal() {
return this.user.instanceId === null;
}
isRemote() {
return !this.isLocal();
}
getUri() {
return (
this.user.uri ||
new URL(`/users/${this.user.id}`, config.http.base_url).toString()
);
}
static getUri(id: string, uri: string | null, baseUrl: string) {
return uri || new URL(`/users/${id}`, baseUrl).toString();
}
async pin(note: Note) {
return (
await db
.insert(UserToPinnedNotes)
.values({
noteId: note.id,
userId: this.id,
})
.returning()
)[0];
}
async unpin(note: Note) {
return (
await db
.delete(UserToPinnedNotes)
.where(
and(
eq(NoteToMentions.noteId, note.id),
eq(NoteToMentions.userId, this.id),
),
)
.returning()
)[0];
}
static async resolve(uri: string): Promise<User | null> {
// Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri));
if (foundUser) return foundUser;
// Check if URI is of a local user
if (uri.startsWith(config.http.base_url)) {
const uuid = uri.match(idValidator);
if (!uuid || !uuid[0]) {
throw new Error(
`URI ${uri} is of a local user, but it could not be parsed`,
);
}
return await User.fromId(uuid[0]);
}
if (!URL.canParse(uri)) {
throw new Error(`Invalid URI to parse ${uri}`);
}
const response = await fetch(uri, {
method: "GET",
headers: {
Accept: "application/json",
},
});
const data = (await response.json()) as Partial<Lysand.User>;
if (
!(
data.id &&
data.username &&
data.uri &&
data.created_at &&
data.dislikes &&
data.featured &&
data.likes &&
data.followers &&
data.following &&
data.inbox &&
data.outbox &&
data.public_key
)
) {
throw new Error("Invalid user data");
}
// Parse emojis and add them to database
const userEmojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
const instance = await addInstanceIfNotExists(data.uri);
const emojis = [];
for (const emoji of userEmojis) {
emojis.push(await fetchEmoji(emoji));
}
const newUser = (
await db
.insert(Users)
.values({
username: data.username,
uri: data.uri,
createdAt: new Date(data.created_at).toISOString(),
endpoints: {
dislikes: data.dislikes,
featured: data.featured,
likes: data.likes,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
updatedAt: new Date(data.created_at).toISOString(),
instanceId: instance.id,
avatar: data.avatar
? Object.entries(data.avatar)[0][1].content
: "",
header: data.header
? Object.entries(data.header)[0][1].content
: "",
displayName: data.display_name ?? "",
note: getBestContentType(data.bio).content,
publicKey: data.public_key.public_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
})
.returning()
)[0];
// Add emojis to user
if (emojis.length > 0) {
await db.insert(EmojiToUser).values(
emojis.map((emoji) => ({
emojiId: emoji.id,
userId: newUser.id,
})),
);
}
const finalUser = await User.fromId(newUser.id);
if (!finalUser) return null;
// Add to Meilisearch
await addUserToMeilisearch(finalUser);
return finalUser;
}
/**
* Get the user's avatar in raw URL format
* @param config The config to use
* @returns The raw URL for the user's avatar
*/
getAvatarUrl(config: Config) {
if (!this.user.avatar)
return (
config.defaults.avatar ||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.user.username}`
);
return this.user.avatar;
}
/**
* Get the user's header in raw URL format
* @param config The config to use
* @returns The raw URL for the user's header
*/
getHeaderUrl(config: Config) {
if (!this.user.header) return config.defaults.header;
return this.user.header;
}
getAcct() {
return this.isLocal()
? this.user.username
: `${this.user.username}@${this.user.instance?.baseUrl}`;
}
static getAcct(isLocal: boolean, username: string, baseUrl?: string) {
return isLocal ? username : `${username}@${baseUrl}`;
}
toAPI(isOwnAccount = false): APIAccount {
const user = this.getUser();
return {
id: user.id,
username: user.username,
display_name: user.displayName,
note: user.note,
url:
user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(),
avatar: this.getAvatarUrl(config),
header: this.getHeaderUrl(config),
locked: user.isLocked,
created_at: new Date(user.createdAt).toISOString(),
followers_count: user.followerCount,
following_count: user.followingCount,
statuses_count: user.statusCount,
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
// TODO: Add fields
fields: [],
bot: user.isBot,
source: isOwnAccount ? user.source : undefined,
// TODO: Add static avatar and header
avatar_static: this.getAvatarUrl(config),
header_static: this.getHeaderUrl(config),
acct: this.getAcct(),
// TODO: Add these fields
limited: false,
moved: null,
noindex: false,
suspended: false,
discoverable: undefined,
mute_expires_at: undefined,
group: false,
// @ts-expect-error Pleroma extension
pleroma: {
is_admin: user.isAdmin,
is_moderator: user.isAdmin,
},
};
}
toLysand(): Lysand.User {
if (this.isRemote()) {
throw new Error("Cannot convert remote user to Lysand format");
}
const user = this.getUser();
return {
id: user.id,
type: "User",
uri: this.getUri(),
bio: {
"text/html": {
content: user.note,
},
"text/plain": {
content: htmlToText(user.note),
},
},
created_at: new Date(user.createdAt).toISOString(),
dislikes: new URL(
`/users/${user.id}/dislikes`,
config.http.base_url,
).toString(),
featured: new URL(
`/users/${user.id}/featured`,
config.http.base_url,
).toString(),
likes: new URL(
`/users/${user.id}/likes`,
config.http.base_url,
).toString(),
followers: new URL(
`/users/${user.id}/followers`,
config.http.base_url,
).toString(),
following: new URL(
`/users/${user.id}/following`,
config.http.base_url,
).toString(),
inbox: new URL(
`/users/${user.id}/inbox`,
config.http.base_url,
).toString(),
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).toString(),
indexable: false,
username: user.username,
avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined,
header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined,
display_name: user.displayName,
fields: user.source.fields.map((field) => ({
key: {
"text/html": {
content: field.name,
},
"text/plain": {
content: htmlToText(field.name),
},
},
value: {
"text/html": {
content: field.value,
},
"text/plain": {
content: htmlToText(field.value),
},
},
})),
public_key: {
actor: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
public_key: user.publicKey,
},
extensions: {
"org.lysand:custom_emojis": {
emojis: user.emojis.map((emoji) => emojiToLysand(emoji)),
},
},
};
}
toMention(): APIMention {
return {
url: this.getUri(),
username: this.getUser().username,
acct: this.getAcct(),
id: this.id,
};
}
}