refactor(database): 🎨 Refactor note handling into its own class instead of separate functions

This commit is contained in:
Jesse Wierzbinski 2024-04-16 18:09:21 -10:00
parent 2998cb4deb
commit 9081036c6d
No known key found for this signature in database
36 changed files with 1194 additions and 1215 deletions

View file

@ -2,37 +2,19 @@
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
"organizeImports": {
"enabled": true,
"ignore": [
"node_modules/**/*",
"dist/**/*",
"packages/frontend/.output",
"packages/frontend/.nuxt",
"glitch"
]
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
},
"ignore": [
"node_modules/**/*",
"dist/**/*",
"packages/frontend/.output",
"packages/frontend/.nuxt",
"glitch"
]
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"ignore": [
"node_modules/**/*",
"dist/**/*",
"packages/frontend/.output",
"packages/frontend/.nuxt",
"glitch"
]
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
}
}

BIN
bun.lockb

Binary file not shown.

36
cli.ts
View file

@ -8,28 +8,20 @@ import { CliBuilder, CliCommand } from "cli-parser";
import { CliParameterType } from "cli-parser/cli-builder.type";
import Table from "cli-table";
import { config } from "config-manager";
import {
type SQL,
eq,
inArray,
isNotNull,
isNull,
like,
sql,
} from "drizzle-orm";
import { type SQL, and, eq, inArray, like, or, sql } from "drizzle-orm";
import extract from "extract-zip";
import { MediaBackend } from "media-manager";
import { lookup } from "mime-types";
import { getUrl } from "~database/entities/Attachment";
import { findFirstStatuses, findManyStatuses } from "~database/entities/Status";
import {
type User,
createNewLocalUser,
findFirstUser,
findManyUsers,
} from "~database/entities/User";
import { db, client } from "~drizzle/db";
import { client, db } from "~drizzle/db";
import { emoji, openIdAccount, status, user } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
await client.connect();
const args = process.argv;
@ -803,9 +795,7 @@ const cliBuilder = new CliBuilder([
return 1;
}
const note = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const note = await Note.fromId(id);
if (!note) {
console.log(`${chalk.red("✗")} Note not found`);
@ -815,7 +805,7 @@ const cliBuilder = new CliBuilder([
if (!args.noconfirm) {
process.stdout.write(
`Are you sure you want to delete note ${chalk.blue(
note.id,
note.getStatus().id,
)}?\n${chalk.red(
chalk.bold(
"This is a destructive action and cannot be undone!",
@ -832,10 +822,12 @@ const cliBuilder = new CliBuilder([
}
}
await db.delete(status).where(eq(status.id, note.id));
await note.delete();
console.log(
`${chalk.green("✓")} Deleted note ${chalk.blue(note.id)}`,
`${chalk.green("✓")} Deleted note ${chalk.blue(
note.getStatus().id,
)}`,
);
return 0;
@ -968,8 +960,8 @@ const cliBuilder = new CliBuilder([
instanceQuery = sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NOT NULL)`;
}
const notes = await findManyStatuses({
where: (status, { or, and }) =>
const notes = (
await Note.manyFromSql(
and(
or(
...fields.map((field) =>
@ -979,8 +971,10 @@ const cliBuilder = new CliBuilder([
),
instanceQuery,
),
limit: Number(limit),
});
undefined,
Number(limit),
)
).map((n) => n.getStatus());
if (redact) {
for (const note of notes) {

View file

@ -2,6 +2,8 @@ import { config } from "config-manager";
import type * as Lysand from "lysand-types";
import { type User, getUserUri } from "./User";
export const localObjectURI = (id: string) => `/objects/${id}`;
export const objectToInboxRequest = async (
object: Lysand.Entity,
author: User,

View file

@ -1,12 +1,9 @@
import type { InferSelectModel } from "drizzle-orm";
import { db } from "~drizzle/db";
import type { notification } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
import type { Notification as APINotification } from "~types/mastodon/notification";
import {
type StatusWithRelations,
findFirstStatuses,
statusToAPI,
} from "./Status";
import type { StatusWithRelations } from "./Status";
import {
type UserWithRelations,
transformOutputToUserWithRelations,
@ -45,12 +42,7 @@ export const findManyNotifications = async (
output.map(async (notif) => ({
...notif,
account: transformOutputToUserWithRelations(notif.account),
status: notif.statusId
? await findFirstStatuses({
where: (status, { eq }) =>
eq(status.id, notif.statusId ?? ""),
})
: null,
status: (await Note.fromId(notif.statusId))?.getStatus() ?? null,
})),
);
};
@ -64,7 +56,9 @@ export const notificationToAPI = async (
id: notification.id,
type: notification.type,
status: notification.status
? await statusToAPI(notification.status, notification.account)
? await Note.fromStatus(notification.status).toAPI(
notification.account,
)
: undefined,
};
};

View file

@ -1,6 +1,5 @@
import { config } from "config-manager";
// import { Worker } from "bullmq";
import { type StatusWithRelations, statusToLysand } from "./Status";
/* export const federationWorker = new Worker(
"federation",

View file

@ -27,38 +27,31 @@ import {
import { parse } from "marked";
import { db } from "~drizzle/db";
import {
type application,
attachment,
emojiToStatus,
instance,
type like,
notification,
status,
statusToMentions,
user,
} from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
import { LogLevel } from "~packages/log-manager";
import type { Note } from "~types/lysand/Object";
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
import type { Status as APIStatus } from "~types/mastodon/status";
import { type Application, applicationToAPI } from "./Application";
import {
attachmentFromLysand,
attachmentToAPI,
attachmentToLysand,
} from "./Attachment";
import type { Application } from "./Application";
import { attachmentFromLysand, attachmentToLysand } from "./Attachment";
import {
type EmojiWithInstance,
emojiToAPI,
emojiToLysand,
fetchEmoji,
parseEmojis,
} from "./Emoji";
import { objectToInboxRequest } from "./Federation";
import type { Like } from "./Like";
import {
type User,
type UserWithInstance,
type UserWithRelations,
type UserWithRelationsAndRelationships,
findManyUsers,
getUserUri,
resolveUser,
@ -66,21 +59,20 @@ import {
transformOutputToUserWithRelations,
userExtrasTemplate,
userRelations,
userToAPI,
} from "./User";
export type Status = InferSelectModel<typeof status>;
export type StatusWithRelations = Status & {
author: UserWithRelations;
mentions: UserWithRelations[];
mentions: UserWithInstance[];
attachments: InferSelectModel<typeof attachment>[];
reblog: StatusWithoutRecursiveRelations | null;
emojis: EmojiWithInstance[];
likes: InferSelectModel<typeof like>[];
inReplyTo: StatusWithoutRecursiveRelations | null;
quoting: StatusWithoutRecursiveRelations | null;
application: InferSelectModel<typeof application> | null;
likes: Like[];
inReplyTo: Status | null;
quoting: Status | null;
application: Application | null;
reblogCount: number;
likeCount: number;
replyCount: number;
@ -88,15 +80,10 @@ export type StatusWithRelations = Status & {
export type StatusWithoutRecursiveRelations = Omit<
StatusWithRelations,
| "inReplyTo"
| "quoting"
| "reblog"
| "reblogCount"
| "likeCount"
| "replyCount"
"inReplyTo" | "quoting" | "reblog"
>;
export const statusExtras = {
export const noteExtras = {
reblogCount:
sql`(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."reblogId" = "status".id)`.as(
"reblog_count",
@ -111,49 +98,12 @@ export const statusExtras = {
),
};
export const statusExtrasTemplate = (name: string) => ({
// @ts-ignore
reblogCount: sql([
`(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."reblogId" = ${name}.id)`,
]).as("reblog_count"),
// @ts-ignore
likeCount: sql([
`(SELECT COUNT(*) FROM "Like" "like" WHERE "like"."likedId" = ${name}.id)`,
]).as("like_count"),
// @ts-ignore
replyCount: sql([
`(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."inReplyToPostId" = ${name}.id)`,
]).as("reply_count"),
});
/**
* Returns whether this status is viewable by a user.
* @param user The user to check.
* @returns Whether this status is viewable by the user.
* Wrapper against the Status object to make it easier to work with
* @param query
* @returns
*/
export const isViewableByUser = async (
status: StatusWithRelations,
user: UserWithRelations | null,
) => {
if (status.authorId === user?.id) return true;
if (status.visibility === "public") return true;
if (status.visibility === "unlisted") return true;
if (status.visibility === "private") {
return user
? await db.query.relationship.findFirst({
where: (relationship, { and, eq }) =>
and(
eq(relationship.ownerId, user?.id),
eq(relationship.subjectId, status.authorId),
eq(relationship.following, true),
),
})
: false;
}
return user && status.mentions.includes(user);
};
export const findManyStatuses = async (
export const findManyNotes = async (
query: Parameters<typeof db.query.status.findMany>[0],
): Promise<StatusWithRelations[]> => {
const output = await db.query.status.findMany({
@ -182,8 +132,9 @@ export const findManyStatuses = async (
mentions: {
with: {
user: {
with: userRelations,
extras: userExtrasTemplate("status_mentions_user"),
with: {
instance: true,
},
},
},
},
@ -218,74 +169,15 @@ export const findManyStatuses = async (
extras: userExtrasTemplate("status_reblog_author"),
},
},
},
inReplyTo: {
with: {
attachments: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
likes: true,
application: true,
mentions: {
with: {
user: {
with: userRelations,
extras: userExtrasTemplate(
"status_inReplyTo_mentions_user",
),
},
},
},
author: {
with: {
...userRelations,
},
extras: userExtrasTemplate("status_inReplyTo_author"),
},
},
},
quoting: {
with: {
attachments: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
likes: true,
application: true,
mentions: {
with: {
user: {
with: userRelations,
extras: userExtrasTemplate(
"status_quoting_mentions_user",
),
},
},
},
author: {
with: {
...userRelations,
},
extras: userExtrasTemplate("status_quoting_author"),
},
extras: {
...noteExtras,
},
},
inReplyTo: true,
quoting: true,
},
extras: {
...statusExtras,
...noteExtras,
...query?.extras,
},
});
@ -293,65 +185,38 @@ export const findManyStatuses = async (
return output.map((post) => ({
...post,
author: transformOutputToUserWithRelations(post.author),
mentions: post.mentions.map(
(mention) =>
mention.user &&
transformOutputToUserWithRelations(mention.user),
),
reblog: post.reblog && {
...post.reblog,
author: transformOutputToUserWithRelations(post.reblog.author),
mentions: post.reblog.mentions.map(
(mention) =>
mention.user &&
transformOutputToUserWithRelations(mention.user),
),
emojis: post.reblog.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
},
inReplyTo: post.inReplyTo && {
...post.inReplyTo,
author: transformOutputToUserWithRelations(post.inReplyTo.author),
mentions: post.inReplyTo.mentions.map(
(mention) =>
mention.user &&
transformOutputToUserWithRelations(mention.user),
),
emojis: post.inReplyTo.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
},
quoting: post.quoting && {
...post.quoting,
author: transformOutputToUserWithRelations(post.quoting.author),
mentions: post.quoting.mentions.map(
(mention) =>
mention.user &&
transformOutputToUserWithRelations(mention.user),
),
emojis: post.quoting.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
},
mentions: post.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints as User["endpoints"],
})),
emojis: (post.emojis ?? []).map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
reblog: post.reblog && {
...post.reblog,
author: transformOutputToUserWithRelations(post.reblog.author),
mentions: post.reblog.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints as User["endpoints"],
})),
emojis: (post.reblog.emojis ?? []).map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
reblogCount: Number(post.reblog.reblogCount),
likeCount: Number(post.reblog.likeCount),
replyCount: Number(post.reblog.replyCount),
},
reblogCount: Number(post.reblogCount),
likeCount: Number(post.likeCount),
replyCount: Number(post.replyCount),
}));
};
export const findFirstStatuses = async (
export const findFirstNote = async (
query: Parameters<typeof db.query.status.findFirst>[0],
): Promise<StatusWithRelations | null> => {
const output = await db.query.status.findFirst({
@ -380,8 +245,9 @@ export const findFirstStatuses = async (
mentions: {
with: {
user: {
with: userRelations,
extras: userExtrasTemplate("status_mentions_user"),
with: {
instance: true,
},
},
},
},
@ -416,74 +282,15 @@ export const findFirstStatuses = async (
extras: userExtrasTemplate("status_reblog_author"),
},
},
},
inReplyTo: {
with: {
attachments: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
likes: true,
application: true,
mentions: {
with: {
user: {
with: userRelations,
extras: userExtrasTemplate(
"status_inReplyTo_mentions_user",
),
},
},
},
author: {
with: {
...userRelations,
},
extras: userExtrasTemplate("status_inReplyTo_author"),
},
},
},
quoting: {
with: {
attachments: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
likes: true,
application: true,
mentions: {
with: {
user: {
with: userRelations,
extras: userExtrasTemplate(
"status_quoting_mentions_user",
),
},
},
},
author: {
with: {
...userRelations,
},
extras: userExtrasTemplate("status_quoting_author"),
},
extras: {
...noteExtras,
},
},
inReplyTo: true,
quoting: true,
},
extras: {
...statusExtras,
...noteExtras,
...query?.extras,
},
});
@ -493,74 +300,48 @@ export const findFirstStatuses = async (
return {
...output,
author: transformOutputToUserWithRelations(output.author),
mentions: output.mentions.map((mention) =>
transformOutputToUserWithRelations(mention.user),
),
reblog: output.reblog && {
...output.reblog,
author: transformOutputToUserWithRelations(output.reblog.author),
mentions: output.reblog.mentions.map(
(mention) =>
mention.user &&
transformOutputToUserWithRelations(mention.user),
),
emojis: output.reblog.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
},
inReplyTo: output.inReplyTo && {
...output.inReplyTo,
author: transformOutputToUserWithRelations(output.inReplyTo.author),
mentions: output.inReplyTo.mentions.map(
(mention) =>
mention.user &&
transformOutputToUserWithRelations(mention.user),
),
emojis: output.inReplyTo.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
},
quoting: output.quoting && {
...output.quoting,
author: transformOutputToUserWithRelations(output.quoting.author),
mentions: output.quoting.mentions.map(
(mention) =>
mention.user &&
transformOutputToUserWithRelations(mention.user),
),
emojis: output.quoting.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
},
mentions: output.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints as User["endpoints"],
})),
emojis: (output.emojis ?? []).map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
reblog: output.reblog && {
...output.reblog,
author: transformOutputToUserWithRelations(output.reblog.author),
mentions: output.reblog.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints as User["endpoints"],
})),
emojis: (output.reblog.emojis ?? []).map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
reblogCount: Number(output.reblog.reblogCount),
likeCount: Number(output.reblog.likeCount),
replyCount: Number(output.reblog.replyCount),
},
reblogCount: Number(output.reblogCount),
likeCount: Number(output.likeCount),
replyCount: Number(output.replyCount),
};
};
export const resolveStatus = async (
export const resolveNote = async (
uri?: string,
providedNote?: Lysand.Note,
): Promise<StatusWithRelations> => {
): Promise<Note> => {
if (!uri && !providedNote) {
throw new Error("No URI or note provided");
}
const foundStatus = await findFirstStatuses({
where: (status, { eq }) =>
eq(status.uri, uri ?? providedNote?.uri ?? ""),
});
const foundStatus = await Note.fromSql(
eq(status.uri, uri ?? providedNote?.uri ?? ""),
);
if (foundStatus) return foundStatus;
@ -632,7 +413,7 @@ export const resolveStatus = async (
}
}
const createdStatus = await createNewStatus(
const createdNote = await Note.fromData(
author,
note.content ?? {
"text/plain": {
@ -652,86 +433,19 @@ export const resolveStatus = async (
) as Promise<UserWithRelations>[],
),
attachments.map((a) => a.id),
note.replies_to ? await resolveStatus(note.replies_to) : undefined,
note.quotes ? await resolveStatus(note.quotes) : undefined,
note.replies_to
? (await resolveNote(note.replies_to)).getStatus().id
: undefined,
note.quotes
? (await resolveNote(note.quotes)).getStatus().id
: undefined,
);
if (!createdStatus) {
if (!createdNote) {
throw new Error("Failed to create status");
}
return createdStatus;
};
/**
* Return all the ancestors of this post,
*/
export const getAncestors = async (
status: StatusWithRelations,
fetcher: UserWithRelationsAndRelationships | null,
) => {
const ancestors: StatusWithRelations[] = [];
let currentStatus = status;
while (currentStatus.inReplyToPostId) {
const parent = await findFirstStatuses({
where: (status, { eq }) =>
eq(status.id, currentStatus.inReplyToPostId ?? ""),
});
if (!parent) break;
ancestors.push(parent);
currentStatus = parent;
}
// Filter for posts that are viewable by the user
const viewableAncestors = ancestors.filter((ancestor) =>
isViewableByUser(ancestor, fetcher),
);
return viewableAncestors;
};
/**
* Return all the descendants of this post (recursive)
* Temporary implementation, will be replaced with a recursive SQL query when Prisma adds support for it
*/
export const getDescendants = async (
status: StatusWithRelations,
fetcher: UserWithRelationsAndRelationships | null,
depth = 0,
) => {
const descendants: StatusWithRelations[] = [];
const currentStatus = status;
// Fetch all children of children of children recursively calling getDescendants
const children = await findManyStatuses({
where: (status, { eq }) => eq(status.inReplyToPostId, currentStatus.id),
});
for (const child of children) {
descendants.push(child);
if (depth < 20) {
const childDescendants = await getDescendants(
child,
fetcher,
depth + 1,
);
descendants.push(...childDescendants);
}
}
// Filter for posts that are viewable by the user
const viewableDescendants = descendants.filter((descendant) =>
isViewableByUser(descendant, fetcher),
);
return viewableDescendants;
return createdNote;
};
export const createMentionRegExp = () =>
@ -907,122 +621,12 @@ export const contentToHtml = async (
return htmlContent;
};
/**
* Creates a new status and saves it to the database.
* @returns A promise that resolves with the new status.
*/
export const createNewStatus = async (
author: User,
content: Lysand.ContentFormat,
visibility: APIStatus["visibility"],
is_sensitive: boolean,
spoiler_text: string,
emojis: EmojiWithInstance[],
uri?: string,
mentions?: UserWithRelations[],
/** List of IDs of database Attachment objects */
media_attachments?: string[],
inReplyTo?: StatusWithRelations,
quoting?: StatusWithRelations,
application?: Application,
): Promise<StatusWithRelations | null> => {
const htmlContent = await contentToHtml(content, mentions);
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
if (author.instanceId === null) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
(emoji, index, self) =>
index === self.findIndex((t) => t.id === emoji.id),
);
}
const newStatus = (
await db
.insert(status)
.values({
authorId: author.id,
content: htmlContent,
contentSource:
content["text/plain"]?.content ||
content["text/markdown"]?.content ||
Object.entries(content)[0][1].content ||
"",
contentType: "text/html",
visibility,
sensitive: is_sensitive,
spoilerText: spoiler_text,
uri: uri || null,
inReplyToPostId: inReplyTo?.id,
quotingPostId: quoting?.id,
applicationId: application?.id ?? null,
updatedAt: new Date().toISOString(),
})
.returning()
)[0];
// Connect emojis
for (const emoji of foundEmojis) {
await db
.insert(emojiToStatus)
.values({
emojiId: emoji.id,
statusId: newStatus.id,
})
.execute();
}
// Connect mentions
for (const mention of mentions ?? []) {
await db
.insert(statusToMentions)
.values({
statusId: newStatus.id,
userId: mention.id,
})
.execute();
}
// Set attachment parents
if (media_attachments && media_attachments.length > 0) {
await db
.update(attachment)
.set({
statusId: newStatus.id,
})
.where(inArray(attachment.id, media_attachments));
}
// Send notifications for mentioned local users
for (const mention of mentions ?? []) {
if (mention.instanceId === null) {
await db.insert(notification).values({
accountId: author.id,
notifiedId: mention.id,
type: "mention",
statusId: newStatus.id,
});
}
}
return (
(await findFirstStatuses({
where: (status, { eq }) => eq(status.id, newStatus.id),
})) || null
);
};
export const federateStatus = async (status: StatusWithRelations) => {
const toFederateTo = await getUsersToFederateTo(status);
for (const user of toFederateTo) {
export const federateNote = async (note: Note) => {
for (const user of await note.getUsersToFederateTo()) {
// TODO: Add queue system
const request = await objectToInboxRequest(
statusToLysand(status),
status.author,
note.toLysand(),
note.getAuthor(),
user,
);
@ -1038,57 +642,14 @@ export const federateStatus = async (status: StatusWithRelations) => {
dualLogger.log(
LogLevel.ERROR,
"Federation.Status",
`Failed to federate status ${status.id} to ${user.uri}`,
`Failed to federate status ${note.getStatus().id} to ${
user.uri
}`,
);
}
}
};
export const getUsersToFederateTo = async (
status: StatusWithRelations,
): Promise<UserWithRelations[]> => {
// Mentioned users
const mentionedUsers =
status.mentions.length > 0
? await findManyUsers({
where: (user, { or, and, isNotNull, eq, inArray }) =>
and(
isNotNull(user.instanceId),
inArray(
user.id,
status.mentions.map((mention) => mention.id),
),
),
with: {
...userRelations,
},
})
: [];
const usersThatCanSeePost = await findManyUsers({
where: (user, { isNotNull }) => isNotNull(user.instanceId),
with: {
...userRelations,
relationships: {
where: (relationship, { eq, and }) =>
and(
eq(relationship.subjectId, user.id),
eq(relationship.following, true),
),
},
},
});
const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost];
const deduplicatedUsersById = fusedUsers.filter(
(user, index, self) =>
index === self.findIndex((t) => t.id === user.id),
);
return deduplicatedUsersById;
};
export const editStatus = async (
statusToEdit: StatusWithRelations,
data: {
@ -1102,7 +663,7 @@ export const editStatus = async (
mentions?: User[];
media_attachments?: string[];
},
): Promise<StatusWithRelations | null> => {
): Promise<Note | null> => {
const mentions = await parseTextMentions(data.content);
// Parse emojis
@ -1122,20 +683,20 @@ export const editStatus = async (
},
});
const updated = (
await db
.update(status)
.set({
content: htmlContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
})
.where(eq(status.id, statusToEdit.id))
.returning()
)[0];
const note = await Note.fromId(statusToEdit.id);
if (!note) {
return null;
}
const updated = await note.update({
content: htmlContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
});
// Connect emojis
for (const emoji of data.emojis) {
@ -1179,11 +740,7 @@ export const editStatus = async (
})
.where(inArray(attachment.id, data.media_attachments ?? []));
return (
(await findFirstStatuses({
where: (status, { eq }) => eq(status.id, updated.id),
})) || null
);
return await Note.fromId(updated.id);
};
export const isFavouritedBy = async (status: Status, user: User) => {
@ -1193,128 +750,6 @@ export const isFavouritedBy = async (status: Status, user: User) => {
}));
};
/**
* Converts this status to an API status.
* @returns A promise that resolves with the API status.
*/
export const statusToAPI = async (
statusToConvert: StatusWithRelations,
userFetching?: UserWithRelations,
): Promise<APIStatus> => {
const wasPinnedByUser = userFetching
? !!(await db.query.userPinnedNotes.findFirst({
where: (relation, { and, eq }) =>
and(
eq(relation.statusId, statusToConvert.id),
eq(relation.userId, userFetching?.id),
),
}))
: false;
const wasRebloggedByUser = userFetching
? !!(await db.query.status.findFirst({
where: (status, { eq, and }) =>
and(
eq(status.authorId, userFetching?.id),
eq(status.reblogId, statusToConvert.id),
),
}))
: false;
const wasMutedByUser = userFetching
? !!(await db.query.relationship.findFirst({
where: (relationship, { and, eq }) =>
and(
eq(relationship.ownerId, userFetching.id),
eq(relationship.subjectId, statusToConvert.authorId),
eq(relationship.muting, true),
),
}))
: false;
// Convert mentions of local users from @username@host to @username
const mentionedLocalUsers = statusToConvert.mentions.filter(
(mention) => mention.instanceId === null,
);
let replacedContent = statusToConvert.content;
for (const mention of mentionedLocalUsers) {
replacedContent = replacedContent.replace(
createRegExp(
exactly(
`@${mention.username}@${
new URL(config.http.base_url).host
}`,
),
[global],
),
`@${mention.username}`,
);
}
return {
id: statusToConvert.id,
in_reply_to_id: statusToConvert.inReplyToPostId || null,
in_reply_to_account_id: statusToConvert.inReplyTo?.authorId || null,
account: userToAPI(statusToConvert.author),
created_at: new Date(statusToConvert.createdAt).toISOString(),
application: statusToConvert.application
? applicationToAPI(statusToConvert.application)
: null,
card: null,
content: replacedContent,
emojis: statusToConvert.emojis.map((emoji) => emojiToAPI(emoji)),
favourited: !!(statusToConvert.likes ?? []).find(
(like) => like.likerId === userFetching?.id,
),
favourites_count: (statusToConvert.likes ?? []).length,
media_attachments: (statusToConvert.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment,
),
mentions: statusToConvert.mentions.map((mention) => userToAPI(mention)),
language: null,
muted: wasMutedByUser,
pinned: wasPinnedByUser,
// TODO: Add polls
poll: null,
reblog: statusToConvert.reblog
? await statusToAPI(
statusToConvert.reblog as unknown as StatusWithRelations,
userFetching,
)
: null,
reblogged: wasRebloggedByUser,
reblogs_count: statusToConvert.reblogCount,
replies_count: statusToConvert.replyCount,
sensitive: statusToConvert.sensitive,
spoiler_text: statusToConvert.spoilerText,
tags: [],
uri:
statusToConvert.uri ||
new URL(
`/@${statusToConvert.author.username}/${statusToConvert.id}`,
config.http.base_url,
).toString(),
visibility: statusToConvert.visibility as APIStatus["visibility"],
url:
statusToConvert.uri ||
new URL(
`/@${statusToConvert.author.username}/${statusToConvert.id}`,
config.http.base_url,
).toString(),
bookmarked: false,
quote: !!statusToConvert.quotingPostId /* statusToConvert.quoting
? await statusToAPI(
statusToConvert.quoting as unknown as StatusWithRelations,
userFetching,
)
: null, */,
// @ts-expect-error Pleroma extension
quote_id: statusToConvert.quotingPostId || undefined,
};
};
export const getStatusUri = (status?: Status | null) => {
if (!status) return undefined;

View file

@ -17,6 +17,7 @@ import {
} from "~drizzle/schema";
import { LogLevel } from "~packages/log-manager";
import type { Account as APIAccount } from "~types/mastodon/account";
import type { Mention as APIMention } from "~types/mastodon/mention";
import type { Source as APISource } from "~types/mastodon/source";
import type { Application } from "./Application";
import {
@ -30,16 +31,10 @@ import { addInstanceIfNotExists } from "./Instance";
import { createNewRelationship } from "./Relationship";
import type { Token } from "./Token";
export type User = InferSelectModel<typeof user> & {
endpoints?: Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}>;
export type User = InferSelectModel<typeof user>;
export type UserWithInstance = User & {
instance: InferSelectModel<typeof instance> | null;
};
export type UserWithRelations = User & {
@ -109,21 +104,6 @@ export const userExtrasTemplate = (name: string) => ({
]).as("status_count"),
});
/* const a = await db.query.user.findFirst({
with: {
instance: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
},
},
},
},
},
}); */
export interface AuthData {
user: UserWithRelations | null;
token: string;
@ -774,6 +754,16 @@ export const generateUserKeys = async () => {
};
};
export const userToMention = (user: UserWithInstance): APIMention => ({
url: getUserUri(user),
username: user.username,
acct:
user.instance === null
? user.username
: `${user.username}@${user.instance.baseUrl}`,
id: user.id,
});
export const userToAPI = (
userToConvert: UserWithRelations,
isOwnAccount = false,

View file

@ -289,7 +289,15 @@ export const user = pgTable(
email: text("email"),
note: text("note").default("").notNull(),
isAdmin: boolean("is_admin").default(false).notNull(),
endpoints: jsonb("endpoints"),
endpoints: jsonb("endpoints").$type<Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}> | null>(),
source: jsonb("source").notNull(),
avatar: text("avatar").notNull(),
header: text("header").notNull(),

39
drizzle/types.ts Normal file
View file

@ -0,0 +1,39 @@
import type {
BuildQueryResult,
DBQueryConfig,
ExtractTablesWithRelations,
} from "drizzle-orm";
import type * as schema from "./schema";
type Schema = typeof schema;
type TablesWithRelations = ExtractTablesWithRelations<Schema>;
export type IncludeRelation<TableName extends keyof TablesWithRelations> =
DBQueryConfig<
"one" | "many",
boolean,
TablesWithRelations,
TablesWithRelations[TableName]
>["with"];
export type IncludeColumns<TableName extends keyof TablesWithRelations> =
DBQueryConfig<
"one" | "many",
boolean,
TablesWithRelations,
TablesWithRelations[TableName]
>["columns"];
export type InferQueryModel<
TableName extends keyof TablesWithRelations,
Columns extends IncludeColumns<TableName> | undefined = undefined,
With extends IncludeRelation<TableName> | undefined = undefined,
> = BuildQueryResult<
TablesWithRelations,
TablesWithRelations[TableName],
{
columns: Columns;
with: With;
}
>;

View file

@ -0,0 +1,4 @@
import { Note } from "./note";
import { Timeline } from "./timeline";
export { Note, Timeline };

View file

@ -0,0 +1,618 @@
import {
type InferInsertModel,
type SQL,
and,
desc,
eq,
inArray,
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
import type * as Lysand from "lysand-types";
import { createRegExp, exactly, global } from "magic-regexp";
import {
type Application,
applicationToAPI,
} from "~database/entities/Application";
import {
attachmentToAPI,
attachmentToLysand,
} from "~database/entities/Attachment";
import {
type EmojiWithInstance,
emojiToAPI,
emojiToLysand,
parseEmojis,
} from "~database/entities/Emoji";
import { localObjectURI } from "~database/entities/Federation";
import {
type Status,
type StatusWithRelations,
contentToHtml,
findFirstNote,
findManyNotes,
getStatusUri,
} from "~database/entities/Status";
import {
type User,
type UserWithRelations,
type UserWithRelationsAndRelationships,
findManyUsers,
getUserUri,
userToAPI,
userToMention,
} from "~database/entities/User";
import { db } from "~drizzle/db";
import {
attachment,
emojiToStatus,
notification,
status,
statusToMentions,
user,
userRelations,
} from "~drizzle/schema";
import { config } from "~packages/config-manager";
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
import type { Status as APIStatus } from "~types/mastodon/status";
/**
* Gives helpers to fetch notes from database in a nice format
*/
export class Note {
private constructor(private status: StatusWithRelations) {}
static async fromId(id: string | null): Promise<Note | null> {
if (!id) return null;
return await Note.fromSql(eq(status.id, id));
}
static async fromIds(ids: string[]): Promise<Note[]> {
return await Note.manyFromSql(inArray(status.id, ids));
}
static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(status.id),
) {
const found = await findFirstNote({
where: sql,
orderBy,
});
if (!found) return null;
return new Note(found);
}
static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(status.id),
limit?: number,
offset?: number,
) {
const found = await findManyNotes({
where: sql,
orderBy,
limit,
offset,
});
return found.map((s) => new Note(s));
}
async getUsersToFederateTo() {
// Mentioned users
const mentionedUsers =
this.getStatus().mentions.length > 0
? await findManyUsers({
where: (user, { and, isNotNull, inArray }) =>
and(
isNotNull(user.instanceId),
inArray(
user.id,
this.getStatus().mentions.map(
(mention) => mention.id,
),
),
),
})
: [];
const usersThatCanSeePost = await findManyUsers({
where: (user, { isNotNull }) => isNotNull(user.instanceId),
with: {
...userRelations,
relationships: {
where: (relationship, { eq, and }) =>
and(
eq(relationship.subjectId, user.id),
eq(relationship.following, true),
),
},
},
});
const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost];
const deduplicatedUsersById = fusedUsers.filter(
(user, index, self) =>
index === self.findIndex((t) => t.id === user.id),
);
return deduplicatedUsersById;
}
static fromStatus(status: StatusWithRelations) {
return new Note(status);
}
static fromStatuses(statuses: StatusWithRelations[]) {
return statuses.map((s) => new Note(s));
}
isNull() {
return this.status === null;
}
getStatus() {
return this.status;
}
getAuthor() {
return this.status.author;
}
async getReplyChildren() {
return await Note.manyFromSql(
eq(status.inReplyToPostId, this.status.id),
);
}
async unpin(unpinner: User) {
return await db
.delete(statusToMentions)
.where(
and(
eq(statusToMentions.statusId, this.status.id),
eq(statusToMentions.userId, unpinner.id),
),
);
}
static async insert(values: InferInsertModel<typeof status>) {
return (await db.insert(status).values(values).returning())[0];
}
static async fromData(
author: User,
content: Lysand.ContentFormat,
visibility: APIStatus["visibility"],
is_sensitive: boolean,
spoiler_text: string,
emojis: EmojiWithInstance[],
uri?: string,
mentions?: UserWithRelations[],
/** List of IDs of database Attachment objects */
media_attachments?: string[],
replyId?: string,
quoteId?: string,
application?: Application,
): Promise<Note | null> {
const htmlContent = await contentToHtml(content, mentions);
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
if (author.instanceId === null) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
(emoji, index, self) =>
index === self.findIndex((t) => t.id === emoji.id),
);
}
const newNote = await Note.insert({
authorId: author.id,
content: htmlContent,
contentSource:
content["text/plain"]?.content ||
content["text/markdown"]?.content ||
Object.entries(content)[0][1].content ||
"",
contentType: "text/html",
visibility,
sensitive: is_sensitive,
spoilerText: spoiler_text,
uri: uri || null,
inReplyToPostId: replyId ?? null,
quotingPostId: quoteId ?? null,
applicationId: application?.id ?? null,
});
// Connect emojis
for (const emoji of foundEmojis) {
await db
.insert(emojiToStatus)
.values({
emojiId: emoji.id,
statusId: newNote.id,
})
.execute();
}
// Connect mentions
for (const mention of mentions ?? []) {
await db
.insert(statusToMentions)
.values({
statusId: newNote.id,
userId: mention.id,
})
.execute();
}
// Set attachment parents
if (media_attachments && media_attachments.length > 0) {
await db
.update(attachment)
.set({
statusId: newNote.id,
})
.where(inArray(attachment.id, media_attachments));
}
// Send notifications for mentioned local users
for (const mention of mentions ?? []) {
if (mention.instanceId === null) {
await db.insert(notification).values({
accountId: author.id,
notifiedId: mention.id,
type: "mention",
statusId: newNote.id,
});
}
}
return await Note.fromId(newNote.id);
}
async updateFromData(
content?: Lysand.ContentFormat,
visibility?: APIStatus["visibility"],
is_sensitive?: boolean,
spoiler_text?: string,
emojis: EmojiWithInstance[] = [],
mentions: UserWithRelations[] = [],
/** List of IDs of database Attachment objects */
media_attachments: string[] = [],
) {
const htmlContent = content
? await contentToHtml(content, mentions)
: undefined;
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
if (this.getAuthor().instanceId === null && htmlContent) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
(emoji, index, self) =>
index === self.findIndex((t) => t.id === emoji.id),
);
}
const newNote = await this.update({
content: htmlContent,
contentSource: content
? content["text/plain"]?.content ||
content["text/markdown"]?.content ||
Object.entries(content)[0][1].content ||
""
: undefined,
contentType: "text/html",
visibility,
sensitive: is_sensitive,
spoilerText: spoiler_text,
});
// Connect emojis
await db
.delete(emojiToStatus)
.where(eq(emojiToStatus.statusId, this.status.id));
for (const emoji of foundEmojis) {
await db
.insert(emojiToStatus)
.values({
emojiId: emoji.id,
statusId: this.status.id,
})
.execute();
}
// Connect mentions
await db
.delete(statusToMentions)
.where(eq(statusToMentions.statusId, this.status.id));
for (const mention of mentions ?? []) {
await db
.insert(statusToMentions)
.values({
statusId: this.status.id,
userId: mention.id,
})
.execute();
}
// Set attachment parents
if (media_attachments && media_attachments.length > 0) {
await db
.update(attachment)
.set({
statusId: this.status.id,
})
.where(inArray(attachment.id, media_attachments));
}
return await Note.fromId(newNote.id);
}
async delete() {
return (
await db
.delete(status)
.where(eq(status.id, this.status.id))
.returning()
)[0];
}
async update(newStatus: Partial<Status>) {
return (
await db
.update(status)
.set(newStatus)
.where(eq(status.id, this.status.id))
.returning()
)[0];
}
static async deleteMany(ids: string[]) {
return await db
.delete(status)
.where(inArray(status.id, ids))
.returning();
}
/**
* Returns whether this status is viewable by a user.
* @param user The user to check.
* @returns Whether this status is viewable by the user.
*/
async isViewableByUser(user: UserWithRelations | null) {
if (this.getAuthor().id === user?.id) return true;
if (this.getStatus().visibility === "public") return true;
if (this.getStatus().visibility === "unlisted") return true;
if (this.getStatus().visibility === "private") {
return user
? await db.query.relationship.findFirst({
where: (relationship, { and, eq }) =>
and(
eq(relationship.ownerId, user?.id),
eq(relationship.subjectId, status.authorId),
eq(relationship.following, true),
),
})
: false;
}
return (
user &&
this.getStatus().mentions.find((mention) => mention.id === user.id)
);
}
async toAPI(userFetching?: UserWithRelations | null): Promise<APIStatus> {
const data = this.getStatus();
const wasPinnedByUser = userFetching
? !!(await db.query.userPinnedNotes.findFirst({
where: (relation, { and, eq }) =>
and(
eq(relation.statusId, data.id),
eq(relation.userId, userFetching?.id),
),
}))
: false;
const wasRebloggedByUser = userFetching
? !!(await Note.fromSql(
and(
eq(status.authorId, userFetching?.id),
eq(status.reblogId, data.id),
),
))
: false;
const wasMutedByUser = userFetching
? !!(await db.query.relationship.findFirst({
where: (relationship, { and, eq }) =>
and(
eq(relationship.ownerId, userFetching.id),
eq(relationship.subjectId, data.authorId),
eq(relationship.muting, true),
),
}))
: false;
// Convert mentions of local users from @username@host to @username
const mentionedLocalUsers = data.mentions.filter(
(mention) => mention.instanceId === null,
);
let replacedContent = data.content;
for (const mention of mentionedLocalUsers) {
replacedContent = replacedContent.replace(
createRegExp(
exactly(
`@${mention.username}@${
new URL(config.http.base_url).host
}`,
),
[global],
),
`@${mention.username}`,
);
}
return {
id: data.id,
in_reply_to_id: data.inReplyToPostId || null,
in_reply_to_account_id: data.inReplyTo?.authorId || null,
account: userToAPI(data.author),
created_at: new Date(data.createdAt).toISOString(),
application: data.application
? applicationToAPI(data.application)
: null,
card: null,
content: replacedContent,
emojis: data.emojis.map((emoji) => emojiToAPI(emoji)),
favourited: !!(data.likes ?? []).find(
(like) => like.likerId === userFetching?.id,
),
favourites_count: (data.likes ?? []).length,
media_attachments: (data.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment,
),
mentions: data.mentions.map((mention) => userToMention(mention)),
language: null,
muted: wasMutedByUser,
pinned: wasPinnedByUser,
// TODO: Add polls
poll: null,
reblog: data.reblog
? await Note.fromStatus(
data.reblog as StatusWithRelations,
).toAPI(userFetching)
: null,
reblogged: wasRebloggedByUser,
reblogs_count: data.reblogCount,
replies_count: data.replyCount,
sensitive: data.sensitive,
spoiler_text: data.spoilerText,
tags: [],
uri:
data.uri ||
new URL(
`/@${data.author.username}/${data.id}`,
config.http.base_url,
).toString(),
visibility: data.visibility as APIStatus["visibility"],
url: data.uri || this.getMastoURI(),
bookmarked: false,
quote: !!data.quotingPostId,
// @ts-expect-error Pleroma extension
quote_id: data.quotingPostId || undefined,
};
}
getURI() {
return localObjectURI(this.getStatus().id);
}
getMastoURI() {
return `/@${this.getAuthor().username}/${this.getStatus().id}`;
}
toLysand(): Lysand.Note {
const status = this.getStatus();
return {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: getUserUri(status.author),
uri: this.getURI(),
content: {
"text/html": {
content: status.content,
},
"text/plain": {
content: htmlToText(status.content),
},
},
attachments: (status.attachments ?? []).map((attachment) =>
attachmentToLysand(attachment),
),
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mention.uri || ""),
quotes: getStatusUri(status.quoting) ?? undefined,
replies_to: getStatusUri(status.inReplyTo) ?? undefined,
subject: status.spoilerText,
visibility: status.visibility as Lysand.Visibility,
extensions: {
"org.lysand:custom_emojis": {
emojis: status.emojis.map((emoji) => emojiToLysand(emoji)),
},
// TODO: Add polls and reactions
},
};
}
/**
* Return all the ancestors of this post,
*/
async getAncestors(fetcher: UserWithRelationsAndRelationships | null) {
const ancestors: Note[] = [];
let currentStatus: Note = this;
while (currentStatus.getStatus().inReplyToPostId) {
const parent = await Note.fromId(
currentStatus.getStatus().inReplyToPostId,
);
if (!parent) {
break;
}
ancestors.push(parent);
currentStatus = parent;
}
// Filter for posts that are viewable by the user
const viewableAncestors = ancestors.filter((ancestor) =>
ancestor.isViewableByUser(fetcher),
);
return viewableAncestors;
}
/**
* Return all the descendants of this post (recursive)
* Temporary implementation, will be replaced with a recursive SQL query when I get to it
*/
async getDescendants(
fetcher: UserWithRelationsAndRelationships | null,
depth = 0,
) {
const descendants: Note[] = [];
for (const child of await this.getReplyChildren()) {
descendants.push(child);
if (depth < 20) {
const childDescendants = await child.getDescendants(
fetcher,
depth + 1,
);
descendants.push(...childDescendants);
}
}
// Filter for posts that are viewable by the user
const viewableDescendants = descendants.filter((descendant) =>
descendant.isViewableByUser(fetcher),
);
return viewableDescendants;
}
}

