Replace eslint and prettier with Biome

This commit is contained in:
Jesse Wierzbinski 2024-04-06 19:30:49 -10:00
parent 4a5a2ea590
commit af0d627f19
No known key found for this signature in database
199 changed files with 16493 additions and 16361 deletions

View file

@ -1,6 +1,6 @@
import type { APIApplication } from "~types/entities/application";
import type { Application } from "@prisma/client";
import { client } from "~database/datasource";
import type { APIApplication } from "~types/entities/application";
/**
* Represents an application that can authenticate with the API.
@ -12,18 +12,18 @@ import { client } from "~database/datasource";
* @returns The application associated with the given access token, or null if no such application exists.
*/
export const getFromToken = async (
token: string
token: string,
): Promise<Application | null> => {
const dbToken = await client.token.findFirst({
where: {
access_token: token,
},
include: {
application: true,
},
});
const dbToken = await client.token.findFirst({
where: {
access_token: token,
},
include: {
application: true,
},
});
return dbToken?.application || null;
return dbToken?.application || null;
};
/**
@ -31,9 +31,9 @@ export const getFromToken = async (
* @returns The API application representation of this application.
*/
export const applicationToAPI = (app: Application): APIApplication => {
return {
name: app.name,
website: app.website,
vapid_key: app.vapid_key,
};
return {
name: app.name,
website: app.website,
vapid_key: app.vapid_key,
};
};

View file

@ -1,69 +1,70 @@
import type { Attachment } from "@prisma/client";
import type { ConfigType } from "config-manager";
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";
export const attachmentToAPI = (
attachment: Attachment
attachment: Attachment,
): APIAsyncAttachment | APIAttachment => {
let type = "unknown";
let type = "unknown";
if (attachment.mime_type.startsWith("image/")) {
type = "image";
} else if (attachment.mime_type.startsWith("video/")) {
type = "video";
} else if (attachment.mime_type.startsWith("audio/")) {
type = "audio";
}
if (attachment.mime_type.startsWith("image/")) {
type = "image";
} else if (attachment.mime_type.startsWith("video/")) {
type = "video";
} else if (attachment.mime_type.startsWith("audio/")) {
type = "audio";
}
return {
id: attachment.id,
type: type as any,
url: attachment.url,
remote_url: attachment.remote_url,
preview_url: attachment.thumbnail_url,
text_url: null,
meta: {
width: attachment.width || undefined,
height: attachment.height || undefined,
fps: attachment.fps || undefined,
size:
attachment.width && attachment.height
? `${attachment.width}x${attachment.height}`
: undefined,
duration: attachment.duration || undefined,
length: attachment.size?.toString() || undefined,
aspect:
attachment.width && attachment.height
? attachment.width / attachment.height
: undefined,
original: {
width: attachment.width || undefined,
height: attachment.height || undefined,
size:
attachment.width && attachment.height
? `${attachment.width}x${attachment.height}`
: undefined,
aspect:
attachment.width && attachment.height
? attachment.width / attachment.height
: undefined,
},
// Idk whether size or length is the right value
},
description: attachment.description,
blurhash: attachment.blurhash,
};
return {
id: attachment.id,
type: type as "image" | "video" | "audio" | "unknown",
url: attachment.url,
remote_url: attachment.remote_url,
preview_url: attachment.thumbnail_url,
text_url: null,
meta: {
width: attachment.width || undefined,
height: attachment.height || undefined,
fps: attachment.fps || undefined,
size:
attachment.width && attachment.height
? `${attachment.width}x${attachment.height}`
: undefined,
duration: attachment.duration || undefined,
length: attachment.size?.toString() || undefined,
aspect:
attachment.width && attachment.height
? attachment.width / attachment.height
: undefined,
original: {
width: attachment.width || undefined,
height: attachment.height || undefined,
size:
attachment.width && attachment.height
? `${attachment.width}x${attachment.height}`
: undefined,
aspect:
attachment.width && attachment.height
? attachment.width / attachment.height
: undefined,
},
// Idk whether size or length is the right value
},
description: attachment.description,
blurhash: attachment.blurhash,
};
};
export const getUrl = (name: string, config: ConfigType) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${name}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, @typescript-eslint/no-unnecessary-condition
} else if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${name}`;
}
return "";
export const getUrl = (name: string, config: Config) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${name}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, @typescript-eslint/no-unnecessary-condition
}
if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${name}`;
}
return "";
};

View file

