refactor: 🚚 Rename functions, move getUrl to Attachment

This commit is contained in:
Jesse Wierzbinski 2024-06-28 17:50:56 -10:00
parent 11c3931007
commit d09f74e58a
No known key found for this signature in database
60 changed files with 93 additions and 96 deletions

View file

@ -0,0 +1,36 @@
import type { InferSelectModel } from "drizzle-orm";
import { db } from "~/drizzle/db";
import type { Applications } from "~/drizzle/schema";
import type { Application as apiApplication } from "~/types/mastodon/application";
export type Application = InferSelectModel<typeof Applications>;
/**
* Retrieves the application associated with the given access token.
* @param token The access token to retrieve the application for.
* @returns The application associated with the given access token, or null if no such application exists.
*/
export const getFromToken = async (
token: string,
): Promise<Application | null> => {
const result = await db.query.Tokens.findFirst({
where: (tokens, { eq }) => eq(tokens.accessToken, token),
with: {
application: true,
},
});
return result?.application || null;
};
/**
* Converts this application to an API application.
* @returns The API application representation of this application.
*/
export const applicationToApi = (app: Application): apiApplication => {
return {
name: app.name,
website: app.website,
vapid_key: app.vapidKey,
};
};

View file

@ -0,0 +1,27 @@
import { emojiValidatorWithColons } from "@/api";
import { type InferSelectModel, inArray } from "drizzle-orm";
import { Emojis, type Instances } from "~/drizzle/schema";
import { Emoji } from "~/packages/database-interface/emoji";
export type EmojiWithInstance = InferSelectModel<typeof Emojis> & {
instance: InferSelectModel<typeof Instances> | 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[]> => {
const matches = text.match(emojiValidatorWithColons);
if (!matches || matches.length === 0) {
return [];
}
return Emoji.manyFromSql(
inArray(
Emojis.shortcode,
matches.map((match) => match.replace(/:/g, "")),
),
);
};

View file

@ -0,0 +1,73 @@
import { debugRequest } from "@/api";
import { getLogger } from "@logtape/logtape";
import { SignatureConstructor } from "@lysand-org/federation";
import type { Entity, Undo } from "@lysand-org/federation/types";
import { config } from "config-manager";
import type { User } from "~/packages/database-interface/user";
export const localObjectUri = (id: string) =>
new URL(`/objects/${id}`, config.http.base_url).toString();
export const objectToInboxRequest = async (
object: Entity,
author: User,
userToSendTo: User,
): Promise<Request> => {
if (userToSendTo.isLocal() || !userToSendTo.data.endpoints?.inbox) {
throw new Error("UserToSendTo has no inbox or is a local user");
}
if (author.isRemote()) {
throw new Error("Author is a remote user");
}
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(author.data.privateKey ?? "", "base64"),
"Ed25519",
false,
["sign"],
);
const ctor = new SignatureConstructor(privateKey, author.getUri());
const userInbox = new URL(userToSendTo.data.endpoints?.inbox ?? "");
const request = new Request(userInbox, {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: new URL(config.http.base_url).host,
},
body: JSON.stringify(object),
});
const { request: signed, signedString } = await ctor.sign(request);
if (config.debug.federation) {
// Debug request
await debugRequest(signed);
const logger = getLogger("federation");
// Log public key
logger.debug`Sender public key: ${author.data.publicKey}`;
// Log signed string
logger.debug`Signed string:\n${signedString}`;
}
return signed;
};
export const undoFederationRequest = (undoer: User, uri: string): Undo => {
const id = crypto.randomUUID();
return {
type: "Undo",
id,
author: undoer.getUri(),
created_at: new Date().toISOString(),
object: uri,
uri: new URL(`/undos/${id}`, config.http.base_url).toString(),
};
};

View file

