Begin moving project to use Drizzle instead of prisma

This commit is contained in:
Jesse Wierzbinski 2024-04-11 01:39:07 -10:00
parent b107bed935
commit f7abe06a60
No known key found for this signature in database
49 changed files with 7602 additions and 1267 deletions

View file

@ -1,10 +1,9 @@
import type { Application } from "@prisma/client";
import { client } from "~database/datasource";
import type { InferSelectModel } from "drizzle-orm";
import { db } from "~drizzle/db";
import type { application } from "~drizzle/schema";
import type { APIApplication } from "~types/entities/application";
/**
* Represents an application that can authenticate with the API.
*/
export type Application = InferSelectModel<typeof application>;
/**
* Retrieves the application associated with the given access token.
@ -14,16 +13,14 @@ import type { APIApplication } from "~types/entities/application";
export const getFromToken = async (
token: string,
): Promise<Application | null> => {
const dbToken = await client.token.findFirst({
where: {
access_token: token,
},
include: {
const result = await db.query.token.findFirst({
where: (tokens, { eq }) => eq(tokens.accessToken, token),
with: {
application: true,
},
});
return dbToken?.application || null;
return result?.application || null;
};
/**
@ -34,6 +31,6 @@ export const applicationToAPI = (app: Application): APIApplication => {
return {
name: app.name,
website: app.website,
vapid_key: app.vapid_key,
vapid_key: app.vapidKey,
};
};

View file

@ -1,21 +1,24 @@
import type { Attachment } from "@prisma/client";
import type { Config } from "config-manager";
import { MediaBackendType } from "media-manager";
import type { APIAsyncAttachment } from "~types/entities/async_attachment";
import type { APIAttachment } from "~types/entities/attachment";
import type * as Lysand from "lysand-types";
import { client } from "~database/datasource";
import { db } from "~drizzle/db";
import { attachment } from "~drizzle/schema";
import type { InferSelectModel } from "drizzle-orm";
export type Attachment = InferSelectModel<typeof attachment>;
export const attachmentToAPI = (
attachment: Attachment,
): APIAsyncAttachment | APIAttachment => {
let type = "unknown";
if (attachment.mime_type.startsWith("image/")) {
if (attachment.mimeType.startsWith("image/")) {
type = "image";
} else if (attachment.mime_type.startsWith("video/")) {
} else if (attachment.mimeType.startsWith("video/")) {
type = "video";
} else if (attachment.mime_type.startsWith("audio/")) {
} else if (attachment.mimeType.startsWith("audio/")) {
type = "audio";
}
@ -23,8 +26,8 @@ export const attachmentToAPI = (
id: attachment.id,
type: type as "image" | "video" | "audio" | "unknown",
url: attachment.url,
remote_url: attachment.remote_url,
preview_url: attachment.thumbnail_url,
remote_url: attachment.remoteUrl,
preview_url: attachment.thumbnailUrl,
text_url: null,
meta: {
width: attachment.width || undefined,
@ -63,7 +66,7 @@ export const attachmentToLysand = (
attachment: Attachment,
): Lysand.ContentFormat => {
return {
[attachment.mime_type]: {
[attachment.mimeType]: {
content: attachment.url,
blurhash: attachment.blurhash ?? undefined,
description: attachment.description ?? undefined,
@ -82,13 +85,15 @@ export const attachmentToLysand = (
};
export const attachmentFromLysand = async (
attachment: Lysand.ContentFormat,
): Promise<Attachment> => {
const key = Object.keys(attachment)[0];
const value = attachment[key];
attachmentToConvert: Lysand.ContentFormat,
): Promise<InferSelectModel<typeof attachment>> => {
const key = Object.keys(attachmentToConvert)[0];
const value = attachmentToConvert[key];
return await client.attachment.create({
data: {
const result = await db
.insert(attachment)
.values({
mimeType: key,
url: value.content,
description: value.description || undefined,
duration: value.duration || undefined,
@ -97,10 +102,11 @@ export const attachmentFromLysand = async (
size: value.size || undefined,
width: value.width || undefined,
sha256: value.hash?.sha256 || undefined,
mime_type: key,
blurhash: value.blurhash || undefined,
},
});
})
.returning();
return result[0];
};
export const getUrl = (name: string, config: Config) => {

View file

@ -1,33 +1,36 @@
import type { Emoji } from "@prisma/client";
import { client } from "~database/datasource";
import type { APIEmoji } from "~types/entities/emoji";
import type * as Lysand from "lysand-types";
import { addInstanceIfNotExists } from "./Instance";
import { db } from "~drizzle/db";
import { emoji, instance } from "~drizzle/schema";
import { and, eq, type InferSelectModel } from "drizzle-orm";
/**
* Represents an emoji entity in the database.
*/
export type EmojiWithInstance = InferSelectModel<typeof emoji> & {
instance: InferSelectModel<typeof instance> | null;
};
/**
* Used for parsing emojis from local text
* @param text The text to parse
* @returns An array of emojis
*/
export const parseEmojis = async (text: string): Promise<Emoji[]> => {
export const parseEmojis = async (text: string) => {
const regex = /:[a-zA-Z0-9_]+:/g;
const matches = text.match(regex);
if (!matches) return [];
return await client.emoji.findMany({
where: {
shortcode: {
in: matches.map((match) => match.replace(/:/g, "")),
},
instanceId: null,
},
include: {
const emojis = await db.query.emoji.findMany({
where: (emoji, { eq, or }) =>
or(
...matches
.map((match) => match.replace(/:/g, ""))
.map((match) => eq(emoji.shortcode, match)),
),
with: {
instance: true,
},
});
return emojis;
};
/**
@ -36,62 +39,72 @@ export const parseEmojis = async (text: string): Promise<Emoji[]> => {
* @param host Host to fetch the emoji from if remote
* @returns The emoji
*/
export const fetchEmoji = async (emoji: Lysand.Emoji, host?: string) => {
const existingEmoji = await client.emoji.findFirst({
where: {
shortcode: emoji.name,
instance: host
? {
base_url: host,
}
: null,
},
});
export const fetchEmoji = async (
emojiToFetch: Lysand.Emoji,
host?: string,
): Promise<EmojiWithInstance> => {
const existingEmoji = await db
.select()
.from(emoji)
.innerJoin(instance, eq(emoji.instanceId, instance.id))
.where(
and(
eq(emoji.shortcode, emojiToFetch.name),
host ? eq(instance.baseUrl, host) : undefined,
),
)
.limit(1);
if (existingEmoji) return existingEmoji;
if (existingEmoji[0])
return {
...existingEmoji[0].Emoji,
instance: existingEmoji[0].Instance,
};
const instance = host ? await addInstanceIfNotExists(host) : null;
const foundInstance = host ? await addInstanceIfNotExists(host) : null;
return await client.emoji.create({
data: {
shortcode: emoji.name,
url: Object.entries(emoji.url)[0][1].content,
alt:
emoji.alt ||
Object.entries(emoji.url)[0][1].description ||
undefined,
content_type: Object.keys(emoji.url)[0],
visible_in_picker: true,
instance: host
? {
connect: {
id: instance?.id,
},
}
: undefined,
},
});
const result = (
await db
.insert(emoji)
.values({
shortcode: emojiToFetch.name,
url: Object.entries(emojiToFetch.url)[0][1].content,
alt:
emojiToFetch.alt ||
Object.entries(emojiToFetch.url)[0][1].description ||
undefined,
contentType: Object.keys(emojiToFetch.url)[0],
visibleInPicker: true,
instanceId: foundInstance?.id,
})
.returning()
)[0];
return {
...result,
instance: foundInstance,
};
};
/**
* Converts the emoji to an APIEmoji object.
* @returns The APIEmoji object.
*/
export const emojiToAPI = (emoji: Emoji): APIEmoji => {
export const emojiToAPI = (emoji: EmojiWithInstance): APIEmoji => {
return {
shortcode: emoji.shortcode,
static_url: emoji.url, // TODO: Add static version
url: emoji.url,
visible_in_picker: emoji.visible_in_picker,
visible_in_picker: emoji.visibleInPicker,
category: undefined,
};
};
export const emojiToLysand = (emoji: Emoji): Lysand.Emoji => {
export const emojiToLysand = (emoji: EmojiWithInstance): Lysand.Emoji => {
return {
name: emoji.shortcode,
url: {
[emoji.content_type]: {
[emoji.contentType]: {
content: emoji.url,
description: emoji.alt || undefined,
},

View file

@ -1,14 +1,13 @@
import type { User } from "@prisma/client";
import type * as Lysand from "lysand-types";
import { config } from "config-manager";
import { getUserUri } from "./User";
import { getUserUri, type User } from "./User";
export const objectToInboxRequest = async (
object: Lysand.Entity,
author: User,
userToSendTo: User,
): Promise<Request> => {
if (!userToSendTo.instanceId || !userToSendTo.endpoints.inbox) {
if (!userToSendTo.instanceId || !userToSendTo.endpoints?.inbox) {
throw new Error("UserToSendTo has no inbox or is a local user");
}

View file

@ -1,6 +1,6 @@
import type { Instance } from "@prisma/client";
import { client } from "~database/datasource";
import { db } from "~drizzle/db";
import type * as Lysand from "lysand-types";
import { instance } from "~drizzle/schema";
/**
* Represents an instance in the database.
@ -11,16 +11,12 @@ import type * as Lysand from "lysand-types";
* @param url
* @returns Either the database instance if it already exists, or a newly created instance.
*/
export const addInstanceIfNotExists = async (
url: string,
): Promise<Instance> => {
export const addInstanceIfNotExists = async (url: string) => {
const origin = new URL(url).origin;
const host = new URL(url).host;
const found = await client.instance.findFirst({
where: {
base_url: host,
},
const found = await db.query.instance.findFirst({
where: (instance, { eq }) => eq(instance.baseUrl, host),
});
if (found) return found;
@ -40,12 +36,15 @@ export const addInstanceIfNotExists = async (
throw new Error("Invalid instance metadata (missing name or version)");
}
return await client.instance.create({
data: {
base_url: host,
name: metadata.name,
version: metadata.version,
logo: metadata.logo,
},
});
return (
await db
.insert(instance)
.values({
baseUrl: host,
name: metadata.name,
version: metadata.version,
logo: metadata.logo,
})
.returning()
)[0];
};

View file

@ -1,9 +1,12 @@
import type { Like } from "@prisma/client";
import { config } from "config-manager";
import { client } from "~database/datasource";
import type { StatusWithRelations } from "./Status";
import type { UserWithRelations } from "./User";
import type * as Lysand from "lysand-types";
import { and, eq, type InferSelectModel } from "drizzle-orm";
import { notification, like } from "~drizzle/schema";
import { db } from "~drizzle/db";
export type Like = InferSelectModel<typeof like>;
/**
* Represents a Like entity in the database.
@ -33,22 +36,18 @@ export const createLike = async (
user: UserWithRelations,
status: StatusWithRelations,
) => {
await client.like.create({
data: {
likedId: status.id,
likerId: user.id,
},
await db.insert(like).values({
likedId: status.id,
likerId: user.id,
});
if (status.author.instanceId === user.instanceId) {
// Notify the user that their post has been favourited
await client.notification.create({
data: {
accountId: user.id,
type: "favourite",
notifiedId: status.authorId,
statusId: status.id,
},
await db.insert(notification).values({
accountId: user.id,
type: "favourite",
notifiedId: status.authorId,
statusId: status.id,
});
} else {
// TODO: Add database jobs for federating this
@ -64,22 +63,21 @@ export const deleteLike = async (
user: UserWithRelations,
status: StatusWithRelations,
) => {
await client.like.deleteMany({
where: {
likedId: status.id,
likerId: user.id,
},
});
await db
.delete(like)
.where(and(eq(like.likedId, status.id), eq(like.likerId, user.id)));
// Notify the user that their post has been favourited
await client.notification.deleteMany({
where: {
accountId: user.id,
type: "favourite",
notifiedId: status.authorId,
statusId: status.id,
},
});
await db
.delete(notification)
.where(
and(
eq(notification.accountId, user.id),
eq(notification.type, "favourite"),
eq(notification.notifiedId, status.authorId),
eq(notification.statusId, status.id),
),
);
if (user.instanceId === null && status.author.instanceId !== null) {
// User is local, federate the delete

View file

@ -1,7 +1,10 @@
import type { Notification } from "@prisma/client";
import type { APINotification } from "~types/entities/notification";
import { type StatusWithRelations, statusToAPI } from "./Status";
import { type UserWithRelations, userToAPI } from "./User";
import type { InferSelectModel } from "drizzle-orm";
import type { notification } from "~drizzle/schema";
export type Notification = InferSelectModel<typeof notification>;
export type NotificationWithRelations = Notification & {
status: StatusWithRelations | null;

View file

@ -1,15 +1,22 @@
import type { LysandObject } from "@prisma/client";
import { client } from "~database/datasource";
import type { LysandObjectType } from "~types/lysand/Object";
import type { InferSelectModel } from "drizzle-orm";
import { db } from "~drizzle/db";
import { lysandObject } from "~drizzle/schema";
import { findFirstUser } from "./User";
import type * as Lysand from "lysand-types";
export type LysandObject = InferSelectModel<typeof lysandObject>;
/**
* Represents a Lysand object in the database.
*/
export const createFromObject = async (object: LysandObjectType) => {
const foundObject = await client.lysandObject.findFirst({
where: { remote_id: object.id },
include: {
export const createFromObject = async (
object: Lysand.Entity,
authorUri: string,
) => {
const foundObject = await db.query.lysandObject.findFirst({
where: (o, { eq }) => eq(o.remoteId, object.id),
with: {
author: true,
},
});
@ -18,45 +25,43 @@ export const createFromObject = async (object: LysandObjectType) => {
return foundObject;
}
const author = await client.lysandObject.findFirst({
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
where: { uri: (object as any).author },
const author = await findFirstUser({
where: (user, { eq }) => eq(user.uri, authorUri),
});
return await client.lysandObject.create({
data: {
authorId: author?.id,
created_at: new Date(object.created_at).toISOString(),
extensions: object.extensions || {},
remote_id: object.id,
type: object.type,
uri: object.uri,
// Rest of data (remove id, author, created_at, extensions, type, uri)
extra_data: Object.fromEntries(
Object.entries(object).filter(
([key]) =>
![
"id",
"author",
"created_at",
"extensions",
"type",
"uri",
].includes(key),
),
return await db.insert(lysandObject).values({
authorId: author?.id,
createdAt: new Date(object.created_at).toISOString(),
extensions: object.extensions,
remoteId: object.id,
type: object.type,
uri: object.uri,
// Rest of data (remove id, author, created_at, extensions, type, uri)
extraData: Object.fromEntries(
Object.entries(object).filter(
([key]) =>
![
"id",
"author",
"created_at",
"extensions",
"type",
"uri",
].includes(key),
),
},
),
});
};
export const toLysand = (lyObject: LysandObject): LysandObjectType => {
export const toLysand = (lyObject: LysandObject): Lysand.Entity => {
return {
id: lyObject.remote_id || lyObject.id,
created_at: new Date(lyObject.created_at).toISOString(),
id: lyObject.remoteId || lyObject.id,
created_at: new Date(lyObject.createdAt).toISOString(),
type: lyObject.type,
uri: lyObject.uri,
...lyObject.extra_data,
extensions: lyObject.extensions,
...(lyObject.extraData as object),
// @ts-expect-error Assume stored JSON is valid
extensions: lyObject.extensions as object,
};
};

View file

@ -1,4 +1,3 @@
import type { User } from "@prisma/client";
import { config } from "config-manager";
// import { Worker } from "bullmq";
import { type StatusWithRelations, statusToLysand } from "./Status";
@ -118,70 +117,6 @@ import { type StatusWithRelations, statusToLysand } from "./Status";
}
); */
/**
* Convert a string into an ArrayBuffer
* from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
*/
export const str2ab = (str: string) => {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
};
export const federateStatusTo = async (
status: StatusWithRelations,
sender: User,
user: User,
) => {
const privateKey = await crypto.subtle.importKey(
"pkcs8",
str2ab(atob(user.privateKey ?? "")),
"Ed25519",
false,
["sign"],
);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode("request_body"),
);
const userInbox = new URL(user.endpoints.inbox);
const date = new Date();
const signature = await crypto.subtle.sign(
"Ed25519",
privateKey,
new TextEncoder().encode(
`(request-target): post ${userInbox.pathname}\n` +
`host: ${userInbox.host}\n` +
`date: ${date.toUTCString()}\n` +
`digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)),
)}\n`,
),
);
const signatureBase64 = btoa(
String.fromCharCode(...new Uint8Array(signature)),
);
return fetch(userInbox, {
method: "POST",
headers: {
"Content-Type": "application/json",
Date: date.toUTCString(),
Origin: config.http.base_url,
Signature: `keyId="${sender.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
},
body: JSON.stringify(statusToLysand(status)),
});
};
export const addStatusFederationJob = async (statusId: string) => {
/* await federationQueue.add("federation", {
id: statusId,

View file

@ -1,10 +1,11 @@
import type { Relationship, User } from "@prisma/client";
import { client } from "~database/datasource";
import type { APIRelationship } from "~types/entities/relationship";
import type { User } from "./User";
import type { InferSelectModel } from "drizzle-orm";
import { relationship } from "~drizzle/schema";
import { db } from "~drizzle/db";
/**
* Stores Mastodon API relationships
*/
export type Relationship = InferSelectModel<typeof relationship>;
/**
* Creates a new relationship between two users.
@ -16,25 +17,29 @@ export const createNewRelationship = async (
owner: User,
other: User,
): Promise<Relationship> => {
return await client.relationship.create({
data: {
ownerId: owner.id,
subjectId: other.id,
languages: [],
following: false,
showingReblogs: false,
notifying: false,
followedBy: false,
blocking: false,
blockedBy: false,
muting: false,
mutingNotifications: false,
requested: false,
domainBlocking: false,
endorsed: false,
note: "",
},
});
return (
await db
.insert(relationship)
.values({
ownerId: owner.id,
subjectId: other.id,
languages: [],
following: false,
showingReblogs: false,
notifying: false,
followedBy: false,
blocking: false,
blockedBy: false,
muting: false,
mutingNotifications: false,
requested: false,
domainBlocking: false,
endorsed: false,
note: "",
updatedAt: new Date().toISOString(),
})
.returning()
)[0];
};
export const checkForBidirectionalRelationships = async (
@ -42,18 +47,14 @@ export const checkForBidirectionalRelationships = async (
user2: User,
createIfNotExists = true,
): Promise<boolean> => {
const relationship1 = await client.relationship.findFirst({
where: {
ownerId: user1.id,
subjectId: user2.id,
},
const relationship1 = await db.query.relationship.findFirst({
where: (rel, { and, eq }) =>
and(eq(rel.ownerId, user1.id), eq(rel.subjectId, user2.id)),
});
const relationship2 = await client.relationship.findFirst({
where: {
ownerId: user2.id,
subjectId: user1.id,
},
const relationship2 = await db.query.relationship.findFirst({
where: (rel, { and, eq }) =>
and(eq(rel.ownerId, user2.id), eq(rel.subjectId, user1.id)),
});
if (!relationship1 && !relationship2 && createIfNotExists) {
@ -82,7 +83,7 @@ export const relationshipToAPI = (rel: Relationship): APIRelationship => {
notifying: rel.notifying,
requested: rel.requested,
showing_reblogs: rel.showingReblogs,
languages: rel.languages,
languages: rel.languages ?? [],
note: rel.note,
};
};

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,128 @@
import { addUserToMeilisearch } from "@meilisearch";
import type { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { type Config, config } from "config-manager";
import { htmlToText } from "html-to-text";
import { client } from "~database/datasource";
import type { APIAccount } from "~types/entities/account";
import type { APISource } from "~types/entities/source";
import type * as Lysand from "lysand-types";
import { fetchEmoji, emojiToAPI, emojiToLysand } from "./Emoji";
import {
fetchEmoji,
emojiToAPI,
emojiToLysand,
type EmojiWithInstance,
} from "./Emoji";
import { addInstanceIfNotExists } from "./Instance";
import { userRelations } from "./relations";
import { createNewRelationship } from "./Relationship";
import { getBestContentType, urlToContentFormat } from "@content_types";
import { objectToInboxRequest } from "./Federation";
import { and, eq, sql, type InferSelectModel } from "drizzle-orm";
import {
emojiToUser,
instance,
notification,
relationship,
user,
} from "~drizzle/schema";
import { db } from "~drizzle/db";
export type User = InferSelectModel<typeof user> & {
endpoints?: Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}>;
};
export type UserWithRelations = User & {
instance: InferSelectModel<typeof instance> | null;
emojis: EmojiWithInstance[];
followerCount: number;
followingCount: number;
statusCount: number;
};
export type UserWithRelationsAndRelationships = UserWithRelations & {
relationships: InferSelectModel<typeof relationship>[];
relationshipSubjects: InferSelectModel<typeof relationship>[];
};
export const userRelations: {
instance: true;
emojis: {
with: {
emoji: {
with: {
instance: true;
};
};
};
};
} = {
instance: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
};
export const userExtras = {
followerCount:
sql`(SELECT COUNT(*) FROM "Relationship" "relationships" WHERE ("relationships"."ownerId" = "user".id AND "relationships"."following" = true))`.as(
"follower_count",
),
followingCount:
sql`(SELECT COUNT(*) FROM "Relationship" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "user".id AND "relationshipSubjects"."following" = true))`.as(
"following_count",
),
statusCount:
sql`(SELECT COUNT(*) FROM "Status" "statuses" WHERE "statuses"."authorId" = "user".id)`.as(
"status_count",
),
};
export const userExtrasTemplate = (name: string) => ({
// @ts-ignore
followerCount: sql([
`(SELECT COUNT(*) FROM "Relationship" "relationships" WHERE ("relationships"."ownerId" = "${name}".id AND "relationships"."following" = true))`,
]).as("follower_count"),
// @ts-ignore
followingCount: sql([
`(SELECT COUNT(*) FROM "Relationship" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "${name}".id AND "relationshipSubjects"."following" = true))`,
]).as("following_count"),
// @ts-ignore
statusCount: sql([
`(SELECT COUNT(*) FROM "Status" "statuses" WHERE "statuses"."authorId" = "${name}".id)`,
]).as("status_count"),
});
/* const a = await db.query.user.findFirst({
with: {
instance: true,
emojis: {
with: {
instance: true,
},
},
},
extras: {
//
followerCount: sql`SELECT COUNT(*) FROM relationship WHERE owner_id = user.id AND following = true`,
},
}); */
export interface AuthData {
user: UserWithRelations | null;
token: string;
}
/**
* Represents a user in the database.
* Stores local and remote users
*/
const userRelations2 = Prisma.validator<Prisma.UserDefaultArgs>()({
include: userRelations,
});
export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
/**
* Get the user's avatar in raw URL format
* @param config The config to use
@ -68,19 +161,22 @@ export const followRequestUser = async (
reblogs = false,
notify = false,
languages: string[] = [],
) => {
): Promise<InferSelectModel<typeof relationship>> => {
const isRemote = follower.instanceId !== followee.instanceId;
const relationship = await client.relationship.update({
where: { id: relationshipId },
data: {
following: isRemote ? false : !followee.isLocked,
requested: isRemote ? true : followee.isLocked,
showingReblogs: reblogs,
notifying: notify,
languages: languages,
},
});
const updatedRelationship = (
await db
.update(relationship)
.set({
following: isRemote ? false : !followee.isLocked,
requested: isRemote ? true : followee.isLocked,
showingReblogs: reblogs,
notifying: notify,
languages: languages,
})
.where(eq(relationship.id, relationshipId))
.returning()
)[0];
if (isRemote) {
// Federate
@ -100,35 +196,26 @@ export const followRequestUser = async (
`Failed to federate follow request from ${follower.id} to ${followee.uri}`,
);
return await client.relationship.update({
where: { id: relationshipId },
data: {
following: false,
requested: false,
},
});
return (
await db
.update(relationship)
.set({
following: false,
requested: false,
})
.where(eq(relationship.id, relationshipId))
.returning()
)[0];
}
} else {
if (followee.isLocked) {
await client.notification.create({
data: {
accountId: follower.id,
type: "follow_request",
notifiedId: followee.id,
},
});
} else {
await client.notification.create({
data: {
accountId: follower.id,
type: "follow",
notifiedId: followee.id,
},
});
}
await db.insert(notification).values({
accountId: followee.id,
type: followee.isLocked ? "follow_request" : "follow",
notifiedId: follower.id,
});
}
return relationship;
return updatedRelationship;
};
export const sendFollowAccept = async (follower: User, followee: User) => {
@ -169,13 +256,88 @@ export const sendFollowReject = async (follower: User, followee: User) => {
}
};
export const resolveUser = async (uri: string) => {
// Check if user not already in database
const foundUser = await client.user.findUnique({
where: {
uri,
export const transformOutputToUserWithRelations = (
user: Omit<User, "endpoints"> & {
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
emojis: {
a: string;
b: string;
emoji?: EmojiWithInstance;
}[];
instance: InferSelectModel<typeof instance> | null;
endpoints: unknown;
},
): UserWithRelations => {
return {
...user,
followerCount: Number(user.followerCount),
followingCount: Number(user.followingCount),
statusCount: Number(user.statusCount),
endpoints:
user.endpoints ??
({} as Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}>),
emojis: user.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
};
};
export const findManyUsers = async (
query: Parameters<typeof db.query.user.findMany>[0],
): Promise<UserWithRelations[]> => {
const output = await db.query.user.findMany({
...query,
with: {
...userRelations,
...query?.with,
},
include: userRelations,
extras: {
...userExtras,
...query?.extras,
},
});
return output.map((user) => transformOutputToUserWithRelations(user));
};
export const findFirstUser = async (
query: Parameters<typeof db.query.user.findFirst>[0],
): Promise<UserWithRelations | null> => {
const output = await db.query.user.findFirst({
...query,
with: {
...userRelations,
...query?.with,
},
extras: {
...userExtras,
...query?.extras,
},
});
if (!output) return null;
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;
@ -192,12 +354,12 @@ export const resolveUser = async (uri: string) => {
);
}
return client.user.findUnique({
where: {
id: uuid[0],
},
include: userRelations,
const foundLocalUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, uuid[0]),
with: userRelations,
});
return foundLocalUser || null;
}
if (!URL.canParse(uri)) {
@ -244,50 +406,62 @@ export const resolveUser = async (uri: string) => {
emojis.push(await fetchEmoji(emoji));
}
const user = await client.user.create({
data: {
username: data.username,
uri: data.uri,
createdAt: new Date(data.created_at),
endpoints: {
dislikes: data.dislikes,
featured: data.featured,
likes: data.likes,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
emojis: {
connect: emojis.map((emoji) => ({
id: emoji.id,
})),
},
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: [],
},
},
include: userRelations,
const newUser = (
await db
.insert(user)
.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,
},
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
await db.insert(emojiToUser).values(
emojis.map((emoji) => ({
a: emoji.id,
b: newUser.id,
})),
);
const finalUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, newUser.id),
with: userRelations,
});
// Add to Meilisearch
await addUserToMeilisearch(user);
if (!finalUser) return null;
return user;
// Add to Meilisearch
await addUserToMeilisearch(finalUser);
return finalUser;
};
export const getUserUri = (user: User) => {
@ -301,19 +475,25 @@ export const getUserUri = (user: User) => {
* Resolves a WebFinger identifier to a user.
* @param identifier Either a UUID or a username
*/
export const resolveWebFinger = async (identifier: string, host: string) => {
export const resolveWebFinger = async (
identifier: string,
host: string,
): Promise<UserWithRelations | null> => {
// Check if user not already in database
const foundUser = await client.user.findUnique({
where: {
username: identifier,
instance: {
base_url: host,
},
},
include: userRelations,
});
const foundUser = await db
.select()
.from(user)
.innerJoin(instance, eq(user.instanceId, instance.id))
.where(and(eq(user.username, identifier), eq(instance.baseUrl, host)))
.limit(1);
if (foundUser) return foundUser;
if (foundUser[0])
return (
(await findFirstUser({
where: (user, { eq }) => eq(user.id, foundUser[0].User.id),
with: userRelations,
})) || null
);
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
@ -383,49 +563,57 @@ export const createNewLocalUser = async (data: {
avatar?: string;
header?: string;
admin?: boolean;
}) => {
}): Promise<UserWithRelations | null> => {
const keys = await generateUserKeys();
const user = await client.user.create({
data: {
username: data.username,
displayName: data.display_name ?? data.username,
password: await Bun.password.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar,
header: data.header ?? config.defaults.avatar,
isAdmin: data.admin ?? false,
publicKey: keys.public_key,
privateKey: keys.private_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
},
include: userRelations,
const newUser = (
await db
.insert(user)
.values({
username: data.username,
displayName: data.display_name ?? data.username,
password: await Bun.password.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar,
header: data.header ?? config.defaults.avatar,
isAdmin: data.admin ?? false,
publicKey: keys.public_key,
privateKey: keys.private_key,
updatedAt: new Date().toISOString(),
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
})
.returning()
)[0];
const finalUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, newUser.id),
with: userRelations,
});
// Add to Meilisearch
await addUserToMeilisearch(user);
if (!finalUser) return null;
return user;
// Add to Meilisearch
await addUserToMeilisearch(finalUser);
return finalUser;
};
/**
* Parses mentions from a list of URIs
*/
export const parseMentionsUris = async (mentions: string[]) => {
return await client.user.findMany({
where: {
uri: {
in: mentions,
},
},
include: userRelations,
export const parseMentionsUris = async (
mentions: string[],
): Promise<UserWithRelations[]> => {
return await findManyUsers({
where: (user, { inArray }) => inArray(user.uri, mentions),
with: userRelations,
});
};
@ -434,23 +622,22 @@ export const parseMentionsUris = async (mentions: string[]) => {
* @param access_token The access token to retrieve the user from.
* @returns The user associated with the given access token.
*/
export const retrieveUserFromToken = async (access_token: string) => {
export const retrieveUserFromToken = async (
access_token: string,
): Promise<UserWithRelations | null> => {
if (!access_token) return null;
const token = await client.token.findFirst({
where: {
access_token,
},
include: {
user: {
include: userRelations,
},
},
const token = await db.query.token.findFirst({
where: (tokens, { eq }) => eq(tokens.accessToken, access_token),
});
if (!token) return null;
if (!token || !token.userId) return null;
return token.user;
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, token.userId ?? ""),
});
return user;
};
/**
@ -461,34 +648,24 @@ export const retrieveUserFromToken = async (access_token: string) => {
export const getRelationshipToOtherUser = async (
user: UserWithRelations,
other: User,
) => {
const relationship = await client.relationship.findFirst({
where: {
ownerId: user.id,
subjectId: other.id,
},
): Promise<InferSelectModel<typeof relationship>> => {
const foundRelationship = await db.query.relationship.findFirst({
where: (relationship, { and, eq }) =>
and(
eq(relationship.ownerId, user.id),
eq(relationship.subjectId, other.id),
),
});
if (!relationship) {
if (!foundRelationship) {
// Create new relationship
const newRelationship = await createNewRelationship(user, other);
await client.user.update({
where: { id: user.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
return newRelationship;
}
return relationship;
return foundRelationship;
};
/**
@ -526,40 +703,42 @@ export const generateUserKeys = async () => {
};
export const userToAPI = (
user: UserWithRelations,
userToConvert: UserWithRelations,
isOwnAccount = false,
): APIAccount => {
return {
id: user.id,
username: user.username,
display_name: user.displayName,
note: user.note,
id: userToConvert.id,
username: userToConvert.username,
display_name: userToConvert.displayName,
note: userToConvert.note,
url:
user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(),
avatar: getAvatarUrl(user, config),
header: getHeaderUrl(user, config),
locked: user.isLocked,
created_at: new Date(user.createdAt).toISOString(),
followers_count: user.relationshipSubjects.filter((r) => r.following)
.length,
following_count: user.relationships.filter((r) => r.following).length,
statuses_count: user._count.statuses,
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
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: user.isBot,
bot: userToConvert.isBot,
source:
isOwnAccount && user.source
? (user.source as APISource)
isOwnAccount && userToConvert.source
? (userToConvert.source as APISource)
: undefined,
// TODO: Add static avatar and header
avatar_static: "",
header_static: "",
acct:
user.instance === null
? user.username
: `${user.username}@${user.instance.base_url}`,
userToConvert.instance === null
? userToConvert.username
: `${userToConvert.username}@${userToConvert.instance.baseUrl}`,
// TODO: Add these fields
limited: false,
moved: null,
@ -569,8 +748,8 @@ export const userToAPI = (
mute_expires_at: undefined,
group: false,
pleroma: {
is_admin: user.isAdmin,
is_moderator: user.isAdmin,
is_admin: userToConvert.isAdmin,
is_moderator: userToConvert.isAdmin,
},
};
};