@ -1,7 +1,7 @@
import type { Emoji } from "@prisma/client";
import { client } from "~database/datasource";
import type { APIEmoji } from "~types/entities/emoji";
import type { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis";
import { client } from "~database/datasource";
import type { Emoji } from "@prisma/client";
/**
* Represents an emoji entity in the database.
@ -13,41 +13,41 @@ import type { Emoji } from "@prisma/client";
* @returns An array of emojis
*/
export const parseEmojis = async (text: string): Promise<Emoji[]> => {
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: {
instance: true,
},
});
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: {
instance: true,
},
});
};
export const addEmojiIfNotExists = async (emoji: LysandEmoji) => {
const existingEmoji = await client.emoji.findFirst({
where: {
shortcode: emoji.name,
instance: null,
},
});
const existingEmoji = await client.emoji.findFirst({
where: {
shortcode: emoji.name,
instance: null,
},
});
if (existingEmoji) return existingEmoji;
if (existingEmoji) return existingEmoji;
return await client.emoji.create({
data: {
shortcode: emoji.name,
url: emoji.url[0].content,
alt: emoji.alt || null,
content_type: emoji.url[0].content_type,
visible_in_picker: true,
},
});
return await client.emoji.create({
data: {
shortcode: emoji.name,
url: emoji.url[0].content,
alt: emoji.alt || null,
content_type: emoji.url[0].content_type,
visible_in_picker: true,
},
});
};
/**
@ -55,43 +55,43 @@ export const addEmojiIfNotExists = async (emoji: LysandEmoji) => {
* @returns The APIEmoji object.
*/
export const emojiToAPI = (emoji: Emoji): APIEmoji => {
return {
shortcode: emoji.shortcode,
static_url: emoji.url, // TODO: Add static version
url: emoji.url,
visible_in_picker: emoji.visible_in_picker,
category: undefined,
};
return {
shortcode: emoji.shortcode,
static_url: emoji.url, // TODO: Add static version
url: emoji.url,
visible_in_picker: emoji.visible_in_picker,
category: undefined,
};
};
export const emojiToLysand = (emoji: Emoji): LysandEmoji => {
return {
name: emoji.shortcode,
url: [
{
content: emoji.url,
content_type: emoji.content_type,
},
],
alt: emoji.alt || undefined,
};
return {
name: emoji.shortcode,
url: [
{
content: emoji.url,
content_type: emoji.content_type,
},
],
alt: emoji.alt || undefined,
};
};
/**
* Converts the emoji to an ActivityPub object.
* @returns The ActivityPub object.
*/
export const emojiToActivityPub = (emoji: Emoji): any => {
// replace any with your ActivityPub Emoji type
return {
type: "Emoji",
name: `:${emoji.shortcode}:`,
updated: new Date().toISOString(),
icon: {
type: "Image",
url: emoji.url,
mediaType: emoji.content_type,
alt: emoji.alt || undefined,
},
};
export const emojiToActivityPub = (emoji: Emoji): object => {
// replace any with your ActivityPub Emoji type
return {
type: "Emoji",
name: `:${emoji.shortcode}:`,
updated: new Date().toISOString(),
icon: {
type: "Image",
url: emoji.url,
mediaType: emoji.content_type,
alt: emoji.alt || undefined,
},
};
};

View file

@ -12,38 +12,38 @@ import type { ServerMetadata } from "~types/lysand/Object";
* @returns Either the database instance if it already exists, or a newly created instance.
*/
export const addInstanceIfNotExists = async (
url: string
url: string,
): Promise<Instance> => {
const origin = new URL(url).origin;
const hostname = new URL(url).hostname;
const origin = new URL(url).origin;
const hostname = new URL(url).hostname;
const found = await client.instance.findFirst({
where: {
base_url: hostname,
},
});
const found = await client.instance.findFirst({
where: {
base_url: hostname,
},
});
if (found) return found;
if (found) return found;
// Fetch the instance configuration
const metadata = (await fetch(`${origin}/.well-known/lysand`).then(res =>
res.json()
)) as Partial<ServerMetadata>;
// Fetch the instance configuration
const metadata = (await fetch(`${origin}/.well-known/lysand`).then((res) =>
res.json(),
)) as Partial<ServerMetadata>;
if (metadata.type !== "ServerMetadata") {
throw new Error("Invalid instance metadata");
}
if (metadata.type !== "ServerMetadata") {
throw new Error("Invalid instance metadata");
}
if (!(metadata.name && metadata.version)) {
throw new Error("Invalid instance metadata");
}
if (!(metadata.name && metadata.version)) {
throw new Error("Invalid instance metadata");
}
return await client.instance.create({
data: {
base_url: hostname,
name: metadata.name,
version: metadata.version,
logo: metadata.logo as any,
},
});
return await client.instance.create({
data: {
base_url: hostname,
name: metadata.name,
version: metadata.version,
logo: metadata.logo,
},
});
};

View file

@ -1,23 +1,25 @@
import type { Like, Prisma } from "@prisma/client";
import { config } from "config-manager";
import { client } from "~database/datasource";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Like as LysandLike } from "~types/lysand/Object";
import type { Like } from "@prisma/client";
import { client } from "~database/datasource";
import type { UserWithRelations } from "./User";
import type { StatusWithRelations } from "./Status";
import { config } from "config-manager";
import type { UserWithRelations } from "./User";
/**
* Represents a Like entity in the database.
*/
export const toLysand = (like: Like): LysandLike => {
return {
id: like.id,
author: (like as any).liker?.uri,
type: "Like",
created_at: new Date(like.createdAt).toISOString(),
object: (like as any).liked?.uri,
uri: `${config.http.base_url}/actions/${like.id}`,
};
return {
id: like.id,
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
author: (like as any).liker?.uri,
type: "Like",
created_at: new Date(like.createdAt).toISOString(),
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
object: (like as any).liked?.uri,
uri: `${config.http.base_url}/actions/${like.id}`,
};
};
/**
@ -26,29 +28,29 @@ export const toLysand = (like: Like): LysandLike => {
* @param status Status being liked
*/
export const createLike = async (
user: UserWithRelations,
status: StatusWithRelations
user: UserWithRelations,
status: StatusWithRelations,
) => {
await client.like.create({
data: {
likedId: status.id,
likerId: user.id,
},
});
await client.like.create({
data: {
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,
},
});
} else {
// TODO: Add database jobs for federating this
}
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,
},
});
} else {
// TODO: Add database jobs for federating this
}
};
/**
@ -57,28 +59,28 @@ export const createLike = async (
* @param status Status being unliked
*/
export const deleteLike = async (
user: UserWithRelations,
status: StatusWithRelations
user: UserWithRelations,
status: StatusWithRelations,
) => {
await client.like.deleteMany({
where: {
likedId: status.id,
likerId: user.id,
},
});
await client.like.deleteMany({
where: {
likedId: status.id,
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,
},
});
// 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,
},
});
if (user.instanceId === null && status.author.instanceId !== null) {
// User is local, federate the delete
// TODO: Federate this
}
if (user.instanceId === null && status.author.instanceId !== null) {
// User is local, federate the delete
// TODO: Federate this
}
};

View file

@ -4,20 +4,20 @@ import { type StatusWithRelations, statusToAPI } from "./Status";
import { type UserWithRelations, userToAPI } from "./User";
export type NotificationWithRelations = Notification & {
status: StatusWithRelations | null;
account: UserWithRelations;
status: StatusWithRelations | null;
account: UserWithRelations;
};
export const notificationToAPI = async (
notification: NotificationWithRelations
notification: NotificationWithRelations,
): Promise<APINotification> => {
return {
account: userToAPI(notification.account),
created_at: new Date(notification.createdAt).toISOString(),
id: notification.id,
type: notification.type,
status: notification.status
? await statusToAPI(notification.status, notification.account)
: undefined,
};
return {
account: userToAPI(notification.account),
created_at: new Date(notification.createdAt).toISOString(),
id: notification.id,
type: notification.type,
status: notification.status
? await statusToAPI(notification.status, notification.account)
: undefined,
};
};

View file

@ -9,79 +9,79 @@ import type { LysandObjectType } from "~types/lysand/Object";
*/
export const createFromObject = async (object: LysandObjectType) => {
const foundObject = await client.lysandObject.findFirst({
where: { remote_id: object.id },
include: {
author: true,
},
});
const foundObject = await client.lysandObject.findFirst({
where: { remote_id: object.id },
include: {
author: true,
},
});
if (foundObject) {
return foundObject;
}
if (foundObject) {
return foundObject;
}
const author = await client.lysandObject.findFirst({
where: { uri: (object as any).author },
});
const author = await client.lysandObject.findFirst({
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
where: { uri: (object as any).author },
});
return await client.lysandObject.create({
data: {
authorId: author?.id,
created_at: new Date(object.created_at),
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 client.lysandObject.create({
data: {
authorId: author?.id,
created_at: new Date(object.created_at),
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),
),
),
},
});
};
export const toLysand = (lyObject: LysandObject): LysandObjectType => {
return {
id: lyObject.remote_id || lyObject.id,
created_at: new Date(lyObject.created_at).toISOString(),
type: lyObject.type,
uri: lyObject.uri,
// @ts-expect-error This works, I promise
...lyObject.extra_data,
extensions: lyObject.extensions,
};
return {
id: lyObject.remote_id || lyObject.id,
created_at: new Date(lyObject.created_at).toISOString(),
type: lyObject.type,
uri: lyObject.uri,
...lyObject.extra_data,
extensions: lyObject.extensions,
};
};
export const isPublication = (lyObject: LysandObject): boolean => {
return lyObject.type === "Note" || lyObject.type === "Patch";
return lyObject.type === "Note" || lyObject.type === "Patch";
};
export const isAction = (lyObject: LysandObject): boolean => {
return [
"Like",
"Follow",
"Dislike",
"FollowAccept",
"FollowReject",
"Undo",
"Announce",
].includes(lyObject.type);
return [
"Like",
"Follow",
"Dislike",
"FollowAccept",
"FollowReject",
"Undo",
"Announce",
].includes(lyObject.type);
};
export const isActor = (lyObject: LysandObject): boolean => {
return lyObject.type === "User";
return lyObject.type === "User";
};
export const isExtension = (lyObject: LysandObject): boolean => {
return lyObject.type === "Extension";
return lyObject.type === "Extension";
};