@ -0,0 +1,51 @@
import type { ServerMetadata } from "@lysand-org/federation/types";
import { db } from "~/drizzle/db";
import { Instances } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
/**
* Represents an instance in the database.
*/
/**
* Adds an instance to the database if it doesn't already exist.
* @param url
* @returns Either the database instance if it already exists, or a newly created instance.
*/
export const addInstanceIfNotExists = async (url: string) => {
const origin = new URL(url).origin;
const host = new URL(url).host;
const found = await db.query.Instances.findFirst({
where: (instance, { eq }) => eq(instance.baseUrl, host),
});
if (found) {
return found;
}
// Fetch the instance configuration
const metadata = (await fetch(new URL("/.well-known/lysand", origin), {
proxy: config.http.proxy.address,
}).then((res) => res.json())) as ServerMetadata;
if (metadata.type !== "ServerMetadata") {
throw new Error("Invalid instance metadata (wrong type)");
}
if (!(metadata.name && metadata.version)) {
throw new Error("Invalid instance metadata (missing name or version)");
}
return (
await db
.insert(Instances)
.values({
baseUrl: host,
name: metadata.name,
version: metadata.version,
logo: metadata.logo,
})
.returning()
)[0];
};

77
classes/functions/like.ts Normal file
View file

@ -0,0 +1,77 @@
import type { Like } from "@lysand-org/federation/types";
import { config } from "config-manager";
import { type InferSelectModel, and, eq } from "drizzle-orm";
import { db } from "~/drizzle/db";
import { Likes, Notifications } from "~/drizzle/schema";
import type { Note } from "~/packages/database-interface/note";
import type { User } from "~/packages/database-interface/user";
export type LikeType = InferSelectModel<typeof Likes>;
/**
* Represents a Like entity in the database.
*/
export const likeToLysand = (like: LikeType): Like => {
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: new URL(`/objects/${like.id}`, config.http.base_url).toString(),
};
};
/**
* Create a like
* @param user User liking the status
* @param note Status being liked
*/
export const createLike = async (user: User, note: Note) => {
await db.insert(Likes).values({
likedId: note.id,
likerId: user.id,
});
if (note.author.data.instanceId === user.data.instanceId) {
// Notify the user that their post has been favourited
await db.insert(Notifications).values({
accountId: user.id,
type: "favourite",
notifiedId: note.author.id,
noteId: note.id,
});
} else {
// TODO: Add database jobs for federating this
}
};
/**
* Delete a like
* @param user User deleting their like
* @param note Status being unliked
*/
export const deleteLike = async (user: User, note: Note) => {
await db
.delete(Likes)
.where(and(eq(Likes.likedId, note.id), eq(Likes.likerId, user.id)));
// Notify the user that their post has been favourited
await db
.delete(Notifications)
.where(
and(
eq(Notifications.accountId, user.id),
eq(Notifications.type, "favourite"),
eq(Notifications.notifiedId, note.author.id),
eq(Notifications.noteId, note.id),
),
);
if (user.isLocal() && note.author.isRemote()) {
// User is local, federate the delete
// TODO: Federate this
}
};

View file

@ -0,0 +1,64 @@
import type { InferSelectModel } from "drizzle-orm";
import { db } from "~/drizzle/db";
import type { Notifications } from "~/drizzle/schema";
import { Note } from "~/packages/database-interface/note";
import { User } from "~/packages/database-interface/user";
import type { Notification as apiNotification } from "~/types/mastodon/notification";
import type { StatusWithRelations } from "./status";
import {
type UserWithRelations,
transformOutputToUserWithRelations,
userExtrasTemplate,
userRelations,
} from "./user";
export type Notification = InferSelectModel<typeof Notifications>;
export type NotificationWithRelations = Notification & {
status: StatusWithRelations | null;
account: UserWithRelations;
};
export const findManyNotifications = async (
query: Parameters<typeof db.query.Notifications.findMany>[0],
userId?: string,
): Promise<NotificationWithRelations[]> => {
const output = await db.query.Notifications.findMany({
...query,
with: {
...query?.with,
account: {
with: {
...userRelations,
},
extras: userExtrasTemplate("Notifications_account"),
},
},
extras: {
...query?.extras,
},
});
return await Promise.all(
output.map(async (notif) => ({
...notif,
account: transformOutputToUserWithRelations(notif.account),
status: (await Note.fromId(notif.noteId, userId))?.data ?? null,
})),
);
};
export const notificationToApi = async (
notification: NotificationWithRelations,
): Promise<apiNotification> => {
const account = new User(notification.account);
return {
account: account.toApi(),
created_at: new Date(notification.createdAt).toISOString(),
id: notification.id,
type: notification.type,
status: notification.status
? await new Note(notification.status).toApi(account)
: undefined,
};
};

View file

