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

View file

@ -1,6 +1,6 @@
import { config } from "config-manager";
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}`;
@ -9,17 +9,17 @@ export const objectToInboxRequest = async (
author: User,
userToSendTo: User,
): 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");
}
if (author.instanceId) {
if (author.isRemote()) {
throw new Error("Author is a remote user");
}
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(author.privateKey ?? "", "base64"),
Buffer.from(author.getUser().privateKey ?? "", "base64"),
"Ed25519",
false,
["sign"],
@ -30,7 +30,7 @@ export const objectToInboxRequest = async (
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();
@ -41,14 +41,14 @@ export const objectToInboxRequest = async (
`(request-target): post ${userInbox.pathname}\n` +
`host: ${userInbox.host}\n` +
`date: ${date.toISOString()}\n` +
`digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)),
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
"base64",
)}\n`,
),
);
const signatureBase64 = btoa(
String.fromCharCode(...new Uint8Array(signature)),
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
"base64",
);
return new Request(userInbox, {
@ -57,9 +57,7 @@ export const objectToInboxRequest = async (
"Content-Type": "application/json",
Date: date.toISOString(),
Origin: new URL(config.http.base_url).host,
Signature: `keyId="${getUserUri(
author,
)}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
Signature: `keyId="${author.getUri()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
},
body: JSON.stringify(object),
});

View file

@ -15,7 +15,7 @@ export const addInstanceIfNotExists = async (url: string) => {
const origin = new URL(url).origin;
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),
});

View file

@ -3,8 +3,8 @@ import { type InferSelectModel, and, eq } from "drizzle-orm";
import type * as Lysand from "lysand-types";
import { db } from "~drizzle/db";
import { Likes, Notifications } from "~drizzle/schema";
import type { StatusWithRelations } from "./Status";
import type { UserWithRelations } from "./User";
import type { Note } from "~packages/database-interface/note";
import type { User } from "~packages/database-interface/user";
export type Like = InferSelectModel<typeof Likes>;
@ -27,24 +27,21 @@ export const likeToLysand = (like: Like): Lysand.Like => {
/**
* Create a like
* @param user User liking the status
* @param status Status being liked
* @param note Status being liked
*/
export const createLike = async (
user: UserWithRelations,
status: StatusWithRelations,
) => {
export const createLike = async (user: User, note: Note) => {
await db.insert(Likes).values({
likedId: status.id,
likedId: note.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
await db.insert(Notifications).values({
accountId: user.id,
type: "favourite",
notifiedId: status.authorId,
noteId: status.id,
notifiedId: note.getAuthor().id,
noteId: note.id,
});
} else {
// TODO: Add database jobs for federating this
@ -54,15 +51,12 @@ export const createLike = async (
/**
* Delete a like
* @param user User deleting their like
* @param status Status being unliked
* @param note Status being unliked
*/
export const deleteLike = async (
user: UserWithRelations,
status: StatusWithRelations,
) => {
export const deleteLike = async (user: User, note: Note) => {
await db
.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
await db
@ -71,12 +65,12 @@ export const deleteLike = async (
and(
eq(Notifications.accountId, user.id),
eq(Notifications.type, "favourite"),
eq(Notifications.notifiedId, status.authorId),
eq(Notifications.noteId, status.id),
eq(Notifications.notifiedId, note.getAuthor().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
// TODO: Federate this
}

View file

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

View file

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

View file

@ -1,8 +1,9 @@
import type { InferSelectModel } from "drizzle-orm";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import type { User } from "~packages/database-interface/user";
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>;

View file

@ -1,3 +1,4 @@
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import { dualLogger } from "@loggers";
import { sanitizeHtml } from "@sanitization";
import { config } from "config-manager";
@ -10,7 +11,6 @@ import {
or,
sql,
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
import linkifyHtml from "linkify-html";
import type * as Lysand from "lysand-types";
import {
@ -24,46 +24,30 @@ import {
maybe,
oneOrMore,
} 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 {
Attachments,
EmojiToNote,
Instances,
NoteToMentions,
Notes,
Notifications,
Users,
} from "~drizzle/schema";
import { type Attachments, Instances, Notes, Users } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
import { User } from "~packages/database-interface/user";
import { LogLevel } from "~packages/log-manager";
import type { Status as APIStatus } from "~types/mastodon/status";
import type { Application } from "./Application";
import { attachmentFromLysand, attachmentToLysand } from "./Attachment";
import {
type EmojiWithInstance,
emojiToLysand,
fetchEmoji,
parseEmojis,
} from "./Emoji";
import { attachmentFromLysand } from "./Attachment";
import { type EmojiWithInstance, fetchEmoji } from "./Emoji";
import { objectToInboxRequest } from "./Federation";
import type { Like } from "./Like";
import {
type User,
type UserType,
type UserWithInstance,
type UserWithRelations,
findManyUsers,
getUserUri,
resolveUser,
resolveWebFinger,
transformOutputToUserWithRelations,
userExtrasTemplate,
userRelations,
} 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>;
@ -362,7 +346,7 @@ export const resolveNote = async (
throw new Error("Invalid object author");
}
const author = await resolveUser(note.author);
const author = await User.resolve(note.author);
if (!author) {
throw new Error("Invalid object author");
@ -415,10 +399,8 @@ export const resolveNote = async (
note.uri,
await Promise.all(
(note.mentions ?? [])
.map((mention) => resolveUser(mention))
.filter(
(mention) => mention !== null,
) as Promise<UserWithRelations>[],
.map((mention) => User.resolve(mention))
.filter((mention) => mention !== null) as Promise<User>[],
),
attachments.map((a) => a.id),
note.replies_to
@ -454,9 +436,7 @@ export const createMentionRegExp = () =>
* @param text The text to parse mentions from.
* @returns An array of users mentioned in the text.
*/
export const parseTextMentions = async (
text: string,
): Promise<UserWithRelations[]> => {
export const parseTextMentions = async (text: string): Promise<User[]> => {
const mentionedPeople = [...text.matchAll(createMentionRegExp())] ?? [];
if (mentionedPeople.length === 0) return [];
@ -497,13 +477,12 @@ export const parseTextMentions = async (
const finalList =
foundUsers.length > 0
? await findManyUsers({
where: (user, { inArray }) =>
inArray(
user.id,
foundUsers.map((u) => u.id),
),
})
? await User.manyFromSql(
inArray(
Users.id,
foundUsers.map((u) => u.id),
),
)
: [];
// Attempt to resolve mentions that were not found
@ -521,49 +500,47 @@ export const parseTextMentions = async (
return finalList;
};
export const replaceTextMentions = async (
text: string,
mentions: UserWithRelations[],
) => {
export const replaceTextMentions = async (text: string, mentions: User[]) => {
let finalText = text;
for (const mention of mentions) {
const user = mention.getUser();
// Replace @username and @username@domain
if (mention.instance) {
if (user.instance) {
finalText = finalText.replace(
createRegExp(
exactly(`@${mention.username}@${mention.instance.baseUrl}`),
exactly(`@${user.username}@${user.instance.baseUrl}`),
[global],
),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
mention,
)}">@${mention.username}@${mention.instance.baseUrl}</a>`,
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
user.username
}@${user.instance.baseUrl}</a>`,
);
} else {
finalText = finalText.replace(
// Only replace @username if it doesn't have another @ right after
createRegExp(
exactly(`@${mention.username}`)
exactly(`@${user.username}`)
.notBefore(anyOf(letter, digit, charIn("@")))
.notAfter(anyOf(letter, digit, charIn("@"))),
[global],
),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
mention,
)}">@${mention.username}</a>`,
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
user.username
}</a>`,
);
finalText = finalText.replace(
createRegExp(
exactly(
`@${mention.username}@${
`@${user.username}@${
new URL(config.http.base_url).host
}`,
),
[global],
),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
mention,
)}">@${mention.username}</a>`,
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
user.username
}</a>`,
);
}
}
@ -573,7 +550,7 @@ export const replaceTextMentions = async (
export const contentToHtml = async (
content: Lysand.ContentFormat,
mentions: UserWithRelations[] = [],
mentions: User[] = [],
): Promise<string> => {
let htmlContent: string;
@ -663,152 +640,17 @@ export const federateNote = async (note: Note) => {
dualLogger.log(
LogLevel.ERROR,
"Federation.Status",
`Failed to federate status ${note.getStatus().id} to ${
user.uri
}`,
`Failed to federate status ${
note.getStatus().id
} to ${user.getUri()}`,
);
}
}
};
export const editStatus = async (
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) => {
export const isFavouritedBy = async (status: Status, user: UserType) => {
return !!(await db.query.Likes.findFirst({
where: (like, { and, eq }) =>
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 { addUserToMeilisearch } from "@meilisearch";
import { type Config, config } from "config-manager";
import { type InferSelectModel, and, eq, sql } from "drizzle-orm";
import { htmlToText } from "html-to-text";
import { config } from "config-manager";
import { type InferSelectModel, and, eq, inArray, sql } from "drizzle-orm";
import type * as Lysand from "lysand-types";
import { db } from "~drizzle/db";
import {
Applications,
EmojiToUser,
Instances,
Notifications,
Relationships,
Tokens,
Users,
} from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
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 EmojiWithInstance,
emojiToAPI,
emojiToLysand,
fetchEmoji,
} from "./Emoji";
import type { EmojiWithInstance } from "./Emoji";
import { objectToInboxRequest } from "./Federation";
import { addInstanceIfNotExists } from "./Instance";
import { createNewRelationship } from "./Relationship";
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;
};
export type UserWithRelations = User & {
export type UserWithRelations = UserType & {
instance: InferSelectModel<typeof Instances> | null;
emojis: EmojiWithInstance[];
followerCount: number;
@ -105,35 +94,11 @@ export const userExtrasTemplate = (name: string) => ({
});
export interface AuthData {
user: UserWithRelations | null;
user: User | null;
token: string;
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> => {
// Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
@ -152,14 +117,14 @@ export const followRequestUser = async (
notify = false,
languages: string[] = [],
): Promise<InferSelectModel<typeof Relationships>> => {
const isRemote = followee.instanceId !== null;
const isRemote = followee.isRemote();
const updatedRelationship = (
await db
.update(Relationships)
.set({
following: isRemote ? false : !followee.isLocked,
requested: isRemote ? true : followee.isLocked,
following: isRemote ? false : !followee.getUser().isLocked,
requested: isRemote ? true : followee.getUser().isLocked,
showingReblogs: reblogs,
notifying: notify,
languages: languages,
@ -190,7 +155,9 @@ export const followRequestUser = async (
dualLogger.log(
LogLevel.ERROR,
"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 (
@ -207,7 +174,7 @@ export const followRequestUser = async (
} else {
await db.insert(Notifications).values({
accountId: follower.id,
type: followee.isLocked ? "follow_request" : "follow",
type: followee.getUser().isLocked ? "follow_request" : "follow",
notifiedId: followee.id,
});
}
@ -236,7 +203,9 @@ export const sendFollowAccept = async (follower: User, followee: User) => {
dualLogger.log(
LogLevel.ERROR,
"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(
LogLevel.ERROR,
"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 = (
user: Omit<User, "endpoints"> & {
user: Omit<UserType, "endpoints"> & {
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
@ -343,146 +314,6 @@ export const findFirstUser = async (
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.
* @param identifier Either a UUID or a username
@ -490,7 +321,7 @@ export const getUserUri = (user: User) => {
export const resolveWebFinger = async (
identifier: string,
host: string,
): Promise<UserWithRelations | null> => {
): Promise<User | null> => {
// Check if user not already in database
const foundUser = await db
.select()
@ -499,12 +330,7 @@ export const resolveWebFinger = async (
.where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host)))
.limit(1);
if (foundUser[0])
return (
(await findFirstUser({
where: (user, { eq }) => eq(user.id, foundUser[0].Users.id),
})) || null
);
if (foundUser[0]) return await User.fromId(foundUser[0].Users.id);
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;
admin?: boolean;
skipPasswordHash?: boolean;
}): Promise<UserWithRelations | null> => {
}): Promise<User | null> => {
const keys = await generateUserKeys();
const newUser = (
@ -606,9 +432,7 @@ export const createNewLocalUser = async (data: {
.returning()
)[0];
const finalUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, newUser.id),
});
const finalUser = await User.fromId(newUser.id);
if (!finalUser) return null;
@ -623,11 +447,8 @@ export const createNewLocalUser = async (data: {
*/
export const parseMentionsUris = async (
mentions: string[],
): Promise<UserWithRelations[]> => {
return await findManyUsers({
where: (user, { inArray }) => inArray(user.uri, mentions),
with: userRelations,
});
): Promise<User[]> => {
return await User.manyFromSql(inArray(Users.uri, mentions));
};
/**
@ -637,16 +458,14 @@ export const parseMentionsUris = async (
*/
export const retrieveUserFromToken = async (
access_token: string,
): Promise<UserWithRelations | null> => {
): Promise<User | null> => {
if (!access_token) return null;
const token = await retrieveToken(access_token);
if (!token || !token.userId) return null;
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, token.userId ?? ""),
});
const user = await User.fromId(token.userId);
return user;
};
@ -654,7 +473,7 @@ export const retrieveUserFromToken = async (
export const retrieveUserAndApplicationFromToken = async (
access_token: string,
): Promise<{
user: UserWithRelations | null;
user: User | null;
application: 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 };
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, output.token.userId ?? ""),
});
const user = await User.fromId(output.token.userId);
return { user, application: output.application ?? null };
};
@ -698,7 +515,7 @@ export const retrieveToken = async (
* @returns The relationship to the other user.
*/
export const getRelationshipToOtherUser = async (
user: UserWithRelations,
user: User,
other: User,
): Promise<InferSelectModel<typeof Relationships>> => {
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 = (
follower: User,
followee: User,
): Lysand.Follow => {
if (follower.instanceId) {
if (follower.isRemote()) {
throw new Error("Follower must be a local user");
}
if (!followee.instanceId) {
if (!followee.isRemote()) {
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");
}
@ -916,8 +583,8 @@ export const followRequestToLysand = (
return {
type: "Follow",
id: id,
author: getUserUri(follower),
followee: followee.uri,
author: follower.getUri(),
followee: followee.getUri(),
created_at: new Date().toISOString(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
};
@ -927,15 +594,15 @@ export const followAcceptToLysand = (
follower: User,
followee: User,
): Lysand.FollowAccept => {
if (!follower.instanceId) {
if (!follower.isRemote()) {
throw new Error("Follower must be a remote user");
}
if (followee.instanceId) {
if (followee.isRemote()) {
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");
}
@ -944,9 +611,9 @@ export const followAcceptToLysand = (
return {
type: "FollowAccept",
id: id,
author: getUserUri(followee),
author: followee.getUri(),
created_at: new Date().toISOString(),
follower: follower.uri,
follower: follower.getUri(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
};
};

View file

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

View file

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

View file

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

View file

@ -2,11 +2,8 @@ import { join } from "node:path";
import { redirect } from "@response";
import type { BunFile } from "bun";
import { config } from "config-manager";
import {
type UserWithRelations,
retrieveUserFromToken,
userToAPI,
} from "~database/entities/User";
import { retrieveUserFromToken } from "~database/entities/User";
import type { User } from "~packages/database-interface/user";
import type { LogManager, MultiLogManager } from "~packages/log-manager";
import { languages } from "./glitch-languages";
@ -104,7 +101,7 @@ const handleSignInRequest = async (
req: Request,
path: string,
url: URL,
user: UserWithRelations | null,
user: User | null,
accessToken: string,
) => {
if (req.method === "POST") {
@ -181,7 +178,7 @@ const returnFile = async (file: BunFile, content?: string) => {
const handleDefaultRequest = async (
req: Request,
path: string,
user: UserWithRelations | null,
user: User | null,
accessToken: string,
) => {
const file = Bun.file(join(config.frontend.glitch.assets, path));
@ -204,7 +201,7 @@ const handleDefaultRequest = async (
const brandingTransforms = async (
fileContents: string,
accessToken: string,
user: UserWithRelations | null,
user: User | null,
) => {
let newFileContents = fileContents;
for (const server of config.frontend.glitch.server) {
@ -239,7 +236,7 @@ const brandingTransforms = async (
const htmlTransforms = async (
fileContents: string,
accessToken: string,
user: UserWithRelations | null,
user: User | null,
) => {
// Find script id="initial-state" and replace its contents with custom json
const rewriter = new HTMLRewriter()
@ -290,7 +287,7 @@ const htmlTransforms = async (
},
accounts: user
? {
[user.id]: userToAPI(user, true),
[user.id]: user.toAPI(true),
}
: {},
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 { fromZodError } from "zod-validation-error";
import type { Application } from "~database/entities/Application";
import {
type AuthData,
type UserWithRelations,
getFromRequest,
} from "~database/entities/User";
import { type AuthData, getFromRequest } from "~database/entities/User";
import type { User } from "~packages/database-interface/user";
type MaybePromise<T> = T | Promise<T>;
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
@ -24,11 +21,11 @@ export type RouteHandler<
matchedRoute: MatchedRoute,
extraData: {
auth: {
// If the route doesn't require authentication, set the type to UserWithRelations | null
// Otherwise set to UserWithRelations
// If the route doesn't require authentication, set the type to User | null
// Otherwise set to User
user: RouteMeta["auth"]["required"] extends true
? UserWithRelations
: UserWithRelations | null;
? User
: User | null;
token: RouteMeta["auth"]["required"] extends true
? string
: string | null;

View file

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

View file

@ -2,10 +2,11 @@ import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api";
import { z } from "zod";
import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
import { Tokens, Users } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import { User } from "~packages/database-interface/user";
import { eq } from "drizzle-orm";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -44,13 +45,14 @@ export default apiRoute<typeof meta, typeof schema>(
302,
);
const user = await findFirstUser({
where: (user, { eq }) => eq(user.email, email),
});
const user = await User.fromSql(eq(Users.email, email));
if (
!user ||
!(await Bun.password.verify(password, user.password || ""))
!(await Bun.password.verify(
password,
user.getUser().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 { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
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 { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
followRequestUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -46,9 +46,7 @@ export default apiRoute<typeof meta, typeof schema>(
const { languages, notify, reblogs } = extraData.parsedRequest;
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
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 { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import {
type UserWithRelations,
findFirstUser,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Users } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -42,32 +39,23 @@ export default apiRoute<typeof meta, typeof schema>(
// TODO: Add pinned
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
if (!otherUser) return errorResponse("User not found", 404);
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-ignore
where: (follower, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(follower.id, max_id) : undefined,
since_id ? gte(follower.id, since_id) : undefined,
min_id ? gt(follower.id, min_id) : undefined,
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,
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
),
limit,
req.url,
);
return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))),
await Promise.all(objects.map((object) => object.toAPI())),
200,
{
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 { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import {
type UserWithRelations,
findFirstUser,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Users } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -42,32 +39,23 @@ export default apiRoute<typeof meta, typeof schema>(
// TODO: Add pinned
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
if (!otherUser) return errorResponse("User not found", 404);
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-ignore
where: (following, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(following.id, max_id) : undefined,
since_id ? gte(following.id, since_id) : undefined,
min_id ? gt(following.id, min_id) : undefined,
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,
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
),
limit,
req.url,
);
return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))),
await Promise.all(objects.map((object) => object.toAPI())),
200,
{
Link: link,

View file

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

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { findFirstUser, userToAPI } from "~database/entities/User";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -26,11 +26,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const foundUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
}).catch(() => null);
const foundUser = await User.fromId(id);
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 { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -49,9 +47,7 @@ export default apiRoute<typeof meta, typeof schema>(
const { notifications, duration } = extraData.parsedRequest;
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const user = await User.fromId(id);
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 { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -43,9 +41,7 @@ export default apiRoute<typeof meta, typeof schema>(
const { comment } = extraData.parsedRequest;
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
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 { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
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 { and, eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
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));
if (otherUser.instanceId === null) {
if (otherUser.isLocal()) {
// Also remove from followers list
await db
.update(Relationships)

View file

@ -2,9 +2,9 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { findFirstUser } from "~database/entities/User";
import { Notes } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -52,9 +52,7 @@ export default apiRoute<typeof meta, typeof schema>(
pinned,
} = extraData.parsedRequest;
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const user = await User.fromId(id);
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 { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -32,9 +30,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
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 { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
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 { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401);
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const user = await User.fromId(id);
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 { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!self) return errorResponse("Unauthorized", 401);
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
const otherUser = await User.fromId(id);
if (!otherUser) return errorResponse("User not found", 404);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { sql } from "drizzle-orm";
import { eq, like, not, or, sql } from "drizzle-orm";
import {
anyOf,
charIn,
@ -13,13 +13,9 @@ import {
oneOrMore,
} from "magic-regexp";
import { z } from "zod";
import {
type UserWithRelations,
findManyUsers,
resolveWebFinger,
userToAPI,
} from "~database/entities/User";
import { resolveWebFinger } from "~database/entities/User";
import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -78,7 +74,7 @@ export default apiRoute<typeof meta, typeof schema>(
// Remove any leading @
const [username, host] = q.replace(/^@/, "").split("@");
const accounts: UserWithRelations[] = [];
const accounts: User[] = [];
if (resolve && username && host) {
const resolvedUser = await resolveWebFinger(username, host);
@ -88,21 +84,22 @@ export default apiRoute<typeof meta, typeof schema>(
}
} else {
accounts.push(
...(await findManyUsers({
where: (account, { or, like }) =>
or(
like(account.displayName, `%${q}%`),
like(account.username, `%${q}%`),
following
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${account.id} AND "Relationships"."following" = true)`
: undefined,
),
offset,
...(await User.manyFromSql(
or(
like(Users.displayName, `%${q}%`),
like(Users.username, `%${q}%`),
following && self
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)`
: undefined,
self ? not(eq(Users.id, self.id)) : undefined,
),
undefined,
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 { parseEmojis } from "~database/entities/Emoji";
import { contentToHtml } from "~database/entities/Status";
import { findFirstUser, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db";
import { EmojiToUser, Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["PATCH"],
@ -51,11 +51,12 @@ export const schema = z.object({
export default apiRoute<typeof meta, typeof schema>(
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 self = user.getUser();
const {
display_name,
@ -231,12 +232,9 @@ export default apiRoute<typeof meta, typeof schema>(
.execute();
}
const output = await findFirstUser({
where: (user, { eq }) => eq(user.id, self.id),
});
const output = await User.fromId(self.id);
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 { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -22,7 +21,5 @@ export default apiRoute((req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401);
return jsonResponse({
...userToAPI(user, true),
});
return jsonResponse(user.toAPI(true));
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
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));
return jsonResponse(
userToAPI({
...self,
avatar: "",
}),
);
return jsonResponse({
...(await User.fromId(self.id))?.toAPI(),
avatar: "",
});
});

View file

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

View file

@ -30,27 +30,27 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
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)
if (!status?.isViewableByUser(user))
if (!note?.isViewableByUser(user))
return errorResponse("Record not found", 404);
const existingLike = await db.query.Likes.findFirst({
where: (like, { and, eq }) =>
and(
eq(like.likedId, status.getStatus().id),
eq(like.likedId, note.getStatus().id),
eq(like.likerId, user.id),
),
});
if (!existingLike) {
await createLike(user, status.getStatus());
await createLike(user, note);
}
return jsonResponse({
...(await status.toAPI(user)),
...(await note.toAPI(user)),
favourited: true,
favourites_count: status.getStatus().likeCount + 1,
favourites_count: note.getStatus().likeCount + 1,
} as APIStatus);
});

View file

@ -73,7 +73,7 @@ describe(meta.route, () => {
expect(objects.length).toBe(1);
for (const [index, status] of objects.entries()) {
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 { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import {
type UserWithRelations,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Users } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -48,28 +45,19 @@ export default apiRoute<typeof meta, typeof schema>(
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-ignore
where: (liker, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(liker.id, max_id) : undefined,
since_id ? gte(liker.id, since_id) : undefined,
min_id ? gt(liker.id, min_id) : undefined,
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,
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`,
),
limit,
req.url,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
objects.map((user) => user.toAPI()),
200,
{
Link: link,

View file

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

View file

@ -73,7 +73,7 @@ describe(meta.route, () => {
expect(objects.length).toBe(1);
for (const [index, status] of objects.entries()) {
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 { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import {
type UserWithRelations,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Users } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -48,28 +45,19 @@ export default apiRoute<typeof meta, typeof schema>(
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-ignore
where: (reblogger, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(reblogger.id, max_id) : undefined,
since_id ? gte(reblogger.id, since_id) : undefined,
min_id ? gt(reblogger.id, min_id) : undefined,
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,
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${status.id} AND "Notes"."authorId" = ${Users.id})`,
),
limit,
req.url,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
objects.map((user) => user.toAPI()),
200,
{
Link: link,

View file

@ -29,17 +29,17 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
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)
if (!foundStatus?.isViewableByUser(user))
if (!note?.isViewableByUser(user))
return errorResponse("Record not found", 404);
await deleteLike(user, foundStatus.getStatus());
await deleteLike(user, note);
return jsonResponse({
...(await foundStatus.toAPI(user)),
...(await note.toAPI(user)),
favourited: false,
favourites_count: foundStatus.getStatus().likeCount - 1,
favourites_count: note.getStatus().likeCount - 1,
} as APIStatus);
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +1,19 @@
import { apiRoute, applyConfig } from "@api";
import { dualLogger } from "@loggers";
import { errorResponse, response } from "@response";
import { errorResponse, jsonResponse, response } from "@response";
import { eq } from "drizzle-orm";
import type * as Lysand from "lysand-types";
import { isValidationError } from "zod-validation-error";
import { resolveNote } from "~database/entities/Status";
import {
findFirstUser,
getRelationshipToOtherUser,
resolveUser,
sendFollowAccept,
} from "~database/entities/User";
import { db } from "~drizzle/db";
import { Notifications, Relationships } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
import { LogLevel } from "~packages/log-manager";
import { EntityValidator, SignatureValidator } from "~packages/lysand-utils";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -29,227 +30,215 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute, extraData) => {
const uuid = matchedRoute.params.uuid;
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, uuid),
});
const user = await User.fromId(uuid);
if (!user) {
return errorResponse("User not found", 404);
}
// Process incoming request
const body = extraData.parsedRequest as Lysand.Entity;
// Verify request signature
// TODO: Check if instance is defederated
// TODO: Reverse DNS lookup with Origin header
// biome-ignore lint/correctness/noConstantCondition: Temporary
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");
const origin = req.headers.get("Origin");
const date = req.headers.get("Date");
if (!signatureHeader) {
if (!Signature) {
return errorResponse("Missing Signature header", 400);
}
if (!origin) {
return errorResponse("Missing Origin header", 400);
}
if (!date) {
if (!DateHeader) {
return errorResponse("Missing Date header", 400);
}
const signature = signatureHeader
.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]
const keyId = Signature.split("keyId=")[1]
.split(",")[0]
.replace(/"/g, "");
console.log(`Resolving keyId ${keyId}`);
const sender = await resolveUser(keyId);
const sender = await User.resolve(keyId);
if (!sender) {
return errorResponse("Invalid keyId", 400);
return errorResponse("Could not resolve keyId", 400);
}
const public_key = await crypto.subtle.importKey(
"spki",
Buffer.from(sender.publicKey, "base64"),
"Ed25519",
false,
["verify"],
const validator = await SignatureValidator.fromStringKey(
sender.getUser().publicKey,
Signature,
DateHeader,
req.method,
new URL(req.url),
await req.text(),
);
const expectedSignedString =
`(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),
);
const isValid = await validator.validate();
if (!isValid) {
return errorResponse("Invalid signature", 400);
}
}
// Add sent data to database
switch (body.type) {
case "Note": {
const note = body as Lysand.Note;
const validator = new EntityValidator(
extraData.parsedRequest as Lysand.Entity,
);
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) {
return errorResponse("Author not found", 400);
}
const account = await User.resolve(note.author);
const newStatus = await resolveNote(undefined, note).catch((e) => {
dualLogger.logError(
LogLevel.ERROR,
"Inbox.NoteResolve",
e as Error,
if (!account) {
return errorResponse("Author not found", 404);
}
const newStatus = await resolveNote(undefined, note).catch(
(e) => {
dualLogger.logError(
LogLevel.ERROR,
"Inbox.NoteResolve",
e as Error,
);
return null;
},
);
return null;
});
if (!newStatus) {
return errorResponse("Failed to add status", 500);
if (!newStatus) {
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": {
const follow = body as Lysand.Follow;
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);
} catch (e) {
if (isValidationError(e)) {
return errorResponse(e.message, 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 { errorResponse, jsonResponse } from "@response";
import { findFirstUser, userToLysand } from "~database/entities/User";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -17,13 +17,11 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute) => {
const uuid = matchedRoute.params.uuid;
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, uuid),
});
const user = await User.fromId(uuid);
if (!user) {
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 { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { findFirstUser, getAvatarUrl } from "~database/entities/User";
import { eq } from "drizzle-orm";
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({
allowedMethods: ["GET"],
@ -44,20 +46,18 @@ export default apiRoute<typeof meta, typeof schema>(
const isUuid = requestedUser.split("@")[0].match(idValidator);
const user = await findFirstUser({
where: (user, { eq }) =>
eq(
isUuid ? user.id : user.username,
requestedUser.split("@")[0],
),
});
const user = await User.fromSql(
eq(isUuid ? Users.id : Users.username, requestedUser.split("@")[0]),
);
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse({
subject: `acct:${isUuid ? user.id : user.username}@${host}`,
subject: `acct:${
isUuid ? user.id : user.getUser().username
}@${host}`,
links: [
{
@ -70,8 +70,8 @@ export default apiRoute<typeof meta, typeof schema>(
},
{
rel: "avatar",
type: lookup(getAvatarUrl(user, config)),
href: getAvatarUrl(user, config),
type: lookup(user.getAvatarUrl(config)),
href: user.getAvatarUrl(config),
},
],
});

View file

@ -72,7 +72,7 @@ describe("API Tests", () => {
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.locked).toBe(false);
expect(account.created_at).toBeDefined();
@ -81,7 +81,10 @@ describe("API Tests", () => {
expect(account.statuses_count).toBe(0);
expect(account.note).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_static).toBeDefined();

View file

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

View file

@ -1,15 +1,12 @@
import { randomBytes } from "node:crypto";
import { asc, inArray, like } from "drizzle-orm";
import type { Status } from "~database/entities/Status";
import {
type User,
type UserWithRelations,
createNewLocalUser,
} from "~database/entities/User";
import { createNewLocalUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Notes, Tokens, Users } from "~drizzle/schema";
import { server } from "~index";
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
* CURRENTLY NOT WORKING, NEEDS TO BE FIXED
@ -30,7 +27,7 @@ export const deleteOldTestUsers = async () => {
};
export const getTestUsers = async (count: number) => {
const users: UserWithRelations[] = [];
const users: User[] = [];
const passwords: string[] = [];
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 { Meilisearch } from "meilisearch";
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 { Notes, Users } from "~drizzle/schema";
import type { User } from "~packages/database-interface/user";
export const meilisearch = new Meilisearch({
host: `${config.meilisearch.host}:${config.meilisearch.port}`,
@ -71,10 +72,10 @@ export const addUserToMeilisearch = async (user: User) => {
await meilisearch.index(MeiliIndexType.Accounts).addDocuments([
{
id: user.id,
username: user.username,
displayName: user.displayName,
note: user.note,
createdAt: user.createdAt,
username: user.getUser().username,
displayName: user.getUser().displayName,
note: user.getUser().note,
createdAt: user.getUser().createdAt,
},
]);
};

View file

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