View file

@ -1,7 +1,7 @@
// import { Worker } from "bullmq";
import { statusToLysand, type StatusWithRelations } from "./Status";
import type { User } from "@prisma/client";
import { config } from "config-manager";
// import { Worker } from "bullmq";
import { type StatusWithRelations, statusToLysand } from "./Status";
/* export const federationWorker = new Worker(
"federation",
@ -123,68 +123,68 @@ import { config } from "config-manager";
* 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;
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
status: StatusWithRelations,
sender: User,
user: User,
) => {
const privateKey = await crypto.subtle.importKey(
"pkcs8",
str2ab(atob(user.privateKey ?? "")),
"Ed25519",
false,
["sign"]
);
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 digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode("request_body"),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const userInbox = new URL(user.endpoints.inbox);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const userInbox = new URL(user.endpoints.inbox);
const date = new Date();
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 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))
);
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)),
});
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", {
/* await federationQueue.add("federation", {
id: statusId,
}); */
};

View file

@ -1,6 +1,6 @@
import type { Relationship, User } from "@prisma/client";
import type { APIRelationship } from "~types/entities/relationship";
import { client } from "~database/datasource";
import type { APIRelationship } from "~types/entities/relationship";
/**
* Stores Mastodon API relationships
@ -13,55 +13,55 @@ import { client } from "~database/datasource";
* @returns The newly created relationship.
*/
export const createNewRelationship = async (
owner: User,
other: User
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 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: "",
},
});
};
export const checkForBidirectionalRelationships = async (
user1: User,
user2: User,
createIfNotExists = true
user1: User,
user2: User,
createIfNotExists = true,
): Promise<boolean> => {
const relationship1 = await client.relationship.findFirst({
where: {
ownerId: user1.id,
subjectId: user2.id,
},
});
const relationship1 = await client.relationship.findFirst({
where: {
ownerId: user1.id,
subjectId: user2.id,
},
});
const relationship2 = await client.relationship.findFirst({
where: {
ownerId: user2.id,
subjectId: user1.id,
},
});
const relationship2 = await client.relationship.findFirst({
where: {
ownerId: user2.id,
subjectId: user1.id,
},
});
if (!relationship1 && !relationship2 && createIfNotExists) {
await createNewRelationship(user1, user2);
await createNewRelationship(user2, user1);
}
if (!relationship1 && !relationship2 && createIfNotExists) {
await createNewRelationship(user1, user2);
await createNewRelationship(user2, user1);
}
return !!relationship1 && !!relationship2;
return !!relationship1 && !!relationship2;
};
/**
@ -69,20 +69,20 @@ export const checkForBidirectionalRelationships = async (
* @returns The API-friendly relationship.
*/
export const relationshipToAPI = (rel: Relationship): APIRelationship => {
return {
blocked_by: rel.blockedBy,
blocking: rel.blocking,
domain_blocking: rel.domainBlocking,
endorsed: rel.endorsed,
followed_by: rel.followedBy,
following: rel.following,
id: rel.subjectId,
muting: rel.muting,
muting_notifications: rel.mutingNotifications,
notifying: rel.notifying,
requested: rel.requested,
showing_reblogs: rel.showingReblogs,
languages: rel.languages,
note: rel.note,
};
return {
blocked_by: rel.blockedBy,
blocking: rel.blocking,
domain_blocking: rel.domainBlocking,
endorsed: rel.endorsed,
followed_by: rel.followedBy,
following: rel.following,
id: rel.subjectId,
muting: rel.muting,
muting_notifications: rel.mutingNotifications,
notifying: rel.notifying,
requested: rel.requested,
showing_reblogs: rel.showingReblogs,
languages: rel.languages,
note: rel.note,
};
};

View file