View file

@ -0,0 +1,6 @@
{
"name": "database-interface",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,85 @@
import { type SQL, gt } from "drizzle-orm";
import { status } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import { Note } from "./note";
enum TimelineType {
NOTE = "Note",
}
export class Timeline {
constructor(private type: TimelineType) {}
static async getNoteTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
) {
return new Timeline(TimelineType.NOTE).fetchTimeline(sql, limit, url);
}
private async fetchTimeline<T>(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
) {
const objects: Note[] = [];
switch (this.type) {
case TimelineType.NOTE:
objects.push(
...(await Note.manyFromSql(sql, undefined, limit)),
);
break;
}
const linkHeader = [];
const urlWithoutQuery = new URL(
new URL(url).pathname,
config.http.base_url,
).toString();
if (objects.length > 0) {
switch (this.type) {
case TimelineType.NOTE: {
const objectBefore = await Note.fromSql(
gt(status.id, objects[0].getStatus().id),
);
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${
objects[0].getStatus().id
}>; rel="prev"`,
);
}
if (objects.length >= (limit ?? 20)) {
const objectAfter = await Note.fromSql(
gt(
status.id,
objects[objects.length - 1].getStatus().id,
),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${
limit ?? 20
}&max_id=${
objects[objects.length - 1].getStatus().id
}>; rel="next"`,
);
}
}
break;
}
}
}
return {
link: linkHeader.join(", "),
objects,
};
}
}

