Finish rewrite of everything with Prisma

This commit is contained in:
Jesse Wierzbinski 2023-11-11 15:37:14 -10:00
parent 5eed8374cd
commit dc0ec47543
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
47 changed files with 1283 additions and 1036 deletions

View file

@ -5,7 +5,7 @@
## What is this?
This is a project to create a federated social network based on the [ActivityPub](https://www.w3.org/TR/activitypub/) standard. It is currently in early alpha phase, with very basic federation and API support.
This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and API support.
This project aims to be a fully featured social network, with a focus on privacy and security. It will implement the Mastodon API for support with clients that already support Mastodon or Pleroma.
@ -15,7 +15,7 @@ This project aims to be a fully featured social network, with a focus on privacy
### Requirements
- The [Bun Runtime](https://bun.sh), version 0.8 or later (use of the latest version is recommended)
- The [Bun Runtime](https://bun.sh), version 1.0.5 or later (usage of the latest version is recommended)
- A PostgreSQL database
- (Optional but recommended) A Linux-based operating system
@ -60,11 +60,8 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil
> **Warning**: Federation has not been tested outside of automated tests. It is not recommended to use this software in production.
Lysand is currently able to federate basic `Note` objects with `Create`, `Update` and `Delete` activities supported. (as well as `Accept` and `Reject`, but with no tests)
Planned federation features are:
- Activities: `Follow`, `Block`, `Undo`, `Announce`, `Like`, `Dislike`, `Flag`, `Ignore` and more
- Objects: `Emoji` and more
The following extensions are currently supported or being worked on:
- `org.lysand:custom_emojis`: Custom emojis
## API
@ -186,6 +183,8 @@ Configuration can be found inside the `config.toml` file. The following values a
### ActivityPub
> **Note**: These options do nothing and date back to when Lysand had ActivityPub support. They will be removed in a future version.
- `use_tombstones`: Whether to use ActivityPub Tombstones instead of deleting objects. Example: `true`
- `fetch_all_collection_members`: Whether to fetch all members of collections (followers, following, etc) when receiving them. Example: `false`
- `reject_activities`: An array of instance domain names without "https" or glob patterns. Rejects all activities from these instances, simply doesn't save them at all. Example: `[ "mastodon.social" ]`

BIN
bun.lockb

Binary file not shown.

View file

@ -21,6 +21,7 @@ export const parseEmojis = async (text: string): Promise<Emoji[]> => {
shortcode: {
in: matches.map(match => match.replace(/:/g, "")),
},
instanceId: null,
},
include: {
instance: true,

View file

@ -12,7 +12,7 @@ import { client } from "~database/datasource";
* @param other The user who is the subject of the relationship.
* @returns The newly created relationship.
*/
export const createNew = async (
export const createNewRelationship = async (
owner: User,
other: User
): Promise<Relationship> => {
@ -41,8 +41,10 @@ export const createNew = async (
* Converts the relationship to an API-friendly format.
* @returns The API-friendly relationship.
*/
// eslint-disable-next-line @typescript-eslint/require-await
export const toAPI = async (rel: Relationship): Promise<APIRelationship> => {
export const relationshipToAPI = async (
rel: Relationship
// eslint-disable-next-line @typescript-eslint/require-await
): Promise<APIRelationship> => {
return {
blocked_by: rel.blockedBy,
blocking: rel.blocking,

View file

@ -47,9 +47,9 @@ export const statusAndUserRelations = {
instance: true,
mentions: true,
pinnedBy: true,
replies: {
include: {
_count: true,
_count: {
select: {
replies: true,
},
},
},
@ -57,9 +57,10 @@ export const statusAndUserRelations = {
instance: true,
mentions: true,
pinnedBy: true,
replies: {
include: {
_count: true,
_count: {
select: {
replies: true,
likes: true,
},
},
reblog: {
@ -77,9 +78,9 @@ export const statusAndUserRelations = {
instance: true,
mentions: true,
pinnedBy: true,
replies: {
include: {
_count: true,
_count: {
select: {
replies: true,
},
},
},
@ -99,9 +100,9 @@ export const statusAndUserRelations = {
instance: true,
mentions: true,
pinnedBy: true,
replies: {
include: {
_count: true,
_count: {
select: {
replies: true,
},
},
},
@ -113,7 +114,7 @@ export const statusAndUserRelations = {
},
};
type StatusWithRelations = Status & {
export type StatusWithRelations = Status & {
author: UserWithRelations;
application: Application | null;
emojis: Emoji[];
@ -126,16 +127,17 @@ type StatusWithRelations = Status & {
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
replies: Status[] & {
_count: number;
_count: {
replies: number;
};
})
| null;
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
replies: Status[] & {
_count: number;
_count: {
replies: number;
likes: number;
};
reblog:
| (Status & {
@ -146,8 +148,8 @@ type StatusWithRelations = Status & {
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
replies: Status[] & {
_count: number;
_count: {
replies: number;
};
})
| null;
@ -160,8 +162,8 @@ type StatusWithRelations = Status & {
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
replies: Status[] & {
_count: number;
_count: {
replies: number;
};
})
| null;
@ -196,12 +198,13 @@ export const isViewableByUser = (status: Status, user: User | null) => {
export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
// Check if already in database
const existingStatus = await client.status.findFirst({
where: {
uri: uri,
},
include: statusAndUserRelations,
});
const existingStatus: StatusWithRelations | null =
await client.status.findFirst({
where: {
uri: uri,
},
include: statusAndUserRelations,
});
if (existingStatus) return existingStatus;
@ -228,7 +231,7 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
quotingStatus = await fetchFromRemote(body.quotes[0]);
}
return await createNew({
return await createNewStatus({
account: author,
content: content?.content || "",
content_type: content?.content_type,
@ -254,18 +257,79 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
* Return all the ancestors of this post,
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getAncestors = async (fetcher: UserWithRelations | null) => {
// TODO: Implement
return [];
export const getAncestors = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null
) => {
const ancestors: StatusWithRelations[] = [];
let currentStatus = status;
while (currentStatus.inReplyToPostId) {
const parent = await client.status.findFirst({
where: {
id: currentStatus.inReplyToPostId,
},
include: statusAndUserRelations,
});
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,
* Return all the descendants of this post (recursive)
* Temporary implementation, will be replaced with a recursive SQL query when Prisma adds support for it
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getDescendants = async (fetcher: UserWithRelations | null) => {
// TODO: Implement
return [];
export const getDescendants = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null,
depth = 0
) => {
const descendants: StatusWithRelations[] = [];
const currentStatus = status;
// Fetch all children of children of children recursively calling getDescendants
const children = await client.status.findMany({
where: {
inReplyToPostId: currentStatus.id,
},
include: statusAndUserRelations,
});
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;
};
/**
@ -273,7 +337,7 @@ export const getDescendants = async (fetcher: UserWithRelations | null) => {
* @param data The data for the new status.
* @returns A promise that resolves with the new status.
*/
const createNew = async (data: {
export const createNewStatus = async (data: {
account: User;
application: Application | null;
content: string;
@ -408,7 +472,7 @@ export const statusToAPI = async (
reblogId: status.id,
},
}),
replies_count: status.replies._count,
replies_count: status._count.replies,
sensitive: status.sensitive,
spoiler_text: status.spoilerText,
tags: [],

View file

@ -32,9 +32,10 @@ export const userRelations = {
relationships: true,
relationshipSubjects: true,
pinnedNotes: true,
statuses: {
_count: {
select: {
_count: true,
statuses: true,
likes: true,
},
},
};
@ -46,8 +47,9 @@ export type UserWithRelations = User & {
relationships: Relationship[];
relationshipSubjects: Relationship[];
pinnedNotes: Status[];
statuses: {
length: number;
_count: {
statuses: number;
likes: number;
};
};
@ -353,7 +355,7 @@ export const userToAPI = async (
followers_count: user.relationshipSubjects.filter(r => r.following)
.length,
following_count: user.relationships.filter(r => r.following).length,
statuses_count: user.statuses.length,
statuses_count: user._count.statuses,
emojis: await Promise.all(user.emojis.map(emoji => emojiToAPI(emoji))),
// TODO: Add fields
fields: [],

View file

@ -66,12 +66,14 @@
"chalk": "^5.3.0",
"html-to-text": "^9.0.5",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "^1.9.0",
"jsonld": "^8.3.1",
"marked": "^9.1.2",
"pg": "^8.11.3",
"prisma": "^5.5.2",
"reflect-metadata": "^0.1.13",
"sharp": "^0.32.6",
"typeorm": "^0.3.17"
}
}

View file

@ -151,6 +151,10 @@ model User {
header String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isBot Boolean @default(false)
isLocked Boolean @default(false)
isDiscoverable Boolean @default(false)
sanctions String[] @default([])
publicKey String
privateKey String? // Nullable
relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship

View file

@ -1,8 +1,15 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -25,29 +32,42 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -56,6 +76,12 @@ export default async (
relationship.blocking = true;
}
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
blocking: true,
},
});
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,9 +1,16 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -26,7 +33,7 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
@ -36,25 +43,38 @@ export default async (
languages?: string[];
}>(req);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -63,7 +83,7 @@ export default async (
relationship.following = true;
}
if (reblogs) {
relationship.showing_reblogs = true;
relationship.showingReblogs = true;
}
if (notify) {
relationship.notifying = true;
@ -72,6 +92,15 @@ export default async (
relationship.languages = languages;
}
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
following: true,
showingReblogs: reblogs ?? false,
notifying: notify ?? false,
languages: languages ?? [],
},
});
return jsonResponse(relationshipToAPI(relationship));
};

View file

@ -1,7 +1,13 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { UserAction, userRelations } from "~database/entities/User";
import {
UserWithRelations,
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -24,15 +30,13 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
let foundUser: UserAction | null;
let foundUser: UserWithRelations | null;
try {
foundUser = await UserAction.findOne({
where: {
id,
},
relations: userRelations,
foundUser = await client.user.findUnique({
where: { id },
include: userRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
@ -40,5 +44,5 @@ export default async (
if (!foundUser) return errorResponse("User not found", 404);
return jsonResponse(await foundUser.toAPI(user?.id === foundUser.id));
return jsonResponse(await userToAPI(foundUser, user?.id === foundUser.id));
};

View file

@ -1,9 +1,16 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -26,7 +33,7 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
@ -36,25 +43,38 @@ export default async (
duration: number;
}>(req);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -63,11 +83,18 @@ export default async (
relationship.muting = true;
}
if (notifications ?? true) {
relationship.muting_notifications = true;
relationship.mutingNotifications = true;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
muting: true,
mutingNotifications: notifications ?? true,
},
});
// TODO: Implement duration
await relationship.save();
return jsonResponse(await relationship.toAPI());
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,9 +1,16 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -26,7 +33,7 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
@ -34,31 +41,50 @@ export default async (
comment: string;
}>(req);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship.note = comment ?? "";
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
note: relationship.note,
},
});
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,8 +1,15 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -25,29 +32,42 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -56,6 +76,12 @@ export default async (
relationship.endorsed = true;
}
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
endorsed: true,
},
});
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,8 +1,15 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -25,37 +32,71 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
if (relationship.followed_by) {
relationship.followed_by = false;
if (relationship.followedBy) {
relationship.followedBy = false;
}
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
followedBy: false,
},
});
if (user.instanceId === null) {
// Also remove from followers list
await client.relationship.update({
// @ts-expect-error Idk why there's this error
where: {
ownerId: user.id,
subjectId: self.id,
following: true,
},
data: {
following: false,
},
});
}
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { UserAction, userRelations } from "~database/entities/User";
import { statusAndUserRelations, statusToAPI } from "~database/entities/Status";
import { userRelations } from "~database/entities/User";
import { applyConfig } from "@api";
import { FindManyOptions } from "typeorm";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -47,79 +47,29 @@ export default async (
tagged?: string;
} = matchedRoute.query;
const user = await UserAction.findOne({
where: {
id,
},
relations: userRelations,
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Get list of boosts for this status
let query: FindManyOptions<Status> = {
const objects = await client.status.findMany({
where: {
account: {
id: user.id,
},
authorId: id,
isReblog: exclude_reblogs ? true : undefined,
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
relations: statusAndUserRelations,
include: statusAndUserRelations,
take: limit ?? 20,
order: {
id: "DESC",
orderBy: {
id: "desc",
},
};
if (max_id) {
const maxStatus = await Status.findOneBy({ id: max_id });
if (maxStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxStatus.created_at,
},
},
};
}
}
if (since_id) {
const sinceStatus = await Status.findOneBy({ id: since_id });
if (sinceStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: sinceStatus.created_at,
},
},
};
}
}
if (min_id) {
const minStatus = await Status.findOneBy({ id: min_id });
if (minStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gte: minStatus.created_at,
},
},
};
}
}
const objects = await Status.find(query);
});
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
@ -129,14 +79,13 @@ export default async (
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`
);
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`
`<${urlWithoutQuery}?since_id=${objects.at(-1)
?.id}&limit=${limit}>; rel="prev"`
);
}
return jsonResponse(
await Promise.all(objects.map(async status => await status.toAPI())),
await Promise.all(objects.map(status => statusToAPI(status))),
200,
{
Link: linkHeader.join(", "),

View file

@ -1,8 +1,15 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -25,29 +32,42 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -56,6 +76,12 @@ export default async (
relationship.blocking = false;
}
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
blocking: false,
},
});
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,8 +1,15 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -25,29 +32,42 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -56,6 +76,12 @@ export default async (
relationship.following = false;
}
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
following: false,
},
});
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,8 +1,15 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -25,29 +32,42 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -58,6 +78,12 @@ export default async (
// TODO: Implement duration
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
muting: false,
},
});
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,8 +1,15 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Relationship } from "~database/entities/Relationship";
import { UserAction, userRelations } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -25,29 +32,42 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
const user = await UserAction.findOne({
where: {
id,
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
relations: userRelations,
});
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await self.getRelationshipToOtherUser(user);
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
const newRelationship = await Relationship.createNew(self, user);
const newRelationship = await createNewRelationship(self, user);
self.relationships.push(newRelationship);
await self.save();
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
@ -56,6 +76,12 @@ export default async (
relationship.endorsed = false;
}
await relationship.save();
return jsonResponse(await relationship.toAPI());
await client.relationship.update({
where: { id: relationship.id },
data: {
endorsed: false,
},
});
return jsonResponse(await relationshipToAPI(relationship));
};

View file

@ -1,14 +1,18 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { UserAction } from "~database/entities/User";
import { APIAccount } from "~types/entities/account";
import {
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/accounts/familiar_followers",
ratelimits: {
max: 30,
max: 5,
duration: 60,
},
auth: {
@ -20,7 +24,7 @@ export const meta = applyConfig({
* Find familiar followers (followers of a user that you also follow)
*/
export default async (req: Request): Promise<Response> => {
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
@ -33,47 +37,34 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
const response = (
await Promise.all(
ids.map(async id => {
// Find followers of user that you also follow
// Get user
const user = await UserAction.findOne({
where: { id },
relations: {
relationships: {
subject: {
relationships: true,
},
},
const followersOfIds = await client.user.findMany({
where: {
relationships: {
some: {
subjectId: {
in: ids,
},
});
following: true,
},
},
},
});
if (!user) return null;
// Find users that you follow in followersOfIds
const output = await client.user.findMany({
where: {
relationships: {
some: {
ownerId: self.id,
subjectId: {
in: followersOfIds.map(u => u.id),
},
following: true,
},
},
},
include: userRelations,
});
// Map to user response
const response = user.relationships
.filter(r => r.following)
.map(r => r.subject)
.filter(u =>
u.relationships.some(
r => r.following && r.subject.id === self.id
)
);
return {
id: id,
accounts: await Promise.all(
response.map(async u => await u.toAPI())
),
};
})
)
).filter(r => r !== null) as {
id: string;
accounts: APIAccount[];
}[];
return jsonResponse(response);
return jsonResponse(output.map(o => userToAPI(o)));
};

View file

@ -2,8 +2,10 @@ import { getConfig } from "@config";
import { parseRequest } from "@request";
import { jsonResponse } from "@response";
import { tempmailDomains } from "@tempmail";
import { UserAction } from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User";
import ISO6391 from "iso-639-1";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -115,7 +117,7 @@ export default async (req: Request): Promise<Response> => {
});
// Check if username is taken
if (await UserAction.findOne({ where: { username: body.username } }))
if (await client.user.findFirst({ where: { username: body.username } }))
errors.details.username.push({
error: "ERR_TAKEN",
description: `is already taken`,
@ -150,6 +152,18 @@ export default async (req: Request): Promise<Response> => {
description: `must be accepted`,
});
if (!body.locale)
errors.details.locale.push({
error: "ERR_BLANK",
description: `can't be blank`,
});
if (!ISO6391.validate(body.locale ?? ""))
errors.details.locale.push({
error: "ERR_INVALID",
description: `must be a valid ISO 639-1 code`,
});
// If any errors are present, return them
if (Object.values(errors.details).some(value => value.length > 0)) {
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
@ -168,14 +182,13 @@ export default async (req: Request): Promise<Response> => {
});
}
// TODO: Check if locale is valid
await UserAction.createNewLocal({
await createNewLocalUser({
username: body.username ?? "",
password: body.password ?? "",
email: body.email ?? "",
});
// TODO: Return access token
return new Response();
return new Response("", {
status: 200,
});
};

View file

@ -1,8 +1,12 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { Relationship } from "~database/entities/Relationship";
import { UserAction } from "~database/entities/User";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getFromRequest } from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -20,7 +24,7 @@ export const meta = applyConfig({
* Find relationships
*/
export default async (req: Request): Promise<Response> => {
const { user: self } = await UserAction.getFromRequest(req);
const { user: self } = await getFromRequest(req);
if (!self) return errorResponse("Unauthorized", 401);
@ -33,34 +37,35 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
// Check if already following
// TODO: Limit ID amount
const relationships = (
await Promise.all(
ids.map(async id => {
const user = await UserAction.findOneBy({ id });
if (!user) return null;
let relationship = await self.getRelationshipToOtherUser(user);
const relationships = await client.relationship.findMany({
where: {
ownerId: self.id,
subjectId: {
in: ids,
},
},
});
if (!relationship) {
// Create new relationship
// Find IDs that dont have a relationship
const missingIds = ids.filter(
id => !relationships.some(r => r.subjectId === id)
);
const newRelationship = await Relationship.createNew(
self,
user
);
// Create the missing relationships
for (const id of missingIds) {
const relationship = await createNewRelationship(self, { id } as any);
self.relationships.push(newRelationship);
await self.save();
relationships.push(relationship);
}
relationship = newRelationship;
}
return relationship;
})
)
).filter(relationship => relationship !== null) as Relationship[];
// Order in the same order as ids
relationships.sort(
(a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId)
);
return jsonResponse(
await Promise.all(relationships.map(async r => await r.toAPI()))
await Promise.all(
relationships.map(async r => await relationshipToAPI(r))
)
);
};

View file

@ -1,12 +1,14 @@
import { getConfig } from "@config";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { UserAction } from "~database/entities/User";
import { getFromRequest, userToAPI } from "~database/entities/User";
import { applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify";
import { sanitizeHtml } from "@sanitization";
import { uploadFile } from "~classes/media";
import { EmojiAction } from "~database/entities/Emoji";
import ISO6391 from "iso-639-1";
import { parseEmojis } from "~database/entities/Emoji";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["PATCH"],
@ -24,7 +26,7 @@ export const meta = applyConfig({
* Patches a user
*/
export default async (req: Request): Promise<Response> => {
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
if (!user) return errorResponse("Unauthorized", 401);
@ -85,7 +87,7 @@ export default async (req: Request): Promise<Response> => {
// Remove emojis
user.emojis = [];
user.display_name = sanitizedDisplayName;
user.displayName = sanitizedDisplayName;
}
if (note) {
@ -112,7 +114,7 @@ export default async (req: Request): Promise<Response> => {
user.note = sanitizedNote;
}
if (source_privacy) {
if (source_privacy && user.source) {
// Check if within allowed privacy values
if (
!["public", "unlisted", "private", "direct"].includes(
@ -125,21 +127,30 @@ export default async (req: Request): Promise<Response> => {
);
}
user.source.privacy = source_privacy;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(user.source as any).privacy = source_privacy;
}
if (source_sensitive) {
if (source_sensitive && user.source) {
// Check if within allowed sensitive values
if (source_sensitive !== "true" && source_sensitive !== "false") {
return errorResponse("Sensitive must be a boolean", 422);
}
user.source.sensitive = source_sensitive === "true";
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(user.source as any).sensitive = source_sensitive === "true";
}
if (source_language) {
// TODO: Check if proper ISO code
user.source.language = source_language;
if (source_language && user.source) {
if (!ISO6391.validate(source_language)) {
return errorResponse(
"Language must be a valid ISO 639-1 code",
422
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(user.source as any).language = source_language;
}
if (avatar) {
@ -176,8 +187,7 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Locked must be a boolean", 422);
}
// TODO: Add a user value for Locked
// user.locked = locked === "true";
user.isLocked = locked === "true";
}
if (bot) {
@ -186,8 +196,7 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Bot must be a boolean", 422);
}
// TODO: Add a user value for bot
// user.bot = bot === "true";
user.isBot = bot === "true";
}
if (discoverable) {
@ -196,14 +205,13 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Discoverable must be a boolean", 422);
}
// TODO: Add a user value for discoverable
// user.discoverable = discoverable === "true";
user.isDiscoverable = discoverable === "true";
}
// Parse emojis
const displaynameEmojis = await EmojiAction.parseEmojis(sanitizedDisplayName);
const noteEmojis = await EmojiAction.parseEmojis(sanitizedNote);
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote);
user.emojis = [...displaynameEmojis, ...noteEmojis];
@ -212,7 +220,31 @@ export default async (req: Request): Promise<Response> => {
(emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index
);
await user.save();
await client.user.update({
where: { id: user.id },
data: {
displayName: user.displayName,
note: user.note,
avatar: user.avatar,
header: user.header,
isLocked: user.isLocked,
isBot: user.isBot,
isDiscoverable: user.isDiscoverable,
emojis: {
disconnect: user.emojis.map(e => ({
id: e.id,
})),
connect: user.emojis.map(e => ({
id: e.id,
})),
},
source: user.source
? {
update: user.source,
}
: undefined,
},
});
return jsonResponse(await user.toAPI());
return jsonResponse(await userToAPI(user));
};

View file

@ -1,5 +1,5 @@
import { errorResponse, jsonResponse } from "@response";
import { UserAction } from "~database/entities/User";
import { getFromRequest, userToAPI } from "~database/entities/User";
import { applyConfig } from "@api";
export const meta = applyConfig({
@ -17,12 +17,12 @@ export const meta = applyConfig({
export default async (req: Request): Promise<Response> => {
// TODO: Add checks for disabled or not email verified accounts
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
if (!user) return errorResponse("Unauthorized", 401);
return jsonResponse({
...(await user.toAPI()),
...(await userToAPI(user)),
source: user.source,
// TODO: Add role support
role: {

View file

@ -2,7 +2,7 @@ import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { randomBytes } from "crypto";
import { ApplicationAction } from "~database/entities/Application";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -27,10 +27,6 @@ export default async (req: Request): Promise<Response> => {
website: string;
}>(req);
const application = new ApplicationAction();
application.name = client_name || "";
// Check if redirect URI is a valid URI, and also an absolute URI
if (redirect_uris) {
try {
@ -42,20 +38,20 @@ export default async (req: Request): Promise<Response> => {
422
);
}
application.redirect_uris = redirect_uris;
} catch {
return errorResponse("Redirect URI must be a valid URI", 422);
}
}
application.scopes = scopes || "read";
application.website = website || null;
application.client_id = randomBytes(32).toString("base64url");
application.secret = randomBytes(64).toString("base64url");
await application.save();
const application = await client.application.create({
data: {
name: client_name || "",
redirect_uris: redirect_uris || "",
scopes: scopes || "read",
website: website || null,
client_id: randomBytes(32).toString("base64url"),
secret: randomBytes(64).toString("base64url"),
},
});
return jsonResponse({
id: application.id,

View file

@ -1,7 +1,7 @@
import { applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { ApplicationAction } from "~database/entities/Application";
import { UserAction } from "~database/entities/User";
import { getFromToken } from "~database/entities/Application";
import { getFromRequest } from "~database/entities/User";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -19,8 +19,8 @@ export const meta = applyConfig({
* Returns OAuth2 credentials
*/
export default async (req: Request): Promise<Response> => {
const { user, token } = await UserAction.getFromRequest(req);
const application = await ApplicationAction.getFromToken(token);
const { user, token } = await getFromRequest(req);
const application = await getFromToken(token);
if (!user) return errorResponse("Unauthorized", 401);
if (!application) return errorResponse("Unauthorized", 401);

View file

@ -1,7 +1,7 @@
import { applyConfig } from "@api";
import { jsonResponse } from "@response";
import { IsNull } from "typeorm";
import { EmojiAction } from "~database/entities/Emoji";
import { client } from "~database/datasource";
import { emojiToAPI } from "~database/entities/Emoji";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -20,11 +20,13 @@ export const meta = applyConfig({
*/
// eslint-disable-next-line @typescript-eslint/require-await
export default async (): Promise<Response> => {
const emojis = await EmojiAction.findBy({
instance: IsNull(),
const emojis = await client.emoji.findMany({
where: {
instanceId: null,
},
});
return jsonResponse(
await Promise.all(emojis.map(async emoji => await emoji.toAPI()))
await Promise.all(emojis.map(emoji => emojiToAPI(emoji)))
);
};

View file

@ -1,8 +1,7 @@
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { jsonResponse } from "@response";
import { Status } from "~database/entities/Status";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -23,8 +22,16 @@ export const meta = applyConfig({
export default async (): Promise<Response> => {
const config = getConfig();
const statusCount = await Status.count();
const userCount = await UserAction.count();
const statusCount = await client.status.count({
where: {
instanceId: null,
},
});
const userCount = await client.user.count({
where: {
instanceId: null,
},
});
// TODO: fill in more values
return jsonResponse({

View file

@ -1,14 +1,20 @@
import { applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import {
getAncestors,
getDescendants,
statusAndUserRelations,
statusToAPI,
} from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 100,
max: 8,
duration: 60,
},
route: "/api/v1/statuses/:id/context",
@ -28,30 +34,25 @@ export default async (
// User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses.
const id = matchedRoute.params.id;
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
let foundStatus: Status | null;
try {
foundStatus = await Status.findOne({
where: {
id,
},
relations: statusAndUserRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
const foundStatus = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
if (!foundStatus) return errorResponse("Record not found", 404);
// Get all ancestors
const ancestors = await foundStatus.getAncestors(user);
const descendants = await foundStatus.getDescendants(user);
const ancestors = await getAncestors(foundStatus, user);
const descendants = await getDescendants(foundStatus, user);
return jsonResponse({
ancestors: await Promise.all(ancestors.map(status => status.toAPI())),
ancestors: await Promise.all(
ancestors.map(status => statusToAPI(status))
),
descendants: await Promise.all(
descendants.map(status => status.toAPI())
descendants.map(status => statusToAPI(status))
),
});
};

View file

@ -2,10 +2,15 @@
import { applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Like } from "~database/entities/Like";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { UserAction, userRelations } from "~database/entities/User";
import { client } from "~database/datasource";
import {
isViewableByUser,
statusAndUserRelations,
statusToAPI,
} from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
import { APIStatus } from "~types/entities/status";
export const meta: APIRouteMeta = applyConfig({
allowedMethods: ["POST"],
@ -28,51 +33,38 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
if (!user) return errorResponse("Unauthorized", 401);
let foundStatus: Status | null;
try {
foundStatus = await Status.findOne({
where: {
id,
},
relations: statusAndUserRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundStatus) return errorResponse("Record not found", 404);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!foundStatus.isViewableByUser(user)) {
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
}
// Check if user has already favourited this status
const existingLike = await Like.findOne({
const existingLike = await client.like.findFirst({
where: {
liked: {
id: foundStatus.id,
},
liker: {
id: user.id,
},
likedId: status.id,
likerId: user.id,
},
relations: [
...userRelations.map(r => `liker.${r}`),
...statusAndUserRelations.map(r => `liked.${r}`),
],
});
if (!existingLike) {
const like = new Like();
like.liker = user;
like.liked = foundStatus;
await like.save();
await client.like.create({
data: {
likedId: status.id,
likerId: user.id,
},
});
}
return jsonResponse(await foundStatus.toAPI());
return jsonResponse({
...(await statusToAPI(status, user)),
favourited: true,
favourites_count: status._count.likes + 1,
} as APIStatus);
};

View file

@ -3,10 +3,16 @@ import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { FindManyOptions } from "typeorm";
import { Like } from "~database/entities/Like";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { UserAction, userRelations } from "~database/entities/User";
import { client } from "~database/datasource";
import {
isViewableByUser,
statusAndUserRelations,
} from "~database/entities/Status";
import {
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
@ -30,33 +36,25 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
let foundStatus: Status | null;
try {
foundStatus = await Status.findOne({
where: {
id,
},
relations: statusAndUserRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundStatus) return errorResponse("Record not found", 404);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!foundStatus.isViewableByUser(user)) {
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
}
const {
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = await parseRequest<{
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(req);
@ -65,53 +63,32 @@ export default async (
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
// Get list of boosts for this status
let query: FindManyOptions<Like> = {
const objects = await client.user.findMany({
where: {
liked: {
id,
likes: {
some: {
likedId: status.id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
relations: userRelations.map(r => `liker.${r}`),
take: limit,
order: {
id: "DESC",
include: {
...userRelations,
likes: {
where: {
likedId: status.id,
},
},
},
};
if (max_id) {
const maxLike = await Like.findOneBy({ id: max_id });
if (maxLike) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxLike.created_at,
},
},
};
}
}
if (since_id) {
const sinceLike = await Like.findOneBy({ id: since_id });
if (sinceLike) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: sinceLike.created_at,
},
},
};
}
}
const objects = await Like.find(query);
take: limit,
orderBy: {
id: "desc",
},
});
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
@ -128,7 +105,7 @@ export default async (
}
return jsonResponse(
await Promise.all(objects.map(async like => await like.liker.toAPI())),
await Promise.all(objects.map(async user => userToAPI(user))),
200,
{
Link: linkHeader.join(", "),

View file

@ -1,8 +1,13 @@
import { applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import {
isViewableByUser,
statusAndUserRelations,
statusToAPI,
} from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
@ -27,31 +32,21 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
let foundStatus: Status | null;
try {
foundStatus = await Status.findOne({
where: {
id,
},
relations: statusAndUserRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundStatus) return errorResponse("Record not found", 404);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!foundStatus.isViewableByUser(user)) {
if (!status || isViewableByUser(status, user))
return errorResponse("Record not found", 404);
}
if (req.method === "GET") {
return jsonResponse(await foundStatus.toAPI());
return jsonResponse(await statusToAPI(status));
} else if (req.method === "DELETE") {
if (foundStatus.account.id !== user?.id) {
if (status.authorId !== user?.id) {
return errorResponse("Unauthorized", 401);
}
@ -60,11 +55,13 @@ export default async (
// Get associated Status object
// Delete status and all associated objects
await foundStatus.remove();
await client.status.delete({
where: { id },
});
return jsonResponse(
{
...(await foundStatus.toAPI()),
...(await statusToAPI(status)),
// TODO: Add
// text: Add source text
// poll: Add source poll

View file

@ -3,9 +3,16 @@ import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { FindManyOptions } from "typeorm";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import {
isViewableByUser,
statusAndUserRelations,
} from "~database/entities/Status";
import {
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
@ -29,33 +36,25 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
let foundStatus: Status | null;
try {
foundStatus = await Status.findOne({
where: {
id,
},
relations: statusAndUserRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundStatus) return errorResponse("Record not found", 404);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!foundStatus.isViewableByUser(user)) {
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
}
const {
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = await parseRequest<{
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(req);
@ -64,53 +63,33 @@ export default async (
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
// Get list of boosts for this status
let query: FindManyOptions<Status> = {
const objects = await client.user.findMany({
where: {
reblog: {
id,
statuses: {
some: {
reblogId: status.id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
relations: statusAndUserRelations,
take: limit,
order: {
id: "DESC",
include: {
...userRelations,
statuses: {
where: {
reblogId: status.id,
},
include: statusAndUserRelations,
},
},
};
if (max_id) {
const maxPost = await Status.findOneBy({ id: max_id });
if (maxPost) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxPost.created_at,
},
},
};
}
}
if (since_id) {
const sincePost = await Status.findOneBy({ id: since_id });
if (sincePost) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: sincePost.created_at,
},
},
};
}
}
const objects = await Status.find(query);
take: limit,
orderBy: {
id: "desc",
},
});
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
@ -127,7 +106,7 @@ export default async (
}
return jsonResponse(
await Promise.all(objects.map(async object => await object.toAPI())),
await Promise.all(objects.map(async user => userToAPI(user))),
200,
{
Link: linkHeader.join(", "),

View file

@ -2,10 +2,15 @@
import { applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Like } from "~database/entities/Like";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import {
isViewableByUser,
statusAndUserRelations,
statusToAPI,
} from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
import { APIStatus } from "~types/entities/status";
export const meta: APIRouteMeta = applyConfig({
allowedMethods: ["POST"],
@ -28,37 +33,29 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
const { user } = await UserAction.getFromRequest(req);
const { user } = await getFromRequest(req);
if (!user) return errorResponse("Unauthorized", 401);
let foundStatus: Status | null;
try {
foundStatus = await Status.findOne({
where: {
id,
},
relations: statusAndUserRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundStatus) return errorResponse("Record not found", 404);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!foundStatus.isViewableByUser(user)) {
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
}
await Like.delete({
liked: {
id: foundStatus.id,
},
liker: {
id: user.id,
await client.like.deleteMany({
where: {
likedId: status.id,
likerId: user.id,
},
});
return jsonResponse(await foundStatus.toAPI());
return jsonResponse({
...(await statusToAPI(status, user)),
favourited: false,
favourites_count: status._count.likes - 1,
} as APIStatus);
};

View file

@ -8,9 +8,15 @@ import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import { MatchedRoute } from "bun";
import { parse } from "marked";
import { ApplicationAction } from "~database/entities/Application";
import { Status, statusRelations } from "~database/entities/Status";
import { AuthData, UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import { getFromToken } from "~database/entities/Application";
import {
StatusWithRelations,
createNewStatus,
statusAndUserRelations,
statusToAPI,
} from "~database/entities/Status";
import { AuthData, UserWithRelations } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
@ -34,7 +40,7 @@ export default async (
authData: AuthData
): Promise<Response> => {
const { user, token } = authData;
const application = await ApplicationAction.getFromToken(token);
const application = await getFromToken(token);
if (!user) return errorResponse("Unauthorized", 401);
@ -126,18 +132,16 @@ export default async (
}
// Get reply account and status if exists
let replyStatus: Status | null = null;
let replyUser: UserAction | null = null;
let replyStatus: StatusWithRelations | null = null;
let replyUser: UserWithRelations | null = null;
if (in_reply_to_id) {
replyStatus = await Status.findOne({
where: {
id: in_reply_to_id,
},
relations: statusRelations,
replyStatus = await client.status.findUnique({
where: { id: in_reply_to_id },
include: statusAndUserRelations,
});
replyUser = replyStatus?.account || null;
replyUser = replyStatus?.author || null;
}
// Check if status body doesnt match filters
@ -145,8 +149,7 @@ export default async (
return errorResponse("Status contains blocked words", 422);
}
// Create status
const newStatus = await Status.createNew({
const newStatus = await createNewStatus({
account: user,
application,
content: sanitizedStatus,
@ -171,5 +174,5 @@ export default async (
// TODO: add database jobs to deliver the post
return jsonResponse(await newStatus.toAPI());
return jsonResponse(await statusToAPI(newStatus, user));
};

View file

@ -2,10 +2,9 @@
import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { FindManyOptions } from "typeorm";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { AuthData } from "~database/entities/User";
import { client } from "~database/datasource";
import { statusAndUserRelations, statusToAPI } from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
@ -23,11 +22,9 @@ export const meta: APIRouteMeta = applyConfig({
/**
* Fetch home timeline statuses
*/
export default async (
req: Request,
matchedRoute: MatchedRoute,
authData: AuthData
): Promise<Response> => {
export default async (req: Request): Promise<Response> => {
const { user } = await getFromRequest(req);
const {
limit = 20,
max_id,
@ -40,85 +37,54 @@ export default async (
limit?: number;
}>(req);
const { user } = authData;
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
let query: FindManyOptions<Status> = {
if (!user) return errorResponse("Unauthorized", 401);
const objects = await client.status.findMany({
where: {
visibility: "public",
account: [
{
relationships: {
id: user?.id,
followed_by: true,
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
author: {
relationships: {
some: {
subjectId: user.id,
following: true,
},
},
{
id: user?.id,
},
],
},
order: {
created_at: "DESC",
},
},
include: statusAndUserRelations,
take: limit,
relations: statusAndUserRelations,
};
orderBy: {
id: "desc",
},
});
if (max_id) {
const maxPost = await Status.findOneBy({ id: max_id });
if (maxPost) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxPost.created_at,
},
},
};
}
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`
);
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`
);
}
if (min_id) {
const minPost = await Status.findOneBy({ id: min_id });
if (minPost) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: minPost.created_at,
},
},
};
}
}
if (since_id) {
const sincePost = await Status.findOneBy({ id: since_id });
if (sincePost) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gte: sincePost.created_at,
},
},
};
}
}
const objects = await Status.find(query);
return jsonResponse(
await Promise.all(objects.map(async object => await object.toAPI()))
await Promise.all(objects.map(async status => statusToAPI(status))),
200,
{
Link: linkHeader.join(", "),
}
);
};

View file

@ -1,8 +1,8 @@
import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { FindManyOptions, IsNull, Not } from "typeorm";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { client } from "~database/datasource";
import { statusAndUserRelations, statusToAPI } from "~database/entities/Status";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
@ -17,36 +17,13 @@ export const meta: APIRouteMeta = applyConfig({
},
});
const updateQuery = async (
id: string | undefined,
operator: string,
query: FindManyOptions<Status>
) => {
if (!id) return query;
const post = await Status.findOneBy({ id });
if (post) {
query = {
...query,
where: {
...query.where,
created_at: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
...(query.where as any)?.created_at,
[operator]: post.created_at,
},
},
};
}
return query;
};
export default async (req: Request): Promise<Response> => {
const {
local,
limit = 20,
max_id,
min_id,
only_media,
// only_media,
remote,
since_id,
} = await parseRequest<{
@ -67,48 +44,47 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Cannot use both local and remote", 400);
}
let query: FindManyOptions<Status> = {
const objects = await client.status.findMany({
where: {
visibility: "public",
},
order: {
created_at: "DESC",
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
instanceId: remote
? {
not: null,
}
: local
? null
: undefined,
},
include: statusAndUserRelations,
take: limit,
relations: statusAndUserRelations,
};
orderBy: {
id: "desc",
},
});
query = await updateQuery(max_id, "$lt", query);
query = await updateQuery(min_id, "$gt", query);
query = await updateQuery(since_id, "$gte", query);
if (only_media) {
// TODO: add
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`
);
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`
);
}
if (local) {
query = {
...query,
where: {
...query.where,
instance: IsNull(),
},
};
}
if (remote) {
query = {
...query,
where: {
...query.where,
instance: Not(IsNull()),
},
};
}
const objects = await Status.find(query);
return jsonResponse(
await Promise.all(objects.map(async object => await object.toAPI()))
await Promise.all(objects.map(async status => statusToAPI(status))),
200,
{
Link: linkHeader.join(", "),
}
);
};

View file

@ -2,9 +2,8 @@ import { applyConfig } from "@api";
import { errorResponse } from "@response";
import { MatchedRoute } from "bun";
import { randomBytes } from "crypto";
import { ApplicationAction } from "~database/entities/Application";
import { Token } from "~database/entities/Token";
import { UserAction, userRelations } from "~database/entities/User";
import { client } from "~database/datasource";
import { userRelations } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({
@ -45,33 +44,44 @@ export default async (
return errorResponse("Missing username or password", 400);
// Get user
const user = await UserAction.findOne({
const user = await client.user.findFirst({
where: {
email,
},
relations: userRelations,
include: userRelations,
});
if (!user || !(await Bun.password.verify(password, user.password || "")))
return errorResponse("Invalid username or password", 401);
// Get application
const application = await ApplicationAction.findOneBy({
client_id,
const application = await client.application.findFirst({
where: {
client_id,
},
});
if (!application) return errorResponse("Invalid client_id", 404);
const token = new Token();
token.access_token = randomBytes(64).toString("base64url");
token.code = randomBytes(32).toString("hex");
token.application = application;
token.scope = scopes.join(" ");
token.user = user;
await token.save();
const token = await client.application.update({
where: { id: application.id },
data: {
tokens: {
create: {
access_token: randomBytes(64).toString("base64url"),
code: randomBytes(32).toString("hex"),
scope: scopes.join(" "),
token_type: "bearer",
user: {
connect: {
id: user.id,
},
},
},
},
},
});
// Redirect back to application
return Response.redirect(`${redirect_uri}?code=${token.code}`, 302);
return Response.redirect(`${redirect_uri}?code=${token.secret}`, 302);
};

View file

@ -1,7 +1,7 @@
import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { Token } from "~database/entities/Token";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -36,14 +36,19 @@ export default async (req: Request): Promise<Response> => {
);
// Get associated token
const token = await Token.findOneBy({
code,
application: {
client_id,
secret: client_secret,
redirect_uris: redirect_uri,
const token = await client.token.findFirst({
where: {
code,
application: {
client_id,
secret: client_secret,
redirect_uris: redirect_uri,
},
scope: scope?.replaceAll("+", " "),
},
include: {
application: true,
},
scope: scope?.replaceAll("+", " "),
});
if (!token)

View file

@ -5,17 +5,16 @@ import { getConfig } from "@config";
import { getBestContentType } from "@content_types";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { EmojiAction } from "~database/entities/Emoji";
import { LysandObject } from "~database/entities/Object";
import { Status } from "~database/entities/Status";
import { UserAction, userRelations } from "~database/entities/User";
import { client } from "~database/datasource";
import { parseEmojis } from "~database/entities/Emoji";
import { createFromObject } from "~database/entities/Object";
import {
ContentFormat,
LysandAction,
LysandObjectType,
LysandPublication,
Patch,
} from "~types/lysand/Object";
createNewStatus,
fetchFromRemote,
statusAndUserRelations,
} from "~database/entities/Status";
import { parseMentionsUris, userRelations } from "~database/entities/User";
import { LysandAction, LysandPublication, Patch } from "~types/lysand/Object";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -61,11 +60,11 @@ export default async (
// Process request body
const body = (await req.json()) as LysandPublication | LysandAction;
const author = await UserAction.findOne({
const author = await client.user.findUnique({
where: {
uri: body.author,
username,
},
relations: userRelations,
include: userRelations,
});
if (!author) {
@ -116,7 +115,7 @@ export default async (
// author.public_key is base64 encoded raw public key
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(author.public_key, "base64"),
Buffer.from(author.publicKey, "base64"),
"Ed25519",
false,
["verify"]
@ -141,13 +140,13 @@ export default async (
switch (type) {
case "Note": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
const content = getBestContentType(body.contents);
const emojis = await EmojiAction.parseEmojis(content?.content || "");
const emojis = await parseEmojis(content?.content || "");
const newStatus = await Status.createNew({
const newStatus = await createNewStatus({
account: author,
content: content?.content || "",
content_type: content?.content_type,
@ -158,39 +157,49 @@ export default async (
sensitive: body.is_sensitive,
uri: body.uri,
emojis: emojis,
mentions: await UserAction.parseMentions(body.mentions),
mentions: await parseMentionsUris(body.mentions),
});
// If there is a reply, fetch all the reply parents and add them to the database
if (body.replies_to.length > 0) {
newStatus.in_reply_to_post = await Status.fetchFromRemote(
body.replies_to[0]
);
newStatus.inReplyToPostId =
(await fetchFromRemote(body.replies_to[0]))?.id || null;
}
// Same for quotes
if (body.quotes.length > 0) {
newStatus.quoting_post = await Status.fetchFromRemote(
body.quotes[0]
);
newStatus.quotingPostId =
(await fetchFromRemote(body.quotes[0]))?.id || null;
}
await newStatus.save();
await client.status.update({
where: {
id: newStatus.id,
},
data: {
inReplyToPostId: newStatus.inReplyToPostId,
quotingPostId: newStatus.quotingPostId,
},
});
break;
}
case "Patch": {
const patch = body as Patch;
// Store the object in the LysandObject table
await LysandObject.createFromObject(patch);
await createFromObject(patch);
// Edit the status
const content = getBestContentType(patch.contents);
const emojis = await EmojiAction.parseEmojis(content?.content || "");
const emojis = await parseEmojis(content?.content || "");
const status = await Status.findOneBy({
id: patch.patched_id,
const status = await client.status.findUnique({
where: {
uri: patch.patched_id,
},
include: statusAndUserRelations,
});
if (!status) {
@ -198,64 +207,81 @@ export default async (
}
status.content = content?.content || "";
status.content_type = content?.content_type || "text/plain";
status.spoiler_text = patch.subject || "";
status.contentType = content?.content_type || "text/plain";
status.spoilerText = patch.subject || "";
status.sensitive = patch.is_sensitive;
status.emojis = emojis;
// If there is a reply, fetch all the reply parents and add them to the database
if (body.replies_to.length > 0) {
status.in_reply_to_post = await Status.fetchFromRemote(
body.replies_to[0]
);
status.inReplyToPostId =
(await fetchFromRemote(body.replies_to[0]))?.id || null;
}
// Same for quotes
if (body.quotes.length > 0) {
status.quoting_post = await Status.fetchFromRemote(
body.quotes[0]
);
status.quotingPostId =
(await fetchFromRemote(body.quotes[0]))?.id || null;
}
await client.status.update({
where: {
id: status.id,
},
data: {
content: status.content,
contentType: status.contentType,
spoilerText: status.spoilerText,
sensitive: status.sensitive,
emojis: {
connect: status.emojis.map(emoji => ({
id: emoji.id,
})),
},
inReplyToPostId: status.inReplyToPostId,
quotingPostId: status.quotingPostId,
},
});
break;
}
case "Like": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
case "Dislike": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
case "Follow": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
case "FollowAccept": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
case "FollowReject": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
case "Announce": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
case "Undo": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
case "Extension": {
// Store the object in the LysandObject table
await LysandObject.createFromObject(body);
await createFromObject(body);
break;
}
default: {

View file

@ -4,7 +4,8 @@ import { applyConfig } from "@api";
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { UserAction, userRelations } from "~database/entities/User";
import { client } from "~database/datasource";
import { userRelations, userToLysand } from "~database/entities/User";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -29,16 +30,16 @@ export default async (
const config = getConfig();
const user = await UserAction.findOne({
const user = await client.user.findUnique({
where: {
id: uuid,
},
relations: userRelations,
include: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(user.toLysand());
return jsonResponse(userToLysand(user));
};

View file

@ -1,10 +1,12 @@
import { jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { userRelations } from "~database/entities/User";
import { getConfig, getHost } from "@config";
import { applyConfig } from "@api";
import { Status } from "~database/entities/Status";
import { In } from "typeorm";
import {
statusAndUserRelations,
statusToLysand,
} from "~database/entities/Status";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -29,26 +31,25 @@ export default async (
const pageNumber = Number(matchedRoute.query.page) || 1;
const config = getConfig();
const statuses = await Status.find({
const statuses = await client.status.findMany({
where: {
account: {
id: uuid,
authorId: uuid,
visibility: {
in: ["public", "unlisted"],
},
visibility: In(["public", "unlisted"]),
},
relations: userRelations,
take: 20,
skip: 20 * (pageNumber - 1),
include: statusAndUserRelations,
});
const totalStatuses = await Status.count({
const totalStatuses = await client.status.count({
where: {
account: {
id: uuid,
authorId: uuid,
visibility: {
in: ["public", "unlisted"],
},
visibility: In(["public", "unlisted"]),
},
relations: userRelations,
});
return jsonResponse({
@ -65,6 +66,6 @@ export default async (
pageNumber > 1
? `${getHost()}/users/${uuid}/outbox?page=${pageNumber - 1}`
: undefined,
items: statuses.map(s => s.toLysand()),
items: statuses.map(s => statusToLysand(s)),
});
};

View file

@ -1,71 +1,63 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getConfig } from "@config";
import { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { ApplicationAction } from "~database/entities/Application";
import { EmojiAction } from "~database/entities/Emoji";
import { Token, TokenType } from "~database/entities/Token";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
import { UserWithRelations, createNewLocalUser } from "~database/entities/User";
import { APIEmoji } from "~types/entities/emoji";
import { APIInstance } from "~types/entities/instance";
const config = getConfig();
let token: Token;
let user: UserAction;
let user2: UserAction;
let user: UserWithRelations;
describe("API Tests", () => {
beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
user = await UserAction.createNewLocal({
user = await createNewLocalUser({
email: "test@test.com",
username: "test",
password: "test",
display_name: "",
});
// Initialize second test user
user2 = await UserAction.createNewLocal({
email: "test2@test.com",
username: "test2",
password: "test2",
display_name: "",
token = await client.token.create({
data: {
access_token: "test",
application: {
create: {
client_id: "test",
name: "Test Application",
redirect_uris: "https://example.com",
scopes: "read write",
secret: "test",
website: "https://example.com",
vapid_key: null,
},
},
code: "test",
scope: "read write",
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
});
const app = new ApplicationAction();
app.name = "Test Application";
app.website = "https://example.com";
app.client_id = "test";
app.redirect_uris = "https://example.com";
app.scopes = "read write";
app.secret = "test";
app.vapid_key = null;
await app.save();
// Initialize test token
token = new Token();
token.access_token = "test";
token.application = app;
token.code = "test";
token.scope = "read write";
token.token_type = TokenType.BEARER;
token.user = user;
token = await token.save();
});
afterAll(async () => {
await user.remove();
await user2.remove();
await AppDataSource.destroy();
await client.user.deleteMany({
where: {
username: {
in: ["test", "test2"],
},
},
});
});
describe("GET /api/v1/instance", () => {
@ -106,15 +98,15 @@ describe("API Tests", () => {
describe("GET /api/v1/custom_emojis", () => {
beforeAll(async () => {
const emoji = new EmojiAction();
emoji.instance = null;
emoji.url = "https://example.com/test.png";
emoji.content_type = "image/png";
emoji.shortcode = "test";
emoji.visible_in_picker = true;
await emoji.save();
await client.emoji.create({
data: {
instanceId: null,
url: "https://example.com/test.png",
content_type: "image/png",
shortcode: "test",
visible_in_picker: true,
},
});
});
test("should return an array of at least one custom emoji", async () => {
const response = await fetch(
@ -139,7 +131,11 @@ describe("API Tests", () => {
expect(emojis[0].url).toBe("https://example.com/test.png");
});
afterAll(async () => {
await EmojiAction.delete({ shortcode: "test" });
await client.emoji.deleteMany({
where: {
shortcode: "test",
},
});
});
});
});

View file

@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getConfig } from "@config";
import { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { ApplicationAction } from "~database/entities/Application";
import { Token, TokenType } from "~database/entities/Token";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
import { UserWithRelations, createNewLocalUser } from "~database/entities/User";
import { APIAccount } from "~types/entities/account";
import { APIRelationship } from "~types/entities/relationship";
import { APIStatus } from "~types/entities/status";
@ -13,59 +13,59 @@ import { APIStatus } from "~types/entities/status";
const config = getConfig();
let token: Token;
let user: UserAction;
let user2: UserAction;
let user: UserWithRelations;
let user2: UserWithRelations;
describe("API Tests", () => {
beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
user = await UserAction.createNewLocal({
user = await createNewLocalUser({
email: "test@test.com",
username: "test",
password: "test",
display_name: "",
});
// Initialize second test user
user2 = await UserAction.createNewLocal({
user2 = await createNewLocalUser({
email: "test2@test.com",
username: "test2",
password: "test2",
display_name: "",
});
const app = new ApplicationAction();
app.name = "Test Application";
app.website = "https://example.com";
app.client_id = "test";
app.redirect_uris = "https://example.com";
app.scopes = "read write";
app.secret = "test";
app.vapid_key = null;
await app.save();
// Initialize test token
token = new Token();
token.access_token = "test";
token.application = app;
token.code = "test";
token.scope = "read write";
token.token_type = TokenType.BEARER;
token.user = user;
token = await token.save();
token = await client.token.create({
data: {
access_token: "test",
application: {
create: {
client_id: "test",
name: "Test Application",
redirect_uris: "https://example.com",
scopes: "read write",
secret: "test",
website: "https://example.com",
vapid_key: null,
},
},
code: "test",
scope: "read write",
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
});
});
afterAll(async () => {
await user.remove();
await user2.remove();
await AppDataSource.destroy();
await client.user.deleteMany({
where: {
username: {
in: ["test", "test2"],
},
},
});
});
describe("POST /api/v1/accounts/:id", () => {

View file

@ -1,72 +1,65 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getConfig } from "@config";
import { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { ApplicationAction } from "~database/entities/Application";
import { Token, TokenType } from "~database/entities/Token";
import { UserAction } from "~database/entities/User";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
import { UserWithRelations, createNewLocalUser } from "~database/entities/User";
import { APIAccount } from "~types/entities/account";
import { APIContext } from "~types/entities/context";
import { APIStatus } from "~types/entities/status";
const config = getConfig();
let token: Token;
let user: UserAction;
let user2: UserAction;
let user: UserWithRelations;
let status: APIStatus | null = null;
let status2: APIStatus | null = null;
describe("API Tests", () => {
beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
user = await UserAction.createNewLocal({
user = await createNewLocalUser({
email: "test@test.com",
username: "test",
password: "test",
display_name: "",
});
// Initialize second test user
user2 = await UserAction.createNewLocal({
email: "test2@test.com",
username: "test2",
password: "test2",
display_name: "",
token = await client.token.create({
data: {
access_token: "test",
application: {
create: {
client_id: "test",
name: "Test Application",
redirect_uris: "https://example.com",
scopes: "read write",
secret: "test",
website: "https://example.com",
vapid_key: null,
},
},
code: "test",
scope: "read write",
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
});
const app = new ApplicationAction();
app.name = "Test Application";
app.website = "https://example.com";
app.client_id = "test";
app.redirect_uris = "https://example.com";
app.scopes = "read write";
app.secret = "test";
app.vapid_key = null;
await app.save();
// Initialize test token
token = new Token();
token.access_token = "test";
token.application = app;
token.code = "test";
token.scope = "read write";
token.token_type = TokenType.BEARER;
token.user = user;
token = await token.save();
});
afterAll(async () => {
await user.remove();
await user2.remove();
await AppDataSource.destroy();
await client.user.deleteMany({
where: {
username: {
in: ["test", "test2"],
},
},
});
});
describe("POST /api/v1/statuses", () => {
@ -322,7 +315,7 @@ describe("API Tests", () => {
"application/json"
);
const users = (await response.json()) as UserAction[];
const users = (await response.json()) as APIAccount[];
expect(users.length).toBe(1);
expect(users[0].id).toBe(user.id);

View file

@ -1,9 +1,8 @@
import { getConfig } from "@config";
import { Application, Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { ApplicationAction } from "~database/entities/Application";
import { Token } from "~database/entities/Token";
import { UserAction, userRelations } from "~database/entities/User";
import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User";
const config = getConfig();
@ -13,10 +12,8 @@ let code: string;
let token: Token;
beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
await UserAction.createNewLocal({
// Init test user
await createNewLocalUser({
email: "test@test.com",
username: "test",
password: "test",
@ -139,7 +136,7 @@ describe("GET /api/v1/apps/verify_credentials", () => {
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const credentials = (await response.json()) as Partial<ApplicationAction>;
const credentials = (await response.json()) as Partial<Application>;
expect(credentials.name).toBe("Test Application");
expect(credentials.website).toBe("https://example.com");
@ -150,31 +147,9 @@ describe("GET /api/v1/apps/verify_credentials", () => {
afterAll(async () => {
// Clean up user
const user = await UserAction.findOne({
await client.user.delete({
where: {
username: "test",
},
relations: userRelations,
});
// Clean up tokens
const tokens = await Token.findBy({
user: {
username: "test",
},
});
const applications = await ApplicationAction.findBy({
client_id,
secret: client_secret,
});
await Promise.all(tokens.map(async token => await token.remove()));
await Promise.all(
applications.map(async application => await application.remove())
);
if (user) await user.remove();
await AppDataSource.destroy();
});