@ -1,37 +1,37 @@
import { getBestContentType } from "@content_types";
import { addStausToMeilisearch } from "@meilisearch";
import {
type Application,
type Emoji,
Prisma,
type Relationship,
type Status,
type User,
} from "@prisma/client";
import { sanitizeHtml } from "@sanitization";
import { config } from "config-manager";
import { htmlToText } from "html-to-text";
import linkifyHtml from "linkify-html";
import linkifyStr from "linkify-string";
import { parse } from "marked";
import { client } from "~database/datasource";
import type { APIAttachment } from "~types/entities/attachment";
import type { APIStatus } from "~types/entities/status";
import type { LysandPublication, Note } from "~types/lysand/Object";
import { applicationToAPI } from "./Application";
import { attachmentToAPI } from "./Attachment";
import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { UserWithRelations } from "./User";
import { fetchRemoteUser, parseMentionsUris, userToAPI } from "./User";
import { client } from "~database/datasource";
import type { LysandPublication, Note } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
import { getBestContentType } from "@content_types";
import {
Prisma,
type Application,
type Emoji,
type Relationship,
type Status,
type User,
} from "@prisma/client";
import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
import type { APIStatus } from "~types/entities/status";
import { applicationToAPI } from "./Application";
import { attachmentToAPI } from "./Attachment";
import type { APIAttachment } from "~types/entities/attachment";
import { sanitizeHtml } from "@sanitization";
import { parse } from "marked";
import linkifyStr from "linkify-string";
import linkifyHtml from "linkify-html";
import { addStausToMeilisearch } from "@meilisearch";
import { config } from "config-manager";
import { statusAndUserRelations, userRelations } from "./relations";
const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({
include: statusAndUserRelations,
include: statusAndUserRelations,
});
export type StatusWithRelations = Prisma.StatusGetPayload<
typeof statusRelations
typeof statusRelations
>;
/**
@ -44,76 +44,75 @@ export type StatusWithRelations = Prisma.StatusGetPayload<
* @returns Whether this status is viewable by the user.
*/
export const isViewableByUser = (status: Status, user: User | null) => {
if (status.authorId === user?.id) return true;
if (status.visibility === "public") return true;
else if (status.visibility === "unlisted") return true;
else if (status.visibility === "private") {
// @ts-expect-error Prisma TypeScript types dont include relations
return !!(user?.relationships as Relationship[]).find(
rel => rel.id === status.authorId
);
} else {
// @ts-expect-error Prisma TypeScript types dont include relations
return user && (status.mentions as User[]).includes(user);
}
if (status.authorId === user?.id) return true;
if (status.visibility === "public") return true;
if (status.visibility === "unlisted") return true;
if (status.visibility === "private") {
// @ts-expect-error Prisma TypeScript types dont include relations
return !!(user?.relationships as Relationship[]).find(
(rel) => rel.id === status.authorId,
);
}
// @ts-expect-error Prisma TypeScript types dont include relations
return user && (status.mentions as User[]).includes(user);
};
export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
// Check if already in database
// Check if already in database
const existingStatus: StatusWithRelations | null =
await client.status.findFirst({
where: {
uri: uri,
},
include: statusAndUserRelations,
});
const existingStatus: StatusWithRelations | null =
await client.status.findFirst({
where: {
uri: uri,
},
include: statusAndUserRelations,
});
if (existingStatus) return existingStatus;
if (existingStatus) return existingStatus;
const status = await fetch(uri);
const status = await fetch(uri);
if (status.status === 404) return null;
if (status.status === 404) return null;
const body = (await status.json()) as LysandPublication;
const body = (await status.json()) as LysandPublication;
const content = getBestContentType(body.contents);
const content = getBestContentType(body.contents);
const emojis = await parseEmojis(content?.content || "");
const emojis = await parseEmojis(content?.content || "");
const author = await fetchRemoteUser(body.author);
const author = await fetchRemoteUser(body.author);
let replyStatus: Status | null = null;
let quotingStatus: Status | null = null;
let replyStatus: Status | null = null;
let quotingStatus: Status | null = null;
if (body.replies_to.length > 0) {
replyStatus = await fetchFromRemote(body.replies_to[0]);
}
if (body.replies_to.length > 0) {
replyStatus = await fetchFromRemote(body.replies_to[0]);
}
if (body.quotes.length > 0) {
quotingStatus = await fetchFromRemote(body.quotes[0]);
}
if (body.quotes.length > 0) {
quotingStatus = await fetchFromRemote(body.quotes[0]);
}
return await createNewStatus({
account: author,
content: content?.content || "",
content_type: content?.content_type,
application: null,
// TODO: Add visibility
visibility: "public",
spoiler_text: body.subject || "",
uri: body.uri,
sensitive: body.is_sensitive,
emojis: emojis,
mentions: await parseMentionsUris(body.mentions),
reply: replyStatus
? {
status: replyStatus,
user: (replyStatus as any).author,
}
: undefined,
quote: quotingStatus || undefined,
});
return await createNewStatus({
account: author,
content: content?.content || "",
content_type: content?.content_type,
application: null,
// TODO: Add visibility
visibility: "public",
spoiler_text: body.subject || "",
uri: body.uri,
sensitive: body.is_sensitive,
emojis: emojis,
mentions: await parseMentionsUris(body.mentions),
reply: replyStatus
? {
status: replyStatus,
user: (replyStatus as StatusWithRelations).author,
}
: undefined,
quote: quotingStatus || undefined,
});
};
/**
@ -121,34 +120,34 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getAncestors = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null
status: StatusWithRelations,
fetcher: UserWithRelations | null,
) => {
const ancestors: StatusWithRelations[] = [];
const ancestors: StatusWithRelations[] = [];
let currentStatus = status;
let currentStatus = status;
while (currentStatus.inReplyToPostId) {
const parent = await client.status.findFirst({
where: {
id: currentStatus.inReplyToPostId,
},
include: statusAndUserRelations,
});
while (currentStatus.inReplyToPostId) {
const parent = await client.status.findFirst({
where: {
id: currentStatus.inReplyToPostId,
},
include: statusAndUserRelations,
});
if (!parent) break;
if (!parent) break;
ancestors.push(parent);
ancestors.push(parent);
currentStatus = parent;
}
currentStatus = parent;
}
// Filter for posts that are viewable by the user
// Filter for posts that are viewable by the user
const viewableAncestors = ancestors.filter(ancestor =>
isViewableByUser(ancestor, fetcher)
);
return viewableAncestors;
const viewableAncestors = ancestors.filter((ancestor) =>
isViewableByUser(ancestor, fetcher),
);
return viewableAncestors;
};
/**
@ -157,42 +156,42 @@ export const getAncestors = async (
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getDescendants = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null,
depth = 0
status: StatusWithRelations,
fetcher: UserWithRelations | null,
depth = 0,
) => {
const descendants: StatusWithRelations[] = [];
const descendants: StatusWithRelations[] = [];
const currentStatus = status;
const currentStatus = status;
// Fetch all children of children of children recursively calling getDescendants
// Fetch all children of children of children recursively calling getDescendants
const children = await client.status.findMany({
where: {
inReplyToPostId: currentStatus.id,
},
include: statusAndUserRelations,
});
const children = await client.status.findMany({
where: {
inReplyToPostId: currentStatus.id,
},
include: statusAndUserRelations,
});
for (const child of children) {
descendants.push(child);
for (const child of children) {
descendants.push(child);
if (depth < 20) {
const childDescendants = await getDescendants(
child,
fetcher,
depth + 1
);
descendants.push(...childDescendants);
}
}
if (depth < 20) {
const childDescendants = await getDescendants(
child,
fetcher,
depth + 1,
);
descendants.push(...childDescendants);
}
}
// Filter for posts that are viewable by the user
// Filter for posts that are viewable by the user
const viewableDescendants = descendants.filter(descendant =>
isViewableByUser(descendant, fetcher)
);
return viewableDescendants;
const viewableDescendants = descendants.filter((descendant) =>
isViewableByUser(descendant, fetcher),
);
return viewableDescendants;
};
/**
@ -201,250 +200,250 @@ export const getDescendants = async (
* @returns A promise that resolves with the new status.
*/
export const createNewStatus = async (data: {
account: User;
application: Application | null;
content: string;
visibility: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis?: Emoji[];
content_type?: string;
uri?: string;
mentions?: User[];
media_attachments?: string[];
reply?: {
status: Status;
user: User;
};
quote?: Status;
account: User;
application: Application | null;
content: string;
visibility: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis?: Emoji[];
content_type?: string;
uri?: string;
mentions?: User[];
media_attachments?: string[];
reply?: {
status: Status;
user: User;
};
quote?: Status;
}) => {
// Get people mentioned in the content (match @username or @username@domain.com mentions)
const mentionedPeople =
data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? [];
// Get people mentioned in the content (match @username or @username@domain.com mentions)
const mentionedPeople =
data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? [];
let mentions = data.mentions || [];
let mentions = data.mentions || [];
// Parse emojis
const emojis = await parseEmojis(data.content);
// Parse emojis
const emojis = await parseEmojis(data.content);
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
// Get list of mentioned users
if (mentions.length === 0) {
mentions = await client.user.findMany({
where: {
OR: mentionedPeople.map(person => ({
username: person.split("@")[1],
instance: {
base_url: person.split("@")[2],
},
})),
},
include: userRelations,
});
}
// Get list of mentioned users
if (mentions.length === 0) {
mentions = await client.user.findMany({
where: {
OR: mentionedPeople.map((person) => ({
username: person.split("@")[1],
instance: {
base_url: person.split("@")[2],
},
})),
},
include: userRelations,
});
}
let formattedContent;
let formattedContent = "";
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content))
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
// Parse as plaintext
formattedContent = linkifyStr(data.content);
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content)),
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
// Parse as plaintext
formattedContent = linkifyStr(data.content);
// Split by newline and add <p> tags
formattedContent = formattedContent
.split("\n")
.map(line => `<p>${line}</p>`)
.join("\n");
}
// Split by newline and add <p> tags
formattedContent = formattedContent
.split("\n")
.map((line) => `<p>${line}</p>`)
.join("\n");
}
let status = await client.status.create({
data: {
authorId: data.account.id,
applicationId: data.application?.id,
content: formattedContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
emojis: {
connect: data.emojis.map(emoji => {
return {
id: emoji.id,
};
}),
},
attachments: data.media_attachments
? {
connect: data.media_attachments.map(attachment => {
return {
id: attachment,
};
}),
}
: undefined,
inReplyToPostId: data.reply?.status.id,
quotingPostId: data.quote?.id,
instanceId: data.account.instanceId || undefined,
isReblog: false,
uri:
data.uri ||
`${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
mentions: {
connect: mentions.map(mention => {
return {
id: mention.id,
};
}),
},
},
include: statusAndUserRelations,
});
let status = await client.status.create({
data: {
authorId: data.account.id,
applicationId: data.application?.id,
content: formattedContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
emojis: {
connect: data.emojis.map((emoji) => {
return {
id: emoji.id,
};
}),
},
attachments: data.media_attachments
? {
connect: data.media_attachments.map((attachment) => {
return {
id: attachment,
};
}),
}
: undefined,
inReplyToPostId: data.reply?.status.id,
quotingPostId: data.quote?.id,
instanceId: data.account.instanceId || undefined,
isReblog: false,
uri:
data.uri ||
`${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
mentions: {
connect: mentions.map((mention) => {
return {
id: mention.id,
};
}),
},
},
include: statusAndUserRelations,
});
// Update URI
status = await client.status.update({
where: {
id: status.id,
},
data: {
uri: data.uri || `${config.http.base_url}/statuses/${status.id}`,
},
include: statusAndUserRelations,
});
// Update URI
status = await client.status.update({
where: {
id: status.id,
},
data: {
uri: data.uri || `${config.http.base_url}/statuses/${status.id}`,
},
include: statusAndUserRelations,
});
// Create notification
if (status.inReplyToPost) {
await client.notification.create({
data: {
notifiedId: status.inReplyToPost.authorId,
accountId: status.authorId,
type: "mention",
statusId: status.id,
},
});
}
// Create notification
if (status.inReplyToPost) {
await client.notification.create({
data: {
notifiedId: status.inReplyToPost.authorId,
accountId: status.authorId,
type: "mention",
statusId: status.id,
},
});
}
// Add to search index
await addStausToMeilisearch(status);
// Add to search index
await addStausToMeilisearch(status);
return status;
return status;
};
export const editStatus = async (
status: StatusWithRelations,
data: {
content: string;
visibility?: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis?: Emoji[];
content_type?: string;
uri?: string;
mentions?: User[];
media_attachments?: string[];
}
status: StatusWithRelations,
data: {
content: string;
visibility?: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis?: Emoji[];
content_type?: string;
uri?: string;
mentions?: User[];
media_attachments?: string[];
},
) => {
// Get people mentioned in the content (match @username or @username@domain.com mentions
const mentionedPeople =
data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? [];
// Get people mentioned in the content (match @username or @username@domain.com mentions
const mentionedPeople =
data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? [];
let mentions = data.mentions || [];
let mentions = data.mentions || [];
// Parse emojis
const emojis = await parseEmojis(data.content);
// Parse emojis
const emojis = await parseEmojis(data.content);
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
// Get list of mentioned users
if (mentions.length === 0) {
mentions = await client.user.findMany({
where: {
OR: mentionedPeople.map(person => ({
username: person.split("@")[1],
instance: {
base_url: person.split("@")[2],
},
})),
},
include: userRelations,
});
}
// Get list of mentioned users
if (mentions.length === 0) {
mentions = await client.user.findMany({
where: {
OR: mentionedPeople.map((person) => ({
username: person.split("@")[1],
instance: {
base_url: person.split("@")[2],
},
})),
},
include: userRelations,
});
}
let formattedContent;
let formattedContent = "";
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content))
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
// Parse as plaintext
formattedContent = linkifyStr(data.content);
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content)),
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
// Parse as plaintext
formattedContent = linkifyStr(data.content);
// Split by newline and add <p> tags
formattedContent = formattedContent
.split("\n")
.map(line => `<p>${line}</p>`)
.join("\n");
}
// Split by newline and add <p> tags
formattedContent = formattedContent
.split("\n")
.map((line) => `<p>${line}</p>`)
.join("\n");
}
const newStatus = await client.status.update({
where: {
id: status.id,
},
data: {
content: formattedContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
emojis: {
connect: data.emojis.map(emoji => {
return {
id: emoji.id,
};
}),
},
attachments: data.media_attachments
? {
connect: data.media_attachments.map(attachment => {
return {
id: attachment,
};
}),
}
: undefined,
mentions: {
connect: mentions.map(mention => {
return {
id: mention.id,
};
}),
},
},
include: statusAndUserRelations,
});
const newStatus = await client.status.update({
where: {
id: status.id,
},
data: {
content: formattedContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
emojis: {
connect: data.emojis.map((emoji) => {
return {
id: emoji.id,
};
}),
},
attachments: data.media_attachments
? {
connect: data.media_attachments.map((attachment) => {
return {
id: attachment,
};
}),
}
: undefined,
mentions: {
connect: mentions.map((mention) => {
return {
id: mention.id,
};
}),
},
},
include: statusAndUserRelations,
});
return newStatus;
return newStatus;
};
export const isFavouritedBy = async (status: Status, user: User) => {
return !!(await client.like.findFirst({
where: {
likerId: user.id,
likedId: status.id,
},
}));
return !!(await client.like.findFirst({
where: {
likerId: user.id,
likedId: status.id,
},
}));
};
/**
@ -452,67 +451,67 @@ export const isFavouritedBy = async (status: Status, user: User) => {
* @returns A promise that resolves with the API status.
*/
export const statusToAPI = async (
status: StatusWithRelations,
user?: UserWithRelations
status: StatusWithRelations,
user?: UserWithRelations,
): Promise<APIStatus> => {
return {
id: status.id,
in_reply_to_id: status.inReplyToPostId || null,
in_reply_to_account_id: status.inReplyToPost?.authorId || null,
// @ts-expect-error Prisma TypeScript types dont include relations
account: userToAPI(status.author),
created_at: new Date(status.createdAt).toISOString(),
application: status.application
? applicationToAPI(status.application)
: null,
card: null,
content: status.content,
emojis: status.emojis.map(emoji => emojiToAPI(emoji)),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourited: !!(status.likes ?? []).find(
like => like.likerId === user?.id
),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourites_count: (status.likes ?? []).length,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
media_attachments: (status.attachments ?? []).map(
a => attachmentToAPI(a) as APIAttachment
),
// @ts-expect-error Prisma TypeScript types dont include relations
mentions: status.mentions.map(mention => userToAPI(mention)),
language: null,
muted: user
? user.relationships.find(r => r.subjectId == status.authorId)
?.muting || false
: false,
pinned: status.pinnedBy.find(u => u.id === user?.id) ? true : false,
// TODO: Add pols
poll: null,
reblog: status.reblog
? await statusToAPI(status.reblog as unknown as StatusWithRelations)
: null,
reblogged: !!(await client.status.findFirst({
where: {
authorId: user?.id,
reblogId: status.id,
},
})),
reblogs_count: status._count.reblogs,
replies_count: status._count.replies,
sensitive: status.sensitive,
spoiler_text: status.spoilerText,
tags: [],
uri: `${config.http.base_url}/statuses/${status.id}`,
visibility: "public",
url: `${config.http.base_url}/statuses/${status.id}`,
bookmarked: false,
quote: status.quotingPost
? await statusToAPI(
status.quotingPost as unknown as StatusWithRelations
)
: null,
quote_id: status.quotingPost?.id || undefined,
};
return {
id: status.id,
in_reply_to_id: status.inReplyToPostId || null,
in_reply_to_account_id: status.inReplyToPost?.authorId || null,
// @ts-expect-error Prisma TypeScript types dont include relations
account: userToAPI(status.author),
created_at: new Date(status.createdAt).toISOString(),
application: status.application
? applicationToAPI(status.application)
: null,
card: null,
content: status.content,
emojis: status.emojis.map((emoji) => emojiToAPI(emoji)),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourited: !!(status.likes ?? []).find(
(like) => like.likerId === user?.id,
),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourites_count: (status.likes ?? []).length,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
media_attachments: (status.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment,
),
// @ts-expect-error Prisma TypeScript types dont include relations
mentions: status.mentions.map((mention) => userToAPI(mention)),
language: null,
muted: user
? user.relationships.find((r) => r.subjectId === status.authorId)
?.muting || false
: false,
pinned: status.pinnedBy.find((u) => u.id === user?.id) ? true : false,
// TODO: Add pols
poll: null,
reblog: status.reblog
? await statusToAPI(status.reblog as unknown as StatusWithRelations)
: null,
reblogged: !!(await client.status.findFirst({
where: {
authorId: user?.id,
reblogId: status.id,
},
})),
reblogs_count: status._count.reblogs,
replies_count: status._count.replies,
sensitive: status.sensitive,
spoiler_text: status.spoilerText,
tags: [],
uri: `${config.http.base_url}/statuses/${status.id}`,
visibility: "public",
url: `${config.http.base_url}/statuses/${status.id}`,
bookmarked: false,
quote: status.quotingPost
? await statusToAPI(
status.quotingPost as unknown as StatusWithRelations,
)
: null,
quote_id: status.quotingPost?.id || undefined,
};
};
/* export const statusToActivityPub = async (
@ -563,35 +562,35 @@ export const statusToAPI = async (
}; */
export const statusToLysand = (status: StatusWithRelations): Note => {
return {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: status.authorId,
uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`,
contents: [
{
content: status.content,
content_type: "text/html",
},
{
// Content converted to plaintext
content: htmlToText(status.content),
content_type: "text/plain",
},
],
// TODO: Add attachments
attachments: [],
is_sensitive: status.sensitive,
mentions: status.mentions.map(mention => mention.uri),
quotes: status.quotingPost ? [status.quotingPost.uri] : [],
replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [],
subject: status.spoilerText,
extensions: {
"org.lysand:custom_emojis": {
emojis: status.emojis.map(emoji => emojiToLysand(emoji)),
},
// TODO: Add polls and reactions
},
};
return {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: status.authorId,
uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`,
contents: [
{
content: status.content,
content_type: "text/html",
},
{
// Content converted to plaintext
content: htmlToText(status.content),
content_type: "text/plain",
},
],
// TODO: Add attachments
attachments: [],
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mention.uri),
quotes: status.quotingPost ? [status.quotingPost.uri] : [],
replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [],
subject: status.spoilerText,
extensions: {
"org.lysand:custom_emojis": {
emojis: status.emojis.map((emoji) => emojiToLysand(emoji)),
},
// TODO: Add polls and reactions
},
};
};