View file

@ -1,13 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
import { z } from "zod";
import {
type StatusWithRelations,
findManyStatuses,
statusToAPI,
} from "~database/entities/Status";
import { findFirstUser } from "~database/entities/User";
import { status } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -62,32 +59,23 @@ export default apiRoute<typeof meta, typeof schema>(
if (!user) return errorResponse("User not found", 404);
if (pinned) {
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-ignore
where: (status, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
eq(status.authorId, id),
sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`,
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
const { objects, link } = await Timeline.getNoteTimeline(
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
eq(status.authorId, id),
sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`,
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
),
limit,
req.url,
);
return jsonResponse(
await Promise.all(
objects.map((status) => statusToAPI(status, user)),
),
await Promise.all(objects.map((note) => note.toAPI(user))),
200,
{
Link: link,
@ -95,32 +83,23 @@ export default apiRoute<typeof meta, typeof schema>(
);
}
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-ignore
where: (status, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
eq(status.authorId, id),
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
exclude_reblogs ? eq(status.reblogId, null) : undefined,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
const { objects, link } = await Timeline.getNoteTimeline(
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
eq(status.authorId, id),
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
exclude_reblogs ? isNull(status.reblogId) : undefined,
),
limit,
req.url,
);
return jsonResponse(
await Promise.all(
objects.map((status) => statusToAPI(status, user)),
),
await Promise.all(objects.map((note) => note.toAPI(user))),
200,
{
Link: link,

View file

@ -1,12 +1,9 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import {
type StatusWithRelations,
findManyStatuses,
statusToAPI,
} from "~database/entities/Status";
import { status } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -35,28 +32,19 @@ export default apiRoute<typeof meta, typeof schema>(
if (!user) return errorResponse("Unauthorized", 401);
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-ignore
where: (status, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
const { objects, link } = await Timeline.getNoteTimeline(
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`,
),
limit,
req.url,
);
return jsonResponse(
await Promise.all(
objects.map(async (status) => statusToAPI(status, user)),
),
await Promise.all(objects.map(async (note) => note.toAPI(user))),
200,
{
Link: link,

View file

@ -1,13 +1,8 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import type { Relationship } from "~database/entities/Relationship";
import {
findFirstStatuses,
getAncestors,
getDescendants,
statusToAPI,
} from "~database/entities/Status";
import { db } from "~drizzle/db";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -34,9 +29,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const foundStatus = await Note.fromId(id);
if (!foundStatus) return errorResponse("Record not found", 404);
@ -55,8 +48,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
: null;
// Get all ancestors
const ancestors = await getAncestors(
foundStatus,
const ancestors = await foundStatus.getAncestors(
user
? {
...user,
@ -65,8 +57,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
}
: null,
);
const descendants = await getDescendants(
foundStatus,
const descendants = await foundStatus.getDescendants(
user
? {
...user,
@ -78,10 +70,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
return jsonResponse({
ancestors: await Promise.all(
ancestors.map((status) => statusToAPI(status, user || undefined)),
ancestors.map((status) => status.toAPI(user)),
),
descendants: await Promise.all(
descendants.map((status) => statusToAPI(status, user || undefined)),
descendants.map((status) => status.toAPI(user)),
),
});
});

View file

@ -1,12 +1,8 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { createLike } from "~database/entities/Like";
import {
findFirstStatuses,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { db } from "~drizzle/db";
import { Note } from "~packages/database-interface/note";
import type { Status as APIStatus } from "~types/mastodon/status";
export const meta = applyConfig({
@ -34,26 +30,27 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401);
const status = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const status = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
if (!status?.isViewableByUser(user))
return errorResponse("Record not found", 404);
const existingLike = await db.query.like.findFirst({
where: (like, { and, eq }) =>
and(eq(like.likedId, status.id), eq(like.likerId, user.id)),
and(
eq(like.likedId, status.getStatus().id),
eq(like.likerId, user.id),
),
});
if (!existingLike) {
await createLike(user, status);
await createLike(user, status.getStatus());
}
return jsonResponse({
...(await statusToAPI(status, user)),
...(await status.toAPI(user)),
favourited: true,
favourites_count: status.likeCount + 1,
favourites_count: status.getStatus().likeCount + 1,
} as APIStatus);
});

View file

@ -2,12 +2,12 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
import {
type UserWithRelations,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -40,12 +40,10 @@ export default apiRoute<typeof meta, typeof schema>(
const { user } = extraData.auth;
const status = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const status = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
if (!status?.isViewableByUser(user))
return errorResponse("Record not found", 404);
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
@ -59,7 +57,9 @@ export default apiRoute<typeof meta, typeof schema>(
max_id ? lt(liker.id, max_id) : undefined,
since_id ? gte(liker.id, since_id) : undefined,
min_id ? gt(liker.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${liker.id})`,
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${
status.getStatus().id
} AND "Like"."likerId" = ${liker.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),

View file

@ -1,19 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import { config } from "config-manager";
import { eq } from "drizzle-orm";
import ISO6391 from "iso-639-1";
import { parse } from "marked";
import { z } from "zod";
import {
editStatus,
findFirstStatuses,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { db } from "~drizzle/db";
import { status } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["GET", "DELETE", "PUT"],
@ -31,7 +22,7 @@ export const meta = applyConfig({
export const schema = z.object({
status: z.string().max(config.validation.max_note_size).optional(),
// TODO: Add regex to validate
content_type: z.string().optional(),
content_type: z.string().optional().default("text/plain"),
media_ids: z
.array(z.string().regex(idValidator))
.max(config.validation.max_media_attachments)
@ -65,49 +56,37 @@ export default apiRoute<typeof meta, typeof schema>(
const { user } = extraData.auth;
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const foundStatus = await Note.fromId(id);
const config = await extraData.configManager.getConfig();
// Check if user is authorized to view this status (if it's private)
if (!foundStatus || !isViewableByUser(foundStatus, user))
if (!foundStatus?.isViewableByUser(user))
return errorResponse("Record not found", 404);
if (req.method === "GET") {
return jsonResponse(await statusToAPI(foundStatus));
return jsonResponse(await foundStatus.toAPI(user));
}
if (req.method === "DELETE") {
if (foundStatus.authorId !== user?.id) {
if (foundStatus.getAuthor().id !== user?.id) {
return errorResponse("Unauthorized", 401);
}
// TODO: Implement delete and redraft functionality
// Delete status and all associated objects
await db.delete(status).where(eq(status.id, id));
await foundStatus.delete();
return jsonResponse(
{
...(await statusToAPI(foundStatus, user)),
// TODO: Add
// text: Add source text
// poll: Add source poll
// media_attachments
},
200,
);
return jsonResponse(await foundStatus.toAPI(user), 200);
}
if (req.method === "PUT") {
if (foundStatus.authorId !== user?.id) {
if (foundStatus.getAuthor().id !== user?.id) {
return errorResponse("Unauthorized", 401);
}
const {
status: statusText,
content_type,
"poll[expires_in]": expires_in,
"poll[options]": options,
media_ids,
spoiler_text,
@ -131,22 +110,6 @@ export default apiRoute<typeof meta, typeof schema>(
);
}
let sanitizedStatus: string;
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(
await parse(statusText ?? ""),
);
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(
await parse(statusText ?? ""),
);
} else {
sanitizedStatus = await sanitizeHtml(statusText ?? "");
}
// Check if status body doesnt match filters
if (
config.filters.note_content.some((filter) =>
@ -168,20 +131,27 @@ export default apiRoute<typeof meta, typeof schema>(
}
}
// Update status
const newStatus = await editStatus(foundStatus, {
content: sanitizedStatus,
content_type,
media_attachments: media_ids,
spoiler_text: spoiler_text ?? "",
sensitive: sensitive ?? false,
});
const newNote = await foundStatus.updateFromData(
statusText
? {
[content_type]: {
content: statusText,
},
}
: undefined,
undefined,
sensitive,
spoiler_text,
undefined,
undefined,
media_ids,
);
if (!newStatus) {
if (!newNote) {
return errorResponse("Failed to update status", 500);
}
return jsonResponse(await statusToAPI(newStatus, user));
return jsonResponse(await newNote.toAPI(user));
}
return jsonResponse({});

View file

@ -1,8 +1,8 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
import { db } from "~drizzle/db";
import { statusToMentions } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -29,15 +29,13 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401);
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const foundStatus = await Note.fromId(id);
// Check if status exists
if (!foundStatus) return errorResponse("Record not found", 404);
// Check if status is user's
if (foundStatus.authorId !== user.id)
if (foundStatus.getAuthor().id !== user.id)
return errorResponse("Unauthorized", 401);
// Check if post is already pinned
@ -45,7 +43,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
await db.query.userPinnedNotes.findFirst({
where: (userPinnedNote, { and, eq }) =>
and(
eq(userPinnedNote.statusId, foundStatus.id),
eq(userPinnedNote.statusId, foundStatus.getStatus().id),
eq(userPinnedNote.userId, user.id),
),
})
@ -54,9 +52,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
}
await db.insert(statusToMentions).values({
statusId: foundStatus.id,
statusId: foundStatus.getStatus().id,
userId: user.id,
});
return jsonResponse(statusToAPI(foundStatus, user));
return jsonResponse(await foundStatus.toAPI(user));
});

View file

@ -1,13 +1,10 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import {
findFirstStatuses,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { db } from "~drizzle/db";
import { notification, status } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -41,12 +38,10 @@ export default apiRoute<typeof meta, typeof schema>(
const { visibility } = extraData.parsedRequest;
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const foundStatus = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private)
if (!foundStatus || !isViewableByUser(foundStatus, user))
if (!foundStatus?.isViewableByUser(user))
return errorResponse("Record not found", 404);
const existingReblog = await db.query.status.findFirst({
@ -61,42 +56,35 @@ export default apiRoute<typeof meta, typeof schema>(
return errorResponse("Already reblogged", 422);
}
const newReblog = (
await db
.insert(status)
.values({
authorId: user.id,
reblogId: foundStatus.id,
visibility,
sensitive: false,
updatedAt: new Date().toISOString(),
applicationId: application?.id ?? null,
})
.returning()
)[0];
const newReblog = await Note.insert({
authorId: user.id,
reblogId: foundStatus.getStatus().id,
visibility,
sensitive: false,
updatedAt: new Date().toISOString(),
applicationId: application?.id ?? null,
});
if (!newReblog) {
return errorResponse("Failed to reblog", 500);
}
const finalNewReblog = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, newReblog.id),
});
const finalNewReblog = await Note.fromId(newReblog.id);
if (!finalNewReblog) {
return errorResponse("Failed to reblog", 500);
}
// Create notification for reblog if reblogged user is on the same instance
if (foundStatus.author.instanceId === user.instanceId) {
if (foundStatus.getAuthor().instanceId === user.instanceId) {
await db.insert(notification).values({
accountId: user.id,
notifiedId: foundStatus.authorId,
notifiedId: foundStatus.getAuthor().id,
type: "reblog",
statusId: foundStatus.reblogId,
statusId: foundStatus.getStatus().reblogId,
});
}
return jsonResponse(await statusToAPI(finalNewReblog, user));
return jsonResponse(await finalNewReblog.toAPI(user));
},
);

View file

@ -2,12 +2,12 @@ import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
import {
type UserWithRelations,
findManyUsers,
userToAPI,
} from "~database/entities/User";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -40,12 +40,10 @@ export default apiRoute<typeof meta, typeof schema>(
const { user } = extraData.auth;
const status = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const status = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
if (!status?.isViewableByUser(user))
return errorResponse("Record not found", 404);
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
@ -59,7 +57,9 @@ export default apiRoute<typeof meta, typeof schema>(
max_id ? lt(reblogger.id, max_id) : undefined,
since_id ? gte(reblogger.id, since_id) : undefined,
min_id ? gt(reblogger.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${status.id} AND "Status"."authorId" = ${reblogger.id})`,
sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${
status.getStatus().id
} AND "Status"."authorId" = ${reblogger.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse } from "@response";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -27,12 +27,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401);
const status = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const status = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
if (!status?.isViewableByUser(user))
return errorResponse("Record not found", 404);
return errorResponse("Not implemented yet");

View file

@ -1,11 +1,7 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { deleteLike } from "~database/entities/Like";
import {
findFirstStatuses,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { Note } from "~packages/database-interface/note";
import type { Status as APIStatus } from "~types/mastodon/status";
export const meta = applyConfig({
@ -33,19 +29,17 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401);
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const foundStatus = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private)
if (!foundStatus || !isViewableByUser(foundStatus, user))
if (!foundStatus?.isViewableByUser(user))
return errorResponse("Record not found", 404);
await deleteLike(user, foundStatus);
await deleteLike(user, foundStatus.getStatus());
return jsonResponse({
...(await statusToAPI(foundStatus, user)),
...(await foundStatus.toAPI(user)),
favourited: false,
favourites_count: foundStatus.likeCount - 1,
favourites_count: foundStatus.getStatus().likeCount - 1,
} as APIStatus);
});

View file

@ -1,9 +1,6 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { and, eq } from "drizzle-orm";
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
import { db } from "~drizzle/db";
import { statusToMentions } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -30,26 +27,18 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401);
const status = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const status = await Note.fromId(id);
// Check if status exists
if (!status) return errorResponse("Record not found", 404);
// Check if status is user's
if (status.authorId !== user.id) return errorResponse("Unauthorized", 401);
if (status.getAuthor().id !== user.id)
return errorResponse("Unauthorized", 401);
await db
.delete(statusToMentions)
.where(
and(
eq(statusToMentions.statusId, status.id),
eq(statusToMentions.userId, user.id),
),
);
await status.unpin(user);
if (!status) return errorResponse("Record not found", 404);
return jsonResponse(statusToAPI(status, user));
return jsonResponse(await status.toAPI(user));
});

View file

@ -1,13 +1,8 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import {
findFirstStatuses,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { db } from "~drizzle/db";
import { and, eq } from "drizzle-orm";
import { status } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
import type { Status as APIStatus } from "~types/mastodon/status";
export const meta = applyConfig({
@ -35,28 +30,28 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
if (!user) return errorResponse("Unauthorized", 401);
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const foundStatus = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private)
if (!foundStatus || !isViewableByUser(foundStatus, user))
if (!foundStatus?.isViewableByUser(user))
return errorResponse("Record not found", 404);
const existingReblog = await findFirstStatuses({
where: (status, { eq }) =>
eq(status.authorId, user.id) && eq(status.reblogId, foundStatus.id),
});
const existingReblog = await Note.fromSql(
and(
eq(status.authorId, user.id),
eq(status.reblogId, foundStatus.getStatus().id),
),
);
if (!existingReblog) {
return errorResponse("Not already reblogged", 422);
}
await db.delete(status).where(eq(status.id, existingReblog.id));
await existingReblog.delete();
return jsonResponse({
...(await statusToAPI(foundStatus, user)),
...(await foundStatus.toAPI(user)),
reblogged: false,
reblogs_count: foundStatus.reblogCount - 1,
reblogs_count: foundStatus.getStatus().reblogCount - 1,
} as APIStatus);
});

View file

@ -6,14 +6,9 @@ import ISO6391 from "iso-639-1";
import { parse } from "marked";
import { z } from "zod";
import type { StatusWithRelations } from "~database/entities/Status";
import {
createNewStatus,
federateStatus,
findFirstStatuses,
parseTextMentions,
statusToAPI,
} from "~database/entities/Status";
import { federateNote, parseTextMentions } from "~database/entities/Status";
import { db } from "~drizzle/db";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -125,28 +120,10 @@ export default apiRoute<typeof meta, typeof schema>(
}
// Get reply account and status if exists
let replyStatus: StatusWithRelations | null = null;
let quote: StatusWithRelations | null = null;
if (in_reply_to_id) {
replyStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, in_reply_to_id),
}).catch(() => null);
if (!replyStatus) {
return errorResponse("Reply status not found", 404);
}
}
if (quote_id) {
quote = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, quote_id),
}).catch(() => null);
if (!quote) {
return errorResponse("Quote status not found", 404);
}
}
const replyStatus: StatusWithRelations | null =
(await Note.fromId(in_reply_to_id ?? null))?.getStatus() ?? null;
const quote: StatusWithRelations | null =
(await Note.fromId(quote_id ?? null))?.getStatus() ?? null;
// Check if status body doesnt match filters
if (
@ -171,7 +148,7 @@ export default apiRoute<typeof meta, typeof schema>(
const mentions = await parseTextMentions(sanitizedStatus);
const newStatus = await createNewStatus(
const newNote = await Note.fromData(
user,
{
[content_type]: {
@ -185,19 +162,19 @@ export default apiRoute<typeof meta, typeof schema>(
undefined,
mentions,
media_ids,
replyStatus ?? undefined,
quote ?? undefined,
in_reply_to_id ?? undefined,
quote_id ?? undefined,
application ?? undefined,
);
if (!newStatus) {
if (!newNote) {
return errorResponse("Failed to create status", 500);
}
if (federate) {
await federateStatus(newStatus);
await federateNote(newNote);
}
return jsonResponse(await statusToAPI(newStatus, user));
return jsonResponse(await newNote.toAPI(user));
},
);

View file

@ -1,12 +1,9 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { and, eq, gt, gte, lt, or, sql } from "drizzle-orm";
import { z } from "zod";
import {
type StatusWithRelations,
findManyStatuses,
statusToAPI,
} from "~database/entities/Status";
import { status } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -38,38 +35,29 @@ export default apiRoute<typeof meta, typeof schema>(
if (!user) return errorResponse("Unauthorized", 401);
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) =>
and(
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
),
or(
eq(status.authorId, user.id),
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
// WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`,
// All statuses from users that the user is following
// WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
),
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
req,
const { objects, link } = await Timeline.getNoteTimeline(
and(
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
),
or(
eq(status.authorId, user.id),
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
// WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`,
// All statuses from users that the user is following
// WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
),
),
limit,
req.url,
);
return jsonResponse(
await Promise.all(
objects.map(async (status) => statusToAPI(status, user)),
),
await Promise.all(objects.map(async (note) => note.toAPI(user))),
200,
{
Link: link,

View file

@ -1,13 +1,9 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { sql } from "drizzle-orm";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import {
type StatusWithRelations,
findManyStatuses,
statusToAPI,
} from "~database/entities/Status";
import { status } from "~drizzle/schema";
import { Timeline } from "~packages/database-interface/timeline";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -41,39 +37,28 @@ export default apiRoute<typeof meta, typeof schema>(
return errorResponse("Cannot use both local and remote", 400);
}
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (status, { lt, gte, gt, and, isNull, isNotNull }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
// use authorId to grab user, then use user.instanceId to filter local/remote statuses
remote
? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NOT NULL)`
: undefined,
local
? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NULL)`
: undefined,
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
req,
const { objects, link } = await Timeline.getNoteTimeline(
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
// use authorId to grab user, then use user.instanceId to filter local/remote statuses
remote
? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NOT NULL)`
: undefined,
local
? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NULL)`
: undefined,
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
),
limit,
req.url,
);
return jsonResponse(
await Promise.all(
objects.map(async (status) =>
statusToAPI(status, user || undefined),
),
),
await Promise.all(objects.map(async (note) => note.toAPI(user))),
200,
{
Link: link,

View file

@ -2,9 +2,8 @@ import { apiRoute, applyConfig } from "@api";
import { dualLogger } from "@loggers";
import { MeiliIndexType, meilisearch } from "@meilisearch";
import { errorResponse, jsonResponse } from "@response";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { findManyStatuses, statusToAPI } from "~database/entities/Status";
import {
findFirstUser,
findManyUsers,
@ -12,7 +11,8 @@ import {
userToAPI,
} from "~database/entities/User";
import { db } from "~drizzle/db";
import { instance, user } from "~drizzle/schema";
import { instance, status, user } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
import { LogLevel } from "~packages/log-manager";
export const meta = applyConfig({
@ -178,29 +178,27 @@ export default apiRoute<typeof meta, typeof schema>(
orderBy: (user, { desc }) => desc(user.createdAt),
});
const statuses = await findManyStatuses({
where: (status, { and, eq, inArray }) =>
and(
inArray(
status.id,
statusResults.map((hit) => hit.id),
),
account_id ? eq(status.authorId, account_id) : undefined,
self
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
self?.id
} AND Relationships.following = ${
following ? true : false
} AND Relationships.ownerId = ${status.authorId})`
: undefined,
const statuses = await Note.manyFromSql(
and(
inArray(
status.id,
statusResults.map((hit) => hit.id),
),
orderBy: (status, { desc }) => desc(status.createdAt),
});
account_id ? eq(status.authorId, account_id) : undefined,
self
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
self?.id
} AND Relationships.following = ${
following ? true : false
} AND Relationships.ownerId = ${status.authorId})`
: undefined,
),
);
return jsonResponse({
accounts: accounts.map((account) => userToAPI(account)),
statuses: await Promise.all(
statuses.map((status) => statusToAPI(status)),
statuses.map((status) => status.toAPI(self)),
),
hashtags: [],
});

View file

@ -1,14 +1,11 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { sql } from "drizzle-orm";
import { likeToLysand, type Like } from "~database/entities/Like";
import {
findFirstStatuses,
statusToLysand,
type StatusWithRelations,
} from "~database/entities/Status";
import { db } from "~drizzle/db";
import { and, eq, inArray, sql } from "drizzle-orm";
import type * as Lysand from "lysand-types";
import { type Like, likeToLysand } from "~database/entities/Like";
import { db } from "~drizzle/db";
import { status } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -25,18 +22,16 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute) => {
const uuid = matchedRoute.params.uuid;
let foundObject: StatusWithRelations | Like | null = null;
let foundObject: Note | Like | null = null;
let apiObject: Lysand.Entity | null = null;
foundObject =
(await findFirstStatuses({
where: (status, { eq, and, inArray }) =>
and(
eq(status.id, uuid),
inArray(status.visibility, ["public", "unlisted"]),
),
})) ?? null;
apiObject = foundObject ? statusToLysand(foundObject) : null;
foundObject = await Note.fromSql(
and(
eq(status.id, uuid),
inArray(status.visibility, ["public", "unlisted"]),
),
);
apiObject = foundObject ? foundObject.toLysand() : null;
if (!foundObject) {
foundObject =

View file

@ -3,7 +3,7 @@ import { dualLogger } from "@loggers";
import { errorResponse, response } from "@response";
import { eq } from "drizzle-orm";
import type * as Lysand from "lysand-types";
import { resolveStatus } from "~database/entities/Status";
import { resolveNote } from "~database/entities/Status";
import {
findFirstUser,
getRelationshipToOtherUser,
@ -126,16 +126,14 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
return errorResponse("Author not found", 400);
}
const newStatus = await resolveStatus(undefined, note).catch(
(e) => {
dualLogger.logError(
LogLevel.ERROR,
"Inbox.NoteResolve",
e as Error,
);
return null;
},
);
const newStatus = await resolveNote(undefined, note).catch((e) => {
dualLogger.logError(
LogLevel.ERROR,
"Inbox.NoteResolve",
e as Error,
);
return null;
});
if (!newStatus) {
return errorResponse("Failed to add status", 500);

View file

@ -1,9 +1,9 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { and, count, eq, inArray } from "drizzle-orm";
import { findManyStatuses, statusToLysand } from "~database/entities/Status";
import { db } from "~drizzle/db";
import { status } from "~drizzle/schema";
import { Note } from "~packages/database-interface/note";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -23,18 +23,17 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).hostname;
const statuses = await findManyStatuses({
where: (status, { eq, and, inArray }) =>
and(
eq(status.authorId, uuid),
inArray(status.visibility, ["public", "unlisted"]),
),
offset: 20 * (pageNumber - 1),
limit: 20,
orderBy: (status, { desc }) => desc(status.createdAt),
});
const notes = await Note.manyFromSql(
and(
eq(status.authorId, uuid),
inArray(status.visibility, ["public", "unlisted"]),
),
undefined,
20,
20 * (pageNumber - 1),
);
const totalStatuses = await db
const totalNotes = await db
.select({
count: count(),
})
@ -49,11 +48,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
return jsonResponse({
first: `${host}/users/${uuid}/outbox?page=1`,
last: `${host}/users/${uuid}/outbox?page=1`,
total_items: totalStatuses,
total_items: totalNotes,
// Server actor
author: new URL("/users/actor", config.http.base_url).toString(),
next:
statuses.length === 20
notes.length === 20
? new URL(
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
config.http.base_url,
@ -66,6 +65,6 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
config.http.base_url,
).toString()
: undefined,
items: statuses.map((s) => statusToLysand(s)),
items: notes.map((note) => note.toLysand()),
});
});

View file

@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { inArray, like } from "drizzle-orm";
import { type Status, findManyStatuses } from "~database/entities/Status";
import { asc, inArray, like } from "drizzle-orm";
import type { Status } from "~database/entities/Status";
import {
type User,
type UserWithRelations,
@ -9,6 +9,7 @@ import {
import { db } from "~drizzle/db";
import { status, token, user } from "~drizzle/schema";
import { server } from "~index";
import { Note } from "~packages/database-interface/note";
/**
* This allows us to send a test request to the server even when it isnt running
* CURRENTLY NOT WORKING, NEEDS TO BE FIXED
@ -86,20 +87,15 @@ export const getTestStatuses = async (
const statuses: Status[] = [];
for (let i = 0; i < count; i++) {
const newStatus = (
await db
.insert(status)
.values({
content: `${i} ${randomBytes(32).toString("hex")}`,
authorId: user.id,
sensitive: false,
updatedAt: new Date().toISOString(),
visibility: "public",
applicationId: null,
...partial,
})
.returning()
)[0];
const newStatus = await Note.insert({
content: `${i} ${randomBytes(32).toString("hex")}`,
authorId: user.id,
sensitive: false,
updatedAt: new Date().toISOString(),
visibility: "public",
applicationId: null,
...partial,
});
if (!newStatus) {
throw new Error("Failed to create test status");
@ -108,14 +104,13 @@ export const getTestStatuses = async (
statuses.push(newStatus);
}
const statusesWithRelations = await findManyStatuses({
where: (status, { inArray }) =>
return (
await Note.manyFromSql(
inArray(
status.id,
statuses.map((s) => s.id),
),
orderBy: (status, { asc }) => asc(status.id),
});
return statusesWithRelations;
asc(status.id),
)
).map((n) => n.getStatus());
};

View file

@ -3,17 +3,17 @@ import type {
Notification,
findManyNotifications,
} from "~database/entities/Notification";
import type { Status, findManyStatuses } from "~database/entities/Status";
import type { Status, findManyNotes } from "~database/entities/Status";
import type { User, findManyUsers } from "~database/entities/User";
import type { db } from "~drizzle/db";
export async function fetchTimeline<T extends User | Status | Notification>(
model:
| typeof findManyStatuses
| typeof findManyNotes
| typeof findManyUsers
| typeof findManyNotifications,
args:
| Parameters<typeof findManyStatuses>[0]
| Parameters<typeof findManyNotes>[0]
| Parameters<typeof findManyUsers>[0]
| Parameters<typeof db.query.notification.findMany>[0],
req: Request,