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

BIN
bun.lockb

Binary file not shown.

76
cli.ts
View file

@ -13,15 +13,11 @@ import extract from "extract-zip";
import { MediaBackend } from "media-manager"; import { MediaBackend } from "media-manager";
import { lookup } from "mime-types"; import { lookup } from "mime-types";
import { getUrl } from "~database/entities/Attachment"; import { getUrl } from "~database/entities/Attachment";
import { import { type UserType, createNewLocalUser } from "~database/entities/User";
type User,
createNewLocalUser,
findFirstUser,
findManyUsers,
} from "~database/entities/User";
import { client, db } from "~drizzle/db"; import { client, db } from "~drizzle/db";
import { Emojis, Notes, OpenIdAccounts, Users } from "~drizzle/schema"; import { Emojis, Notes, OpenIdAccounts, Users } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note"; import { Note } from "~packages/database-interface/note";
import { User } from "~packages/database-interface/user";
await client.connect(); await client.connect();
const args = process.argv; const args = process.argv;
@ -111,13 +107,12 @@ const cliBuilder = new CliBuilder([
} }
// Check if user already exists // Check if user already exists
const user = await findFirstUser({ const user = await User.fromSql(
where: (user, { or, eq }) => or(eq(Users.username, username), eq(Users.email, email)),
or(eq(user.username, username), eq(user.email, email)), );
});
if (user) { if (user) {
if (user.username === username) { if (user.getUser().username === username) {
console.log( console.log(
`${chalk.red("✗")} User with username ${chalk.blue( `${chalk.red("✗")} User with username ${chalk.blue(
username, username,
@ -143,7 +138,7 @@ const cliBuilder = new CliBuilder([
console.log( console.log(
`${chalk.green("✓")} Created user ${chalk.blue( `${chalk.green("✓")} Created user ${chalk.blue(
newUser?.username, newUser?.getUser().username,
)}${admin ? chalk.green(" (admin)") : ""}`, )}${admin ? chalk.green(" (admin)") : ""}`,
); );
@ -196,9 +191,7 @@ const cliBuilder = new CliBuilder([
return 1; return 1;
} }
const foundUser = await findFirstUser({ const foundUser = await User.fromSql(eq(Users.username, username));
where: (user, { eq }) => eq(user.username, username),
});
if (!foundUser) { if (!foundUser) {
console.log(`${chalk.red("✗")} User not found`); console.log(`${chalk.red("✗")} User not found`);
@ -208,7 +201,7 @@ const cliBuilder = new CliBuilder([
if (!args.noconfirm) { if (!args.noconfirm) {
process.stdout.write( process.stdout.write(
`Are you sure you want to delete user ${chalk.blue( `Are you sure you want to delete user ${chalk.blue(
foundUser.username, foundUser.getUser().username,
)}?\n${chalk.red( )}?\n${chalk.red(
chalk.bold( chalk.bold(
"This is a destructive action and cannot be undone!", "This is a destructive action and cannot be undone!",
@ -229,7 +222,7 @@ const cliBuilder = new CliBuilder([
console.log( console.log(
`${chalk.green("✓")} Deleted user ${chalk.blue( `${chalk.green("✓")} Deleted user ${chalk.blue(
foundUser.username, foundUser.getUser().username,
)}`, )}`,
); );
@ -312,22 +305,19 @@ const cliBuilder = new CliBuilder([
return 1; return 1;
} }
// @ts-ignore let users = (
let users: (User & { await User.manyFromSql(
instance?: { admins ? eq(Users.isAdmin, true) : undefined,
baseUrl: string; undefined,
}; args.limit ?? 200,
})[] = await findManyUsers({ )
where: (user, { eq }) => ).map((u) => u.getUser());
admins ? eq(user.isAdmin, true) : undefined,
limit: args.limit ?? 200,
});
// If instance is not in fields, remove them // If instance is not in fields, remove them
if (fields.length > 0 && !fields.includes("instance")) { if (fields.length > 0 && !fields.includes("instance")) {
users = users.map((user) => ({ users = users.map((user) => ({
...user, ...user,
instance: undefined, instance: null,
})); }));
} }
@ -505,14 +495,14 @@ const cliBuilder = new CliBuilder([
return 1; return 1;
} }
const users = await findManyUsers({ const users: User["user"][] = (
where: (user, { or, eq }) => await User.manyFromSql(
or( // @ts-ignore
// @ts-expect-error or(...fields.map((field) => eq(users[field], query))),
...fields.map((field) => eq(user[field], query)), undefined,
), Number(limit),
limit: Number(limit), )
}); ).map((u) => u.getUser());
if (redact) { if (redact) {
for (const user of users) { for (const user of users) {
@ -631,9 +621,7 @@ const cliBuilder = new CliBuilder([
return 1; return 1;
} }
const user = await findFirstUser({ const user = await User.fromSql(eq(Users.username, username));
where: (user, { eq }) => eq(user.username, username),
});
if (!user) { if (!user) {
console.log(`${chalk.red("✗")} User not found`); console.log(`${chalk.red("✗")} User not found`);
@ -653,7 +641,7 @@ const cliBuilder = new CliBuilder([
if (linkedOpenIdAccounts.find((a) => a.issuerId === issuerId)) { if (linkedOpenIdAccounts.find((a) => a.issuerId === issuerId)) {
console.log( console.log(
`${chalk.red("✗")} User ${chalk.blue( `${chalk.red("✗")} User ${chalk.blue(
user.username, user.getUser().username,
)} is already connected to this OpenID Connect issuer with another account`, )} is already connected to this OpenID Connect issuer with another account`,
); );
return 1; return 1;
@ -670,7 +658,7 @@ const cliBuilder = new CliBuilder([
`${chalk.green( `${chalk.green(
"✓", "✓",
)} Connected OpenID Connect account to user ${chalk.blue( )} Connected OpenID Connect account to user ${chalk.blue(
user.username, user.getUser().username,
)}`, )}`,
); );
@ -732,9 +720,7 @@ const cliBuilder = new CliBuilder([
return 1; return 1;
} }
const user = await findFirstUser({ const user = await User.fromId(account.userId);
where: (user, { eq }) => eq(user.id, account.userId ?? ""),
});
await db await db
.delete(OpenIdAccounts) .delete(OpenIdAccounts)
@ -744,7 +730,7 @@ const cliBuilder = new CliBuilder([
`${chalk.green( `${chalk.green(
"✓", "✓",
)} Disconnected OpenID account from user ${chalk.blue( )} Disconnected OpenID account from user ${chalk.blue(
user?.username, user?.getUser().username,
)}`, )}`,
); );

View file

@ -1,6 +1,6 @@
import { config } from "config-manager"; import { config } from "config-manager";
import type * as Lysand from "lysand-types"; import type * as Lysand from "lysand-types";
import { type User, getUserUri } from "./User"; import type { User } from "~packages/database-interface/user";
export const localObjectURI = (id: string) => `/objects/${id}`; export const localObjectURI = (id: string) => `/objects/${id}`;
@ -9,17 +9,17 @@ export const objectToInboxRequest = async (
author: User, author: User,
userToSendTo: User, userToSendTo: User,
): Promise<Request> => { ): Promise<Request> => {
if (!userToSendTo.instanceId || !userToSendTo.endpoints?.inbox) { if (userToSendTo.isLocal() || !userToSendTo.getUser().endpoints?.inbox) {
throw new Error("UserToSendTo has no inbox or is a local user"); throw new Error("UserToSendTo has no inbox or is a local user");
} }
if (author.instanceId) { if (author.isRemote()) {
throw new Error("Author is a remote user"); throw new Error("Author is a remote user");
} }
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
Buffer.from(author.privateKey ?? "", "base64"), Buffer.from(author.getUser().privateKey ?? "", "base64"),
"Ed25519", "Ed25519",
false, false,
["sign"], ["sign"],
@ -30,7 +30,7 @@ export const objectToInboxRequest = async (
new TextEncoder().encode(JSON.stringify(object)), new TextEncoder().encode(JSON.stringify(object)),
); );
const userInbox = new URL(userToSendTo.endpoints.inbox); const userInbox = new URL(userToSendTo.getUser().endpoints?.inbox ?? "");
const date = new Date(); const date = new Date();
@ -41,14 +41,14 @@ export const objectToInboxRequest = async (
`(request-target): post ${userInbox.pathname}\n` + `(request-target): post ${userInbox.pathname}\n` +
`host: ${userInbox.host}\n` + `host: ${userInbox.host}\n` +
`date: ${date.toISOString()}\n` + `date: ${date.toISOString()}\n` +
`digest: SHA-256=${btoa( `digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
String.fromCharCode(...new Uint8Array(digest)), "base64",
)}\n`, )}\n`,
), ),
); );
const signatureBase64 = btoa( const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
String.fromCharCode(...new Uint8Array(signature)), "base64",
); );
return new Request(userInbox, { return new Request(userInbox, {
@ -57,9 +57,7 @@ export const objectToInboxRequest = async (
"Content-Type": "application/json", "Content-Type": "application/json",
Date: date.toISOString(), Date: date.toISOString(),
Origin: new URL(config.http.base_url).host, Origin: new URL(config.http.base_url).host,
Signature: `keyId="${getUserUri( Signature: `keyId="${author.getUri()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
author,
)}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
}, },
body: JSON.stringify(object), body: JSON.stringify(object),
}); });

View file

@ -15,7 +15,7 @@ export const addInstanceIfNotExists = async (url: string) => {
const origin = new URL(url).origin; const origin = new URL(url).origin;
const host = new URL(url).host; const host = new URL(url).host;
const found = await db.query.instance.findFirst({ const found = await db.query.Instances.findFirst({
where: (instance, { eq }) => eq(instance.baseUrl, host), where: (instance, { eq }) => eq(instance.baseUrl, host),
}); });

View file

@ -3,8 +3,8 @@ import { type InferSelectModel, and, eq } from "drizzle-orm";
import type * as Lysand from "lysand-types"; import type * as Lysand from "lysand-types";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Likes, Notifications } from "~drizzle/schema"; import { Likes, Notifications } from "~drizzle/schema";
import type { StatusWithRelations } from "./Status"; import type { Note } from "~packages/database-interface/note";
import type { UserWithRelations } from "./User"; import type { User } from "~packages/database-interface/user";
export type Like = InferSelectModel<typeof Likes>; export type Like = InferSelectModel<typeof Likes>;
@ -27,24 +27,21 @@ export const likeToLysand = (like: Like): Lysand.Like => {
/** /**
* Create a like * Create a like
* @param user User liking the status * @param user User liking the status
* @param status Status being liked * @param note Status being liked
*/ */
export const createLike = async ( export const createLike = async (user: User, note: Note) => {
user: UserWithRelations,
status: StatusWithRelations,
) => {
await db.insert(Likes).values({ await db.insert(Likes).values({
likedId: status.id, likedId: note.id,
likerId: user.id, likerId: user.id,
}); });
if (status.author.instanceId === user.instanceId) { if (note.getAuthor().getUser().instanceId === user.getUser().instanceId) {
// Notify the user that their post has been favourited // Notify the user that their post has been favourited
await db.insert(Notifications).values({ await db.insert(Notifications).values({
accountId: user.id, accountId: user.id,
type: "favourite", type: "favourite",
notifiedId: status.authorId, notifiedId: note.getAuthor().id,
noteId: status.id, noteId: note.id,
}); });
} else { } else {
// TODO: Add database jobs for federating this // TODO: Add database jobs for federating this
@ -54,15 +51,12 @@ export const createLike = async (
/** /**
* Delete a like * Delete a like
* @param user User deleting their like * @param user User deleting their like
* @param status Status being unliked * @param note Status being unliked
*/ */
export const deleteLike = async ( export const deleteLike = async (user: User, note: Note) => {
user: UserWithRelations,
status: StatusWithRelations,
) => {
await db await db
.delete(Likes) .delete(Likes)
.where(and(eq(Likes.likedId, status.id), eq(Likes.likerId, user.id))); .where(and(eq(Likes.likedId, note.id), eq(Likes.likerId, user.id)));
// Notify the user that their post has been favourited // Notify the user that their post has been favourited
await db await db
@ -71,12 +65,12 @@ export const deleteLike = async (
and( and(
eq(Notifications.accountId, user.id), eq(Notifications.accountId, user.id),
eq(Notifications.type, "favourite"), eq(Notifications.type, "favourite"),
eq(Notifications.notifiedId, status.authorId), eq(Notifications.notifiedId, note.getAuthor().id),
eq(Notifications.noteId, status.id), eq(Notifications.noteId, note.id),
), ),
); );
if (user.instanceId === null && status.author.instanceId !== null) { if (user.isLocal() && note.getAuthor().isRemote()) {
// User is local, federate the delete // User is local, federate the delete
// TODO: Federate this // TODO: Federate this
} }

View file

@ -2,6 +2,7 @@ import type { InferSelectModel } from "drizzle-orm";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import type { Notifications } from "~drizzle/schema"; import type { Notifications } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note"; import { Note } from "~packages/database-interface/note";
import { User } from "~packages/database-interface/user";
import type { Notification as APINotification } from "~types/mastodon/notification"; import type { Notification as APINotification } from "~types/mastodon/notification";
import type { StatusWithRelations } from "./Status"; import type { StatusWithRelations } from "./Status";
import { import {
@ -9,7 +10,6 @@ import {
transformOutputToUserWithRelations, transformOutputToUserWithRelations,
userExtrasTemplate, userExtrasTemplate,
userRelations, userRelations,
userToAPI,
} from "./User"; } from "./User";
export type Notification = InferSelectModel<typeof Notifications>; export type Notification = InferSelectModel<typeof Notifications>;
@ -50,15 +50,14 @@ export const findManyNotifications = async (
export const notificationToAPI = async ( export const notificationToAPI = async (
notification: NotificationWithRelations, notification: NotificationWithRelations,
): Promise<APINotification> => { ): Promise<APINotification> => {
const account = new User(notification.account);
return { return {
account: userToAPI(notification.account), account: account.toAPI(),
created_at: new Date(notification.createdAt).toISOString(), created_at: new Date(notification.createdAt).toISOString(),
id: notification.id, id: notification.id,
type: notification.type, type: notification.type,
status: notification.status status: notification.status
? await Note.fromStatus(notification.status).toAPI( ? await Note.fromStatus(notification.status).toAPI(account)
notification.account,
)
: undefined, : undefined,
}; };
}; };

View file

@ -14,7 +14,7 @@ export const createFromObject = async (
object: Lysand.Entity, object: Lysand.Entity,
authorUri: string, authorUri: string,
) => { ) => {
const foundObject = await db.query.lysandObject.findFirst({ const foundObject = await db.query.LysandObjects.findFirst({
where: (o, { eq }) => eq(o.remoteId, object.id), where: (o, { eq }) => eq(o.remoteId, object.id),
with: { with: {
author: true, author: true,

View file

@ -1,8 +1,9 @@
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import type { User } from "~packages/database-interface/user";
import type { Relationship as APIRelationship } from "~types/mastodon/relationship"; import type { Relationship as APIRelationship } from "~types/mastodon/relationship";
import type { User } from "./User"; import type { UserType } from "./User";
export type Relationship = InferSelectModel<typeof Relationships>; export type Relationship = InferSelectModel<typeof Relationships>;

View file

@ -1,3 +1,4 @@
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import { dualLogger } from "@loggers"; import { dualLogger } from "@loggers";
import { sanitizeHtml } from "@sanitization"; import { sanitizeHtml } from "@sanitization";
import { config } from "config-manager"; import { config } from "config-manager";
@ -10,7 +11,6 @@ import {
or, or,
sql, sql,
} from "drizzle-orm"; } from "drizzle-orm";
import { htmlToText } from "html-to-text";
import linkifyHtml from "linkify-html"; import linkifyHtml from "linkify-html";
import type * as Lysand from "lysand-types"; import type * as Lysand from "lysand-types";
import { import {
@ -24,46 +24,30 @@ import {
maybe, maybe,
oneOrMore, oneOrMore,
} from "magic-regexp"; } from "magic-regexp";
import MarkdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { import { type Attachments, Instances, Notes, Users } from "~drizzle/schema";
Attachments,
EmojiToNote,
Instances,
NoteToMentions,
Notes,
Notifications,
Users,
} from "~drizzle/schema";
import { Note } from "~packages/database-interface/note"; import { Note } from "~packages/database-interface/note";
import { User } from "~packages/database-interface/user";
import { LogLevel } from "~packages/log-manager"; import { LogLevel } from "~packages/log-manager";
import type { Status as APIStatus } from "~types/mastodon/status"; import type { Status as APIStatus } from "~types/mastodon/status";
import type { Application } from "./Application"; import type { Application } from "./Application";
import { attachmentFromLysand, attachmentToLysand } from "./Attachment"; import { attachmentFromLysand } from "./Attachment";
import { import { type EmojiWithInstance, fetchEmoji } from "./Emoji";
type EmojiWithInstance,
emojiToLysand,
fetchEmoji,
parseEmojis,
} from "./Emoji";
import { objectToInboxRequest } from "./Federation"; import { objectToInboxRequest } from "./Federation";
import type { Like } from "./Like"; import type { Like } from "./Like";
import { import {
type User, type UserType,
type UserWithInstance, type UserWithInstance,
type UserWithRelations, type UserWithRelations,
findManyUsers,
getUserUri,
resolveUser,
resolveWebFinger, resolveWebFinger,
transformOutputToUserWithRelations, transformOutputToUserWithRelations,
userExtrasTemplate, userExtrasTemplate,
userRelations, userRelations,
} from "./User"; } from "./User";
import MarkdownIt from "markdown-it";
import markdownItTocDoneRight from "markdown-it-toc-done-right";
import markdownItContainer from "markdown-it-container";
import markdownItAnchor from "markdown-it-anchor";
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
export type Status = InferSelectModel<typeof Notes>; export type Status = InferSelectModel<typeof Notes>;
@ -362,7 +346,7 @@ export const resolveNote = async (
throw new Error("Invalid object author"); throw new Error("Invalid object author");
} }
const author = await resolveUser(note.author); const author = await User.resolve(note.author);
if (!author) { if (!author) {
throw new Error("Invalid object author"); throw new Error("Invalid object author");
@ -415,10 +399,8 @@ export const resolveNote = async (
note.uri, note.uri,
await Promise.all( await Promise.all(
(note.mentions ?? []) (note.mentions ?? [])
.map((mention) => resolveUser(mention)) .map((mention) => User.resolve(mention))
.filter( .filter((mention) => mention !== null) as Promise<User>[],
(mention) => mention !== null,
) as Promise<UserWithRelations>[],
), ),
attachments.map((a) => a.id), attachments.map((a) => a.id),
note.replies_to note.replies_to
@ -454,9 +436,7 @@ export const createMentionRegExp = () =>
* @param text The text to parse mentions from. * @param text The text to parse mentions from.
* @returns An array of users mentioned in the text. * @returns An array of users mentioned in the text.
*/ */
export const parseTextMentions = async ( export const parseTextMentions = async (text: string): Promise<User[]> => {
text: string,
): Promise<UserWithRelations[]> => {
const mentionedPeople = [...text.matchAll(createMentionRegExp())] ?? []; const mentionedPeople = [...text.matchAll(createMentionRegExp())] ?? [];
if (mentionedPeople.length === 0) return []; if (mentionedPeople.length === 0) return [];
@ -497,13 +477,12 @@ export const parseTextMentions = async (
const finalList = const finalList =
foundUsers.length > 0 foundUsers.length > 0
? await findManyUsers({ ? await User.manyFromSql(
where: (user, { inArray }) => inArray(
inArray( Users.id,
user.id, foundUsers.map((u) => u.id),
foundUsers.map((u) => u.id), ),
), )
})
: []; : [];
// Attempt to resolve mentions that were not found // Attempt to resolve mentions that were not found
@ -521,49 +500,47 @@ export const parseTextMentions = async (
return finalList; return finalList;
}; };
export const replaceTextMentions = async ( export const replaceTextMentions = async (text: string, mentions: User[]) => {
text: string,
mentions: UserWithRelations[],
) => {
let finalText = text; let finalText = text;
for (const mention of mentions) { for (const mention of mentions) {
const user = mention.getUser();
// Replace @username and @username@domain // Replace @username and @username@domain
if (mention.instance) { if (user.instance) {
finalText = finalText.replace( finalText = finalText.replace(
createRegExp( createRegExp(
exactly(`@${mention.username}@${mention.instance.baseUrl}`), exactly(`@${user.username}@${user.instance.baseUrl}`),
[global], [global],
), ),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri( `<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
mention, user.username
)}">@${mention.username}@${mention.instance.baseUrl}</a>`, }@${user.instance.baseUrl}</a>`,
); );
} else { } else {
finalText = finalText.replace( finalText = finalText.replace(
// Only replace @username if it doesn't have another @ right after // Only replace @username if it doesn't have another @ right after
createRegExp( createRegExp(
exactly(`@${mention.username}`) exactly(`@${user.username}`)
.notBefore(anyOf(letter, digit, charIn("@"))) .notBefore(anyOf(letter, digit, charIn("@")))
.notAfter(anyOf(letter, digit, charIn("@"))), .notAfter(anyOf(letter, digit, charIn("@"))),
[global], [global],
), ),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri( `<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
mention, user.username
)}">@${mention.username}</a>`, }</a>`,
); );
finalText = finalText.replace( finalText = finalText.replace(
createRegExp( createRegExp(
exactly( exactly(
`@${mention.username}@${ `@${user.username}@${
new URL(config.http.base_url).host new URL(config.http.base_url).host
}`, }`,
), ),
[global], [global],
), ),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri( `<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
mention, user.username
)}">@${mention.username}</a>`, }</a>`,
); );
} }
} }
@ -573,7 +550,7 @@ export const replaceTextMentions = async (
export const contentToHtml = async ( export const contentToHtml = async (
content: Lysand.ContentFormat, content: Lysand.ContentFormat,
mentions: UserWithRelations[] = [], mentions: User[] = [],
): Promise<string> => { ): Promise<string> => {
let htmlContent: string; let htmlContent: string;
@ -663,152 +640,17 @@ export const federateNote = async (note: Note) => {
dualLogger.log( dualLogger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Federation.Status", "Federation.Status",
`Failed to federate status ${note.getStatus().id} to ${ `Failed to federate status ${
user.uri note.getStatus().id
}`, } to ${user.getUri()}`,
); );
} }
} }
}; };
export const editStatus = async ( export const isFavouritedBy = async (status: Status, user: UserType) => {
statusToEdit: StatusWithRelations,
data: {
content: string;
visibility?: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis?: EmojiWithInstance[];
content_type?: string;
uri?: string;
mentions?: User[];
media_attachments?: string[];
},
): Promise<Note | null> => {
const mentions = await parseTextMentions(data.content);
// Parse emojis
const emojis = await parseEmojis(data.content);
// Fuse and deduplicate emojis
data.emojis = data.emojis
? [...data.emojis, ...emojis].filter(
(emoji, index, self) =>
index === self.findIndex((t) => t.id === emoji.id),
)
: emojis;
const htmlContent = await contentToHtml({
[data.content_type ?? "text/plain"]: {
content: data.content,
},
});
const note = await Note.fromId(statusToEdit.id);
if (!note) {
return null;
}
const updated = await note.update({
content: htmlContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
});
// Connect emojis
for (const emoji of data.emojis) {
await db
.insert(EmojiToNote)
.values({
emojiId: emoji.id,
noteId: updated.id,
})
.execute();
}
// Connect mentions
for (const mention of mentions) {
await db
.insert(NoteToMentions)
.values({
noteId: updated.id,
userId: mention.id,
})
.execute();
}
// Send notifications for mentioned local users
for (const mention of mentions ?? []) {
if (mention.instanceId === null) {
await db.insert(Notifications).values({
accountId: statusToEdit.authorId,
notifiedId: mention.id,
type: "mention",
noteId: updated.id,
});
}
}
// Set attachment parents
await db
.update(Attachments)
.set({
noteId: updated.id,
})
.where(inArray(Attachments.id, data.media_attachments ?? []));
return await Note.fromId(updated.id);
};
export const isFavouritedBy = async (status: Status, user: User) => {
return !!(await db.query.Likes.findFirst({ return !!(await db.query.Likes.findFirst({
where: (like, { and, eq }) => where: (like, { and, eq }) =>
and(eq(like.likerId, user.id), eq(like.likedId, status.id)), and(eq(like.likerId, user.id), eq(like.likedId, status.id)),
})); }));
}; };
export const getStatusUri = (status?: Status | null) => {
if (!status) return undefined;
return (
status.uri ||
new URL(`/objects/${status.id}`, config.http.base_url).toString()
);
};
export const statusToLysand = (status: StatusWithRelations): Lysand.Note => {
return {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: getUserUri(status.author),
uri: getStatusUri(status) ?? "",
content: {
"text/html": {
content: status.content,
},
"text/plain": {
content: htmlToText(status.content),
},
},
attachments: (status.attachments ?? []).map((attachment) =>
attachmentToLysand(attachment),
),
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mention.uri || ""),
quotes: getStatusUri(status.quote) ?? undefined,
replies_to: getStatusUri(status.reply) ?? undefined,
subject: status.spoilerText,
visibility: status.visibility as Lysand.Visibility,
extensions: {
"org.lysand:custom_emojis": {
emojis: status.emojis.map((emoji) => emojiToLysand(emoji)),
},
// TODO: Add polls and reactions
},
};
};

View file

@ -1,43 +1,32 @@
import { getBestContentType, urlToContentFormat } from "@content_types";
import { dualLogger } from "@loggers"; import { dualLogger } from "@loggers";
import { addUserToMeilisearch } from "@meilisearch"; import { addUserToMeilisearch } from "@meilisearch";
import { type Config, config } from "config-manager"; import { config } from "config-manager";
import { type InferSelectModel, and, eq, sql } from "drizzle-orm"; import { type InferSelectModel, and, eq, inArray, sql } from "drizzle-orm";
import { htmlToText } from "html-to-text";
import type * as Lysand from "lysand-types"; import type * as Lysand from "lysand-types";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { import {
Applications, Applications,
EmojiToUser,
Instances, Instances,
Notifications, Notifications,
Relationships, Relationships,
Tokens, Tokens,
Users, Users,
} from "~drizzle/schema"; } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
import { LogLevel } from "~packages/log-manager"; import { LogLevel } from "~packages/log-manager";
import type { Account as APIAccount } from "~types/mastodon/account";
import type { Mention as APIMention } from "~types/mastodon/mention";
import type { Source as APISource } from "~types/mastodon/source";
import type { Application } from "./Application"; import type { Application } from "./Application";
import { import type { EmojiWithInstance } from "./Emoji";
type EmojiWithInstance,
emojiToAPI,
emojiToLysand,
fetchEmoji,
} from "./Emoji";
import { objectToInboxRequest } from "./Federation"; import { objectToInboxRequest } from "./Federation";
import { addInstanceIfNotExists } from "./Instance";
import { createNewRelationship } from "./Relationship"; import { createNewRelationship } from "./Relationship";
import type { Token } from "./Token"; import type { Token } from "./Token";
export type User = InferSelectModel<typeof Users>; export type UserType = InferSelectModel<typeof Users>;
export type UserWithInstance = User & { export type UserWithInstance = UserType & {
instance: InferSelectModel<typeof Instances> | null; instance: InferSelectModel<typeof Instances> | null;
}; };
export type UserWithRelations = User & { export type UserWithRelations = UserType & {
instance: InferSelectModel<typeof Instances> | null; instance: InferSelectModel<typeof Instances> | null;
emojis: EmojiWithInstance[]; emojis: EmojiWithInstance[];
followerCount: number; followerCount: number;
@ -105,35 +94,11 @@ export const userExtrasTemplate = (name: string) => ({
}); });
export interface AuthData { export interface AuthData {
user: UserWithRelations | null; user: User | null;
token: string; token: string;
application: Application | null; application: Application | null;
} }
/**
* Get the user's avatar in raw URL format
* @param config The config to use
* @returns The raw URL for the user's avatar
*/
export const getAvatarUrl = (user: User, config: Config) => {
if (!user.avatar)
return (
config.defaults.avatar ||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${user.username}`
);
return 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
*/
export const getHeaderUrl = (user: User, config: Config) => {
if (!user.header) return config.defaults.header;
return user.header;
};
export const getFromRequest = async (req: Request): Promise<AuthData> => { export const getFromRequest = async (req: Request): Promise<AuthData> => {
// Check auth token // Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || ""; const token = req.headers.get("Authorization")?.split(" ")[1] || "";
@ -152,14 +117,14 @@ export const followRequestUser = async (
notify = false, notify = false,
languages: string[] = [], languages: string[] = [],
): Promise<InferSelectModel<typeof Relationships>> => { ): Promise<InferSelectModel<typeof Relationships>> => {
const isRemote = followee.instanceId !== null; const isRemote = followee.isRemote();
const updatedRelationship = ( const updatedRelationship = (
await db await db
.update(Relationships) .update(Relationships)
.set({ .set({
following: isRemote ? false : !followee.isLocked, following: isRemote ? false : !followee.getUser().isLocked,
requested: isRemote ? true : followee.isLocked, requested: isRemote ? true : followee.getUser().isLocked,
showingReblogs: reblogs, showingReblogs: reblogs,
notifying: notify, notifying: notify,
languages: languages, languages: languages,
@ -190,7 +155,9 @@ export const followRequestUser = async (
dualLogger.log( dualLogger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Federation.FollowRequest", "Federation.FollowRequest",
`Failed to federate follow request from ${follower.id} to ${followee.uri}`, `Failed to federate follow request from ${
follower.id
} to ${followee.getUri()}`,
); );
return ( return (
@ -207,7 +174,7 @@ export const followRequestUser = async (
} else { } else {
await db.insert(Notifications).values({ await db.insert(Notifications).values({
accountId: follower.id, accountId: follower.id,
type: followee.isLocked ? "follow_request" : "follow", type: followee.getUser().isLocked ? "follow_request" : "follow",
notifiedId: followee.id, notifiedId: followee.id,
}); });
} }
@ -236,7 +203,9 @@ export const sendFollowAccept = async (follower: User, followee: User) => {
dualLogger.log( dualLogger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Federation.FollowAccept", "Federation.FollowAccept",
`Failed to federate follow accept from ${followee.id} to ${follower.uri}`, `Failed to federate follow accept from ${
followee.id
} to ${follower.getUri()}`,
); );
} }
}; };
@ -262,13 +231,15 @@ export const sendFollowReject = async (follower: User, followee: User) => {
dualLogger.log( dualLogger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Federation.FollowReject", "Federation.FollowReject",
`Failed to federate follow reject from ${followee.id} to ${follower.uri}`, `Failed to federate follow reject from ${
followee.id
} to ${follower.getUri()}`,
); );
} }
}; };
export const transformOutputToUserWithRelations = ( export const transformOutputToUserWithRelations = (
user: Omit<User, "endpoints"> & { user: Omit<UserType, "endpoints"> & {
followerCount: unknown; followerCount: unknown;
followingCount: unknown; followingCount: unknown;
statusCount: unknown; statusCount: unknown;
@ -343,146 +314,6 @@ export const findFirstUser = async (
return transformOutputToUserWithRelations(output); return transformOutputToUserWithRelations(output);
}; };
export const resolveUser = async (
uri: string,
): Promise<UserWithRelations | null> => {
// Check if user not already in database
const foundUser = await findFirstUser({
where: (user, { eq }) => eq(user.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(
/[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
);
if (!uuid) {
throw new Error(
`URI ${uri} is of a local user, but it could not be parsed`,
);
}
const foundLocalUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, uuid[0]),
});
return foundLocalUser || null;
}
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 findFirstUser({
where: (user, { eq }) => eq(user.id, newUser.id),
});
if (!finalUser) return null;
// Add to Meilisearch
await addUserToMeilisearch(finalUser);
return finalUser;
};
export const getUserUri = (user: User) => {
return (
user.uri ||
new URL(`/users/${user.id}`, config.http.base_url).toString()
);
};
/** /**
* Resolves a WebFinger identifier to a user. * Resolves a WebFinger identifier to a user.
* @param identifier Either a UUID or a username * @param identifier Either a UUID or a username
@ -490,7 +321,7 @@ export const getUserUri = (user: User) => {
export const resolveWebFinger = async ( export const resolveWebFinger = async (
identifier: string, identifier: string,
host: string, host: string,
): Promise<UserWithRelations | null> => { ): Promise<User | null> => {
// Check if user not already in database // Check if user not already in database
const foundUser = await db const foundUser = await db
.select() .select()
@ -499,12 +330,7 @@ export const resolveWebFinger = async (
.where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host))) .where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host)))
.limit(1); .limit(1);
if (foundUser[0]) if (foundUser[0]) return await User.fromId(foundUser[0].Users.id);
return (
(await findFirstUser({
where: (user, { eq }) => eq(user.id, foundUser[0].Users.id),
})) || null
);
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`; const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
@ -550,7 +376,7 @@ export const resolveWebFinger = async (
); );
} }
return resolveUser(relevantLink.href); return User.resolve(relevantLink.href);
}; };
/** /**
@ -575,7 +401,7 @@ export const createNewLocalUser = async (data: {
header?: string; header?: string;
admin?: boolean; admin?: boolean;
skipPasswordHash?: boolean; skipPasswordHash?: boolean;
}): Promise<UserWithRelations | null> => { }): Promise<User | null> => {
const keys = await generateUserKeys(); const keys = await generateUserKeys();
const newUser = ( const newUser = (
@ -606,9 +432,7 @@ export const createNewLocalUser = async (data: {
.returning() .returning()
)[0]; )[0];
const finalUser = await findFirstUser({ const finalUser = await User.fromId(newUser.id);
where: (user, { eq }) => eq(user.id, newUser.id),
});
if (!finalUser) return null; if (!finalUser) return null;
@ -623,11 +447,8 @@ export const createNewLocalUser = async (data: {
*/ */
export const parseMentionsUris = async ( export const parseMentionsUris = async (
mentions: string[], mentions: string[],
): Promise<UserWithRelations[]> => { ): Promise<User[]> => {
return await findManyUsers({ return await User.manyFromSql(inArray(Users.uri, mentions));
where: (user, { inArray }) => inArray(user.uri, mentions),
with: userRelations,
});
}; };
/** /**
@ -637,16 +458,14 @@ export const parseMentionsUris = async (
*/ */
export const retrieveUserFromToken = async ( export const retrieveUserFromToken = async (
access_token: string, access_token: string,
): Promise<UserWithRelations | null> => { ): Promise<User | null> => {
if (!access_token) return null; if (!access_token) return null;
const token = await retrieveToken(access_token); const token = await retrieveToken(access_token);
if (!token || !token.userId) return null; if (!token || !token.userId) return null;
const user = await findFirstUser({ const user = await User.fromId(token.userId);
where: (user, { eq }) => eq(user.id, token.userId ?? ""),
});
return user; return user;
}; };
@ -654,7 +473,7 @@ export const retrieveUserFromToken = async (
export const retrieveUserAndApplicationFromToken = async ( export const retrieveUserAndApplicationFromToken = async (
access_token: string, access_token: string,
): Promise<{ ): Promise<{
user: UserWithRelations | null; user: User | null;
application: Application | null; application: Application | null;
}> => { }> => {
if (!access_token) return { user: null, application: null }; if (!access_token) return { user: null, application: null };
@ -673,9 +492,7 @@ export const retrieveUserAndApplicationFromToken = async (
if (!output?.token.userId) return { user: null, application: null }; if (!output?.token.userId) return { user: null, application: null };
const user = await findFirstUser({ const user = await User.fromId(output.token.userId);
where: (user, { eq }) => eq(user.id, output.token.userId ?? ""),
});
return { user, application: output.application ?? null }; return { user, application: output.application ?? null };
}; };
@ -698,7 +515,7 @@ export const retrieveToken = async (
* @returns The relationship to the other user. * @returns The relationship to the other user.
*/ */
export const getRelationshipToOtherUser = async ( export const getRelationshipToOtherUser = async (
user: UserWithRelations, user: User,
other: User, other: User,
): Promise<InferSelectModel<typeof Relationships>> => { ): Promise<InferSelectModel<typeof Relationships>> => {
const foundRelationship = await db.query.Relationships.findFirst({ const foundRelationship = await db.query.Relationships.findFirst({
@ -745,169 +562,19 @@ export const generateUserKeys = async () => {
}; };
}; };
export const userToMention = (user: UserWithInstance): APIMention => ({
url: getUserUri(user),
username: user.username,
acct:
user.instance === null
? user.username
: `${user.username}@${user.instance.baseUrl}`,
id: user.id,
});
export const userToAPI = (
userToConvert: UserWithRelations,
isOwnAccount = false,
): APIAccount => {
return {
id: userToConvert.id,
username: userToConvert.username,
display_name: userToConvert.displayName,
note: userToConvert.note,
url:
userToConvert.uri ||
new URL(
`/@${userToConvert.username}`,
config.http.base_url,
).toString(),
avatar: getAvatarUrl(userToConvert, config),
header: getHeaderUrl(userToConvert, config),
locked: userToConvert.isLocked,
created_at: new Date(userToConvert.createdAt).toISOString(),
followers_count: userToConvert.followerCount,
following_count: userToConvert.followingCount,
statuses_count: userToConvert.statusCount,
emojis: userToConvert.emojis.map((emoji) => emojiToAPI(emoji)),
// TODO: Add fields
fields: [],
bot: userToConvert.isBot,
source:
isOwnAccount && userToConvert.source
? (userToConvert.source as APISource)
: undefined,
// TODO: Add static avatar and header
avatar_static: getAvatarUrl(userToConvert, config),
header_static: getHeaderUrl(userToConvert, config),
acct:
userToConvert.instance === null
? userToConvert.username
: `${userToConvert.username}@${userToConvert.instance.baseUrl}`,
// 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: userToConvert.isAdmin,
is_moderator: userToConvert.isAdmin,
},
};
};
/**
* Should only return local users
*/
export const userToLysand = (user: UserWithRelations): Lysand.User => {
if (user.instanceId !== null) {
throw new Error("Cannot convert remote user to Lysand format");
}
return {
id: user.id,
type: "User",
uri: getUserUri(user),
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(getAvatarUrl(user, config)) ?? undefined,
header: urlToContentFormat(getHeaderUrl(user, config)) ?? undefined,
display_name: user.displayName,
fields: (user.source as APISource).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)),
},
},
};
};
export const followRequestToLysand = ( export const followRequestToLysand = (
follower: User, follower: User,
followee: User, followee: User,
): Lysand.Follow => { ): Lysand.Follow => {
if (follower.instanceId) { if (follower.isRemote()) {
throw new Error("Follower must be a local user"); throw new Error("Follower must be a local user");
} }
if (!followee.instanceId) { if (!followee.isRemote()) {
throw new Error("Followee must be a remote user"); throw new Error("Followee must be a remote user");
} }
if (!followee.uri) { if (!followee.getUser().uri) {
throw new Error("Followee must have a URI in database"); throw new Error("Followee must have a URI in database");
} }
@ -916,8 +583,8 @@ export const followRequestToLysand = (
return { return {
type: "Follow", type: "Follow",
id: id, id: id,
author: getUserUri(follower), author: follower.getUri(),
followee: followee.uri, followee: followee.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(), uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
}; };
@ -927,15 +594,15 @@ export const followAcceptToLysand = (
follower: User, follower: User,
followee: User, followee: User,
): Lysand.FollowAccept => { ): Lysand.FollowAccept => {
if (!follower.instanceId) { if (!follower.isRemote()) {
throw new Error("Follower must be a remote user"); throw new Error("Follower must be a remote user");
} }
if (followee.instanceId) { if (followee.isRemote()) {
throw new Error("Followee must be a local user"); throw new Error("Followee must be a local user");
} }
if (!follower.uri) { if (!follower.getUser().uri) {
throw new Error("Follower must have a URI in database"); throw new Error("Follower must have a URI in database");
} }
@ -944,9 +611,9 @@ export const followAcceptToLysand = (
return { return {
type: "FollowAccept", type: "FollowAccept",
id: id, id: id,
author: getUserUri(followee), author: followee.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.uri, follower: follower.getUri(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(), uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
}; };
}; };

View file

@ -1,106 +1,108 @@
{ {
"name": "lysand", "name": "lysand",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"version": "0.4.0", "version": "0.4.0",
"description": "A project to build a federated social network", "description": "A project to build a federated social network",
"author": { "author": {
"email": "contact@cpluspatch.com", "email": "contact@cpluspatch.com",
"name": "CPlusPatch", "name": "CPlusPatch",
"url": "https://cpluspatch.com" "url": "https://cpluspatch.com"
}, },
"bugs": { "bugs": {
"url": "https://github.com/lysand-org/lysand/issues" "url": "https://github.com/lysand-org/lysand/issues"
}, },
"icon": "https://github.com/lysand-org/lysand", "icon": "https://github.com/lysand-org/lysand",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"keywords": ["federated", "activitypub", "bun"], "keywords": ["federated", "activitypub", "bun"],
"workspaces": ["packages/*"], "workspaces": ["packages/*"],
"maintainers": [ "maintainers": [
{ {
"email": "contact@cpluspatch.com", "email": "contact@cpluspatch.com",
"name": "CPlusPatch", "name": "CPlusPatch",
"url": "https://cpluspatch.com" "url": "https://cpluspatch.com"
}
],
"repository": {
"type": "git",
"url": "git+https://github.com/lysand-org/lysand.git"
},
"private": true,
"scripts": {
"dev": "bun run --watch index.ts",
"start": "NODE_ENV=production bun run dist/index.js --prod",
"lint": "bunx @biomejs/biome check .",
"prod-build": "bun run build.ts",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem",
"cli": "bun run cli.ts"
},
"trustedDependencies": [
"@biomejs/biome",
"@fortawesome/fontawesome-common-types",
"@fortawesome/free-regular-svg-icons",
"@fortawesome/free-solid-svg-icons",
"es5-ext",
"esbuild",
"json-editor-vue",
"msgpackr-extract",
"nuxt-app",
"sharp",
"vue-demi"
],
"devDependencies": {
"@biomejs/biome": "^1.7.0",
"@types/cli-table": "^0.3.4",
"@types/html-to-text": "^9.0.4",
"@types/ioredis": "^5.0.0",
"@types/jsonld": "^1.5.13",
"@types/markdown-it-container": "^2.0.10",
"@types/mime-types": "^2.1.4",
"@types/pg": "^8.11.5",
"bun-types": "latest",
"drizzle-kit": "^0.20.14",
"typescript": "latest"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@json2csv/plainjs": "^7.0.6",
"@shikijs/markdown-it": "^1.3.0",
"@tufjs/canonical-json": "^2.0.0",
"blurhash": "^2.0.5",
"bullmq": "^5.7.1",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",
"drizzle-orm": "^0.30.7",
"extract-zip": "^2.0.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "latest",
"jose": "^5.2.4",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",
"log-manager": "workspace:*",
"magic-regexp": "^0.8.0",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^8.6.7",
"markdown-it-container": "^4.0.0",
"markdown-it-toc-done-right": "^4.2.0",
"media-manager": "workspace:*",
"megalodon": "^10.0.0",
"meilisearch": "^0.38.0",
"mime-types": "^2.1.35",
"oauth4webapi": "^2.4.0",
"pg": "^8.11.5",
"request-parser": "workspace:*",
"sharp": "^0.33.3",
"zod": "^3.22.4",
"zod-validation-error": "^3.2.0"
} }
],
"repository": {
"type": "git",
"url": "git+https://github.com/lysand-org/lysand.git"
},
"private": true,
"scripts": {
"dev": "bun run --watch index.ts",
"start": "NODE_ENV=production bun run dist/index.js --prod",
"lint": "bunx @biomejs/biome check .",
"prod-build": "bun run build.ts",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem",
"cli": "bun run cli.ts"
},
"trustedDependencies": [
"@biomejs/biome",
"@fortawesome/fontawesome-common-types",
"@fortawesome/free-regular-svg-icons",
"@fortawesome/free-solid-svg-icons",
"es5-ext",
"esbuild",
"json-editor-vue",
"msgpackr-extract",
"nuxt-app",
"sharp",
"vue-demi"
],
"devDependencies": {
"@biomejs/biome": "^1.7.0",
"@types/cli-table": "^0.3.4",
"@types/html-to-text": "^9.0.4",
"@types/ioredis": "^5.0.0",
"@types/jsonld": "^1.5.13",
"@types/markdown-it-container": "^2.0.10",
"@types/mime-types": "^2.1.4",
"@types/pg": "^8.11.5",
"bun-types": "latest",
"drizzle-kit": "^0.20.14",
"typescript": "latest"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@json2csv/plainjs": "^7.0.6",
"@shikijs/markdown-it": "^1.3.0",
"blurhash": "^2.0.5",
"bullmq": "^5.7.1",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",
"drizzle-orm": "^0.30.7",
"extract-zip": "^2.0.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "latest",
"jose": "^5.2.4",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",
"log-manager": "workspace:*",
"magic-regexp": "^0.8.0",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^8.6.7",
"markdown-it-container": "^4.0.0",
"markdown-it-toc-done-right": "^4.2.0",
"media-manager": "workspace:*",
"megalodon": "^10.0.0",
"meilisearch": "^0.38.0",
"mime-types": "^2.1.35",
"oauth4webapi": "^2.4.0",
"pg": "^8.11.5",
"request-parser": "workspace:*",
"sharp": "^0.33.3",
"zod": "^3.22.4"
}
} }

View file

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

View file

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

View file

@ -2,11 +2,8 @@ import { join } from "node:path";
import { redirect } from "@response"; import { redirect } from "@response";
import type { BunFile } from "bun"; import type { BunFile } from "bun";
import { config } from "config-manager"; import { config } from "config-manager";
import { import { retrieveUserFromToken } from "~database/entities/User";
type UserWithRelations, import type { User } from "~packages/database-interface/user";
retrieveUserFromToken,
userToAPI,
} from "~database/entities/User";
import type { LogManager, MultiLogManager } from "~packages/log-manager"; import type { LogManager, MultiLogManager } from "~packages/log-manager";
import { languages } from "./glitch-languages"; import { languages } from "./glitch-languages";
@ -104,7 +101,7 @@ const handleSignInRequest = async (
req: Request, req: Request,
path: string, path: string,
url: URL, url: URL,
user: UserWithRelations | null, user: User | null,
accessToken: string, accessToken: string,
) => { ) => {
if (req.method === "POST") { if (req.method === "POST") {
@ -181,7 +178,7 @@ const returnFile = async (file: BunFile, content?: string) => {
const handleDefaultRequest = async ( const handleDefaultRequest = async (
req: Request, req: Request,
path: string, path: string,
user: UserWithRelations | null, user: User | null,
accessToken: string, accessToken: string,
) => { ) => {
const file = Bun.file(join(config.frontend.glitch.assets, path)); const file = Bun.file(join(config.frontend.glitch.assets, path));
@ -204,7 +201,7 @@ const handleDefaultRequest = async (
const brandingTransforms = async ( const brandingTransforms = async (
fileContents: string, fileContents: string,
accessToken: string, accessToken: string,
user: UserWithRelations | null, user: User | null,
) => { ) => {
let newFileContents = fileContents; let newFileContents = fileContents;
for (const server of config.frontend.glitch.server) { for (const server of config.frontend.glitch.server) {
@ -239,7 +236,7 @@ const brandingTransforms = async (
const htmlTransforms = async ( const htmlTransforms = async (
fileContents: string, fileContents: string,
accessToken: string, accessToken: string,
user: UserWithRelations | null, user: User | null,
) => { ) => {
// Find script id="initial-state" and replace its contents with custom json // Find script id="initial-state" and replace its contents with custom json
const rewriter = new HTMLRewriter() const rewriter = new HTMLRewriter()
@ -290,7 +287,7 @@ const htmlTransforms = async (
}, },
accounts: user accounts: user
? { ? {
[user.id]: userToAPI(user, true), [user.id]: user.toAPI(true),
} }
: {}, : {},
media_attachments: { media_attachments: {

View file

@ -0,0 +1,262 @@
import type * as Lysand from "lysand-types";
import { fromZodError } from "zod-validation-error";
import { schemas } from "./schemas";
const types = [
"Note",
"User",
"Reaction",
"Poll",
"Vote",
"VoteResult",
"Report",
"ServerMetadata",
"Like",
"Dislike",
"Follow",
"FollowAccept",
"FollowReject",
"Announce",
"Undo",
];
/**
* Validates an incoming Lysand object using Zod, and returns the object if it is valid.
*/
export class EntityValidator {
constructor(private entity: Lysand.Entity) {}
/**
* Validates the entity.
*/
validate<ExpectedType>() {
// Check if type is valid
if (!this.entity.type) {
throw new Error("Entity type is required");
}
const schema = this.matchSchema(this.getType());
const output = schema.safeParse(this.entity);
if (!output.success) {
throw fromZodError(output.error);
}
return output.data as ExpectedType;
}
getType() {
// Check if type is valid, return TypeScript type
if (!this.entity.type) {
throw new Error("Entity type is required");
}
if (!types.includes(this.entity.type)) {
throw new Error(`Unknown entity type: ${this.entity.type}`);
}
return this.entity.type as (typeof types)[number];
}
matchSchema(type: string) {
switch (type) {
case "Note":
return schemas.Note;
case "User":
return schemas.User;
case "Reaction":
return schemas.Reaction;
case "Poll":
return schemas.Poll;
case "Vote":
return schemas.Vote;
case "VoteResult":
return schemas.VoteResult;
case "Report":
return schemas.Report;
case "ServerMetadata":
return schemas.ServerMetadata;
case "Like":
return schemas.Like;
case "Dislike":
return schemas.Dislike;
case "Follow":
return schemas.Follow;
case "FollowAccept":
return schemas.FollowAccept;
case "FollowReject":
return schemas.FollowReject;
case "Announce":
return schemas.Announce;
case "Undo":
return schemas.Undo;
default:
throw new Error(`Unknown entity type: ${type}`);
}
}
}
export class SignatureValidator {
constructor(
private public_key: CryptoKey,
private signature: string,
private date: string,
private method: string,
private url: URL,
private body: string,
) {}
static async fromStringKey(
public_key: string,
signature: string,
date: string,
method: string,
url: URL,
body: string,
) {
return new SignatureValidator(
await crypto.subtle.importKey(
"spki",
Buffer.from(public_key, "base64"),
"Ed25519",
false,
["verify"],
),
signature,
date,
method,
url,
body,
);
}
async validate() {
const signature = this.signature
.split("signature=")[1]
.replace(/"/g, "");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(this.body),
);
const expectedSignedString =
`(request-target): ${this.method.toLowerCase()} ${
this.url.pathname
}\n` +
`host: ${this.url.host}\n` +
`date: ${this.date}\n` +
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
"base64",
)}\n`;
// Check if signed string is valid
const isValid = await crypto.subtle.verify(
"Ed25519",
this.public_key,
Buffer.from(signature, "base64"),
new TextEncoder().encode(expectedSignedString),
);
return isValid;
}
}
export class SignatureConstructor {
constructor(
private private_key: CryptoKey,
private url: URL,
private authorUri: URL,
) {}
static async fromStringKey(private_key: string, url: URL, authorUri: URL) {
return new SignatureConstructor(
await crypto.subtle.importKey(
"pkcs8",
Buffer.from(private_key, "base64"),
"Ed25519",
false,
["sign"],
),
url,
authorUri,
);
}
async sign(method: string, body: string) {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(body),
);
const date = new Date();
const signature = await crypto.subtle.sign(
"Ed25519",
this.private_key,
new TextEncoder().encode(
`(request-target): ${method.toLowerCase()} ${
this.url.pathname
}\n` +
`host: ${this.url.host}\n` +
`date: ${date.toISOString()}\n` +
`digest: SHA-256=${Buffer.from(
new Uint8Array(digest),
).toString("base64")}\n`,
),
);
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
"base64",
);
return {
date: date.toISOString(),
signature: `keyId="${this.authorUri.toString()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
};
}
}
/**
* Extends native fetch with object signing
* Make sure to format your JSON in Canonical JSON format!
* @param url URL to fetch
* @param options Standard Web Fetch API options
* @param privateKey Author private key in base64
* @param authorUri Author URI
* @param baseUrl Base URL of this server
* @returns Fetch response
*/
export const signedFetch = async (
url: string | URL,
options: RequestInit,
privateKey: string,
authorUri: string | URL,
baseUrl: string | URL,
) => {
const urlObj = new URL(url);
const authorUriObj = new URL(authorUri);
const signature = await SignatureConstructor.fromStringKey(
privateKey,
urlObj,
authorUriObj,
);
const { date, signature: signatureHeader } = await signature.sign(
options.method ?? "GET",
options.body?.toString() || "",
);
return fetch(url, {
...options,
headers: {
Date: date,
Origin: new URL(baseUrl).origin,
Signature: signatureHeader,
"Content-Type": "application/json; charset=utf-8",
Accept: "application/json",
...options.headers,
},
});
};

View file

@ -0,0 +1,6 @@
{
"name": "lysand-utils",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "zod": "^3.22.4", "zod-validation-error": "^3.1.0" }
}

View file

@ -0,0 +1,261 @@
import { z } from "zod";
const Entity = z.object({
id: z.string().uuid(),
created_at: z.string(),
uri: z.string().url(),
type: z.string(),
extensions: z.object({
"org.lysand:custom_emojis": z.object({
emojis: z.array(
z.object({
shortcode: z.string(),
url: z.string(),
}),
),
}),
}),
});
const ContentFormat = z.record(
z.string(),
z.object({
content: z.string(),
description: z.string().optional(),
size: z.number().optional(),
hash: z.record(z.string().optional()).optional(),
blurhash: z.string().optional(),
fps: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
duration: z.number().optional(),
}),
);
const Visibility = z.enum(["public", "unlisted", "private", "direct"]);
const Publication = Entity.extend({
type: z.union([z.literal("Note"), z.literal("Patch")]),
author: z.string().url(),
content: ContentFormat.optional(),
attachments: z.array(ContentFormat).optional(),
replies_to: z.string().url().optional(),
quotes: z.string().url().optional(),
mentions: z.array(z.string().url()).optional(),
subject: z.string().optional(),
is_sensitive: z.boolean().optional(),
visibility: Visibility,
extensions: Entity.shape.extensions.extend({
"org.lysand:reactions": z
.object({
reactions: z.string(),
})
.optional(),
"org.lysand:polls": z
.object({
poll: z.object({
options: z.array(ContentFormat),
votes: z.array(z.number()),
multiple_choice: z.boolean().optional(),
expires_at: z.string(),
}),
})
.optional(),
}),
});
const Note = Publication.extend({
type: z.literal("Note"),
});
const Patch = Publication.extend({
type: z.literal("Patch"),
patched_id: z.string().uuid(),
patched_at: z.string(),
});
const ActorPublicKeyData = z.object({
public_key: z.string(),
actor: z.string().url(),
});
const VanityExtension = z.object({
avatar_overlay: ContentFormat.optional(),
avatar_mask: ContentFormat.optional(),
background: ContentFormat.optional(),
audio: ContentFormat.optional(),
pronouns: z.record(
z.string(),
z.array(
z.union([
z.object({
subject: z.string(),
object: z.string(),
dependent_possessive: z.string(),
independent_possessive: z.string(),
reflexive: z.string(),
}),
z.string(),
]),
),
),
birthday: z.string().optional(),
location: z.string().optional(),
activitypub: z.string().optional(),
});
const User = Entity.extend({
type: z.literal("User"),
display_name: z.string().optional(),
username: z.string(),
avatar: ContentFormat.optional(),
header: ContentFormat.optional(),
indexable: z.boolean(),
public_key: ActorPublicKeyData,
bio: ContentFormat.optional(),
fields: z
.array(
z.object({
name: ContentFormat,
value: ContentFormat,
}),
)
.optional(),
featured: z.string().url(),
followers: z.string().url(),
following: z.string().url(),
likes: z.string().url(),
dislikes: z.string().url(),
inbox: z.string().url(),
outbox: z.string().url(),
extensions: Entity.shape.extensions.extend({
"org.lysand:vanity": VanityExtension.optional(),
}),
});
const Action = Entity.extend({
type: z.union([
z.literal("Like"),
z.literal("Dislike"),
z.literal("Follow"),
z.literal("FollowAccept"),
z.literal("FollowReject"),
z.literal("Announce"),
z.literal("Undo"),
]),
author: z.string().url(),
});
const Like = Action.extend({
type: z.literal("Like"),
object: z.string().url(),
});
const Undo = Action.extend({
type: z.literal("Undo"),
object: z.string().url(),
});
const Dislike = Action.extend({
type: z.literal("Dislike"),
object: z.string().url(),
});
const Follow = Action.extend({
type: z.literal("Follow"),
followee: z.string().url(),
});
const FollowAccept = Action.extend({
type: z.literal("FollowAccept"),
follower: z.string().url(),
});
const FollowReject = Action.extend({
type: z.literal("FollowReject"),
follower: z.string().url(),
});
const Announce = Action.extend({
type: z.literal("Announce"),
object: z.string().url(),
});
const Extension = Entity.extend({
type: z.literal("Extension"),
extension_type: z.string(),
});
const Reaction = Extension.extend({
extension_type: z.literal("org.lysand:reactions/Reaction"),
object: z.string().url(),
content: z.string(),
});
const Poll = Extension.extend({
extension_type: z.literal("org.lysand:polls/Poll"),
options: z.array(ContentFormat),
votes: z.array(z.number()),
multiple_choice: z.boolean().optional(),
expires_at: z.string(),
});
const Vote = Extension.extend({
extension_type: z.literal("org.lysand:polls/Vote"),
poll: z.string().url(),
option: z.number(),
});
const VoteResult = Extension.extend({
extension_type: z.literal("org.lysand:polls/VoteResult"),
poll: z.string().url(),
votes: z.array(z.number()),
});
const Report = Extension.extend({
extension_type: z.literal("org.lysand:reports/Report"),
objects: z.array(z.string().url()),
reason: z.string(),
comment: z.string().optional(),
});
const ServerMetadata = Entity.extend({
type: z.literal("ServerMetadata"),
name: z.string(),
version: z.string(),
description: z.string().optional(),
website: z.string().optional(),
moderators: z.array(z.string()).optional(),
admins: z.array(z.string()).optional(),
logo: ContentFormat.optional(),
banner: ContentFormat.optional(),
supported_extensions: z.array(z.string()),
extensions: z.record(z.string(), z.any()).optional(),
});
export const schemas = {
Entity,
ContentFormat,
Visibility,
Publication,
Note,
Patch,
ActorPublicKeyData,
VanityExtension,
User,
Action,
Like,
Undo,
Dislike,
Follow,
FollowAccept,
FollowReject,
Announce,
Extension,
Reaction,
Poll,
Vote,
VoteResult,
Report,
ServerMetadata,
};

View file

@ -1,51 +0,0 @@
import type { APActor, APNote } from "activitypub-types";
import { ActivityPubTranslator } from "./protocols/activitypub";
export enum SupportedProtocols {
ACTIVITYPUB = "activitypub",
}
/**
* ProtocolTranslator
* @summary Translates between federation protocols such as ActivityPub to Lysand and back
* @description This class is responsible for translating between federation protocols such as ActivityPub to Lysand and back.
* This class is not meant to be instantiated directly, but rather for its children to be used.
*/
export class ProtocolTranslator {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
static auto(object: any) {
const protocol = ProtocolTranslator.recognizeProtocol(object);
switch (protocol) {
case SupportedProtocols.ACTIVITYPUB:
return new ActivityPubTranslator();
default:
throw new Error("Unknown protocol");
}
}
/**
* Translates an ActivityPub actor to a Lysand user
* @param data Raw JSON-LD data from an ActivityPub actor
*/
user(data: APActor) {
//
}
/**
* Translates an ActivityPub note to a Lysand status
* @param data Raw JSON-LD data from an ActivityPub note
*/
status(data: APNote) {
//
}
/**
* Automatically recognizes the protocol of a given object
*/
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
private static recognizeProtocol(object: any) {
// Temporary stub
return SupportedProtocols.ACTIVITYPUB;
}
}

View file

@ -1,9 +0,0 @@
{
"name": "protocol-translator",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {},
"devDependencies": {
"activitypub-types": "^1.1.0"
}
}

View file

@ -1,5 +0,0 @@
import { ProtocolTranslator } from "..";
export class ActivityPubTranslator extends ProtocolTranslator {
user() {}
}

View file

@ -7,11 +7,8 @@ import { RequestParser } from "request-parser";
import type { ZodType, z } from "zod"; import type { ZodType, z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import type { Application } from "~database/entities/Application"; import type { Application } from "~database/entities/Application";
import { import { type AuthData, getFromRequest } from "~database/entities/User";
type AuthData, import type { User } from "~packages/database-interface/user";
type UserWithRelations,
getFromRequest,
} from "~database/entities/User";
type MaybePromise<T> = T | Promise<T>; type MaybePromise<T> = T | Promise<T>;
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
@ -24,11 +21,11 @@ export type RouteHandler<
matchedRoute: MatchedRoute, matchedRoute: MatchedRoute,
extraData: { extraData: {
auth: { auth: {
// If the route doesn't require authentication, set the type to UserWithRelations | null // If the route doesn't require authentication, set the type to User | null
// Otherwise set to UserWithRelations // Otherwise set to User
user: RouteMeta["auth"]["required"] extends true user: RouteMeta["auth"]["required"] extends true
? UserWithRelations ? User
: UserWithRelations | null; : User | null;
token: RouteMeta["auth"]["required"] extends true token: RouteMeta["auth"]["required"] extends true
? string ? string
: string | null; : string | null;

View file

@ -1,12 +1,14 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, response } from "@response"; import { errorResponse, response } from "@response";
import { eq } from "drizzle-orm";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { stringify } from "qs"; import { stringify } from "qs";
import { z } from "zod"; import { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { findFirstUser } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Users } from "~drizzle/schema";
import { config } from "~packages/config-manager"; import { config } from "~packages/config-manager";
import { User } from "~packages/database-interface/user";
import { RequestParser } from "~packages/request-parser"; import { RequestParser } from "~packages/request-parser";
export const meta = applyConfig({ export const meta = applyConfig({
@ -77,11 +79,12 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
); );
// Find user // Find user
const user = await findFirstUser({ const user = await User.fromSql(eq(Users.email, email));
where: (user, { eq }) => eq(user.email, email),
});
if (!user || !(await Bun.password.verify(password, user.password || ""))) if (
!user ||
!(await Bun.password.verify(password, user.getUser().password || ""))
)
return returnError( return returnError(
extraData.parsedRequest, extraData.parsedRequest,
"invalid_request", "invalid_request",

View file

@ -2,10 +2,11 @@ import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { z } from "zod"; import { z } from "zod";
import { TokenType } from "~database/entities/Token"; import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema"; import { Tokens, Users } from "~drizzle/schema";
import { config } from "~packages/config-manager"; import { config } from "~packages/config-manager";
import { User } from "~packages/database-interface/user";
import { eq } from "drizzle-orm";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -44,13 +45,14 @@ export default apiRoute<typeof meta, typeof schema>(
302, 302,
); );
const user = await findFirstUser({ const user = await User.fromSql(eq(Users.email, email));
where: (user, { eq }) => eq(user.email, email),
});
if ( if (
!user || !user ||
!(await Bun.password.verify(password, user.password || "")) !(await Bun.password.verify(
password,
user.getUser().password || "",
))
) )
return redirectToLogin("Invalid email or password"); return redirectToLogin("Invalid email or password");

View file

@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);

View file

@ -4,10 +4,10 @@ import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import {
findFirstUser,
followRequestUser, followRequestUser,
getRelationshipToOtherUser, getRelationshipToOtherUser,
} from "~database/entities/User"; } from "~database/entities/User";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -46,9 +46,7 @@ export default apiRoute<typeof meta, typeof schema>(
const { languages, notify, reblogs } = extraData.parsedRequest; const { languages, notify, reblogs } = extraData.parsedRequest;
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);

View file

@ -0,0 +1,105 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import {
deleteOldTestUsers,
getTestUsers,
sendTestRequest,
} from "~tests/utils";
import type { Account as APIAccount } from "~types/mastodon/account";
import { meta } from "./followers";
await deleteOldTestUsers();
const { users, tokens, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
// Follow user
const response = await sendTestRequest(
new Request(
new URL(
`/api/v1/accounts/${users[1].id}/follow`,
config.http.base_url,
),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
});
// /api/v1/accounts/:id/followers
describe(meta.route, () => {
test("should return 200 with followers", async () => {
const response = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", users[1].id),
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
const data = (await response.json()) as APIAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[0].id);
});
test("should return no followers after unfollowing", async () => {
// Unfollow user
const response = await sendTestRequest(
new Request(
new URL(
`/api/v1/accounts/${users[1].id}/unfollow`,
config.http.base_url,
),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
const response2 = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", users[1].id),
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response2.status).toBe(200);
const data = (await response2.json()) as APIAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(0);
});
});

View file

@ -1,13 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { Users } from "~drizzle/schema";
type UserWithRelations, import { Timeline } from "~packages/database-interface/timeline";
findFirstUser, import { User } from "~packages/database-interface/user";
findManyUsers,
userToAPI,
} from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -42,32 +39,23 @@ export default apiRoute<typeof meta, typeof schema>(
// TODO: Add pinned // TODO: Add pinned
const { max_id, min_id, since_id, limit } = extraData.parsedRequest; const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);
const { objects, link } = await fetchTimeline<UserWithRelations>( const { objects, link } = await Timeline.getUserTimeline(
findManyUsers, and(
{ max_id ? lt(Users.id, max_id) : undefined,
// @ts-ignore since_id ? gte(Users.id, since_id) : undefined,
where: (follower, { and, lt, gt, gte, eq, sql }) => min_id ? gt(Users.id, min_id) : undefined,
and( sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
max_id ? lt(follower.id, max_id) : undefined, ),
since_id ? gte(follower.id, since_id) : undefined, limit,
min_id ? gt(follower.id, min_id) : undefined, req.url,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${follower.id} AND "Relationships"."following" = true)`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
); );
return jsonResponse( return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))), await Promise.all(objects.map((object) => object.toAPI())),
200, 200,
{ {
Link: link, Link: link,

View file

@ -0,0 +1,105 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import {
deleteOldTestUsers,
getTestUsers,
sendTestRequest,
} from "~tests/utils";
import type { Account as APIAccount } from "~types/mastodon/account";
import { meta } from "./following";
await deleteOldTestUsers();
const { users, tokens, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
// Follow user
const response = await sendTestRequest(
new Request(
new URL(
`/api/v1/accounts/${users[1].id}/follow`,
config.http.base_url,
),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
});
// /api/v1/accounts/:id/following
describe(meta.route, () => {
test("should return 200 with following", async () => {
const response = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", users[0].id),
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[1].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
const data = (await response.json()) as APIAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[1].id);
});
test("should return no following after unfollowing", async () => {
// Unfollow user
const response = await sendTestRequest(
new Request(
new URL(
`/api/v1/accounts/${users[1].id}/unfollow`,
config.http.base_url,
),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
const response2 = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", users[0].id),
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[1].accessToken}`,
},
},
),
);
expect(response2.status).toBe(200);
const data = (await response2.json()) as APIAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(0);
});
});

View file

@ -1,13 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { Users } from "~drizzle/schema";
type UserWithRelations, import { Timeline } from "~packages/database-interface/timeline";
findFirstUser, import { User } from "~packages/database-interface/user";
findManyUsers,
userToAPI,
} from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -42,32 +39,23 @@ export default apiRoute<typeof meta, typeof schema>(
// TODO: Add pinned // TODO: Add pinned
const { max_id, min_id, since_id, limit } = extraData.parsedRequest; const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);
const { objects, link } = await fetchTimeline<UserWithRelations>( const { objects, link } = await Timeline.getUserTimeline(
findManyUsers, and(
{ max_id ? lt(Users.id, max_id) : undefined,
// @ts-ignore since_id ? gte(Users.id, since_id) : undefined,
where: (following, { and, lt, gt, gte, eq, sql }) => min_id ? gt(Users.id, min_id) : undefined,
and( sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
max_id ? lt(following.id, max_id) : undefined, ),
since_id ? gte(following.id, since_id) : undefined, limit,
min_id ? gt(following.id, min_id) : undefined, req.url,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${following.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
); );
return jsonResponse( return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))), await Promise.all(objects.map((object) => object.toAPI())),
200, 200,
{ {
Link: link, Link: link,

View file

@ -64,17 +64,17 @@ describe(meta.route, () => {
const data = (await response.json()) as APIAccount; const data = (await response.json()) as APIAccount;
expect(data).toMatchObject({ expect(data).toMatchObject({
id: users[0].id, id: users[0].id,
username: users[0].username, username: users[0].getUser().username,
display_name: users[0].displayName, display_name: users[0].getUser().displayName,
avatar: expect.any(String), avatar: expect.any(String),
header: expect.any(String), header: expect.any(String),
locked: users[0].isLocked, locked: users[0].getUser().isLocked,
created_at: new Date(users[0].createdAt).toISOString(), created_at: new Date(users[0].getUser().createdAt).toISOString(),
followers_count: 0, followers_count: 0,
following_count: 0, following_count: 0,
statuses_count: 40, statuses_count: 40,
note: users[0].note, note: users[0].getUser().note,
acct: users[0].username, acct: users[0].getUser().username,
url: expect.any(String), url: expect.any(String),
avatar_static: expect.any(String), avatar_static: expect.any(String),
header_static: expect.any(String), header_static: expect.any(String),

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { findFirstUser, userToAPI } from "~database/entities/User"; import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -26,11 +26,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
const foundUser = await findFirstUser({ const foundUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
}).catch(() => null);
if (!foundUser) return errorResponse("User not found", 404); if (!foundUser) return errorResponse("User not found", 404);
return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id)); return jsonResponse(foundUser.toAPI(user?.id === foundUser.id));
}); });

View file

@ -3,12 +3,10 @@ import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -49,9 +47,7 @@ export default apiRoute<typeof meta, typeof schema>(
const { notifications, duration } = extraData.parsedRequest; const { notifications, duration } = extraData.parsedRequest;
const user = await findFirstUser({ const user = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);

View file

@ -3,12 +3,10 @@ import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -43,9 +41,7 @@ export default apiRoute<typeof meta, typeof schema>(
const { comment } = extraData.parsedRequest; const { comment } = extraData.parsedRequest;
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);

View file

@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);

View file

@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);
@ -54,7 +50,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
}) })
.where(eq(Relationships.id, foundRelationship.id)); .where(eq(Relationships.id, foundRelationship.id));
if (otherUser.instanceId === null) { if (otherUser.isLocal()) {
// Also remove from followers list // Also remove from followers list
await db await db
.update(Relationships) .update(Relationships)

View file

@ -2,9 +2,9 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm"; import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { findFirstUser } from "~database/entities/User";
import { Notes } from "~drizzle/schema"; import { Notes } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline"; import { Timeline } from "~packages/database-interface/timeline";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -52,9 +52,7 @@ export default apiRoute<typeof meta, typeof schema>(
pinned, pinned,
} = extraData.parsedRequest; } = extraData.parsedRequest;
const user = await findFirstUser({ const user = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);

View file

@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -32,9 +30,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);

View file

@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);

View file

@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await findFirstUser({ const user = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);

View file

@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({ const otherUser = await User.fromId(id);
where: (user, { eq }) => eq(user.id, id),
});
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);

View file

@ -1,8 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { findManyUsers, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -67,18 +69,13 @@ export default apiRoute<typeof meta, typeof schema>(
return jsonResponse([]); return jsonResponse([]);
} }
const finalUsers = await findManyUsers({ const finalUsers = await User.manyFromSql(
where: (user, { inArray }) => inArray(
inArray( Users.id,
user.id, relevantRelationships.map((r) => r.subjectId),
relevantRelationships.map((r) => r.subjectId), ),
), );
});
if (finalUsers.length === 0) { return jsonResponse(finalUsers.map((o) => o.toAPI()));
return jsonResponse([]);
}
return jsonResponse(finalUsers.map((o) => userToAPI(o)));
}, },
); );

View file

@ -1,9 +1,12 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { jsonResponse, response } from "@response"; import { jsonResponse, response } from "@response";
import { tempmailDomains } from "@tempmail"; import { tempmailDomains } from "@tempmail";
import { eq } from "drizzle-orm";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
import { createNewLocalUser, findFirstUser } from "~database/entities/User"; import { createNewLocalUser } from "~database/entities/User";
import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -133,11 +136,7 @@ export default apiRoute<typeof meta, typeof schema>(
}); });
// Check if username is taken // Check if username is taken
if ( if (await User.fromSql(eq(Users.username, body.username))) {
await findFirstUser({
where: (user, { eq }) => eq(user.username, body.username ?? ""),
})
) {
errors.details.username.push({ errors.details.username.push({
error: "ERR_TAKEN", error: "ERR_TAKEN",
description: "is already taken", description: "is already taken",

View file

@ -22,7 +22,7 @@ describe(meta.route, () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(
new URL( new URL(
`${meta.route}?acct=${users[0].username}`, `${meta.route}?acct=${users[0].getUser().username}`,
config.http.base_url, config.http.base_url,
), ),
{ {
@ -39,8 +39,8 @@ describe(meta.route, () => {
expect(data).toEqual( expect(data).toEqual(
expect.objectContaining({ expect.objectContaining({
id: users[0].id, id: users[0].id,
username: users[0].username, username: users[0].getUser().username,
display_name: users[0].displayName, display_name: users[0].getUser().displayName,
avatar: expect.any(String), avatar: expect.any(String),
header: expect.any(String), header: expect.any(String),
}), }),

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { dualLogger } from "@loggers"; import { dualLogger } from "@loggers";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { import {
anyOf, anyOf,
charIn, charIn,
@ -13,11 +14,9 @@ import {
oneOrMore, oneOrMore,
} from "magic-regexp"; } from "magic-regexp";
import { z } from "zod"; import { z } from "zod";
import { import { resolveWebFinger } from "~database/entities/User";
findFirstUser, import { Users } from "~drizzle/schema";
resolveWebFinger, import { User } from "~packages/database-interface/user";
userToAPI,
} from "~database/entities/User";
import { LogLevel } from "~packages/log-manager"; import { LogLevel } from "~packages/log-manager";
export const meta = applyConfig({ export const meta = applyConfig({
@ -80,7 +79,7 @@ export default apiRoute<typeof meta, typeof schema>(
); );
if (foundAccount) { if (foundAccount) {
return jsonResponse(userToAPI(foundAccount)); return jsonResponse(foundAccount.toAPI());
} }
return errorResponse("Account not found", 404); return errorResponse("Account not found", 404);
@ -91,12 +90,10 @@ export default apiRoute<typeof meta, typeof schema>(
username = username.slice(1); username = username.slice(1);
} }
const account = await findFirstUser({ const account = await User.fromSql(eq(Users.username, username));
where: (user, { eq }) => eq(user.username, username),
});
if (account) { if (account) {
return jsonResponse(userToAPI(account)); return jsonResponse(account.toAPI());
} }
return errorResponse( return errorResponse(

View file

@ -5,7 +5,7 @@ import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import type { User } from "~database/entities/User"; import type { UserType } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
export const meta = applyConfig({ export const meta = applyConfig({
@ -53,7 +53,7 @@ export default apiRoute<typeof meta, typeof schema>(
for (const id of missingIds) { for (const id of missingIds) {
const relationship = await createNewRelationship(self, { const relationship = await createNewRelationship(self, {
id, id,
} as User); } as UserType);
relationships.push(relationship); relationships.push(relationship);
} }

View file

@ -22,7 +22,7 @@ describe(meta.route, () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(
new URL( new URL(
`${meta.route}?q=${users[0].username}`, `${meta.route}?q=${users[0].getUser().username}`,
config.http.base_url, config.http.base_url,
), ),
{ {
@ -40,8 +40,8 @@ describe(meta.route, () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: users[0].id, id: users[0].id,
username: users[0].username, username: users[0].getUser().username,
display_name: users[0].displayName, display_name: users[0].getUser().displayName,
avatar: expect.any(String), avatar: expect.any(String),
header: expect.any(String), header: expect.any(String),
}), }),

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { sql } from "drizzle-orm"; import { eq, like, not, or, sql } from "drizzle-orm";
import { import {
anyOf, anyOf,
charIn, charIn,
@ -13,13 +13,9 @@ import {
oneOrMore, oneOrMore,
} from "magic-regexp"; } from "magic-regexp";
import { z } from "zod"; import { z } from "zod";
import { import { resolveWebFinger } from "~database/entities/User";
type UserWithRelations,
findManyUsers,
resolveWebFinger,
userToAPI,
} from "~database/entities/User";
import { Users } from "~drizzle/schema"; import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -78,7 +74,7 @@ export default apiRoute<typeof meta, typeof schema>(
// Remove any leading @ // Remove any leading @
const [username, host] = q.replace(/^@/, "").split("@"); const [username, host] = q.replace(/^@/, "").split("@");
const accounts: UserWithRelations[] = []; const accounts: User[] = [];
if (resolve && username && host) { if (resolve && username && host) {
const resolvedUser = await resolveWebFinger(username, host); const resolvedUser = await resolveWebFinger(username, host);
@ -88,21 +84,22 @@ export default apiRoute<typeof meta, typeof schema>(
} }
} else { } else {
accounts.push( accounts.push(
...(await findManyUsers({ ...(await User.manyFromSql(
where: (account, { or, like }) => or(
or( like(Users.displayName, `%${q}%`),
like(account.displayName, `%${q}%`), like(Users.username, `%${q}%`),
like(account.username, `%${q}%`), following && self
following ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)`
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${account.id} AND "Relationships"."following" = true)` : undefined,
: undefined, self ? not(eq(Users.id, self.id)) : undefined,
), ),
offset, undefined,
limit, limit,
})), offset,
)),
); );
} }
return jsonResponse(accounts.map((acct) => userToAPI(acct))); return jsonResponse(accounts.map((acct) => acct.toAPI()));
}, },
); );

View file

@ -11,9 +11,9 @@ import { z } from "zod";
import { getUrl } from "~database/entities/Attachment"; import { getUrl } from "~database/entities/Attachment";
import { parseEmojis } from "~database/entities/Emoji"; import { parseEmojis } from "~database/entities/Emoji";
import { contentToHtml } from "~database/entities/Status"; import { contentToHtml } from "~database/entities/Status";
import { findFirstUser, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { EmojiToUser, Users } from "~drizzle/schema"; import { EmojiToUser, Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["PATCH"], allowedMethods: ["PATCH"],
@ -51,11 +51,12 @@ export const schema = z.object({
export default apiRoute<typeof meta, typeof schema>( export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => { async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth; const { user } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
const self = user.getUser();
const { const {
display_name, display_name,
@ -231,12 +232,9 @@ export default apiRoute<typeof meta, typeof schema>(
.execute(); .execute();
} }
const output = await findFirstUser({ const output = await User.fromId(self.id);
where: (user, { eq }) => eq(user.id, self.id),
});
if (!output) return errorResponse("Couldn't edit user", 500); if (!output) return errorResponse("Couldn't edit user", 500);
return jsonResponse(userToAPI(output)); return jsonResponse(output.toAPI());
}, },
); );

View file

@ -1,6 +1,5 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -22,7 +21,5 @@ export default apiRoute((req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
return jsonResponse({ return jsonResponse(user.toAPI(true));
...userToAPI(user, true),
});
}); });

View file

@ -1,12 +1,9 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { Users } from "~drizzle/schema";
type UserWithRelations, import { Timeline } from "~packages/database-interface/timeline";
findManyUsers,
userToAPI,
} from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -36,27 +33,19 @@ export default apiRoute<typeof meta, typeof schema>(
const { max_id, since_id, min_id, limit } = extraData.parsedRequest; const { max_id, since_id, min_id, limit } = extraData.parsedRequest;
const { objects: blocks, link } = const { objects: blocks, link } = await Timeline.getUserTimeline(
await fetchTimeline<UserWithRelations>( and(
findManyUsers, max_id ? lt(Users.id, max_id) : undefined,
{ since_id ? gte(Users.id, since_id) : undefined,
// @ts-expect-error Yes I KNOW the types are wrong min_id ? gt(Users.id, min_id) : undefined,
where: (subject, { lt, gte, gt, and, sql }) => sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
and( ),
max_id ? lt(subject.id, max_id) : undefined, limit,
since_id ? gte(subject.id, since_id) : undefined, req.url,
min_id ? gt(subject.id, min_id) : undefined, );
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${subject.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse( return jsonResponse(
blocks.map((u) => userToAPI(u)), blocks.map((u) => u.toAPI()),
200, 200,
{ {
Link: link, Link: link,

View file

@ -6,12 +6,12 @@ import {
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import {
findFirstUser,
getRelationshipToOtherUser, getRelationshipToOtherUser,
sendFollowAccept, sendFollowAccept,
} from "~database/entities/User"; } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -32,9 +32,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const { account_id } = matchedRoute.params; const { account_id } = matchedRoute.params;
const account = await findFirstUser({ const account = await User.fromId(account_id);
where: (user, { eq }) => eq(user.id, account_id),
});
if (!account) return errorResponse("Account not found", 404); if (!account) return errorResponse("Account not found", 404);
@ -73,7 +71,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!foundRelationship) return errorResponse("Relationship not found", 404); if (!foundRelationship) return errorResponse("Relationship not found", 404);
// Check if accepting remote follow // Check if accepting remote follow
if (account.instanceId) { if (account.isRemote()) {
// Federate follow accept // Federate follow accept
await sendFollowAccept(account, user); await sendFollowAccept(account, user);
} }

View file

@ -6,12 +6,12 @@ import {
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import {
findFirstUser,
getRelationshipToOtherUser, getRelationshipToOtherUser,
sendFollowReject, sendFollowReject,
} from "~database/entities/User"; } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema"; import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -32,9 +32,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const { account_id } = matchedRoute.params; const { account_id } = matchedRoute.params;
const account = await findFirstUser({ const account = await User.fromId(account_id);
where: (user, { eq }) => eq(user.id, account_id),
});
if (!account) return errorResponse("Account not found", 404); if (!account) return errorResponse("Account not found", 404);
@ -73,7 +71,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!foundRelationship) return errorResponse("Relationship not found", 404); if (!foundRelationship) return errorResponse("Relationship not found", 404);
// Check if rejecting remote follow // Check if rejecting remote follow
if (account.instanceId) { if (account.isRemote()) {
// Federate follow reject // Federate follow reject
await sendFollowReject(account, user); await sendFollowReject(account, user);
} }

View file

@ -1,12 +1,9 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { Users } from "~drizzle/schema";
type UserWithRelations, import { Timeline } from "~packages/database-interface/timeline";
findManyUsers,
userToAPI,
} from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -35,26 +32,19 @@ export default apiRoute<typeof meta, typeof schema>(
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const { objects, link } = await fetchTimeline<UserWithRelations>( const { objects, link } = await Timeline.getUserTimeline(
findManyUsers, and(
{ max_id ? lt(Users.id, max_id) : undefined,
// @ts-expect-error Yes I KNOW the types are wrong since_id ? gte(Users.id, since_id) : undefined,
where: (subject, { lt, gte, gt, and, sql }) => min_id ? gt(Users.id, min_id) : undefined,
and( sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
max_id ? lt(subject.id, max_id) : undefined, ),
since_id ? gte(subject.id, since_id) : undefined, limit,
min_id ? gt(subject.id, min_id) : undefined, req.url,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${subject.id} AND "Relationships"."requested" = true)`,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
); );
return jsonResponse( return jsonResponse(
objects.map((user) => userToAPI(user)), objects.map((user) => user.toAPI()),
200, 200,
{ {
Link: link, Link: link,

View file

@ -1,10 +1,10 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { and, count, countDistinct, eq, gte, isNull, sql } from "drizzle-orm"; import { and, count, countDistinct, eq, gte, isNull, sql } from "drizzle-orm";
import { findFirstUser, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Instances, Notes, Users } from "~drizzle/schema"; import { Instances, Notes, Users } from "~drizzle/schema";
import manifest from "~package.json"; import manifest from "~package.json";
import { User } from "~packages/database-interface/user";
import type { Instance as APIInstance } from "~types/mastodon/instance"; import type { Instance as APIInstance } from "~types/mastodon/instance";
export const meta = applyConfig({ export const meta = applyConfig({
@ -45,11 +45,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
.where(isNull(Users.instanceId)) .where(isNull(Users.instanceId))
)[0].count; )[0].count;
const contactAccount = await findFirstUser({ const contactAccount = await User.fromSql(
where: (user, { isNull, eq, and }) => and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
and(isNull(user.instanceId), eq(user.isAdmin, true)), );
orderBy: (user, { asc }) => asc(user.id),
});
const monthlyActiveUsers = ( const monthlyActiveUsers = (
await db await db
@ -186,7 +184,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
}, },
vapid_public_key: "", vapid_public_key: "",
}, },
contact_account: contactAccount ? userToAPI(contactAccount) : undefined, contact_account: contactAccount?.toAPI() || undefined,
} satisfies APIInstance & { } satisfies APIInstance & {
pleroma: object; pleroma: object;
}); });

View file

@ -1,6 +1,5 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, count, eq } from "drizzle-orm"; import { and, count, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";

View file

@ -1,12 +1,9 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { Users } from "~drizzle/schema";
type UserWithRelations, import { Timeline } from "~packages/database-interface/timeline";
findManyUsers,
userToAPI,
} from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -35,25 +32,17 @@ export default apiRoute<typeof meta, typeof schema>(
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const { objects: blocks, link } = const { objects: mutes, link } = await Timeline.getUserTimeline(
await fetchTimeline<UserWithRelations>( and(
findManyUsers, max_id ? lt(Users.id, max_id) : undefined,
{ since_id ? gte(Users.id, since_id) : undefined,
// @ts-expect-error Yes I KNOW the types are wrong min_id ? gt(Users.id, min_id) : undefined,
where: (subject, { lt, gte, gt, and, sql }) => sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
and( ),
max_id ? lt(subject.id, max_id) : undefined, limit,
since_id ? gte(subject.id, since_id) : undefined, req.url,
min_id ? gt(subject.id, min_id) : undefined, );
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${subject.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse(blocks.map((u) => userToAPI(u))); return jsonResponse(mutes.map((u) => u.toAPI()));
}, },
); );

View file

@ -58,7 +58,7 @@ beforeAll(async () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
status: `@${users[0].username} test mention`, status: `@${users[0].getUser().username} test mention`,
visibility: "direct", visibility: "direct",
federate: false, federate: false,
}), }),

View file

@ -1,9 +1,9 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Users } from "~drizzle/schema"; import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["DELETE"], allowedMethods: ["DELETE"],
@ -27,10 +27,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
await db.update(Users).set({ avatar: "" }).where(eq(Users.id, self.id)); await db.update(Users).set({ avatar: "" }).where(eq(Users.id, self.id));
return jsonResponse( return jsonResponse({
userToAPI({ ...(await User.fromId(self.id))?.toAPI(),
...self, avatar: "",
avatar: "", });
}),
);
}); });

View file

@ -1,9 +1,9 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Users } from "~drizzle/schema"; import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["DELETE"], allowedMethods: ["DELETE"],
@ -28,10 +28,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
// Delete user header // Delete user header
await db.update(Users).set({ header: "" }).where(eq(Users.id, self.id)); await db.update(Users).set({ header: "" }).where(eq(Users.id, self.id));
return jsonResponse( return jsonResponse({
userToAPI({ ...(await User.fromId(self.id))?.toAPI(),
...self, header: "",
header: "", });
}),
);
}); });

View file

@ -30,27 +30,27 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const status = await Note.fromId(id); const note = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if (!status?.isViewableByUser(user)) if (!note?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);
const existingLike = await db.query.Likes.findFirst({ const existingLike = await db.query.Likes.findFirst({
where: (like, { and, eq }) => where: (like, { and, eq }) =>
and( and(
eq(like.likedId, status.getStatus().id), eq(like.likedId, note.getStatus().id),
eq(like.likerId, user.id), eq(like.likerId, user.id),
), ),
}); });
if (!existingLike) { if (!existingLike) {
await createLike(user, status.getStatus()); await createLike(user, note);
} }
return jsonResponse({ return jsonResponse({
...(await status.toAPI(user)), ...(await note.toAPI(user)),
favourited: true, favourited: true,
favourites_count: status.getStatus().likeCount + 1, favourites_count: note.getStatus().likeCount + 1,
} as APIStatus); } as APIStatus);
}); });

View file

@ -73,7 +73,7 @@ describe(meta.route, () => {
expect(objects.length).toBe(1); expect(objects.length).toBe(1);
for (const [index, status] of objects.entries()) { for (const [index, status] of objects.entries()) {
expect(status.id).toBe(users[1].id); expect(status.id).toBe(users[1].id);
expect(status.username).toBe(users[1].username); expect(status.username).toBe(users[1].getUser().username);
} }
}); });
}); });

View file

@ -1,13 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { Users } from "~drizzle/schema";
type UserWithRelations,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Note } from "~packages/database-interface/note"; import { Note } from "~packages/database-interface/note";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -48,28 +45,19 @@ export default apiRoute<typeof meta, typeof schema>(
const { max_id, min_id, since_id, limit } = extraData.parsedRequest; const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const { objects, link } = await fetchTimeline<UserWithRelations>( const { objects, link } = await Timeline.getUserTimeline(
findManyUsers, and(
{ max_id ? lt(Users.id, max_id) : undefined,
// @ts-ignore since_id ? gte(Users.id, since_id) : undefined,
where: (liker, { and, lt, gt, gte, eq, sql }) => min_id ? gt(Users.id, min_id) : undefined,
and( sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`,
max_id ? lt(liker.id, max_id) : undefined, ),
since_id ? gte(liker.id, since_id) : undefined, limit,
min_id ? gt(liker.id, min_id) : undefined, req.url,
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${
status.getStatus().id
} AND "Likes"."likerId" = ${liker.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
); );
return jsonResponse( return jsonResponse(
objects.map((user) => userToAPI(user)), objects.map((user) => user.toAPI()),
200, 200,
{ {
Link: link, Link: link,

View file

@ -51,7 +51,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
return errorResponse("Already pinned", 422); return errorResponse("Already pinned", 422);
} }
await foundStatus.pin(user); await user.pin(foundStatus);
return jsonResponse(await foundStatus.toAPI(user)); return jsonResponse(await foundStatus.toAPI(user));
}); });

View file

@ -73,7 +73,7 @@ describe(meta.route, () => {
expect(objects.length).toBe(1); expect(objects.length).toBe(1);
for (const [index, status] of objects.entries()) { for (const [index, status] of objects.entries()) {
expect(status.id).toBe(users[1].id); expect(status.id).toBe(users[1].id);
expect(status.username).toBe(users[1].username); expect(status.username).toBe(users[1].getUser().username);
} }
}); });
}); });

View file

@ -1,13 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { Users } from "~drizzle/schema";
type UserWithRelations,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Note } from "~packages/database-interface/note"; import { Note } from "~packages/database-interface/note";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -48,28 +45,19 @@ export default apiRoute<typeof meta, typeof schema>(
const { max_id, min_id, since_id, limit } = extraData.parsedRequest; const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const { objects, link } = await fetchTimeline<UserWithRelations>( const { objects, link } = await Timeline.getUserTimeline(
findManyUsers, and(
{ max_id ? lt(Users.id, max_id) : undefined,
// @ts-ignore since_id ? gte(Users.id, since_id) : undefined,
where: (reblogger, { and, lt, gt, gte, eq, sql }) => min_id ? gt(Users.id, min_id) : undefined,
and( sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${status.id} AND "Notes"."authorId" = ${Users.id})`,
max_id ? lt(reblogger.id, max_id) : undefined, ),
since_id ? gte(reblogger.id, since_id) : undefined, limit,
min_id ? gt(reblogger.id, min_id) : undefined, req.url,
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${
status.getStatus().id
} AND "Notes"."authorId" = ${reblogger.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
); );
return jsonResponse( return jsonResponse(
objects.map((user) => userToAPI(user)), objects.map((user) => user.toAPI()),
200, 200,
{ {
Link: link, Link: link,

View file

@ -29,17 +29,17 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const foundStatus = await Note.fromId(id); const note = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if (!foundStatus?.isViewableByUser(user)) if (!note?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);
await deleteLike(user, foundStatus.getStatus()); await deleteLike(user, note);
return jsonResponse({ return jsonResponse({
...(await foundStatus.toAPI(user)), ...(await note.toAPI(user)),
favourited: false, favourited: false,
favourites_count: foundStatus.getStatus().likeCount - 1, favourites_count: note.getStatus().likeCount - 1,
} as APIStatus); } as APIStatus);
}); });

View file

@ -36,7 +36,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (status.getAuthor().id !== user.id) if (status.getAuthor().id !== user.id)
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
await status.unpin(user); await user.unpin(status);
if (!status) return errorResponse("Record not found", 404); if (!status) return errorResponse("Record not found", 404);

View file

@ -306,7 +306,7 @@ describe(meta.route, () => {
Authorization: `Bearer ${tokens[0].accessToken}`, Authorization: `Bearer ${tokens[0].accessToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
status: `Hello, @${users[1].username}!`, status: `Hello, @${users[1].getUser().username}!`,
federate: false, federate: false,
}), }),
}), }),
@ -322,8 +322,8 @@ describe(meta.route, () => {
expect(object.mentions).toBeArrayOfSize(1); expect(object.mentions).toBeArrayOfSize(1);
expect(object.mentions[0]).toMatchObject({ expect(object.mentions[0]).toMatchObject({
id: users[1].id, id: users[1].id,
username: users[1].username, username: users[1].getUser().username,
acct: users[1].username, acct: users[1].getUser().username,
}); });
}); });
@ -336,7 +336,7 @@ describe(meta.route, () => {
Authorization: `Bearer ${tokens[0].accessToken}`, Authorization: `Bearer ${tokens[0].accessToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
status: `Hello, @${users[1].username}@${ status: `Hello, @${users[1].getUser().username}@${
new URL(config.http.base_url).host new URL(config.http.base_url).host
}!`, }!`,
federate: false, federate: false,
@ -354,8 +354,8 @@ describe(meta.route, () => {
expect(object.mentions).toBeArrayOfSize(1); expect(object.mentions).toBeArrayOfSize(1);
expect(object.mentions[0]).toMatchObject({ expect(object.mentions[0]).toMatchObject({
id: users[1].id, id: users[1].id,
username: users[1].username, username: users[1].getUser().username,
acct: users[1].username, acct: users[1].getUser().username,
}); });
}); });
}); });

View file

@ -1,10 +1,10 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { and, countDistinct, eq, gte, isNull } from "drizzle-orm"; import { and, countDistinct, eq, gte, isNull } from "drizzle-orm";
import { findFirstUser, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Notes, Users } from "~drizzle/schema"; import { Notes, Users } from "~drizzle/schema";
import manifest from "~package.json"; import manifest from "~package.json";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -23,11 +23,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
// Get software version from package.json // Get software version from package.json
const version = manifest.version; const version = manifest.version;
const contactAccount = await findFirstUser({
where: (user, { isNull, eq, and }) => const contactAccount = await User.fromSql(
and(isNull(user.instanceId), eq(user.isAdmin, true)), and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
orderBy: (user, { asc }) => asc(user.id), );
});
const monthlyActiveUsers = ( const monthlyActiveUsers = (
await db await db
@ -104,8 +103,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
url: null, url: null,
}, },
contact: { contact: {
email: contactAccount?.email || null, email: contactAccount?.getUser().email || null,
account: contactAccount ? userToAPI(contactAccount) : null, account: contactAccount?.toAPI() || null,
}, },
rules: config.signups.rules.map((rule, index) => ({ rules: config.signups.rules.map((rule, index) => ({
id: String(index), id: String(index),

View file

@ -4,15 +4,11 @@ import { MeiliIndexType, meilisearch } from "@meilisearch";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import { resolveWebFinger } from "~database/entities/User";
findFirstUser,
findManyUsers,
resolveWebFinger,
userToAPI,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Instances, Notes, Users } from "~drizzle/schema"; import { Instances, Notes, Users } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note"; import { Note } from "~packages/database-interface/note";
import { User } from "~packages/database-interface/user";
import { LogLevel } from "~packages/log-manager"; import { LogLevel } from "~packages/log-manager";
export const meta = applyConfig({ export const meta = applyConfig({
@ -100,15 +96,11 @@ export default apiRoute<typeof meta, typeof schema>(
) )
)[0]?.id; )[0]?.id;
const account = accountId const account = accountId ? await User.fromId(accountId) : null;
? await findFirstUser({
where: (user, { eq }) => eq(user.id, accountId),
})
: null;
if (account) { if (account) {
return jsonResponse({ return jsonResponse({
accounts: [userToAPI(account)], accounts: [account.toAPI()],
statuses: [], statuses: [],
hashtags: [], hashtags: [],
}); });
@ -129,7 +121,7 @@ export default apiRoute<typeof meta, typeof schema>(
if (newUser) { if (newUser) {
return jsonResponse({ return jsonResponse({
accounts: [userToAPI(newUser)], accounts: [newUser.toAPI()],
statuses: [], statuses: [],
hashtags: [], hashtags: [],
}); });
@ -160,23 +152,21 @@ export default apiRoute<typeof meta, typeof schema>(
).hits; ).hits;
} }
const accounts = await findManyUsers({ const accounts = await User.manyFromSql(
where: (user, { and, eq, inArray }) => and(
and( inArray(
inArray( Users.id,
user.id, accountResults.map((hit) => hit.id),
accountResults.map((hit) => hit.id),
),
self
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
self?.id
} AND Relationships.following = ${!!following} AND Relationships.ownerId = ${
user.id
})`
: undefined,
), ),
orderBy: (user, { desc }) => desc(user.createdAt), self
}); ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
self?.id
} AND Relationships.following = ${!!following} AND Relationships.ownerId = ${
Users.id
})`
: undefined,
),
);
const statuses = await Note.manyFromSql( const statuses = await Note.manyFromSql(
and( and(
@ -196,7 +186,7 @@ export default apiRoute<typeof meta, typeof schema>(
); );
return jsonResponse({ return jsonResponse({
accounts: accounts.map((account) => userToAPI(account)), accounts: accounts.map((account) => account.toAPI()),
statuses: await Promise.all( statuses: await Promise.all(
statuses.map((status) => status.toAPI(self)), statuses.map((status) => status.toAPI(self)),
), ),

View file

@ -4,10 +4,10 @@ import { response } from "@response";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { z } from "zod"; import { z } from "zod";
import { TokenType } from "~database/entities/Token"; import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema"; import { Tokens } from "~drizzle/schema";
import { config } from "~packages/config-manager"; import { config } from "~packages/config-manager";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -136,9 +136,7 @@ export default apiRoute<typeof meta, typeof schema>(
if (!payload.exp) return returnError("invalid_request", "Invalid exp"); if (!payload.exp) return returnError("invalid_request", "Invalid exp");
// Check if the user is authenticated // Check if the user is authenticated
const user = await findFirstUser({ const user = await User.fromId(payload.sub);
where: (user, { eq }) => eq(user.id, payload.sub ?? ""),
});
if (!user) return returnError("invalid_request", "Invalid sub"); if (!user) return returnError("invalid_request", "Invalid sub");
@ -226,17 +224,20 @@ export default apiRoute<typeof meta, typeof schema>(
// Include the user's profile information // Include the user's profile information
idTokenPayload = { idTokenPayload = {
...idTokenPayload, ...idTokenPayload,
name: user.displayName, name: user.getUser().displayName,
preferred_username: user.username, preferred_username: user.getUser().username,
picture: user.avatar, picture: user.getAvatarUrl(config),
updated_at: new Date(user.updatedAt).toISOString(), updated_at: new Date(
user.getUser().updatedAt,
).toISOString(),
}; };
} }
if (scopeIncludesEmail) { if (scopeIncludesEmail) {
// Include the user's email address // Include the user's email address
idTokenPayload = { idTokenPayload = {
...idTokenPayload, ...idTokenPayload,
email: user.email, email: user.getUser().email,
// TODO: Add verification system
email_verified: true, email_verified: true,
}; };
} }

View file

@ -14,9 +14,9 @@ import {
validateAuthResponse, validateAuthResponse,
} from "oauth4webapi"; } from "oauth4webapi";
import { TokenType } from "~database/entities/Token"; import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema"; import { Tokens } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -154,9 +154,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
return redirectToLogin("No user found with that account"); return redirectToLogin("No user found with that account");
} }
const user = await findFirstUser({ const user = await User.fromId(userId);
where: (user, { eq }) => eq(user.id, userId),
});
if (!user) { if (!user) {
return redirectToLogin("No user found with that account"); return redirectToLogin("No user found with that account");

View file

@ -1,18 +1,19 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { dualLogger } from "@loggers"; import { dualLogger } from "@loggers";
import { errorResponse, response } from "@response"; import { errorResponse, jsonResponse, response } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type * as Lysand from "lysand-types"; import type * as Lysand from "lysand-types";
import { isValidationError } from "zod-validation-error";
import { resolveNote } from "~database/entities/Status"; import { resolveNote } from "~database/entities/Status";
import { import {
findFirstUser,
getRelationshipToOtherUser, getRelationshipToOtherUser,
resolveUser,
sendFollowAccept, sendFollowAccept,
} from "~database/entities/User"; } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Notifications, Relationships } from "~drizzle/schema"; import { Notifications, Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
import { LogLevel } from "~packages/log-manager"; import { LogLevel } from "~packages/log-manager";
import { EntityValidator, SignatureValidator } from "~packages/lysand-utils";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -29,227 +30,215 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const uuid = matchedRoute.params.uuid; const uuid = matchedRoute.params.uuid;
const user = await findFirstUser({ const user = await User.fromId(uuid);
where: (user, { eq }) => eq(user.id, uuid),
});
if (!user) { if (!user) {
return errorResponse("User not found", 404); return errorResponse("User not found", 404);
} }
// Process incoming request
const body = extraData.parsedRequest as Lysand.Entity;
// Verify request signature // Verify request signature
// TODO: Check if instance is defederated // TODO: Check if instance is defederated
// TODO: Reverse DNS lookup with Origin header
// biome-ignore lint/correctness/noConstantCondition: Temporary // biome-ignore lint/correctness/noConstantCondition: Temporary
if (true) { if (true) {
// request is a Request object containing the previous request const Signature = req.headers.get("Signature");
const DateHeader = req.headers.get("Date");
const signatureHeader = req.headers.get("Signature"); if (!Signature) {
const origin = req.headers.get("Origin");
const date = req.headers.get("Date");
if (!signatureHeader) {
return errorResponse("Missing Signature header", 400); return errorResponse("Missing Signature header", 400);
} }
if (!origin) { if (!DateHeader) {
return errorResponse("Missing Origin header", 400);
}
if (!date) {
return errorResponse("Missing Date header", 400); return errorResponse("Missing Date header", 400);
} }
const signature = signatureHeader const keyId = Signature.split("keyId=")[1]
.split("signature=")[1]
.replace(/"/g, "");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(JSON.stringify(body)),
);
const keyId = signatureHeader
.split("keyId=")[1]
.split(",")[0] .split(",")[0]
.replace(/"/g, ""); .replace(/"/g, "");
console.log(`Resolving keyId ${keyId}`); const sender = await User.resolve(keyId);
const sender = await resolveUser(keyId);
if (!sender) { if (!sender) {
return errorResponse("Invalid keyId", 400); return errorResponse("Could not resolve keyId", 400);
} }
const public_key = await crypto.subtle.importKey( const validator = await SignatureValidator.fromStringKey(
"spki", sender.getUser().publicKey,
Buffer.from(sender.publicKey, "base64"), Signature,
"Ed25519", DateHeader,
false, req.method,
["verify"], new URL(req.url),
await req.text(),
); );
const expectedSignedString = const isValid = await validator.validate();
`(request-target): ${req.method.toLowerCase()} ${
new URL(req.url).pathname
}\n` +
`host: ${new URL(req.url).host}\n` +
`date: ${date}\n` +
`digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)),
)}\n`;
// Check if signed string is valid
const isValid = await crypto.subtle.verify(
"Ed25519",
public_key,
Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
new TextEncoder().encode(expectedSignedString),
);
if (!isValid) { if (!isValid) {
return errorResponse("Invalid signature", 400); return errorResponse("Invalid signature", 400);
} }
} }
// Add sent data to database const validator = new EntityValidator(
switch (body.type) { extraData.parsedRequest as Lysand.Entity,
case "Note": { );
const note = body as Lysand.Note;
const account = await resolveUser(note.author); try {
// Add sent data to database
switch (validator.getType()) {
case "Note": {
const note = await validator.validate<Lysand.Note>();
if (!account) { const account = await User.resolve(note.author);
return errorResponse("Author not found", 400);
}
const newStatus = await resolveNote(undefined, note).catch((e) => { if (!account) {
dualLogger.logError( return errorResponse("Author not found", 404);
LogLevel.ERROR, }
"Inbox.NoteResolve",
e as Error, const newStatus = await resolveNote(undefined, note).catch(
(e) => {
dualLogger.logError(
LogLevel.ERROR,
"Inbox.NoteResolve",
e as Error,
);
return null;
},
); );
return null;
});
if (!newStatus) { if (!newStatus) {
return errorResponse("Failed to add status", 500); return errorResponse("Failed to add status", 500);
}
return response("Note created", 201);
} }
case "Follow": {
const follow = await validator.validate<Lysand.Follow>();
return response("Note created", 201); const account = await User.resolve(follow.author);
if (!account) {
return errorResponse("Author not found", 400);
}
const foundRelationship = await getRelationshipToOtherUser(
account,
user,
);
// Check if already following
if (foundRelationship.following) {
return response("Already following", 200);
}
await db
.update(Relationships)
.set({
following: !user.getUser().isLocked,
requested: user.getUser().isLocked,
showingReblogs: true,
notifying: true,
languages: [],
})
.where(eq(Relationships.id, foundRelationship.id));
await db.insert(Notifications).values({
accountId: account.id,
type: user.getUser().isLocked ? "follow_request" : "follow",
notifiedId: user.id,
});
if (!user.getUser().isLocked) {
// Federate FollowAccept
await sendFollowAccept(account, user);
}
return response("Follow request sent", 200);
}
case "FollowAccept": {
const followAccept =
await validator.validate<Lysand.FollowAccept>();
console.log(followAccept);
const account = await User.resolve(followAccept.author);
if (!account) {
return errorResponse("Author not found", 400);
}
console.log(account);
const foundRelationship = await getRelationshipToOtherUser(
user,
account,
);
console.log(foundRelationship);
if (!foundRelationship.requested) {
return response(
"There is no follow request to accept",
200,
);
}
await db
.update(Relationships)
.set({
following: true,
requested: false,
})
.where(eq(Relationships.id, foundRelationship.id));
return response("Follow request accepted", 200);
}
case "FollowReject": {
const followReject =
await validator.validate<Lysand.FollowReject>();
const account = await User.resolve(followReject.author);
if (!account) {
return errorResponse("Author not found", 400);
}
const foundRelationship = await getRelationshipToOtherUser(
user,
account,
);
if (!foundRelationship.requested) {
return response(
"There is no follow request to reject",
200,
);
}
await db
.update(Relationships)
.set({
requested: false,
following: false,
})
.where(eq(Relationships.id, foundRelationship.id));
return response("Follow request rejected", 200);
}
default: {
return errorResponse("Object has not been implemented", 400);
}
} }
case "Follow": { } catch (e) {
const follow = body as Lysand.Follow; if (isValidationError(e)) {
return errorResponse(e.message, 400);
const account = await resolveUser(follow.author);
if (!account) {
return errorResponse("Author not found", 400);
}
const foundRelationship = await getRelationshipToOtherUser(
account,
user,
);
// Check if already following
if (foundRelationship.following) {
return response("Already following", 200);
}
await db
.update(Relationships)
.set({
following: !user.isLocked,
requested: user.isLocked,
showingReblogs: true,
notifying: true,
languages: [],
})
.where(eq(Relationships.id, foundRelationship.id));
await db.insert(Notifications).values({
accountId: account.id,
type: user.isLocked ? "follow_request" : "follow",
notifiedId: user.id,
});
if (!user.isLocked) {
// Federate FollowAccept
await sendFollowAccept(account, user);
}
return response("Follow request sent", 200);
}
case "FollowAccept": {
const followAccept = body as Lysand.FollowAccept;
console.log(followAccept);
const account = await resolveUser(followAccept.author);
if (!account) {
return errorResponse("Author not found", 400);
}
console.log(account);
const foundRelationship = await getRelationshipToOtherUser(
user,
account,
);
console.log(foundRelationship);
if (!foundRelationship.requested) {
return response("There is no follow request to accept", 200);
}
await db
.update(Relationships)
.set({
following: true,
requested: false,
})
.where(eq(Relationships.id, foundRelationship.id));
return response("Follow request accepted", 200);
}
case "FollowReject": {
const followReject = body as Lysand.FollowReject;
const account = await resolveUser(followReject.author);
if (!account) {
return errorResponse("Author not found", 400);
}
const foundRelationship = await getRelationshipToOtherUser(
user,
account,
);
if (!foundRelationship.requested) {
return response("There is no follow request to reject", 200);
}
await db
.update(Relationships)
.set({
requested: false,
following: false,
})
.where(eq(Relationships.id, foundRelationship.id));
return response("Follow request rejected", 200);
}
default: {
return errorResponse("Unknown object type", 400);
} }
dualLogger.logError(LogLevel.ERROR, "Inbox", e as Error);
return jsonResponse(
{
error: "Failed to process request",
message: (e as Error).message,
},
500,
);
} }
//return jsonResponse(userToLysand(user));
}); });

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { findFirstUser, userToLysand } from "~database/entities/User"; import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -17,13 +17,11 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute) => { export default apiRoute(async (req, matchedRoute) => {
const uuid = matchedRoute.params.uuid; const uuid = matchedRoute.params.uuid;
const user = await findFirstUser({ const user = await User.fromId(uuid);
where: (user, { eq }) => eq(user.id, uuid),
});
if (!user) { if (!user) {
return errorResponse("User not found", 404); return errorResponse("User not found", 404);
} }
return jsonResponse(userToLysand(user)); return jsonResponse(user.toLysand());
}); });

View file

@ -1,8 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { z } from "zod"; import { eq } from "drizzle-orm";
import { findFirstUser, getAvatarUrl } from "~database/entities/User";
import { lookup } from "mime-types"; import { lookup } from "mime-types";
import { z } from "zod";
import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -44,20 +46,18 @@ export default apiRoute<typeof meta, typeof schema>(
const isUuid = requestedUser.split("@")[0].match(idValidator); const isUuid = requestedUser.split("@")[0].match(idValidator);
const user = await findFirstUser({ const user = await User.fromSql(
where: (user, { eq }) => eq(isUuid ? Users.id : Users.username, requestedUser.split("@")[0]),
eq( );
isUuid ? user.id : user.username,
requestedUser.split("@")[0],
),
});
if (!user) { if (!user) {
return errorResponse("User not found", 404); return errorResponse("User not found", 404);
} }
return jsonResponse({ return jsonResponse({
subject: `acct:${isUuid ? user.id : user.username}@${host}`, subject: `acct:${
isUuid ? user.id : user.getUser().username
}@${host}`,
links: [ links: [
{ {
@ -70,8 +70,8 @@ export default apiRoute<typeof meta, typeof schema>(
}, },
{ {
rel: "avatar", rel: "avatar",
type: lookup(getAvatarUrl(user, config)), type: lookup(user.getAvatarUrl(config)),
href: getAvatarUrl(user, config), href: user.getAvatarUrl(config),
}, },
], ],
}); });

View file

@ -72,7 +72,7 @@ describe("API Tests", () => {
const account = (await response.json()) as APIAccount; const account = (await response.json()) as APIAccount;
expect(account.username).toBe(user.username); expect(account.username).toBe(user.getUser().username);
expect(account.bot).toBe(false); expect(account.bot).toBe(false);
expect(account.locked).toBe(false); expect(account.locked).toBe(false);
expect(account.created_at).toBeDefined(); expect(account.created_at).toBeDefined();
@ -81,7 +81,10 @@ describe("API Tests", () => {
expect(account.statuses_count).toBe(0); expect(account.statuses_count).toBe(0);
expect(account.note).toBe(""); expect(account.note).toBe("");
expect(account.url).toBe( expect(account.url).toBe(
new URL(`/@${user.username}`, config.http.base_url).toString(), new URL(
`/@${user.getUser().username}`,
config.http.base_url,
).toString(),
); );
expect(account.avatar).toBeDefined(); expect(account.avatar).toBeDefined();
expect(account.avatar_static).toBeDefined(); expect(account.avatar_static).toBeDefined();

View file

@ -61,7 +61,7 @@ describe("POST /api/auth/login/", () => {
test("should get a JWT", async () => { test("should get a JWT", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append("email", users[0]?.email ?? ""); formData.append("email", users[0]?.getUser().email ?? "");
formData.append("password", passwords[0]); formData.append("password", passwords[0]);
const response = await sendTestRequest( const response = await sendTestRequest(

View file

@ -1,15 +1,12 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { asc, inArray, like } from "drizzle-orm"; import { asc, inArray, like } from "drizzle-orm";
import type { Status } from "~database/entities/Status"; import type { Status } from "~database/entities/Status";
import { import { createNewLocalUser } from "~database/entities/User";
type User,
type UserWithRelations,
createNewLocalUser,
} from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Notes, Tokens, Users } from "~drizzle/schema"; import { Notes, Tokens, Users } from "~drizzle/schema";
import { server } from "~index"; import { server } from "~index";
import { Note } from "~packages/database-interface/note"; import { Note } from "~packages/database-interface/note";
import type { User } from "~packages/database-interface/user";
/** /**
* This allows us to send a test request to the server even when it isnt running * This allows us to send a test request to the server even when it isnt running
* CURRENTLY NOT WORKING, NEEDS TO BE FIXED * CURRENTLY NOT WORKING, NEEDS TO BE FIXED
@ -30,7 +27,7 @@ export const deleteOldTestUsers = async () => {
}; };
export const getTestUsers = async (count: number) => { export const getTestUsers = async (count: number) => {
const users: UserWithRelations[] = []; const users: User[] = [];
const passwords: string[] = []; const passwords: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {

View file

@ -4,9 +4,10 @@ import { count } from "drizzle-orm";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager"; import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { Meilisearch } from "meilisearch"; import { Meilisearch } from "meilisearch";
import type { Status } from "~database/entities/Status"; import type { Status } from "~database/entities/Status";
import type { User } from "~database/entities/User"; import type { UserType } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Notes, Users } from "~drizzle/schema"; import { Notes, Users } from "~drizzle/schema";
import type { User } from "~packages/database-interface/user";
export const meilisearch = new Meilisearch({ export const meilisearch = new Meilisearch({
host: `${config.meilisearch.host}:${config.meilisearch.port}`, host: `${config.meilisearch.host}:${config.meilisearch.port}`,
@ -71,10 +72,10 @@ export const addUserToMeilisearch = async (user: User) => {
await meilisearch.index(MeiliIndexType.Accounts).addDocuments([ await meilisearch.index(MeiliIndexType.Accounts).addDocuments([
{ {
id: user.id, id: user.id,
username: user.username, username: user.getUser().username,
displayName: user.displayName, displayName: user.getUser().displayName,
note: user.note, note: user.getUser().note,
createdAt: user.createdAt, createdAt: user.getUser().createdAt,
}, },
]); ]);
}; };

View file

@ -4,10 +4,10 @@ import type {
findManyNotifications, findManyNotifications,
} from "~database/entities/Notification"; } from "~database/entities/Notification";
import type { Status, findManyNotes } from "~database/entities/Status"; import type { Status, findManyNotes } from "~database/entities/Status";
import type { User, findManyUsers } from "~database/entities/User"; import type { UserType, findManyUsers } from "~database/entities/User";
import type { db } from "~drizzle/db"; import type { db } from "~drizzle/db";
export async function fetchTimeline<T extends User | Status | Notification>( export async function fetchTimeline<T extends UserType | Status | Notification>(
model: model:
| typeof findManyNotes | typeof findManyNotes
| typeof findManyUsers | typeof findManyUsers