View file

@ -2,5 +2,5 @@
* The type of token.
*/
export enum TokenType {
BEARER = "Bearer",
BEARER = "Bearer",
}

View file

@ -1,20 +1,20 @@
import type { APIAccount } from "~types/entities/account";
import type { LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
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 { MediaBackendType } from "~packages/media-manager";
import type { APIAccount } from "~types/entities/account";
import type { APISource } from "~types/entities/source";
import type { LysandUser } from "~types/lysand/Object";
import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance";
import type { APISource } from "~types/entities/source";
import { addUserToMeilisearch } from "@meilisearch";
import { config, type Config } from "config-manager";
import { userRelations } from "./relations";
import { MediaBackendType } from "~packages/media-manager";
export interface AuthData {
user: UserWithRelations | null;
token: string;
user: UserWithRelations | null;
token: string;
}
/**
@ -23,7 +23,7 @@ export interface AuthData {
*/
const userRelations2 = Prisma.validator<Prisma.UserDefaultArgs>()({
include: userRelations,
include: userRelations,
});
export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
@ -34,14 +34,15 @@ export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
* @returns The raw URL for the user's avatar
*/
export const getAvatarUrl = (user: User, config: Config) => {
if (!user.avatar) return config.defaults.avatar;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.avatar}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${user.avatar}`;
}
return "";
if (!user.avatar) return config.defaults.avatar;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.avatar}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
}
if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${user.avatar}`;
}
return "";
};
/**
@ -50,128 +51,129 @@ export const getAvatarUrl = (user: User, config: Config) => {
* @returns The raw URL for the user's header
*/
export const getHeaderUrl = (user: User, config: Config) => {
if (!user.header) return config.defaults.header;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.header}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${user.header}`;
}
return "";
if (!user.header) return config.defaults.header;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.header}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
}
if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${user.header}`;
}
return "";
};
export const getFromRequest = async (req: Request): Promise<AuthData> => {
// Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
// Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
return { user: await retrieveUserFromToken(token), token };
return { user: await retrieveUserFromToken(token), token };
};
export const fetchRemoteUser = async (uri: string) => {
// Check if user not already in database
const foundUser = await client.user.findUnique({
where: {
uri,
},
include: userRelations,
});
// Check if user not already in database
const foundUser = await client.user.findUnique({
where: {
uri,
},
include: userRelations,
});
if (foundUser) return foundUser;
if (foundUser) return foundUser;
const response = await fetch(uri, {
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
const response = await fetch(uri, {
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
const data = (await response.json()) as Partial<LysandUser>;
const data = (await response.json()) as Partial<LysandUser>;
if (
!(
data.id &&
data.username &&
data.uri &&
data.created_at &&
data.disliked &&
data.featured &&
data.liked &&
data.followers &&
data.following &&
data.inbox &&
data.outbox &&
data.public_key
)
) {
throw new Error("Invalid user data");
}
if (
!(
data.id &&
data.username &&
data.uri &&
data.created_at &&
data.disliked &&
data.featured &&
data.liked &&
data.followers &&
data.following &&
data.inbox &&
data.outbox &&
data.public_key
)
) {
throw new Error("Invalid user data");
}
// Parse emojis and add them to database
const userEmojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
// Parse emojis and add them to database
const userEmojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
const user = await client.user.create({
data: {
username: data.username,
uri: data.uri,
createdAt: new Date(data.created_at),
endpoints: {
disliked: data.disliked,
featured: data.featured,
liked: data.liked,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
avatar: (data.avatar && data.avatar[0].content) || "",
header: (data.header && data.header[0].content) || "",
displayName: data.display_name ?? "",
note: data.bio?.[0].content ?? "",
publicKey: data.public_key.public_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
},
});
const user = await client.user.create({
data: {
username: data.username,
uri: data.uri,
createdAt: new Date(data.created_at),
endpoints: {
disliked: data.disliked,
featured: data.featured,
liked: data.liked,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
avatar: data.avatar?.[0].content || "",
header: data.header?.[0].content || "",
displayName: data.display_name ?? "",
note: data.bio?.[0].content ?? "",
publicKey: data.public_key.public_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
},
});
// Add to Meilisearch
await addUserToMeilisearch(user);
// Add to Meilisearch
await addUserToMeilisearch(user);
const emojis = [];
const emojis = [];
for (const emoji of userEmojis) {
emojis.push(await addEmojiIfNotExists(emoji));
}
for (const emoji of userEmojis) {
emojis.push(await addEmojiIfNotExists(emoji));
}
const uriData = new URL(data.uri);
const uriData = new URL(data.uri);
return await client.user.update({
where: {
id: user.id,
},
data: {
emojis: {
connect: emojis.map(emoji => ({
id: emoji.id,
})),
},
instanceId: (await addInstanceIfNotExists(uriData.origin)).id,
},
include: userRelations,
});
return await client.user.update({
where: {
id: user.id,
},
data: {
emojis: {
connect: emojis.map((emoji) => ({
id: emoji.id,
})),
},
instanceId: (await addInstanceIfNotExists(uriData.origin)).id,
},
include: userRelations,
});
};
/**
* Fetches the list of followers associated with the actor and updates the user's followers
*/
export const fetchFollowers = () => {
//
//
};
/**
@ -180,75 +182,75 @@ export const fetchFollowers = () => {
* @returns The newly created user.
*/
export const createNewLocalUser = async (data: {
username: string;
display_name?: string;
password: string;
email: string;
bio?: string;
avatar?: string;
header?: string;
admin?: boolean;
username: string;
display_name?: string;
password: string;
email: string;
bio?: string;
avatar?: string;
header?: string;
admin?: boolean;
}) => {
const keys = await generateUserKeys();
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,
uri: "",
publicKey: keys.public_key,
privateKey: keys.private_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
},
});
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,
uri: "",
publicKey: keys.public_key,
privateKey: keys.private_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
},
});
// Add to Meilisearch
await addUserToMeilisearch(user);
// Add to Meilisearch
await addUserToMeilisearch(user);
return await client.user.update({
where: {
id: user.id,
},
data: {
uri: `${config.http.base_url}/users/${user.id}`,
endpoints: {
disliked: `${config.http.base_url}/users/${user.id}/disliked`,
featured: `${config.http.base_url}/users/${user.id}/featured`,
liked: `${config.http.base_url}/users/${user.id}/liked`,
followers: `${config.http.base_url}/users/${user.id}/followers`,
following: `${config.http.base_url}/users/${user.id}/following`,
inbox: `${config.http.base_url}/users/${user.id}/inbox`,
outbox: `${config.http.base_url}/users/${user.id}/outbox`,
},
},
include: userRelations,
});
return await client.user.update({
where: {
id: user.id,
},
data: {
uri: `${config.http.base_url}/users/${user.id}`,
endpoints: {
disliked: `${config.http.base_url}/users/${user.id}/disliked`,
featured: `${config.http.base_url}/users/${user.id}/featured`,
liked: `${config.http.base_url}/users/${user.id}/liked`,
followers: `${config.http.base_url}/users/${user.id}/followers`,
following: `${config.http.base_url}/users/${user.id}/following`,
inbox: `${config.http.base_url}/users/${user.id}/inbox`,
outbox: `${config.http.base_url}/users/${user.id}/outbox`,
},
},
include: userRelations,
});
};
/**
* Parses mentions from a list of URIs
*/
export const parseMentionsUris = async (mentions: string[]) => {
return await client.user.findMany({
where: {
uri: {
in: mentions,
},
},
include: userRelations,
});
return await client.user.findMany({
where: {
uri: {
in: mentions,
},
},
include: userRelations,
});
};
/**
@ -257,22 +259,22 @@ export const parseMentionsUris = async (mentions: string[]) => {
* @returns The user associated with the given access token.
*/
export const retrieveUserFromToken = async (access_token: string) => {
if (!access_token) return null;
if (!access_token) return null;
const token = await client.token.findFirst({
where: {
access_token,
},
include: {
user: {
include: userRelations,
},
},
});
const token = await client.token.findFirst({
where: {
access_token,
},
include: {
user: {
include: userRelations,
},
},
});
if (!token) return null;
if (!token) return null;
return token.user;
return token.user;
};
/**
@ -281,174 +283,174 @@ export const retrieveUserFromToken = async (access_token: string) => {
* @returns The relationship to the other user.
*/
export const getRelationshipToOtherUser = async (
user: UserWithRelations,
other: User
user: UserWithRelations,
other: User,
) => {
return await client.relationship.findFirst({
where: {
ownerId: user.id,
subjectId: other.id,
},
});
return await client.relationship.findFirst({
where: {
ownerId: user.id,
subjectId: other.id,
},
});
};
/**
* Generates keys for the user.
*/
export const generateUserKeys = async () => {
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privateKey = btoa(
String.fromCharCode.apply(null, [
...new Uint8Array(
// jesus help me what do these letters mean
await crypto.subtle.exportKey("pkcs8", keys.privateKey)
),
])
);
const publicKey = btoa(
String.fromCharCode(
...new Uint8Array(
// why is exporting a key so hard
await crypto.subtle.exportKey("spki", keys.publicKey)
)
)
);
const privateKey = btoa(
String.fromCharCode.apply(null, [
...new Uint8Array(
// jesus help me what do these letters mean
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
),
]),
);
const publicKey = btoa(
String.fromCharCode(
...new Uint8Array(
// why is exporting a key so hard
await crypto.subtle.exportKey("spki", keys.publicKey),
),
),
);
// Add header, footer and newlines later on
// These keys are base64 encrypted
return {
private_key: privateKey,
public_key: publicKey,
};
// Add header, footer and newlines later on
// These keys are base64 encrypted
return {
private_key: privateKey,
public_key: publicKey,
};
};
export const userToAPI = (
user: UserWithRelations,
isOwnAccount = false
user: UserWithRelations,
isOwnAccount = false,
): APIAccount => {
return {
id: user.id,
username: user.username,
display_name: user.displayName,
note: user.note,
url: user.uri,
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)),
// TODO: Add fields
fields: [],
bot: user.isBot,
source:
isOwnAccount && user.source
? (user.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}`,
// TODO: Add these fields
limited: false,
moved: null,
noindex: false,
suspended: false,
discoverable: undefined,
mute_expires_at: undefined,
group: false,
pleroma: {
is_admin: user.isAdmin,
is_moderator: user.isAdmin,
},
};
return {
id: user.id,
username: user.username,
display_name: user.displayName,
note: user.note,
url: user.uri,
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)),
// TODO: Add fields
fields: [],
bot: user.isBot,
source:
isOwnAccount && user.source
? (user.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}`,
// TODO: Add these fields
limited: false,
moved: null,
noindex: false,
suspended: false,
discoverable: undefined,
mute_expires_at: undefined,
group: false,
pleroma: {
is_admin: user.isAdmin,
is_moderator: user.isAdmin,
},
};
};
/**
* Should only return local users
*/
export const userToLysand = (user: UserWithRelations): LysandUser => {
if (user.instanceId !== null) {
throw new Error("Cannot convert remote user to Lysand format");
}
if (user.instanceId !== null) {
throw new Error("Cannot convert remote user to Lysand format");
}
return {
id: user.id,
type: "User",
uri: user.uri,
bio: [
{
content: user.note,
content_type: "text/html",
},
{
content: htmlToText(user.note),
content_type: "text/plain",
},
],
created_at: new Date(user.createdAt).toISOString(),
disliked: `${user.uri}/disliked`,
featured: `${user.uri}/featured`,
liked: `${user.uri}/liked`,
followers: `${user.uri}/followers`,
following: `${user.uri}/following`,
inbox: `${user.uri}/inbox`,
outbox: `${user.uri}/outbox`,
indexable: false,
username: user.username,
avatar: [
{
content: getAvatarUrl(user, config) || "",
content_type: `image/${user.avatar.split(".")[1]}`,
},
],
header: [
{
content: getHeaderUrl(user, config) || "",
content_type: `image/${user.header.split(".")[1]}`,
},
],
display_name: user.displayName,
fields: (user.source as any as APISource).fields.map(field => ({
key: [
{
content: field.name,
content_type: "text/html",
},
{
content: htmlToText(field.name),
content_type: "text/plain",
},
],
value: [
{
content: field.value,
content_type: "text/html",
},
{
content: htmlToText(field.value),
content_type: "text/plain",
},
],
})),
public_key: {
actor: `${config.http.base_url}/users/${user.id}`,
public_key: user.publicKey,
},
extensions: {
"org.lysand:custom_emojis": {
emojis: user.emojis.map(emoji => emojiToLysand(emoji)),
},
},
};
return {
id: user.id,
type: "User",
uri: user.uri,
bio: [
{
content: user.note,
content_type: "text/html",
},
{
content: htmlToText(user.note),
content_type: "text/plain",
},
],
created_at: new Date(user.createdAt).toISOString(),
disliked: `${user.uri}/disliked`,
featured: `${user.uri}/featured`,
liked: `${user.uri}/liked`,
followers: `${user.uri}/followers`,
following: `${user.uri}/following`,
inbox: `${user.uri}/inbox`,
outbox: `${user.uri}/outbox`,
indexable: false,
username: user.username,
avatar: [
{
content: getAvatarUrl(user, config) || "",
content_type: `image/${user.avatar.split(".")[1]}`,
},
],
header: [
{
content: getHeaderUrl(user, config) || "",
content_type: `image/${user.header.split(".")[1]}`,
},
],
display_name: user.displayName,
fields: (user.source as APISource).fields.map((field) => ({
key: [
{
content: field.name,
content_type: "text/html",
},
{
content: htmlToText(field.name),
content_type: "text/plain",
},
],
value: [
{
content: field.value,
content_type: "text/html",
},
{
content: htmlToText(field.value),
content_type: "text/plain",
},
],
})),
public_key: {
actor: `${config.http.base_url}/users/${user.id}`,
public_key: user.publicKey,
},
extensions: {
"org.lysand:custom_emojis": {
emojis: user.emojis.map((emoji) => emojiToLysand(emoji)),
},
},
};
};

View file

@ -1,111 +1,111 @@
import type { Prisma } from "@prisma/client";
export const userRelations: Prisma.UserInclude = {
emojis: true,
instance: true,
likes: true,
relationships: true,
relationshipSubjects: true,
pinnedNotes: true,
_count: {
select: {
statuses: true,
likes: true,
},
},
emojis: true,
instance: true,
likes: true,
relationships: true,
relationshipSubjects: true,
pinnedNotes: true,
_count: {
select: {
statuses: true,
likes: true,
},
},
};
export const statusAndUserRelations: Prisma.StatusInclude = {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
reblogs: true,
attachments: true,
instance: true,
mentions: {
include: userRelations,
},
pinnedBy: true,
_count: {
select: {
replies: true,
likes: true,
reblogs: true,
},
},
reblog: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: {
include: userRelations,
},
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
quotingPost: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
likes: {
include: {
liker: true,
},
},
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
reblogs: true,
attachments: true,
instance: true,
mentions: {
include: userRelations,
},
pinnedBy: true,
_count: {
select: {
replies: true,
likes: true,
reblogs: true,
},
},
reblog: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: {
include: userRelations,
},
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
quotingPost: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
likes: {
include: {
liker: true,
},
},
};