@ -0,0 +1,96 @@
import type { InferSelectModel } from "drizzle-orm";
import { db } from "~/drizzle/db";
import { Relationships } from "~/drizzle/schema";
import type { User } from "~/packages/database-interface/user";
import type { Relationship as apiRelationship } from "~/types/mastodon/relationship";
export type Relationship = InferSelectModel<typeof Relationships> & {
requestedBy: boolean;
};
/**
* Creates a new relationship between two users.
* @param owner The user who owns the relationship.
* @param other The user who is the subject of the relationship.
* @returns The newly created relationship.
*/
export const createNewRelationship = async (
owner: User,
other: User,
): Promise<Relationship> => {
return {
...(
await db
.insert(Relationships)
.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],
requestedBy: false,
};
};
export const checkForBidirectionalRelationships = async (
user1: User,
user2: User,
createIfNotExists = true,
): Promise<boolean> => {
const relationship1 = await db.query.Relationships.findFirst({
where: (rel, { and, eq }) =>
and(eq(rel.ownerId, user1.id), eq(rel.subjectId, user2.id)),
});
const relationship2 = await db.query.Relationships.findFirst({
where: (rel, { and, eq }) =>
and(eq(rel.ownerId, user2.id), eq(rel.subjectId, user1.id)),
});
if (!relationship1 && createIfNotExists) {
await createNewRelationship(user1, user2);
}
if (!relationship2 && createIfNotExists) {
await createNewRelationship(user2, user1);
}
return !!relationship1 && !!relationship2;
};
/**
* Converts the relationship to an API-friendly format.
* @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,
requested_by: rel.requestedBy,
showing_reblogs: rel.showingReblogs,
note: rel.note,
};
};

461
classes/functions/status.ts Normal file
View file

@ -0,0 +1,461 @@
import { mentionValidator } from "@/api";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import { getLogger } from "@logtape/logtape";
import type { ContentFormat } from "@lysand-org/federation/types";
import { config } from "config-manager";
import {
type InferSelectModel,
and,
eq,
inArray,
isNull,
or,
sql,
} from "drizzle-orm";
import linkifyHtml from "linkify-html";
import {
anyOf,
charIn,
createRegExp,
digit,
exactly,
global,
letter,
} from "magic-regexp";
import MarkdownIt from "markdown-it";
import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right";
import { db } from "~/drizzle/db";
import {
type Attachments,
Instances,
type Notes,
Users,
} from "~/drizzle/schema";
import type { Note } from "~/packages/database-interface/note";
import { User } from "~/packages/database-interface/user";
import type { Application } from "./application";
import type { EmojiWithInstance } from "./emoji";
import { objectToInboxRequest } from "./federation";
import {
type UserWithInstance,
type UserWithRelations,
resolveWebFinger,
transformOutputToUserWithRelations,
userExtrasTemplate,
userRelations,
} from "./user";
export type Status = InferSelectModel<typeof Notes>;
export type StatusWithRelations = Status & {
author: UserWithRelations;
mentions: UserWithInstance[];
attachments: InferSelectModel<typeof Attachments>[];
reblog: StatusWithoutRecursiveRelations | null;
emojis: EmojiWithInstance[];
reply: Status | null;
quote: Status | null;
application: Application | null;
reblogCount: number;
likeCount: number;
replyCount: number;
pinned: boolean;
reblogged: boolean;
muted: boolean;
liked: boolean;
};
export type StatusWithoutRecursiveRelations = Omit<
StatusWithRelations,
"reply" | "quote" | "reblog"
>;
/**
* Wrapper against the Status object to make it easier to work with
* @param query
* @returns
*/
export const findManyNotes = async (
query: Parameters<typeof db.query.Notes.findMany>[0],
userId?: string,
): Promise<StatusWithRelations[]> => {
const output = await db.query.Notes.findMany({
...query,
with: {
...query?.with,
attachments: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
author: {
with: {
...userRelations,
},
extras: userExtrasTemplate("Notes_author"),
},
mentions: {
with: {
user: {
with: {
instance: true,
},
},
},
},
reblog: {
with: {
attachments: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
likes: true,
application: true,
mentions: {
with: {
user: {
with: userRelations,
extras: userExtrasTemplate(
"Notes_reblog_mentions_user",
),
},
},
},
author: {
with: {
...userRelations,
},
extras: userExtrasTemplate("Notes_reblog_author"),
},
},
extras: {
reblogCount:
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."reblogId" = "Notes_reblog".id)`.as(
"reblog_count",
),
likeCount:
sql`(SELECT COUNT(*) FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id)`.as(
"like_count",
),
replyCount:
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes_reblog".id)`.as(
"reply_count",
),
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes_reblog".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
},
},
reply: true,
quote: true,
},
extras: {
reblogCount:
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."reblogId" = "Notes".id)`.as(
"reblog_count",
),
likeCount:
sql`(SELECT COUNT(*) FROM "Likes" WHERE "Likes"."likedId" = "Notes".id)`.as(
"like_count",
),
replyCount:
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes".id)`.as(
"reply_count",
),
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
...query?.extras,
},
});
return output.map((post) => ({
...post,
author: transformOutputToUserWithRelations(post.author),
mentions: post.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
reblog: post.reblog && {
...post.reblog,
author: transformOutputToUserWithRelations(post.reblog.author),
mentions: post.reblog.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
reblogCount: Number(post.reblog.reblogCount),
likeCount: Number(post.reblog.likeCount),
replyCount: Number(post.reblog.replyCount),
pinned: Boolean(post.reblog.pinned),
reblogged: Boolean(post.reblog.reblogged),
muted: Boolean(post.reblog.muted),
liked: Boolean(post.reblog.liked),
},
reblogCount: Number(post.reblogCount),
likeCount: Number(post.likeCount),
replyCount: Number(post.replyCount),
pinned: Boolean(post.pinned),
reblogged: Boolean(post.reblogged),
muted: Boolean(post.muted),
liked: Boolean(post.liked),
}));
};
/**
* Get people mentioned in the content (match @username or @username@domain.com mentions)
* @param text The text to parse mentions from.
* @returns An array of users mentioned in the text.
*/
export const parseTextMentions = async (text: string): Promise<User[]> => {
const mentionedPeople = [...text.matchAll(mentionValidator)] ?? [];
if (mentionedPeople.length === 0) {
return [];
}
const baseUrlHost = new URL(config.http.base_url).host;
const isLocal = (host?: string) => host === baseUrlHost || !host;
const foundUsers = await db
.select({
id: Users.id,
username: Users.username,
baseUrl: Instances.baseUrl,
})
.from(Users)
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
.where(
or(
...mentionedPeople.map((person) =>
and(
eq(Users.username, person?.[1] ?? ""),
isLocal(person?.[2])
? isNull(Users.instanceId)
: eq(Instances.baseUrl, person?.[2] ?? ""),
),
),
),
);
const notFoundRemoteUsers = mentionedPeople.filter(
(person) =>
!(
isLocal(person?.[2]) ||
foundUsers.find(
(user) =>
user.username === person?.[1] &&
user.baseUrl === person?.[2],
)
),
);
const finalList =
foundUsers.length > 0
? await User.manyFromSql(
inArray(
Users.id,
foundUsers.map((u) => u.id),
),
)
: [];
// Attempt to resolve mentions that were not found
for (const person of notFoundRemoteUsers) {
const user = await resolveWebFinger(
person?.[1] ?? "",
person?.[2] ?? "",
);
if (user) {
finalList.push(user);
}
}
return finalList;
};
export const replaceTextMentions = (text: string, mentions: User[]) => {
let finalText = text;
for (const mention of mentions) {
const user = mention.data;
// Replace @username and @username@domain
if (user.instance) {
finalText = finalText.replace(
createRegExp(
exactly(`@${user.username}@${user.instance.baseUrl}`),
[global],
),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
user.username
}@${user.instance.baseUrl}</a>`,
);
} else {
finalText = finalText.replace(
// Only replace @username if it doesn't have another @ right after
createRegExp(
exactly(`@${user.username}`)
.notBefore(anyOf(letter, digit, charIn("@")))
.notAfter(anyOf(letter, digit, charIn("@"))),
[global],
),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
user.username
}</a>`,
);
finalText = finalText.replace(
createRegExp(
exactly(
`@${user.username}@${
new URL(config.http.base_url).host
}`,
),
[global],
),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
user.username
}</a>`,
);
}
}
return finalText;
};
export const contentToHtml = async (
content: ContentFormat,
mentions: User[] = [],
inline = false,
): Promise<string> => {
let htmlContent: string;
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
if (content["text/html"]) {
htmlContent = await sanitizer(content["text/html"].content);
} else if (content["text/markdown"]) {
htmlContent = await sanitizer(
await markdownParse(content["text/markdown"].content),
);
} else if (content["text/plain"]?.content) {
// Split by newline and add <p> tags
htmlContent = (await sanitizer(content["text/plain"].content))
.split("\n")
.map((line) => `<p>${line}</p>`)
.join("\n");
} else {
htmlContent = "";
}
// Replace mentions text
htmlContent = await replaceTextMentions(htmlContent, mentions ?? []);
// Linkify
htmlContent = linkifyHtml(htmlContent, {
defaultProtocol: "https",
validate: {
email: () => false,
},
target: "_blank",
rel: "nofollow noopener noreferrer",
});
return htmlContent;
};
export const markdownParse = async (content: string) => {
return (await getMarkdownRenderer()).render(content);
};
export const getMarkdownRenderer = () => {
const renderer = MarkdownIt({
html: true,
linkify: true,
});
renderer.use(markdownItTocDoneRight, {
containerClass: "toc",
level: [1, 2, 3, 4],
listType: "ul",
listClass: "toc-list",
itemClass: "toc-item",
linkClass: "toc-link",
});
renderer.use(markdownItTaskLists);
renderer.use(markdownItContainer);
return renderer;
};
export const federateNote = async (note: Note) => {
for (const user of await note.getUsersToFederateTo()) {
// TODO: Add queue system
const request = await objectToInboxRequest(
note.toLysand(),
note.author,
user,
);
// Send request
const response = await fetch(request, {
proxy: config.http.proxy.address,
});
if (!response.ok) {
const logger = getLogger("federation");
logger.debug`${await response.text()}`;
logger.error`Failed to federate status ${note.data.id} to ${user.getUri()}`;
}
}
};

View file

@ -0,0 +1,11 @@
import type { InferSelectModel } from "drizzle-orm";
import type { Tokens } from "~/drizzle/schema";
/**
* The type of token.
*/
export enum TokenType {
Bearer = "Bearer",
}
export type Token = InferSelectModel<typeof Tokens>;

552
classes/functions/user.ts Normal file
View file

@ -0,0 +1,552 @@
import { getLogger } from "@logtape/logtape";
import type {
Follow,
FollowAccept,
FollowReject,
} from "@lysand-org/federation/types";
import { config } from "config-manager";
import { type InferSelectModel, and, eq, sql } from "drizzle-orm";
import { db } from "~/drizzle/db";
import {
Applications,
Instances,
Notifications,
Relationships,
type Roles,
Tokens,
Users,
} from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user";
import type { Application } from "./application";
import type { EmojiWithInstance } from "./emoji";
import { objectToInboxRequest } from "./federation";
import {
type Relationship,
checkForBidirectionalRelationships,
createNewRelationship,
} from "./relationship";
import type { Token } from "./token";
export type UserType = InferSelectModel<typeof Users>;
export type UserWithInstance = UserType & {
instance: InferSelectModel<typeof Instances> | null;
};
export type UserWithRelations = UserType & {
instance: InferSelectModel<typeof Instances> | null;
emojis: EmojiWithInstance[];
followerCount: number;
followingCount: number;
statusCount: number;
roles: InferSelectModel<typeof Roles>[];
};
export const userRelations: {
instance: true;
emojis: {
with: {
emoji: {
with: {
instance: true;
};
};
};
};
roles: {
with: {
role: true;
};
};
} = {
instance: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
roles: {
with: {
role: true,
},
},
};
export const userExtras = {
followerCount:
sql`(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "Users".id AND "relationships"."following" = true))`.as(
"follower_count",
),
followingCount:
sql`(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "Users".id AND "relationshipSubjects"."following" = true))`.as(
"following_count",
),
statusCount:
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "Users".id)`.as(
"status_count",
),
};
export const userExtrasTemplate = (name: string) => ({
// @ts-ignore
followerCount: sql([
`(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "${name}".id AND "relationships"."following" = true))`,
]).as("follower_count"),
// @ts-ignore
followingCount: sql([
`(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "${name}".id AND "relationshipSubjects"."following" = true))`,
]).as("following_count"),
// @ts-ignore
statusCount: sql([
`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "${name}".id)`,
]).as("status_count"),
});
export interface AuthData {
user: User | null;
token: string;
application: Application | null;
}
export const getFromHeader = async (value: string): Promise<AuthData> => {
const token = value.split(" ")[1];
const { user, application } =
await retrieveUserAndApplicationFromToken(token);
return { user, token, application };
};
export const followRequestUser = async (
follower: User,
followee: User,
relationshipId: string,
reblogs = false,
notify = false,
languages: string[] = [],
): Promise<Relationship> => {
const isRemote = followee.isRemote();
await db
.update(Relationships)
.set({
following: isRemote ? false : !followee.data.isLocked,
requested: isRemote ? true : followee.data.isLocked,
showingReblogs: reblogs,
notifying: notify,
languages: languages,
})
.where(eq(Relationships.id, relationshipId));
// Set requested_by on other side
await db
.update(Relationships)
.set({
requestedBy: isRemote ? true : followee.data.isLocked,
followedBy: isRemote ? false : followee.data.isLocked,
})
.where(
and(
eq(Relationships.ownerId, followee.id),
eq(Relationships.subjectId, follower.id),
),
);
const updatedRelationship = await db.query.Relationships.findFirst({
where: (rel, { eq }) => eq(rel.id, relationshipId),
});
if (!updatedRelationship) {
throw new Error("Failed to update relationship");
}
if (isRemote) {
// Federate
// TODO: Make database job
const request = await objectToInboxRequest(
followRequestToLysand(follower, followee),
follower,
followee,
);
// Send request
const response = await fetch(request, {
proxy: config.http.proxy.address,
});
if (!response.ok) {
const logger = getLogger("federation");
logger.debug`${await response.text()}`;
logger.error`Failed to federate follow request from ${follower.id} to ${followee.getUri()}`;
await db
.update(Relationships)
.set({
following: false,
requested: false,
})
.where(eq(Relationships.id, relationshipId));
const result = await db.query.Relationships.findFirst({
where: (rel, { eq }) => eq(rel.id, relationshipId),
});
if (!result) {
throw new Error("Failed to update relationship");
}
return result;
}
} else {
await db.insert(Notifications).values({
accountId: follower.id,
type: followee.data.isLocked ? "follow_request" : "follow",
notifiedId: followee.id,
});
}
return updatedRelationship;
};
export const sendFollowAccept = async (follower: User, followee: User) => {
// TODO: Make database job
const request = await objectToInboxRequest(
followAcceptToLysand(follower, followee),
followee,
follower,
);
// Send request
const response = await fetch(request, {
proxy: config.http.proxy.address,
});
if (!response.ok) {
const logger = getLogger("federation");
logger.debug`${await response.text()}`;
logger.error`Failed to federate follow accept from ${followee.id} to ${follower.getUri()}`;
}
};
export const sendFollowReject = async (follower: User, followee: User) => {
// TODO: Make database job
const request = await objectToInboxRequest(
followRejectToLysand(follower, followee),
followee,
follower,
);
// Send request
const response = await fetch(request, {
proxy: config.http.proxy.address,
});
if (!response.ok) {
const logger = getLogger("federation");
logger.debug`${await response.text()}`;
logger.error`Failed to federate follow reject from ${followee.id} to ${follower.getUri()}`;
}
};
export const transformOutputToUserWithRelations = (
user: Omit<UserType, "endpoints"> & {
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
emojis: {
userId: string;
emojiId: string;
emoji?: EmojiWithInstance;
}[];
instance: InferSelectModel<typeof Instances> | null;
roles: {
userId: string;
roleId: string;
role?: InferSelectModel<typeof Roles>;
}[];
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,
),
roles: user.roles
.map((role) => role.role)
.filter(Boolean) as InferSelectModel<typeof Roles>[],
};
};
export const findManyUsers = async (
query: Parameters<typeof db.query.Users.findMany>[0],
): Promise<UserWithRelations[]> => {
const output = await db.query.Users.findMany({
...query,
with: {
...userRelations,
...query?.with,
},
extras: {
...userExtras,
...query?.extras,
},
});
return output.map((user) => transformOutputToUserWithRelations(user));
};
/**
* Resolves a WebFinger identifier to a user.
* @param identifier Either a UUID or a username
*/
export const resolveWebFinger = async (
identifier: string,
host: string,
): Promise<User | null> => {
// Check if user not already in database
const foundUser = await db
.select()
.from(Users)
.innerJoin(Instances, eq(Users.instanceId, Instances.id))
.where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host)))
.limit(1);
if (foundUser[0]) {
return await User.fromId(foundUser[0].Users.id);
}
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
const response = await fetch(
new URL(
`/.well-known/webfinger?${new URLSearchParams({
resource: `acct:${identifier}@${host}`,
})}`,
hostWithProtocol,
),
{
method: "GET",
headers: {
Accept: "application/json",
},
proxy: config.http.proxy.address,
},
);
if (response.status === 404) {
return null;
}
const data = (await response.json()) as {
subject: string;
links: {
rel: string;
type: string;
href: string;
}[];
};
if (!(data.subject && data.links)) {
throw new Error(
"Invalid WebFinger data (missing subject or links from response)",
);
}
const relevantLink = data.links.find((link) => link.rel === "self");
if (!relevantLink) {
throw new Error(
"Invalid WebFinger data (missing link with rel: 'self')",
);
}
return User.resolve(relevantLink.href);
};
/**
* Retrieves a user from a token.
* @param access_token The access token to retrieve the user from.
* @returns The user associated with the given access token.
*/
export const retrieveUserFromToken = async (
accessToken: string,
): Promise<User | null> => {
if (!accessToken) {
return null;
}
const token = await retrieveToken(accessToken);
if (!token?.userId) {
return null;
}
const user = await User.fromId(token.userId);
return user;
};
export const retrieveUserAndApplicationFromToken = async (
accessToken: string,
): Promise<{
user: User | null;
application: Application | null;
}> => {
if (!accessToken) {
return { user: null, application: null };
}
const output = (
await db
.select({
token: Tokens,
application: Applications,
})
.from(Tokens)
.leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
.where(eq(Tokens.accessToken, accessToken))
.limit(1)
)[0];
if (!output?.token.userId) {
return { user: null, application: null };
}
const user = await User.fromId(output.token.userId);
return { user, application: output.application ?? null };
};
export const retrieveToken = async (
accessToken: string,
): Promise<Token | null> => {
if (!accessToken) {
return null;
}
return (
(await db.query.Tokens.findFirst({
where: (tokens, { eq }) => eq(tokens.accessToken, accessToken),
})) ?? null
);
};
/**
* Gets the relationship to another user.
* @param other The other user to get the relationship to.
* @returns The relationship to the other user.
*/
export const getRelationshipToOtherUser = async (
user: User,
other: User,
): Promise<Relationship> => {
await checkForBidirectionalRelationships(user, other);
const foundRelationship = await db.query.Relationships.findFirst({
where: (relationship, { and, eq }) =>
and(
eq(relationship.ownerId, user.id),
eq(relationship.subjectId, other.id),
),
});
if (!foundRelationship) {
// Create new relationship
const newRelationship = await createNewRelationship(user, other);
return newRelationship;
}
return foundRelationship;
};
export const followRequestToLysand = (
follower: User,
followee: User,
): Follow => {
if (follower.isRemote()) {
throw new Error("Follower must be a local user");
}
if (!followee.isRemote()) {
throw new Error("Followee must be a remote user");
}
if (!followee.data.uri) {
throw new Error("Followee must have a URI in database");
}
const id = crypto.randomUUID();
return {
type: "Follow",
id: id,
author: follower.getUri(),
followee: followee.getUri(),
created_at: new Date().toISOString(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
};
};
export const followAcceptToLysand = (
follower: User,
followee: User,
): FollowAccept => {
if (!follower.isRemote()) {
throw new Error("Follower must be a remote user");
}
if (followee.isRemote()) {
throw new Error("Followee must be a local user");
}
if (!follower.data.uri) {
throw new Error("Follower must have a URI in database");
}
const id = crypto.randomUUID();
return {
type: "FollowAccept",
id: id,
author: followee.getUri(),
created_at: new Date().toISOString(),
follower: follower.getUri(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
};
};
export const followRejectToLysand = (
follower: User,
followee: User,
): FollowReject => {
return {
...followAcceptToLysand(follower, followee),
type: "FollowReject",
};
};