Replace eslint and prettier with Biome

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

View file

@ -1,23 +1,22 @@
import { xmlResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { xmlResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/host-meta",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/host-meta",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
return xmlResponse(`
const config = await extraData.configManager.getConfig();
return xmlResponse(`
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="${config.http.base_url}/.well-known/webfinger?resource={uri}"/>

View file

@ -1,43 +1,49 @@
import { jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/lysand",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/lysand",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
// In the format acct:name@example.com
// In the format acct:name@example.com
return jsonResponse({
type: "ServerMetadata",
name: config.instance.name,
version: "0.0.1",
description: config.instance.description,
logo: config.instance.logo ? [
{
content: config.instance.logo,
content_type: `image/${config.instance.logo.split(".")[1]}`,
}
] : undefined,
banner: config.instance.banner ? [
{
content: config.instance.banner,
content_type: `image/${config.instance.banner.split(".")[1]}`,
}
] : undefined,
supported_extensions: [
"org.lysand:custom_emojis"
],
logo: config.instance.logo
? [
{
content: config.instance.logo,
content_type: `image/${
config.instance.logo.split(".")[1]
}`,
},
]
: undefined,
banner: config.instance.banner
? [
{
content: config.instance.banner,
content_type: `image/${
config.instance.banner.split(".")[1]
}`,
},
]
: undefined,
supported_extensions: ["org.lysand:custom_emojis"],
website: "https://lysand.org",
// TODO: Add admins, moderators field
})
})
});
});

View file

@ -1,25 +1,24 @@
import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/nodeinfo",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/nodeinfo",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
return new Response("", {
status: 301,
headers: {
Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`,
},
});
return new Response("", {
status: 301,
headers: {
Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`,
},
});
});

View file

@ -1,59 +1,59 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/webfinger",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/webfinger",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
// In the format acct:name@example.com
const resource = matchedRoute.query.resource;
const requestedUser = resource.split("acct:")[1];
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).hostname;
// Check if user is a local user
if (requestedUser.split("@")[1] !== host) {
return errorResponse("User is a remote user", 404);
}
const user = await client.user.findUnique({
where: { username: requestedUser.split("@")[0] },
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse({
subject: `acct:${user.username}@${host}`,
links: [
{
rel: "self",
type: "application/activity+json",
href: `${config.http.base_url}/users/${user.username}/actor`
},
{
rel: "https://webfinger.net/rel/profile-page",
type: "text/html",
href: `${config.http.base_url}/users/${user.username}`
},
{
rel: "self",
type: "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\"",
href: `${config.http.base_url}/users/${user.username}/actor`
}
]
})
});
// In the format acct:name@example.com
const resource = matchedRoute.query.resource;
const requestedUser = resource.split("acct:")[1];
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).hostname;
// Check if user is a local user
if (requestedUser.split("@")[1] !== host) {
return errorResponse("User is a remote user", 404);
}
const user = await client.user.findUnique({
where: { username: requestedUser.split("@")[0] },
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse({
subject: `acct:${user.username}@${host}`,
links: [
{
rel: "self",
type: "application/activity+json",
href: `${config.http.base_url}/users/${user.username}/actor`,
},
{
rel: "https://webfinger.net/rel/profile-page",
type: "text/html",
href: `${config.http.base_url}/users/${user.username}`,
},
{
rel: "self",
type: 'application/activity+json; profile="https://www.w3.org/ns/activitystreams"',
href: `${config.http.base_url}/users/${user.username}/actor`,
},
],
});
});

View file

@ -2,20 +2,20 @@ import { apiRoute, applyConfig } from "@api";
import { errorResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["POST", "GET", "PUT", "PATCH", "DELETE"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 100,
},
route: "/[...404]",
allowedMethods: ["POST", "GET", "PUT", "PATCH", "DELETE"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 100,
},
route: "/[...404]",
});
/**
* Default catch-all route, returns a 404 error.
*/
export default apiRoute(() => {
return errorResponse("This API route does not exist", 404);
return errorResponse("This API route does not exist", 404);
});

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/block",
auth: {
required: true,
oauthPermissions: ["write:blocks"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/block",
auth: {
required: true,
oauthPermissions: ["write:blocks"],
},
});
/**
* Blocks a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (!relationship.blocking) {
relationship.blocking = true;
}
if (!relationship.blocking) {
relationship.blocking = true;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
blocking: true,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
blocking: true,
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,99 +1,99 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/follow",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/follow",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
});
/**
* Follow a user
*/
export default apiRoute<{
reblogs?: boolean;
notify?: boolean;
languages?: string[];
reblogs?: boolean;
notify?: boolean;
languages?: string[];
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const { languages, notify, reblogs } = extraData.parsedRequest;
const { languages, notify, reblogs } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (!relationship.following) {
relationship.following = true;
}
if (reblogs) {
relationship.showingReblogs = true;
}
if (notify) {
relationship.notifying = true;
}
if (languages) {
relationship.languages = languages;
}
if (!relationship.following) {
relationship.following = true;
}
if (reblogs) {
relationship.showingReblogs = true;
}
if (notify) {
relationship.notifying = true;
}
if (languages) {
relationship.languages = languages;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
following: true,
showingReblogs: reblogs ?? false,
notifying: notify ?? false,
languages: languages ?? [],
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
following: true,
showingReblogs: reblogs ?? false,
notifying: notify ?? false,
languages: languages ?? [],
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,82 +1,82 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 60,
duration: 60,
},
route: "/accounts/:id/followers",
auth: {
required: false,
oauthPermissions: [],
},
allowedMethods: ["GET"],
ratelimits: {
max: 60,
duration: 60,
},
route: "/accounts/:id/followers",
auth: {
required: false,
oauthPermissions: [],
},
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
const objects = await client.user.findMany({
where: {
relationships: {
some: {
subjectId: user.id,
following: true,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
const objects = await client.user.findMany({
where: {
relationships: {
some: {
subjectId: user.id,
following: true,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(objects.map(object => userToAPI(object))),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -1,82 +1,82 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 60,
duration: 60,
},
route: "/accounts/:id/following",
auth: {
required: false,
oauthPermissions: [],
},
allowedMethods: ["GET"],
ratelimits: {
max: 60,
duration: 60,
},
route: "/accounts/:id/following",
auth: {
required: false,
oauthPermissions: [],
},
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
const objects = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
following: true,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
const objects = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
following: true,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(objects.map(object => userToAPI(object))),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -1,46 +1,46 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import type { UserWithRelations } from "~database/entities/User";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id",
auth: {
required: true,
oauthPermissions: [],
},
allowedMethods: ["GET"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id",
auth: {
required: true,
oauthPermissions: [],
},
});
/**
* Fetch a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
// Check if ID is valid UUID
if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return errorResponse("Invalid ID", 404);
}
const id = matchedRoute.params.id;
// Check if ID is valid UUID
if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return errorResponse("Invalid ID", 404);
}
const { user } = extraData.auth;
const { user } = extraData.auth;
let foundUser: UserWithRelations | null;
try {
foundUser = await client.user.findUnique({
where: { id },
include: userRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
let foundUser: UserWithRelations | null;
try {
foundUser = await client.user.findUnique({
where: { id },
include: userRelations,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundUser) return errorResponse("User not found", 404);
if (!foundUser) return errorResponse("User not found", 404);
return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id));
return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id));
});

View file

@ -1,93 +1,93 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/mute",
auth: {
required: true,
oauthPermissions: ["write:mutes"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/mute",
auth: {
required: true,
oauthPermissions: ["write:mutes"],
},
});
/**
* Mute a user
*/
export default apiRoute<{
notifications: boolean;
duration: number;
notifications: boolean;
duration: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { notifications, duration } = extraData.parsedRequest;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { notifications, duration } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (!relationship.muting) {
relationship.muting = true;
}
if (notifications ?? true) {
relationship.mutingNotifications = true;
}
if (!relationship.muting) {
relationship.muting = true;
}
if (notifications ?? true) {
relationship.mutingNotifications = true;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
muting: true,
mutingNotifications: notifications ?? true,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
muting: true,
mutingNotifications: notifications ?? true,
},
});
// TODO: Implement duration
// TODO: Implement duration
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,83 +1,83 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/note",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/note",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
});
/**
* Sets a user note
*/
export default apiRoute<{
comment: string;
comment: string;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const { comment } = extraData.parsedRequest;
const { comment } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
relationship.note = comment ?? "";
relationship.note = comment ?? "";
await client.relationship.update({
where: { id: relationship.id },
data: {
note: relationship.note,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
note: relationship.note,
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/pin",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/pin",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
});
/**
* Pin a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (!relationship.endorsed) {
relationship.endorsed = true;
}
if (!relationship.endorsed) {
relationship.endorsed = true;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
endorsed: true,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
endorsed: true,
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,95 +1,95 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/remove_from_followers",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/remove_from_followers",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
});
/**
* Removes an account from your followers list
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (relationship.followedBy) {
relationship.followedBy = false;
}
if (relationship.followedBy) {
relationship.followedBy = false;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
followedBy: false,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
followedBy: false,
},
});
if (user.instanceId === null) {
// Also remove from followers list
await client.relationship.updateMany({
where: {
ownerId: user.id,
subjectId: self.id,
following: true,
},
data: {
following: false,
},
});
}
if (user.instanceId === null) {
// Also remove from followers list
await client.relationship.updateMany({
where: {
ownerId: user.id,
subjectId: self.id,
following: true,
},
data: {
following: false,
},
});
}
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,134 +1,136 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { statusToAPI } from "~database/entities/Status";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
import { statusToAPI } from "~database/entities/Status";
import {
userRelations,
statusAndUserRelations,
statusAndUserRelations,
userRelations,
} from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/statuses",
auth: {
required: false,
oauthPermissions: ["read:statuses"],
},
allowedMethods: ["GET"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/statuses",
auth: {
required: false,
oauthPermissions: ["read:statuses"],
},
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: string;
only_media?: boolean;
exclude_replies?: boolean;
exclude_reblogs?: boolean;
// TODO: Add with_muted
pinned?: boolean;
tagged?: string;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: string;
only_media?: boolean;
exclude_replies?: boolean;
exclude_reblogs?: boolean;
// TODO: Add with_muted
pinned?: boolean;
tagged?: string;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
// TODO: Add pinned
const {
max_id,
min_id,
since_id,
limit = "20",
exclude_reblogs,
pinned,
} = extraData.parsedRequest;
// TODO: Add pinned
const {
max_id,
min_id,
since_id,
limit = "20",
exclude_reblogs,
pinned,
} = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
if (pinned) {
const objects = await client.status.findMany({
where: {
authorId: id,
isReblog: false,
pinnedBy: {
some: {
id: user.id,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: statusAndUserRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
if (pinned) {
const objects = await client.status.findMany({
where: {
authorId: id,
isReblog: false,
pinnedBy: {
some: {
id: user.id,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: statusAndUserRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(objects.map(status => statusToAPI(status, user))),
200,
{
Link: linkHeader.join(", "),
}
);
}
return jsonResponse(
await Promise.all(
objects.map((status) => statusToAPI(status, user)),
),
200,
{
Link: linkHeader.join(", "),
},
);
}
const objects = await client.status.findMany({
where: {
authorId: id,
isReblog: exclude_reblogs ? true : undefined,
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: statusAndUserRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
const objects = await client.status.findMany({
where: {
authorId: id,
isReblog: exclude_reblogs ? true : undefined,
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: statusAndUserRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(objects.map(status => statusToAPI(status, user))),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
await Promise.all(objects.map((status) => statusToAPI(status, user))),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unblock",
auth: {
required: true,
oauthPermissions: ["write:blocks"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unblock",
auth: {
required: true,
oauthPermissions: ["write:blocks"],
},
});
/**
* Blocks a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (relationship.blocking) {
relationship.blocking = false;
}
if (relationship.blocking) {
relationship.blocking = false;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
blocking: false,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
blocking: false,
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unfollow",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unfollow",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
});
/**
* Unfollows a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (relationship.following) {
relationship.following = false;
}
if (relationship.following) {
relationship.following = false;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
following: false,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
following: false,
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,83 +1,83 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unmute",
auth: {
required: true,
oauthPermissions: ["write:mutes"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unmute",
auth: {
required: true,
oauthPermissions: ["write:mutes"],
},
});
/**
* Unmute a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (relationship.muting) {
relationship.muting = false;
}
if (relationship.muting) {
relationship.muting = false;
}
// TODO: Implement duration
// TODO: Implement duration
await client.relationship.update({
where: { id: relationship.id },
data: {
muting: false,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
muting: false,
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unpin",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 30,
duration: 60,
},
route: "/accounts/:id/unpin",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
});
/**
* Unpin a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
const user = await client.user.findUnique({
where: { id },
include: {
relationships: {
include: {
owner: true,
subject: true,
},
},
},
});
if (!user) return errorResponse("User not found", 404);
if (!user) return errorResponse("User not found", 404);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
// Check if already following
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) {
// Create new relationship
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(self, user);
const newRelationship = await createNewRelationship(self, user);
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
await client.user.update({
where: { id: self.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
relationship = newRelationship;
}
relationship = newRelationship;
}
if (relationship.endorsed) {
relationship.endorsed = false;
}
if (relationship.endorsed) {
relationship.endorsed = false;
}
await client.relationship.update({
where: { id: relationship.id },
data: {
endorsed: false,
},
});
await client.relationship.update({
where: { id: relationship.id },
data: {
endorsed: false,
},
});
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,67 +1,67 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/accounts/familiar_followers",
ratelimits: {
max: 5,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:follows"],
},
allowedMethods: ["GET"],
route: "/api/v1/accounts/familiar_followers",
ratelimits: {
max: 5,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:follows"],
},
});
/**
* Find familiar followers (followers of a user that you also follow)
*/
export default apiRoute<{
id: string[];
id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const { id: ids } = extraData.parsedRequest;
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
const followersOfIds = await client.user.findMany({
where: {
relationships: {
some: {
subjectId: {
in: ids,
},
following: true,
},
},
},
});
const followersOfIds = await client.user.findMany({
where: {
relationships: {
some: {
subjectId: {
in: ids,
},
following: true,
},
},
},
});
// Find users that you follow in followersOfIds
const output = await client.user.findMany({
where: {
relationships: {
some: {
ownerId: self.id,
subjectId: {
in: followersOfIds.map(f => f.id),
},
following: true,
},
},
},
include: userRelations,
});
// Find users that you follow in followersOfIds
const output = await client.user.findMany({
where: {
relationships: {
some: {
ownerId: self.id,
subjectId: {
in: followersOfIds.map((f) => f.id),
},
following: true,
},
},
},
include: userRelations,
});
return jsonResponse(output.map(o => userToAPI(o)));
return jsonResponse(output.map((o) => userToAPI(o)));
});

View file

@ -1,202 +1,206 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { tempmailDomains } from "@tempmail";
import { apiRoute, applyConfig } from "@api";
import ISO6391 from "iso-639-1";
import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User";
import ISO6391 from "iso-639-1";
export const meta = applyConfig({
allowedMethods: ["POST"],
route: "/api/v1/accounts",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: false,
oauthPermissions: ["write:accounts"],
},
allowedMethods: ["POST"],
route: "/api/v1/accounts",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: false,
oauthPermissions: ["write:accounts"],
},
});
export default apiRoute<{
username: string;
email: string;
password: string;
agreement: boolean;
locale: string;
reason: string;
username: string;
email: string;
password: string;
agreement: boolean;
locale: string;
reason: string;
}>(async (req, matchedRoute, extraData) => {
// TODO: Add Authorization check
// TODO: Add Authorization check
const body = extraData.parsedRequest;
const body = extraData.parsedRequest;
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
if (!config.signups.registration) {
return jsonResponse(
{
error: "Registration is disabled",
},
422
);
}
if (!config.signups.registration) {
return jsonResponse(
{
error: "Registration is disabled",
},
422,
);
}
const errors: {
details: Record<
string,
{
error:
| "ERR_BLANK"
| "ERR_INVALID"
| "ERR_TOO_LONG"
| "ERR_TOO_SHORT"
| "ERR_BLOCKED"
| "ERR_TAKEN"
| "ERR_RESERVED"
| "ERR_ACCEPTED"
| "ERR_INCLUSION";
description: string;
}[]
>;
} = {
details: {
password: [],
username: [],
email: [],
agreement: [],
locale: [],
reason: [],
},
};
const errors: {
details: Record<
string,
{
error:
| "ERR_BLANK"
| "ERR_INVALID"
| "ERR_TOO_LONG"
| "ERR_TOO_SHORT"
| "ERR_BLOCKED"
| "ERR_TAKEN"
| "ERR_RESERVED"
| "ERR_ACCEPTED"
| "ERR_INCLUSION";
description: string;
}[]
>;
} = {
details: {
password: [],
username: [],
email: [],
agreement: [],
locale: [],
reason: [],
},
};
// Check if fields are blank
["username", "email", "password", "agreement", "locale", "reason"].forEach(
value => {
// @ts-expect-error Value is always valid
if (!body[value])
errors.details[value].push({
error: "ERR_BLANK",
description: `can't be blank`,
});
}
);
// Check if fields are blank
for (const value of [
"username",
"email",
"password",
"agreement",
"locale",
"reason",
]) {
// @ts-expect-error We don't care about typing here
if (!body[value]) {
errors.details[value].push({
error: "ERR_BLANK",
description: `can't be blank`,
});
}
}
// Check if username is valid
if (!body.username?.match(/^[a-zA-Z0-9_]+$/))
errors.details.username.push({
error: "ERR_INVALID",
description: `must only contain letters, numbers, and underscores`,
});
// Check if username is valid
if (!body.username?.match(/^[a-zA-Z0-9_]+$/))
errors.details.username.push({
error: "ERR_INVALID",
description: "must only contain letters, numbers, and underscores",
});
// Check if username doesnt match filters
if (
config.filters.username_filters.some(filter =>
body.username?.match(filter)
)
) {
errors.details.username.push({
error: "ERR_INVALID",
description: `contains blocked words`,
});
}
// Check if username doesnt match filters
if (
config.filters.username.some((filter) => body.username?.match(filter))
) {
errors.details.username.push({
error: "ERR_INVALID",
description: "contains blocked words",
});
}
// Check if username is too long
if ((body.username?.length ?? 0) > config.validation.max_username_size)
errors.details.username.push({
error: "ERR_TOO_LONG",
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
});
// Check if username is too long
if ((body.username?.length ?? 0) > config.validation.max_username_size)
errors.details.username.push({
error: "ERR_TOO_LONG",
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
});
// Check if username is too short
if ((body.username?.length ?? 0) < 3)
errors.details.username.push({
error: "ERR_TOO_SHORT",
description: `is too short (minimum is 3 characters)`,
});
// Check if username is too short
if ((body.username?.length ?? 0) < 3)
errors.details.username.push({
error: "ERR_TOO_SHORT",
description: "is too short (minimum is 3 characters)",
});
// Check if username is reserved
if (config.validation.username_blacklist.includes(body.username ?? ""))
errors.details.username.push({
error: "ERR_RESERVED",
description: `is reserved`,
});
// Check if username is reserved
if (config.validation.username_blacklist.includes(body.username ?? ""))
errors.details.username.push({
error: "ERR_RESERVED",
description: "is reserved",
});
// Check if username is taken
if (await client.user.findFirst({ where: { username: body.username } }))
errors.details.username.push({
error: "ERR_TAKEN",
description: `is already taken`,
});
// Check if username is taken
if (await client.user.findFirst({ where: { username: body.username } }))
errors.details.username.push({
error: "ERR_TAKEN",
description: "is already taken",
});
// Check if email is valid
if (
!body.email?.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
)
)
errors.details.email.push({
error: "ERR_INVALID",
description: `must be a valid email address`,
});
// Check if email is valid
if (
!body.email?.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
)
)
errors.details.email.push({
error: "ERR_INVALID",
description: "must be a valid email address",
});
// Check if email is blocked
if (
config.validation.email_blacklist.includes(body.email ?? "") ||
(config.validation.blacklist_tempmail &&
tempmailDomains.domains.includes((body.email ?? "").split("@")[1]))
)
errors.details.email.push({
error: "ERR_BLOCKED",
description: `is from a blocked email provider`,
});
// Check if email is blocked
if (
config.validation.email_blacklist.includes(body.email ?? "") ||
(config.validation.blacklist_tempmail &&
tempmailDomains.domains.includes((body.email ?? "").split("@")[1]))
)
errors.details.email.push({
error: "ERR_BLOCKED",
description: "is from a blocked email provider",
});
// Check if agreement is accepted
if (!body.agreement)
errors.details.agreement.push({
error: "ERR_ACCEPTED",
description: `must be accepted`,
});
// Check if agreement is accepted
if (!body.agreement)
errors.details.agreement.push({
error: "ERR_ACCEPTED",
description: "must be accepted",
});
if (!body.locale)
errors.details.locale.push({
error: "ERR_BLANK",
description: `can't be blank`,
});
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 (!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"
// 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"
const errorsText = Object.entries(errors.details)
.map(
([name, errors]) =>
`${name} ${errors
.map(error => error.description)
.join(", ")}`
)
.join(", ");
return jsonResponse(
{
error: `Validation failed: ${errorsText}`,
details: errors.details,
},
422
);
}
const errorsText = Object.entries(errors.details)
.map(
([name, errors]) =>
`${name} ${errors
.map((error) => error.description)
.join(", ")}`,
)
.join(", ");
return jsonResponse(
{
error: `Validation failed: ${errorsText}`,
details: errors.details,
},
422,
);
}
await createNewLocalUser({
username: body.username ?? "",
password: body.password ?? "",
email: body.email ?? "",
});
await createNewLocalUser({
username: body.username ?? "",
password: body.password ?? "",
email: body.email ?? "",
});
return new Response("", {
status: 200,
});
return new Response("", {
status: 200,
});
});

View file

@ -1,66 +1,67 @@
import { errorResponse, jsonResponse } from "@response";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { apiRoute, applyConfig } from "@api";
import type { User } from "@prisma/client";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/accounts/relationships",
ratelimits: {
max: 30,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:follows"],
},
allowedMethods: ["GET"],
route: "/api/v1/accounts/relationships",
ratelimits: {
max: 30,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:follows"],
},
});
/**
* Find relationships
*/
export default apiRoute<{
id: string[];
id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
if (!self) return errorResponse("Unauthorized", 401);
const { id: ids } = extraData.parsedRequest;
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
const relationships = await client.relationship.findMany({
where: {
ownerId: self.id,
subjectId: {
in: ids,
},
},
});
const relationships = await client.relationship.findMany({
where: {
ownerId: self.id,
subjectId: {
in: ids,
},
},
});
// Find IDs that dont have a relationship
const missingIds = ids.filter(
id => !relationships.some(r => r.subjectId === id)
);
// Find IDs that dont have a relationship
const missingIds = ids.filter(
(id) => !relationships.some((r) => r.subjectId === id),
);
// Create the missing relationships
for (const id of missingIds) {
const relationship = await createNewRelationship(self, { id } as any);
// Create the missing relationships
for (const id of missingIds) {
const relationship = await createNewRelationship(self, { id } as User);
relationships.push(relationship);
}
relationships.push(relationship);
}
// Order in the same order as ids
relationships.sort(
(a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId)
);
// Order in the same order as ids
relationships.sort(
(a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId),
);
return jsonResponse(relationships.map(r => relationshipToAPI(r)));
return jsonResponse(relationships.map((r) => relationshipToAPI(r)));
});

View file

@ -1,75 +1,75 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/accounts/search",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:accounts"],
},
allowedMethods: ["GET"],
route: "/api/v1/accounts/search",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:accounts"],
},
});
export default apiRoute<{
q?: string;
limit?: number;
offset?: number;
resolve?: boolean;
following?: boolean;
q?: string;
limit?: number;
offset?: number;
resolve?: boolean;
following?: boolean;
}>(async (req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts
// TODO: Add checks for disabled or not email verified accounts
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const {
following = false,
limit = 40,
offset,
q,
} = extraData.parsedRequest;
const {
following = false,
limit = 40,
offset,
q,
} = extraData.parsedRequest;
if (limit < 1 || limit > 80) {
return errorResponse("Limit must be between 1 and 80", 400);
}
if (limit < 1 || limit > 80) {
return errorResponse("Limit must be between 1 and 80", 400);
}
// TODO: Add WebFinger resolve
// TODO: Add WebFinger resolve
const accounts = await client.user.findMany({
where: {
OR: [
{
displayName: {
contains: q,
},
},
{
username: {
contains: q,
},
},
],
relationshipSubjects: following
? {
some: {
ownerId: user.id,
following,
},
}
: undefined,
},
take: Number(limit),
skip: Number(offset || 0),
include: userRelations,
});
const accounts = await client.user.findMany({
where: {
OR: [
{
displayName: {
contains: q,
},
},
{
username: {
contains: q,
},
},
],
relationshipSubjects: following
? {
some: {
ownerId: user.id,
following,
},
}
: undefined,
},
take: Number(limit),
skip: Number(offset || 0),
include: userRelations,
});
return jsonResponse(accounts.map(acct => userToAPI(acct)));
return jsonResponse(accounts.map((acct) => userToAPI(acct)));
});

View file

@ -1,72 +1,72 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify";
import { convertTextToHtml } from "@formatting";
import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import ISO6391 from "iso-639-1";
import { parseEmojis } from "~database/entities/Emoji";
import { client } from "~database/datasource";
import type { APISource } from "~types/entities/source";
import { convertTextToHtml } from "@formatting";
import { sanitize } from "isomorphic-dompurify";
import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager";
import { client } from "~database/datasource";
import { getUrl } from "~database/entities/Attachment";
import { parseEmojis } from "~database/entities/Emoji";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3";
import { getUrl } from "~database/entities/Attachment";
import { userRelations } from "~database/entities/relations";
import type { APISource } from "~types/entities/source";
export const meta = applyConfig({
allowedMethods: ["PATCH"],
route: "/api/v1/accounts/update_credentials",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
allowedMethods: ["PATCH"],
route: "/api/v1/accounts/update_credentials",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
});
export default apiRoute<{
display_name: string;
note: string;
avatar: File;
header: File;
locked: string;
bot: string;
discoverable: string;
"source[privacy]": string;
"source[sensitive]": string;
"source[language]": string;
display_name: string;
note: string;
avatar: File;
header: File;
locked: string;
bot: string;
discoverable: string;
"source[privacy]": string;
"source[sensitive]": string;
"source[language]": string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
const {
display_name,
note,
avatar,
header,
locked,
bot,
discoverable,
"source[privacy]": source_privacy,
"source[sensitive]": source_sensitive,
"source[language]": source_language,
} = extraData.parsedRequest;
const {
display_name,
note,
avatar,
header,
locked,
bot,
discoverable,
"source[privacy]": source_privacy,
"source[sensitive]": source_sensitive,
"source[language]": source_language,
} = extraData.parsedRequest;
const sanitizedNote = await sanitizeHtml(note ?? "");
const sanitizedNote = await sanitizeHtml(note ?? "");
const sanitizedDisplayName = sanitize(display_name ?? "", {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
});
const sanitizedDisplayName = sanitize(display_name ?? "", {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
});
/* if (!user.source) {
/* if (!user.source) {
user.source = {
privacy: "public",
sensitive: false,
@ -75,191 +75,192 @@ export default apiRoute<{
};
} */
let mediaManager: MediaBackend;
let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
if (display_name) {
// Check if within allowed display name lengths
if (
sanitizedDisplayName.length < 3 ||
sanitizedDisplayName.length > config.validation.max_displayname_size
) {
return errorResponse(
`Display name must be between 3 and ${config.validation.max_displayname_size} characters`,
422
);
}
if (display_name) {
// Check if within allowed display name lengths
if (
sanitizedDisplayName.length < 3 ||
sanitizedDisplayName.length > config.validation.max_displayname_size
) {
return errorResponse(
`Display name must be between 3 and ${config.validation.max_displayname_size} characters`,
422,
);
}
// Check if display name doesnt match filters
if (
config.filters.displayname.some(filter =>
sanitizedDisplayName.match(filter)
)
) {
return errorResponse("Display name contains blocked words", 422);
}
// Check if display name doesnt match filters
if (
config.filters.displayname.some((filter) =>
sanitizedDisplayName.match(filter),
)
) {
return errorResponse("Display name contains blocked words", 422);
}
// Remove emojis
user.emojis = [];
// Remove emojis
user.emojis = [];
user.displayName = sanitizedDisplayName;
}
user.displayName = sanitizedDisplayName;
}
if (note && user.source) {
// Check if within allowed note length
if (sanitizedNote.length > config.validation.max_note_size) {
return errorResponse(
`Note must be less than ${config.validation.max_note_size} characters`,
422
);
}
if (note && user.source) {
// Check if within allowed note length
if (sanitizedNote.length > config.validation.max_note_size) {
return errorResponse(
`Note must be less than ${config.validation.max_note_size} characters`,
422,
);
}
// Check if bio doesnt match filters
if (config.filters.bio.some(filter => sanitizedNote.match(filter))) {
return errorResponse("Bio contains blocked words", 422);
}
// Check if bio doesnt match filters
if (config.filters.bio.some((filter) => sanitizedNote.match(filter))) {
return errorResponse("Bio contains blocked words", 422);
}
(user.source as APISource).note = sanitizedNote;
// TODO: Convert note to HTML
user.note = await convertTextToHtml(sanitizedNote);
}
(user.source as APISource).note = sanitizedNote;
// TODO: Convert note to HTML
user.note = await convertTextToHtml(sanitizedNote);
}
if (source_privacy && user.source) {
// Check if within allowed privacy values
if (
!["public", "unlisted", "private", "direct"].includes(
source_privacy
)
) {
return errorResponse(
"Privacy must be one of public, unlisted, private, or direct",
422
);
}
if (source_privacy && user.source) {
// Check if within allowed privacy values
if (
!["public", "unlisted", "private", "direct"].includes(
source_privacy,
)
) {
return errorResponse(
"Privacy must be one of public, unlisted, private, or direct",
422,
);
}
(user.source as APISource).privacy = source_privacy;
}
(user.source as APISource).privacy = source_privacy;
}
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);
}
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 as APISource).sensitive = source_sensitive === "true";
}
(user.source as APISource).sensitive = source_sensitive === "true";
}
if (source_language && user.source) {
if (!ISO6391.validate(source_language)) {
return errorResponse(
"Language must be a valid ISO 639-1 code",
422
);
}
if (source_language && user.source) {
if (!ISO6391.validate(source_language)) {
return errorResponse(
"Language must be a valid ISO 639-1 code",
422,
);
}
(user.source as APISource).language = source_language;
}
(user.source as APISource).language = source_language;
}
if (avatar) {
// Check if within allowed avatar length (avatar is an image)
if (avatar.size > config.validation.max_avatar_size) {
return errorResponse(
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
422
);
}
if (avatar) {
// Check if within allowed avatar length (avatar is an image)
if (avatar.size > config.validation.max_avatar_size) {
return errorResponse(
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
422,
);
}
const { uploadedFile } = await mediaManager.addFile(avatar);
const { uploadedFile } = await mediaManager.addFile(avatar);
user.avatar = getUrl(uploadedFile.name, config);
}
user.avatar = getUrl(uploadedFile.name, config);
}
if (header) {
// Check if within allowed header length (header is an image)
if (header.size > config.validation.max_header_size) {
return errorResponse(
`Header must be less than ${config.validation.max_avatar_size} bytes`,
422
);
}
if (header) {
// Check if within allowed header length (header is an image)
if (header.size > config.validation.max_header_size) {
return errorResponse(
`Header must be less than ${config.validation.max_avatar_size} bytes`,
422,
);
}
const { uploadedFile } = await mediaManager.addFile(header);
const { uploadedFile } = await mediaManager.addFile(header);
user.header = getUrl(uploadedFile.name, config);
}
user.header = getUrl(uploadedFile.name, config);
}
if (locked) {
// Check if locked is a boolean
if (locked !== "true" && locked !== "false") {
return errorResponse("Locked must be a boolean", 422);
}
if (locked) {
// Check if locked is a boolean
if (locked !== "true" && locked !== "false") {
return errorResponse("Locked must be a boolean", 422);
}
user.isLocked = locked === "true";
}
user.isLocked = locked === "true";
}
if (bot) {
// Check if bot is a boolean
if (bot !== "true" && bot !== "false") {
return errorResponse("Bot must be a boolean", 422);
}
if (bot) {
// Check if bot is a boolean
if (bot !== "true" && bot !== "false") {
return errorResponse("Bot must be a boolean", 422);
}
user.isBot = bot === "true";
}
user.isBot = bot === "true";
}
if (discoverable) {
// Check if discoverable is a boolean
if (discoverable !== "true" && discoverable !== "false") {
return errorResponse("Discoverable must be a boolean", 422);
}
if (discoverable) {
// Check if discoverable is a boolean
if (discoverable !== "true" && discoverable !== "false") {
return errorResponse("Discoverable must be a boolean", 422);
}
user.isDiscoverable = discoverable === "true";
}
user.isDiscoverable = discoverable === "true";
}
// Parse emojis
// Parse emojis
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote);
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote);
user.emojis = [...displaynameEmojis, ...noteEmojis];
user.emojis = [...displaynameEmojis, ...noteEmojis];
// Deduplicate emojis
user.emojis = user.emojis.filter(
(emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index
);
// Deduplicate emojis
user.emojis = user.emojis.filter(
(emoji, index, self) =>
self.findIndex((e) => e.id === emoji.id) === index,
);
const output = 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 || undefined,
},
include: userRelations,
});
const output = 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 || undefined,
},
include: userRelations,
});
return jsonResponse(userToAPI(output));
return jsonResponse(userToAPI(output));
});

View file

@ -1,28 +1,28 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/accounts/verify_credentials",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:accounts"],
},
allowedMethods: ["GET"],
route: "/api/v1/accounts/verify_credentials",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:accounts"],
},
});
export default apiRoute((req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts
// TODO: Add checks for disabled or not email verified accounts
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
return jsonResponse({
...userToAPI(user, true),
});
return jsonResponse({
...userToAPI(user, true),
});
});

View file

@ -1,65 +1,65 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { randomBytes } from "crypto";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
route: "/api/v1/apps",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: false,
},
allowedMethods: ["POST"],
route: "/api/v1/apps",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: false,
},
});
/**
* Creates a new application to obtain OAuth 2 credentials
*/
export default apiRoute<{
client_name: string;
redirect_uris: string;
scopes: string;
website: string;
client_name: string;
redirect_uris: string;
scopes: string;
website: string;
}>(async (req, matchedRoute, extraData) => {
const { client_name, redirect_uris, scopes, website } =
extraData.parsedRequest;
const { client_name, redirect_uris, scopes, website } =
extraData.parsedRequest;
// Check if redirect URI is a valid URI, and also an absolute URI
if (redirect_uris) {
try {
const redirect_uri = new URL(redirect_uris);
// Check if redirect URI is a valid URI, and also an absolute URI
if (redirect_uris) {
try {
const redirect_uri = new URL(redirect_uris);
if (!redirect_uri.protocol.startsWith("http")) {
return errorResponse(
"Redirect URI must be an absolute URI",
422
);
}
} catch {
return errorResponse("Redirect URI must be a valid URI", 422);
}
}
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"),
},
});
if (!redirect_uri.protocol.startsWith("http")) {
return errorResponse(
"Redirect URI must be an absolute URI",
422,
);
}
} catch {
return errorResponse("Redirect URI must be a valid URI", 422);
}
}
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,
name: application.name,
website: application.website,
client_id: application.client_id,
client_secret: application.secret,
redirect_uri: application.redirect_uris,
vapid_link: application.vapid_key,
});
return jsonResponse({
id: application.id,
name: application.name,
website: application.website,
client_id: application.client_id,
client_secret: application.secret,
redirect_uri: application.redirect_uris,
vapid_link: application.vapid_key,
});
});

View file

@ -3,32 +3,32 @@ import { errorResponse, jsonResponse } from "@response";
import { getFromToken } from "~database/entities/Application";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/apps/verify_credentials",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["GET"],
route: "/api/v1/apps/verify_credentials",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
/**
* Returns OAuth2 credentials
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user, token } = extraData.auth;
const application = await getFromToken(token);
const { user, token } = extraData.auth;
const application = await getFromToken(token);
if (!user) return errorResponse("Unauthorized", 401);
if (!application) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
if (!application) return errorResponse("Unauthorized", 401);
return jsonResponse({
name: application.name,
website: application.website,
vapid_key: application.vapid_key,
redirect_uris: application.redirect_uris,
scopes: application.scopes,
});
return jsonResponse({
name: application.name,
website: application.website,
vapid_key: application.vapid_key,
redirect_uris: application.redirect_uris,
scopes: application.scopes,
});
});

View file

@ -1,37 +1,37 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/blocks",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["GET"],
route: "/api/v1/blocks",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const blocks = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
blocking: true,
},
},
},
include: userRelations,
});
const blocks = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
blocking: true,
},
},
},
include: userRelations,
});
return jsonResponse(blocks.map(u => userToAPI(u)));
return jsonResponse(blocks.map((u) => userToAPI(u)));
});

View file

@ -4,25 +4,25 @@ import { client } from "~database/datasource";
import { emojiToAPI } from "~database/entities/Emoji";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/custom_emojis",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: false,
},
allowedMethods: ["GET"],
route: "/api/v1/custom_emojis",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: false,
},
});
export default apiRoute(async () => {
const emojis = await client.emoji.findMany({
where: {
instanceId: null,
},
});
const emojis = await client.emoji.findMany({
where: {
instanceId: null,
},
});
return jsonResponse(
await Promise.all(emojis.map(emoji => emojiToAPI(emoji)))
);
return jsonResponse(
await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))),
);
});

View file

@ -1,74 +1,74 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { statusAndUserRelations } from "~database/entities/relations";
import { statusToAPI } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/favourites",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["GET"],
route: "/api/v1/favourites",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const objects = await client.status.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
likes: {
some: {
likerId: user.id,
},
},
},
include: statusAndUserRelations,
take: limit,
orderBy: {
id: "desc",
},
});
const objects = await client.status.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
likes: {
some: {
likerId: user.id,
},
},
},
include: statusAndUserRelations,
take: limit,
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(
objects.map(async status => statusToAPI(status, user))
),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
await Promise.all(
objects.map(async (status) => statusToAPI(status, user)),
),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -1,75 +1,75 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
checkForBidirectionalRelationships,
relationshipToAPI,
checkForBidirectionalRelationships,
relationshipToAPI,
} from "~database/entities/Relationship";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
route: "/api/v1/follow_requests/:account_id/authorize",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["POST"],
route: "/api/v1/follow_requests/:account_id/authorize",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const { account_id } = matchedRoute.params;
const { account_id } = matchedRoute.params;
const account = await client.user.findUnique({
where: {
id: account_id,
},
include: userRelations,
});
const account = await client.user.findUnique({
where: {
id: account_id,
},
include: userRelations,
});
if (!account) return errorResponse("Account not found", 404);
if (!account) return errorResponse("Account not found", 404);
// Check if there is a relationship on both sides
await checkForBidirectionalRelationships(user, account);
// Check if there is a relationship on both sides
await checkForBidirectionalRelationships(user, account);
// Authorize follow request
await client.relationship.updateMany({
where: {
subjectId: user.id,
ownerId: account.id,
requested: true,
},
data: {
requested: false,
following: true,
},
});
// Authorize follow request
await client.relationship.updateMany({
where: {
subjectId: user.id,
ownerId: account.id,
requested: true,
},
data: {
requested: false,
following: true,
},
});
// Update followedBy for other user
await client.relationship.updateMany({
where: {
subjectId: account.id,
ownerId: user.id,
},
data: {
followedBy: true,
},
});
// Update followedBy for other user
await client.relationship.updateMany({
where: {
subjectId: account.id,
ownerId: user.id,
},
data: {
followedBy: true,
},
});
const relationship = await client.relationship.findFirst({
where: {
subjectId: account.id,
ownerId: user.id,
},
});
const relationship = await client.relationship.findFirst({
where: {
subjectId: account.id,
ownerId: user.id,
},
});
if (!relationship) return errorResponse("Relationship not found", 404);
if (!relationship) return errorResponse("Relationship not found", 404);
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,63 +1,63 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
checkForBidirectionalRelationships,
relationshipToAPI,
checkForBidirectionalRelationships,
relationshipToAPI,
} from "~database/entities/Relationship";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
route: "/api/v1/follow_requests/:account_id/reject",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["POST"],
route: "/api/v1/follow_requests/:account_id/reject",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const { account_id } = matchedRoute.params;
const { account_id } = matchedRoute.params;
const account = await client.user.findUnique({
where: {
id: account_id,
},
include: userRelations,
});
const account = await client.user.findUnique({
where: {
id: account_id,
},
include: userRelations,
});
if (!account) return errorResponse("Account not found", 404);
if (!account) return errorResponse("Account not found", 404);
// Check if there is a relationship on both sides
await checkForBidirectionalRelationships(user, account);
// Check if there is a relationship on both sides
await checkForBidirectionalRelationships(user, account);
// Reject follow request
await client.relationship.updateMany({
where: {
subjectId: user.id,
ownerId: account.id,
requested: true,
},
data: {
requested: false,
},
});
// Reject follow request
await client.relationship.updateMany({
where: {
subjectId: user.id,
ownerId: account.id,
requested: true,
},
data: {
requested: false,
},
});
const relationship = await client.relationship.findFirst({
where: {
subjectId: account.id,
ownerId: user.id,
},
});
const relationship = await client.relationship.findFirst({
where: {
subjectId: account.id,
ownerId: user.id,
},
});
if (!relationship) return errorResponse("Relationship not found", 404);
if (!relationship) return errorResponse("Relationship not found", 404);
return jsonResponse(relationshipToAPI(relationship));
return jsonResponse(relationshipToAPI(relationship));
});

View file

@ -1,73 +1,73 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/follow_requests",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["GET"],
route: "/api/v1/follow_requests",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const objects = await client.user.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
relationships: {
some: {
subjectId: user.id,
requested: true,
},
},
},
include: userRelations,
take: limit,
orderBy: {
id: "desc",
},
});
const objects = await client.user.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
relationships: {
some: {
subjectId: user.id,
requested: true,
},
},
},
include: userRelations,
take: limit,
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
objects.map(user => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -2,157 +2,157 @@ import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import type { APIInstance } from "~types/entities/instance";
import manifest from "~package.json";
import { userRelations } from "~database/entities/relations";
import manifest from "~package.json";
import type { APIInstance } from "~types/entities/instance";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/instance",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
allowedMethods: ["GET"],
route: "/api/v1/instance",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
// Get software version from package.json
const version = manifest.version;
// Get software version from package.json
const version = manifest.version;
const statusCount = await client.status.count({
where: {
instanceId: null,
},
});
const userCount = await client.user.count({
where: {
instanceId: null,
},
});
const statusCount = await client.status.count({
where: {
instanceId: null,
},
});
const userCount = await client.user.count({
where: {
instanceId: null,
},
});
// Get the first created admin user
const contactAccount = await client.user.findFirst({
where: {
instanceId: null,
isAdmin: true,
},
orderBy: {
id: "asc",
},
include: userRelations,
});
// Get the first created admin user
const contactAccount = await client.user.findFirst({
where: {
instanceId: null,
isAdmin: true,
},
orderBy: {
id: "asc",
},
include: userRelations,
});
// Get user that have posted once in the last 30 days
const monthlyActiveUsers = await client.user.count({
where: {
instanceId: null,
statuses: {
some: {
createdAt: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
},
},
},
});
// Get user that have posted once in the last 30 days
const monthlyActiveUsers = await client.user.count({
where: {
instanceId: null,
statuses: {
some: {
createdAt: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
},
},
},
});
const knownDomainsCount = await client.instance.count();
const knownDomainsCount = await client.instance.count();
// TODO: fill in more values
return jsonResponse({
approval_required: false,
configuration: {
media_attachments: {
image_matrix_limit: config.validation.max_media_attachments,
image_size_limit: config.validation.max_media_size,
supported_mime_types: config.validation.allowed_mime_types,
video_frame_limit: 60,
video_matrix_limit: 10,
video_size_limit: config.validation.max_media_size,
},
polls: {
max_characters_per_option:
config.validation.max_poll_option_size,
max_expiration: config.validation.max_poll_duration,
max_options: config.validation.max_poll_options,
min_expiration: 60,
},
statuses: {
characters_reserved_per_url: 0,
max_characters: config.validation.max_note_size,
max_media_attachments: config.validation.max_media_attachments,
supported_mime_types: [
"text/plain",
"text/markdown",
"text/html",
"text/x.misskeymarkdown",
],
},
},
description: "A test instance",
email: "",
invites_enabled: false,
registrations: config.signups.registration,
languages: ["en"],
rules: config.signups.rules.map((r, index) => ({
id: String(index),
text: r,
})),
stats: {
domain_count: knownDomainsCount,
status_count: statusCount,
user_count: userCount,
},
thumbnail: "",
tos_url: config.signups.tos_url,
title: "Test Instance",
uri: new URL(config.http.base_url).hostname,
urls: {
streaming_api: "",
},
version: `4.2.0+glitch (compatible; Lysand ${version}})`,
max_toot_chars: config.validation.max_note_size,
pleroma: {
metadata: {
// account_activation_required: false,
features: [
"pleroma_api",
"akkoma_api",
"mastodon_api",
// "mastodon_api_streaming",
// "polls",
// "v2_suggestions",
// "pleroma_explicit_addressing",
// "shareable_emoji_packs",
// "multifetch",
// "pleroma:api/v1/notifications:include_types_filter",
"quote_posting",
"editing",
// "bubble_timeline",
// "relay",
// "pleroma_emoji_reactions",
// "exposable_reactions",
// "profile_directory",
// "custom_emoji_reactions",
// "pleroma:get:main/ostatus",
],
post_formats: [
"text/plain",
"text/html",
"text/markdown",
"text/x.misskeymarkdown",
],
privileged_staff: false,
},
stats: {
mau: monthlyActiveUsers,
},
},
contact_account: contactAccount ? userToAPI(contactAccount) : null,
} as APIInstance);
// TODO: fill in more values
return jsonResponse({
approval_required: false,
configuration: {
media_attachments: {
image_matrix_limit: config.validation.max_media_attachments,
image_size_limit: config.validation.max_media_size,
supported_mime_types: config.validation.allowed_mime_types,
video_frame_limit: 60,
video_matrix_limit: 10,
video_size_limit: config.validation.max_media_size,
},
polls: {
max_characters_per_option:
config.validation.max_poll_option_size,
max_expiration: config.validation.max_poll_duration,
max_options: config.validation.max_poll_options,
min_expiration: 60,
},
statuses: {
characters_reserved_per_url: 0,
max_characters: config.validation.max_note_size,
max_media_attachments: config.validation.max_media_attachments,
supported_mime_types: [
"text/plain",
"text/markdown",
"text/html",
"text/x.misskeymarkdown",
],
},
},
description: "A test instance",
email: "",
invites_enabled: false,
registrations: config.signups.registration,
languages: ["en"],
rules: config.signups.rules.map((r, index) => ({
id: String(index),
text: r,
})),
stats: {
domain_count: knownDomainsCount,
status_count: statusCount,
user_count: userCount,
},
thumbnail: "",
tos_url: config.signups.tos_url,
title: "Test Instance",
uri: new URL(config.http.base_url).hostname,
urls: {
streaming_api: "",
},
version: `4.2.0+glitch (compatible; Lysand ${version}})`,
max_toot_chars: config.validation.max_note_size,
pleroma: {
metadata: {
// account_activation_required: false,
features: [
"pleroma_api",
"akkoma_api",
"mastodon_api",
// "mastodon_api_streaming",
// "polls",
// "v2_suggestions",
// "pleroma_explicit_addressing",
// "shareable_emoji_packs",
// "multifetch",
// "pleroma:api/v1/notifications:include_types_filter",
"quote_posting",
"editing",
// "bubble_timeline",
// "relay",
// "pleroma_emoji_reactions",
// "exposable_reactions",
// "profile_directory",
// "custom_emoji_reactions",
// "pleroma:get:main/ostatus",
],
post_formats: [
"text/plain",
"text/html",
"text/markdown",
"text/x.misskeymarkdown",
],
privileged_staff: false,
},
stats: {
mau: monthlyActiveUsers,
},
},
contact_account: contactAccount ? userToAPI(contactAccount) : null,
} as APIInstance);
});

View file

@ -1,109 +1,108 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager";
import { client } from "~database/datasource";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3";
export const meta = applyConfig({
allowedMethods: ["GET", "PUT"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/media/:id",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
allowedMethods: ["GET", "PUT"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/media/:id",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
});
/**
* Get media information
*/
export default apiRoute<{
thumbnail?: File;
description?: string;
focus?: string;
thumbnail?: File;
description?: string;
focus?: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) {
return errorResponse("Unauthorized", 401);
}
if (!user) {
return errorResponse("Unauthorized", 401);
}
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const attachment = await client.attachment.findUnique({
where: {
id,
},
});
const attachment = await client.attachment.findUnique({
where: {
id,
},
});
if (!attachment) {
return errorResponse("Media not found", 404);
}
if (!attachment) {
return errorResponse("Media not found", 404);
}
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
switch (req.method) {
case "GET": {
if (attachment.url) {
return jsonResponse(attachmentToAPI(attachment));
} else {
return new Response(null, {
status: 206,
});
}
}
case "PUT": {
const { description, thumbnail } = extraData.parsedRequest;
switch (req.method) {
case "GET": {
if (attachment.url) {
return jsonResponse(attachmentToAPI(attachment));
}
return new Response(null, {
status: 206,
});
}
case "PUT": {
const { description, thumbnail } = extraData.parsedRequest;
let thumbnailUrl = attachment.thumbnail_url;
let thumbnailUrl = attachment.thumbnail_url;
let mediaManager: MediaBackend;
let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(uploadedFile.name, config);
}
if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(uploadedFile.name, config);
}
const descriptionText = description || attachment.description;
const descriptionText = description || attachment.description;
if (
descriptionText !== attachment.description ||
thumbnailUrl !== attachment.thumbnail_url
) {
const newAttachment = await client.attachment.update({
where: {
id,
},
data: {
description: descriptionText,
thumbnail_url: thumbnailUrl,
},
});
if (
descriptionText !== attachment.description ||
thumbnailUrl !== attachment.thumbnail_url
) {
const newAttachment = await client.attachment.update({
where: {
id,
},
data: {
description: descriptionText,
thumbnail_url: thumbnailUrl,
},
});
return jsonResponse(attachmentToAPI(newAttachment));
}
return jsonResponse(attachmentToAPI(newAttachment));
}
return jsonResponse(attachmentToAPI(attachment));
}
}
return jsonResponse(attachmentToAPI(attachment));
}
}
return errorResponse("Method not allowed", 405);
return errorResponse("Method not allowed", 405);
});

View file

@ -1,136 +1,136 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { encode } from "blurhash";
import sharp from "sharp";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager";
import sharp from "sharp";
import { client } from "~database/datasource";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/media",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/media",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
});
/**
* Upload new media
*/
export default apiRoute<{
file: File;
thumbnail?: File;
description?: string;
// TODO: Add focus
focus?: string;
file: File;
thumbnail?: File;
description?: string;
// TODO: Add focus
focus?: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) {
return errorResponse("Unauthorized", 401);
}
if (!user) {
return errorResponse("Unauthorized", 401);
}
const { file, thumbnail, description } = extraData.parsedRequest;
const { file, thumbnail, description } = extraData.parsedRequest;
if (!file) {
return errorResponse("No file provided", 400);
}
if (!file) {
return errorResponse("No file provided", 400);
}
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
if (file.size > config.validation.max_media_size) {
return errorResponse(
`File too large, max size is ${config.validation.max_media_size} bytes`,
413
);
}
if (file.size > config.validation.max_media_size) {
return errorResponse(
`File too large, max size is ${config.validation.max_media_size} bytes`,
413,
);
}
if (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return errorResponse("Invalid file type", 415);
}
if (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return errorResponse("Invalid file type", 415);
}
if (
description &&
description.length > config.validation.max_media_description_size
) {
return errorResponse(
`Description too long, max length is ${config.validation.max_media_description_size} characters`,
413
);
}
if (
description &&
description.length > config.validation.max_media_description_size
) {
return errorResponse(
`Description too long, max length is ${config.validation.max_media_description_size} characters`,
413,
);
}
const sha256 = new Bun.SHA256();
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
const isImage = file.type.startsWith("image/");
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const blurhash = isImage
? encode(
new Uint8ClampedArray(await file.arrayBuffer()),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4
)
: null;
const blurhash = isImage
? encode(
new Uint8ClampedArray(await file.arrayBuffer()),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
)
: null;
let url = "";
let url = "";
let mediaManager: MediaBackend;
let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
const { uploadedFile } = await mediaManager.addFile(file);
const { uploadedFile } = await mediaManager.addFile(file);
url = getUrl(uploadedFile.name, config);
url = getUrl(uploadedFile.name, config);
let thumbnailUrl = "";
let thumbnailUrl = "";
if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail);
if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(uploadedFile.name, config);
}
thumbnailUrl = getUrl(uploadedFile.name, config);
}
const newAttachment = await client.attachment.create({
data: {
url,
thumbnail_url: thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mime_type: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
},
});
const newAttachment = await client.attachment.create({
data: {
url,
thumbnail_url: thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mime_type: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
},
});
// TODO: Add job to process videos and other media
// TODO: Add job to process videos and other media
return jsonResponse(attachmentToAPI(newAttachment));
return jsonResponse(attachmentToAPI(newAttachment));
});

View file

@ -1,37 +1,37 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/mutes",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["GET"],
route: "/api/v1/mutes",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const blocks = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
muting: true,
},
},
},
include: userRelations,
});
const blocks = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
muting: true,
},
},
},
include: userRelations,
});
return jsonResponse(blocks.map(u => userToAPI(u)));
return jsonResponse(blocks.map((u) => userToAPI(u)));
});

View file

@ -1,102 +1,102 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { notificationToAPI } from "~database/entities/Notification";
import {
userRelations,
statusAndUserRelations,
statusAndUserRelations,
userRelations,
} from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/notifications",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
allowedMethods: ["GET"],
route: "/api/v1/notifications",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
exclude_types?: string[];
types?: string[];
account_id?: string;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
exclude_types?: string[];
types?: string[];
account_id?: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const {
account_id,
exclude_types,
limit = 15,
max_id,
min_id,
since_id,
types,
} = extraData.parsedRequest;
const {
account_id,
exclude_types,
limit = 15,
max_id,
min_id,
since_id,
types,
} = extraData.parsedRequest;
if (limit > 30) return errorResponse("Limit too high", 400);
if (limit > 30) return errorResponse("Limit too high", 400);
if (limit <= 0) return errorResponse("Limit too low", 400);
if (limit <= 0) return errorResponse("Limit too low", 400);
if (types && exclude_types) {
return errorResponse("Can't use both types and exclude_types", 400);
}
if (types && exclude_types) {
return errorResponse("Can't use both types and exclude_types", 400);
}
const objects = await client.notification.findMany({
where: {
notifiedId: user.id,
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
type: {
in: types,
notIn: exclude_types,
},
accountId: account_id,
},
include: {
account: {
include: userRelations,
},
status: {
include: statusAndUserRelations,
},
},
orderBy: {
id: "desc",
},
take: limit,
});
const objects = await client.notification.findMany({
where: {
notifiedId: user.id,
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
type: {
in: types,
notIn: exclude_types,
},
accountId: account_id,
},
include: {
account: {
include: userRelations,
},
status: {
include: statusAndUserRelations,
},
},
orderBy: {
id: "desc",
},
take: limit,
});
// 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.at(-1)?.id
}&limit=${limit}>; rel="prev"`
);
}
// 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.at(-1)?.id
}&limit=${limit}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(objects.map(n => notificationToAPI(n))),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
await Promise.all(objects.map((n) => notificationToAPI(n))),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -5,35 +5,35 @@ import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["DELETE"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/profile/avatar",
auth: {
required: true,
},
allowedMethods: ["DELETE"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/profile/avatar",
auth: {
required: true,
},
});
/**
* Deletes a user avatar
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
// Delete user avatar
const newUser = await client.user.update({
where: {
id: user.id,
},
data: {
avatar: "",
},
include: userRelations,
});
// Delete user avatar
const newUser = await client.user.update({
where: {
id: user.id,
},
data: {
avatar: "",
},
include: userRelations,
});
return jsonResponse(userToAPI(newUser));
return jsonResponse(userToAPI(newUser));
});

View file

@ -5,35 +5,35 @@ import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["DELETE"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/profile/header",
auth: {
required: true,
},
allowedMethods: ["DELETE"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/profile/header",
auth: {
required: true,
},
});
/**
* Deletes a user header
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
// Delete user header
const newUser = await client.user.update({
where: {
id: user.id,
},
data: {
header: "",
},
include: userRelations,
});
// Delete user header
const newUser = await client.user.update({
where: {
id: user.id,
},
data: {
header: "",
},
include: userRelations,
});
return jsonResponse(userToAPI(newUser));
return jsonResponse(userToAPI(newUser));
});

View file

@ -2,51 +2,51 @@ import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import {
getAncestors,
getDescendants,
statusToAPI,
getAncestors,
getDescendants,
statusToAPI,
} from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 8,
duration: 60,
},
route: "/api/v1/statuses/:id/context",
auth: {
required: false,
},
allowedMethods: ["GET"],
ratelimits: {
max: 8,
duration: 60,
},
route: "/api/v1/statuses/:id/context",
auth: {
required: false,
},
});
/**
* Fetch a user
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
// Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20.
// User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses.
const id = matchedRoute.params.id;
// Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20.
// 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 } = extraData.auth;
const { user } = extraData.auth;
const foundStatus = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const foundStatus = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
if (!foundStatus) return errorResponse("Record not found", 404);
if (!foundStatus) return errorResponse("Record not found", 404);
// Get all ancestors
const ancestors = await getAncestors(foundStatus, user);
const descendants = await getDescendants(foundStatus, user);
// Get all ancestors
const ancestors = await getAncestors(foundStatus, user);
const descendants = await getDescendants(foundStatus, user);
return jsonResponse({
ancestors: await Promise.all(
ancestors.map(status => statusToAPI(status, user || undefined))
),
descendants: await Promise.all(
descendants.map(status => statusToAPI(status, user || undefined))
),
});
return jsonResponse({
ancestors: await Promise.all(
ancestors.map((status) => statusToAPI(status, user || undefined)),
),
descendants: await Promise.all(
descendants.map((status) => statusToAPI(status, user || undefined)),
),
});
});

View file

@ -8,50 +8,50 @@ import { statusAndUserRelations } from "~database/entities/relations";
import type { APIStatus } from "~types/entities/status";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/favourite",
auth: {
required: true,
},
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/favourite",
auth: {
required: true,
},
});
/**
* Favourite a post
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
const existingLike = await client.like.findFirst({
where: {
likedId: status.id,
likerId: user.id,
},
});
const existingLike = await client.like.findFirst({
where: {
likedId: status.id,
likerId: user.id,
},
});
if (!existingLike) {
await createLike(user, status);
}
if (!existingLike) {
await createLike(user, status);
}
return jsonResponse({
...(await statusToAPI(status, user)),
favourited: true,
favourites_count: status._count.likes + 1,
} as APIStatus);
return jsonResponse({
...(await statusToAPI(status, user)),
favourited: true,
favourites_count: status._count.likes + 1,
} as APIStatus);
});

View file

@ -4,101 +4,101 @@ import { client } from "~database/datasource";
import { isViewableByUser } from "~database/entities/Status";
import { userToAPI } from "~database/entities/User";
import {
statusAndUserRelations,
userRelations,
statusAndUserRelations,
userRelations,
} from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/favourited_by",
auth: {
required: true,
},
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/favourited_by",
auth: {
required: true,
},
});
/**
* Fetch users who favourited the post
*/
export default apiRoute<{
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
const {
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = extraData.parsedRequest;
const {
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = extraData.parsedRequest;
// Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
// Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
const objects = await client.user.findMany({
where: {
likes: {
some: {
likedId: status.id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
include: {
...userRelations,
likes: {
where: {
likedId: status.id,
},
},
},
take: limit,
orderBy: {
id: "desc",
},
});
const objects = await client.user.findMany({
where: {
likes: {
some: {
likedId: status.id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
include: {
...userRelations,
likes: {
where: {
likedId: status.id,
},
},
},
take: limit,
orderBy: {
id: "desc",
},
});
// 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"`
);
}
// 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"`,
);
}
return jsonResponse(
objects.map(user => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -4,215 +4,217 @@ import { sanitizeHtml } from "@sanitization";
import { parse } from "marked";
import { client } from "~database/datasource";
import {
editStatus,
isViewableByUser,
statusToAPI,
editStatus,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET", "DELETE", "PUT"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id",
auth: {
required: false,
requiredOnMethods: ["DELETE", "PUT"],
},
allowedMethods: ["GET", "DELETE", "PUT"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id",
auth: {
required: false,
requiredOnMethods: ["DELETE", "PUT"],
},
});
/**
* Fetch a user
*/
export default apiRoute<{
status?: string;
spoiler_text?: string;
sensitive?: boolean;
language?: string;
content_type?: string;
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
status?: string;
spoiler_text?: string;
sensitive?: boolean;
language?: string;
content_type?: string;
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
if (req.method === "GET") {
return jsonResponse(await statusToAPI(status));
} else if (req.method === "DELETE") {
if (status.authorId !== user?.id) {
return errorResponse("Unauthorized", 401);
}
if (req.method === "GET") {
return jsonResponse(await statusToAPI(status));
}
if (req.method === "DELETE") {
if (status.authorId !== user?.id) {
return errorResponse("Unauthorized", 401);
}
// TODO: Implement delete and redraft functionality
// TODO: Implement delete and redraft functionality
// Get associated Status object
// Get associated Status object
// Delete status and all associated objects
await client.status.delete({
where: { id },
});
// Delete status and all associated objects
await client.status.delete({
where: { id },
});
return jsonResponse(
{
...(await statusToAPI(status, user)),
// TODO: Add
// text: Add source text
// poll: Add source poll
// media_attachments
},
200
);
} else if (req.method == "PUT") {
if (status.authorId !== user?.id) {
return errorResponse("Unauthorized", 401);
}
return jsonResponse(
{
...(await statusToAPI(status, user)),
// TODO: Add
// text: Add source text
// poll: Add source poll
// media_attachments
},
200,
);
}
if (req.method === "PUT") {
if (status.authorId !== user?.id) {
return errorResponse("Unauthorized", 401);
}
const {
status: statusText,
content_type,
"poll[expires_in]": expires_in,
"poll[options]": options,
media_ids,
spoiler_text,
sensitive,
} = extraData.parsedRequest;
const {
status: statusText,
content_type,
"poll[expires_in]": expires_in,
"poll[options]": options,
media_ids,
spoiler_text,
sensitive,
} = extraData.parsedRequest;
// TODO: Add Poll support
// Validate status
if (!statusText && !(media_ids && media_ids.length > 0)) {
return errorResponse(
"Status is required unless media is attached",
422
);
}
// TODO: Add Poll support
// Validate status
if (!statusText && !(media_ids && media_ids.length > 0)) {
return errorResponse(
"Status is required unless media is attached",
422,
);
}
// Validate media_ids
if (media_ids && !Array.isArray(media_ids)) {
return errorResponse("Media IDs must be an array", 422);
}
// Validate media_ids
if (media_ids && !Array.isArray(media_ids)) {
return errorResponse("Media IDs must be an array", 422);
}
// Validate poll options
if (options && !Array.isArray(options)) {
return errorResponse("Poll options must be an array", 422);
}
// Validate poll options
if (options && !Array.isArray(options)) {
return errorResponse("Poll options must be an array", 422);
}
if (options && options.length > 4) {
return errorResponse("Poll options must be less than 5", 422);
}
if (options && options.length > 4) {
return errorResponse("Poll options must be less than 5", 422);
}
if (media_ids && media_ids.length > 0) {
// Disallow poll
if (options) {
return errorResponse("Cannot attach poll to media", 422);
}
if (media_ids.length > 4) {
return errorResponse("Media IDs must be less than 5", 422);
}
}
if (media_ids && media_ids.length > 0) {
// Disallow poll
if (options) {
return errorResponse("Cannot attach poll to media", 422);
}
if (media_ids.length > 4) {
return errorResponse("Media IDs must be less than 5", 422);
}
}
if (options && options.length > config.validation.max_poll_options) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_options}`,
422
);
}
if (options && options.length > config.validation.max_poll_options) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_options}`,
422,
);
}
if (
options &&
options.some(
option => option.length > config.validation.max_poll_option_size
)
) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
422
);
}
if (
options?.some(
(option) =>
option.length > config.validation.max_poll_option_size,
)
) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
422,
);
}
if (expires_in && expires_in < config.validation.min_poll_duration) {
return errorResponse(
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
422
);
}
if (expires_in && expires_in < config.validation.min_poll_duration) {
return errorResponse(
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
422,
);
}
if (expires_in && expires_in > config.validation.max_poll_duration) {
return errorResponse(
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
422
);
}
if (expires_in && expires_in > config.validation.max_poll_duration) {
return errorResponse(
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
422,
);
}
let sanitizedStatus: string;
let sanitizedStatus: string;
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
} else {
sanitizedStatus = await sanitizeHtml(statusText ?? "");
}
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
} else {
sanitizedStatus = await sanitizeHtml(statusText ?? "");
}
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(
`Status must be less than ${config.validation.max_note_size} characters`,
400
);
}
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(
`Status must be less than ${config.validation.max_note_size} characters`,
400,
);
}
// Check if status body doesnt match filters
if (
config.filters.note_content.some(filter =>
statusText?.match(filter)
)
) {
return errorResponse("Status contains blocked words", 422);
}
// Check if status body doesnt match filters
if (
config.filters.note_content.some((filter) =>
statusText?.match(filter),
)
) {
return errorResponse("Status contains blocked words", 422);
}
// Check if media attachments are all valid
// Check if media attachments are all valid
const foundAttachments = await client.attachment.findMany({
where: {
id: {
in: media_ids ?? [],
},
},
});
const foundAttachments = await client.attachment.findMany({
where: {
id: {
in: media_ids ?? [],
},
},
});
if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
}
if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
}
// Update status
const newStatus = await editStatus(status, {
content: sanitizedStatus,
content_type,
media_attachments: media_ids,
spoiler_text: spoiler_text ?? "",
sensitive: sensitive ?? false,
});
// Update status
const newStatus = await editStatus(status, {
content: sanitizedStatus,
content_type,
media_attachments: media_ids,
spoiler_text: spoiler_text ?? "",
sensitive: sensitive ?? false,
});
return jsonResponse(await statusToAPI(newStatus, user));
}
return jsonResponse(await statusToAPI(newStatus, user));
}
return jsonResponse({});
return jsonResponse({});
});

View file

@ -6,55 +6,55 @@ import { statusToAPI } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/pin",
auth: {
required: true,
},
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/pin",
auth: {
required: true,
},
});
/**
* Pin a post
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
let status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
let status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if status exists
if (!status) return errorResponse("Record not found", 404);
// Check if status exists
if (!status) return errorResponse("Record not found", 404);
// Check if status is user's
if (status.authorId !== user.id) return errorResponse("Unauthorized", 401);
// Check if status is user's
if (status.authorId !== user.id) return errorResponse("Unauthorized", 401);
await client.user.update({
where: { id: user.id },
data: {
pinnedNotes: {
connect: {
id: status.id,
},
},
},
});
await client.user.update({
where: { id: user.id },
data: {
pinnedNotes: {
connect: {
id: status.id,
},
},
},
});
status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
if (!status) return errorResponse("Record not found", 404);
if (!status) return errorResponse("Record not found", 404);
return jsonResponse(statusToAPI(status, user));
return jsonResponse(statusToAPI(status, user));
});

View file

@ -3,95 +3,95 @@ import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { isViewableByUser, statusToAPI } from "~database/entities/Status";
import { type UserWithRelations } from "~database/entities/User";
import type { UserWithRelations } from "~database/entities/User";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/reblog",
auth: {
required: true,
},
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/reblog",
auth: {
required: true,
},
});
/**
* Reblogs a post
*/
export default apiRoute<{
visibility: "public" | "unlisted" | "private";
visibility: "public" | "unlisted" | "private";
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const config = await extraData.configManager.getConfig();
const id = matchedRoute.params.id;
const config = await extraData.configManager.getConfig();
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const { visibility = "public" } = extraData.parsedRequest;
const { visibility = "public" } = extraData.parsedRequest;
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
const existingReblog = await client.status.findFirst({
where: {
authorId: user.id,
reblogId: status.id,
},
});
const existingReblog = await client.status.findFirst({
where: {
authorId: user.id,
reblogId: status.id,
},
});
if (existingReblog) {
return errorResponse("Already reblogged", 422);
}
if (existingReblog) {
return errorResponse("Already reblogged", 422);
}
const newReblog = await client.status.create({
data: {
authorId: user.id,
reblogId: status.id,
isReblog: true,
uri: `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
visibility,
sensitive: false,
},
include: statusAndUserRelations,
});
const newReblog = await client.status.create({
data: {
authorId: user.id,
reblogId: status.id,
isReblog: true,
uri: `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
visibility,
sensitive: false,
},
include: statusAndUserRelations,
});
await client.status.update({
where: { id: newReblog.id },
data: {
uri: `${config.http.base_url}/statuses/${newReblog.id}`,
},
include: statusAndUserRelations,
});
await client.status.update({
where: { id: newReblog.id },
data: {
uri: `${config.http.base_url}/statuses/${newReblog.id}`,
},
include: statusAndUserRelations,
});
// Create notification for reblog if reblogged user is on the same instance
if ((status.author as UserWithRelations).instanceId === user.instanceId) {
await client.notification.create({
data: {
accountId: user.id,
notifiedId: status.authorId,
type: "reblog",
statusId: status.reblogId,
},
});
}
// Create notification for reblog if reblogged user is on the same instance
if ((status.author as UserWithRelations).instanceId === user.instanceId) {
await client.notification.create({
data: {
accountId: user.id,
notifiedId: status.authorId,
type: "reblog",
statusId: status.reblogId,
},
});
}
return jsonResponse(
await statusToAPI(
{
...newReblog,
uri: `${config.http.base_url}/statuses/${newReblog.id}`,
},
user
)
);
return jsonResponse(
await statusToAPI(
{
...newReblog,
uri: `${config.http.base_url}/statuses/${newReblog.id}`,
},
user,
),
);
});

View file

@ -4,102 +4,102 @@ import { client } from "~database/datasource";
import { isViewableByUser } from "~database/entities/Status";
import { userToAPI } from "~database/entities/User";
import {
statusAndUserRelations,
userRelations,
statusAndUserRelations,
userRelations,
} from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/reblogged_by",
auth: {
required: true,
},
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/reblogged_by",
auth: {
required: true,
},
});
/**
* Fetch users who reblogged the post
*/
export default apiRoute<{
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
const {
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = extraData.parsedRequest;
const {
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = extraData.parsedRequest;
// Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
// Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
const objects = await client.user.findMany({
where: {
statuses: {
some: {
reblogId: status.id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
include: {
...userRelations,
statuses: {
where: {
reblogId: status.id,
},
include: statusAndUserRelations,
},
},
take: limit,
orderBy: {
id: "desc",
},
});
const objects = await client.user.findMany({
where: {
statuses: {
some: {
reblogId: status.id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
include: {
...userRelations,
statuses: {
where: {
reblogId: status.id,
},
include: statusAndUserRelations,
},
},
take: limit,
orderBy: {
id: "desc",
},
});
// 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"`
);
}
// 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"`,
);
}
return jsonResponse(
objects.map(user => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -5,35 +5,35 @@ import { isViewableByUser } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/source",
auth: {
required: true,
},
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/source",
auth: {
required: true,
},
});
/**
* Favourite a post
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
return errorResponse("Not implemented yet");
return errorResponse("Not implemented yet");
});

View file

@ -8,41 +8,41 @@ import { statusAndUserRelations } from "~database/entities/relations";
import type { APIStatus } from "~types/entities/status";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/unfavourite",
auth: {
required: true,
},
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/unfavourite",
auth: {
required: true,
},
});
/**
* Unfavourite a post
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
await deleteLike(user, status);
await deleteLike(user, status);
return jsonResponse({
...(await statusToAPI(status, user)),
favourited: false,
favourites_count: status._count.likes - 1,
} as APIStatus);
return jsonResponse({
...(await statusToAPI(status, user)),
favourited: false,
favourites_count: status._count.likes - 1,
} as APIStatus);
});

View file

@ -5,55 +5,55 @@ import { statusToAPI } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/unpin",
auth: {
required: true,
},
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/unpin",
auth: {
required: true,
},
});
/**
* Unpins a post
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
let status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
let status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if status exists
if (!status) return errorResponse("Record not found", 404);
// Check if status exists
if (!status) return errorResponse("Record not found", 404);
// Check if status is user's
if (status.authorId !== user.id) return errorResponse("Unauthorized", 401);
// Check if status is user's
if (status.authorId !== user.id) return errorResponse("Unauthorized", 401);
await client.user.update({
where: { id: user.id },
data: {
pinnedNotes: {
disconnect: {
id: status.id,
},
},
},
});
await client.user.update({
where: { id: user.id },
data: {
pinnedNotes: {
disconnect: {
id: status.id,
},
},
},
});
status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
if (!status) return errorResponse("Record not found", 404);
if (!status) return errorResponse("Record not found", 404);
return jsonResponse(statusToAPI(status, user));
return jsonResponse(statusToAPI(status, user));
});

View file

@ -6,54 +6,54 @@ import { statusAndUserRelations } from "~database/entities/relations";
import type { APIStatus } from "~types/entities/status";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/unreblog",
auth: {
required: true,
},
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/api/v1/statuses/:id/unreblog",
auth: {
required: true,
},
});
/**
* Unreblogs a post
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: { id },
include: statusAndUserRelations,
});
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
const existingReblog = await client.status.findFirst({
where: {
authorId: user.id,
reblogId: status.id,
},
});
const existingReblog = await client.status.findFirst({
where: {
authorId: user.id,
reblogId: status.id,
},
});
if (!existingReblog) {
return errorResponse("Not already reblogged", 422);
}
if (!existingReblog) {
return errorResponse("Not already reblogged", 422);
}
await client.status.delete({
where: { id: existingReblog.id },
});
await client.status.delete({
where: { id: existingReblog.id },
});
return jsonResponse({
...(await statusToAPI(status, user)),
reblogged: false,
reblogs_count: status._count.reblogs - 1,
} as APIStatus);
return jsonResponse({
...(await statusToAPI(status, user)),
reblogged: false,
reblogs_count: status._count.reblogs - 1,
} as APIStatus);
});

View file

@ -10,234 +10,233 @@ import type { UserWithRelations } from "~database/entities/User";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 300,
duration: 60,
},
route: "/api/v1/statuses",
auth: {
required: true,
},
allowedMethods: ["POST"],
ratelimits: {
max: 300,
duration: 60,
},
route: "/api/v1/statuses",
auth: {
required: true,
},
});
/**
* Post new status
*/
export default apiRoute<{
status: string;
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
in_reply_to_id?: string;
quote_id?: string;
sensitive?: boolean;
spoiler_text?: string;
visibility?: "public" | "unlisted" | "private" | "direct";
language?: string;
scheduled_at?: string;
local_only?: boolean;
content_type?: string;
status: string;
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
in_reply_to_id?: string;
quote_id?: string;
sensitive?: boolean;
spoiler_text?: string;
visibility?: "public" | "unlisted" | "private" | "direct";
language?: string;
scheduled_at?: string;
local_only?: boolean;
content_type?: string;
}>(async (req, matchedRoute, extraData) => {
const { user, token } = extraData.auth;
const application = await getFromToken(token);
const { user, token } = extraData.auth;
const application = await getFromToken(token);
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
const {
status,
media_ids,
"poll[expires_in]": expires_in,
// "poll[hide_totals]": hide_totals,
// "poll[multiple]": multiple,
"poll[options]": options,
in_reply_to_id,
quote_id,
// language,
scheduled_at,
sensitive,
spoiler_text,
visibility,
content_type,
} = extraData.parsedRequest;
const {
status,
media_ids,
"poll[expires_in]": expires_in,
// "poll[hide_totals]": hide_totals,
// "poll[multiple]": multiple,
"poll[options]": options,
in_reply_to_id,
quote_id,
// language,
scheduled_at,
sensitive,
spoiler_text,
visibility,
content_type,
} = extraData.parsedRequest;
// Validate status
if (!status && !(media_ids && media_ids.length > 0)) {
return errorResponse(
"Status is required unless media is attached",
422
);
}
// Validate status
if (!status && !(media_ids && media_ids.length > 0)) {
return errorResponse(
"Status is required unless media is attached",
422,
);
}
// Validate media_ids
if (media_ids && !Array.isArray(media_ids)) {
return errorResponse("Media IDs must be an array", 422);
}
// Validate media_ids
if (media_ids && !Array.isArray(media_ids)) {
return errorResponse("Media IDs must be an array", 422);
}
// Validate poll options
if (options && !Array.isArray(options)) {
return errorResponse("Poll options must be an array", 422);
}
// Validate poll options
if (options && !Array.isArray(options)) {
return errorResponse("Poll options must be an array", 422);
}
if (options && options.length > 4) {
return errorResponse("Poll options must be less than 5", 422);
}
if (options && options.length > 4) {
return errorResponse("Poll options must be less than 5", 422);
}
if (media_ids && media_ids.length > 0) {
// Disallow poll
if (options) {
return errorResponse("Cannot attach poll to media", 422);
}
if (media_ids.length > 4) {
return errorResponse("Media IDs must be less than 5", 422);
}
}
if (media_ids && media_ids.length > 0) {
// Disallow poll
if (options) {
return errorResponse("Cannot attach poll to media", 422);
}
if (media_ids.length > 4) {
return errorResponse("Media IDs must be less than 5", 422);
}
}
if (options && options.length > config.validation.max_poll_options) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_options}`,
422
);
}
if (options && options.length > config.validation.max_poll_options) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_options}`,
422,
);
}
if (
options &&
options.some(
option => option.length > config.validation.max_poll_option_size
)
) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
422
);
}
if (
options?.some(
(option) => option.length > config.validation.max_poll_option_size,
)
) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
422,
);
}
if (expires_in && expires_in < config.validation.min_poll_duration) {
return errorResponse(
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
422
);
}
if (expires_in && expires_in < config.validation.min_poll_duration) {
return errorResponse(
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
422,
);
}
if (expires_in && expires_in > config.validation.max_poll_duration) {
return errorResponse(
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
422
);
}
if (expires_in && expires_in > config.validation.max_poll_duration) {
return errorResponse(
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
422,
);
}
if (scheduled_at) {
if (new Date(scheduled_at).getTime() < Date.now()) {
return errorResponse("Scheduled time must be in the future", 422);
}
}
if (scheduled_at) {
if (new Date(scheduled_at).getTime() < Date.now()) {
return errorResponse("Scheduled time must be in the future", 422);
}
}
let sanitizedStatus: string;
let sanitizedStatus: string;
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as any);
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as any);
} else {
sanitizedStatus = await sanitizeHtml(status ?? "");
}
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
} else {
sanitizedStatus = await sanitizeHtml(status ?? "");
}
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(
`Status must be less than ${config.validation.max_note_size} characters`,
400
);
}
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(
`Status must be less than ${config.validation.max_note_size} characters`,
400,
);
}
// Validate visibility
if (
visibility &&
!["public", "unlisted", "private", "direct"].includes(visibility)
) {
return errorResponse("Invalid visibility", 422);
}
// Validate visibility
if (
visibility &&
!["public", "unlisted", "private", "direct"].includes(visibility)
) {
return errorResponse("Invalid visibility", 422);
}
// Get reply account and status if exists
let replyStatus: StatusWithRelations | null = null;
let replyUser: UserWithRelations | null = null;
let quote: StatusWithRelations | null = null;
// Get reply account and status if exists
let replyStatus: StatusWithRelations | null = null;
let replyUser: UserWithRelations | null = null;
let quote: StatusWithRelations | null = null;
if (in_reply_to_id) {
replyStatus = await client.status.findUnique({
where: { id: in_reply_to_id },
include: statusAndUserRelations,
});
if (in_reply_to_id) {
replyStatus = await client.status.findUnique({
where: { id: in_reply_to_id },
include: statusAndUserRelations,
});
if (!replyStatus) {
return errorResponse("Reply status not found", 404);
}
if (!replyStatus) {
return errorResponse("Reply status not found", 404);
}
// @ts-expect-error Prisma Typescript doesn't include relations
replyUser = replyStatus.author;
}
// @ts-expect-error Prisma Typescript doesn't include relations
replyUser = replyStatus.author;
}
if (quote_id) {
quote = await client.status.findUnique({
where: { id: quote_id },
include: statusAndUserRelations,
});
if (quote_id) {
quote = await client.status.findUnique({
where: { id: quote_id },
include: statusAndUserRelations,
});
if (!quote) {
return errorResponse("Quote status not found", 404);
}
}
if (!quote) {
return errorResponse("Quote status not found", 404);
}
}
// Check if status body doesnt match filters
if (config.filters.note_content.some(filter => status?.match(filter))) {
return errorResponse("Status contains blocked words", 422);
}
// Check if status body doesnt match filters
if (config.filters.note_content.some((filter) => status?.match(filter))) {
return errorResponse("Status contains blocked words", 422);
}
// Check if media attachments are all valid
// Check if media attachments are all valid
const foundAttachments = await client.attachment.findMany({
where: {
id: {
in: media_ids ?? [],
},
},
});
const foundAttachments = await client.attachment.findMany({
where: {
id: {
in: media_ids ?? [],
},
},
});
if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
}
if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
}
const newStatus = await createNewStatus({
account: user,
application,
content: sanitizedStatus,
visibility:
visibility ||
(config.defaults.visibility as
| "public"
| "unlisted"
| "private"
| "direct"),
sensitive: sensitive || false,
spoiler_text: spoiler_text || "",
emojis: [],
media_attachments: media_ids,
reply:
replyStatus && replyUser
? {
user: replyUser,
status: replyStatus,
}
: undefined,
quote: quote || undefined,
});
const newStatus = await createNewStatus({
account: user,
application,
content: sanitizedStatus,
visibility:
visibility ||
(config.defaults.visibility as
| "public"
| "unlisted"
| "private"
| "direct"),
sensitive: sensitive || false,
spoiler_text: spoiler_text || "",
emojis: [],
media_attachments: media_ids,
reply:
replyStatus && replyUser
? {
user: replyUser,
status: replyStatus,
}
: undefined,
quote: quote || undefined,
});
// TODO: add database jobs to deliver the post
// TODO: add database jobs to deliver the post
return jsonResponse(await statusToAPI(newStatus, user));
return jsonResponse(await statusToAPI(newStatus, user));
});

View file

@ -5,95 +5,95 @@ import { statusToAPI } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 200,
duration: 60,
},
route: "/api/v1/timelines/home",
auth: {
required: true,
},
allowedMethods: ["GET"],
ratelimits: {
max: 200,
duration: 60,
},
route: "/api/v1/timelines/home",
auth: {
required: true,
},
});
/**
* Fetch home timeline statuses
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const objects = await client.status.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
OR: [
{
author: {
OR: [
{
relationshipSubjects: {
some: {
ownerId: user.id,
following: true,
},
},
},
{
id: user.id,
},
],
},
},
{
// Include posts where the user is mentioned in addition to posts by followed users
mentions: {
some: {
id: user.id,
},
},
},
],
},
include: statusAndUserRelations,
take: limit,
orderBy: {
id: "desc",
},
});
const objects = await client.status.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
OR: [
{
author: {
OR: [
{
relationshipSubjects: {
some: {
ownerId: user.id,
following: true,
},
},
},
{
id: user.id,
},
],
},
},
{
// Include posts where the user is mentioned in addition to posts by followed users
mentions: {
some: {
id: user.id,
},
},
},
],
},
include: statusAndUserRelations,
take: limit,
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(
objects.map(async status => statusToAPI(status, user))
),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
await Promise.all(
objects.map(async (status) => statusToAPI(status, user)),
),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -5,84 +5,86 @@ import { statusToAPI } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 200,
duration: 60,
},
route: "/api/v1/timelines/public",
auth: {
required: false,
},
allowedMethods: ["GET"],
ratelimits: {
max: 200,
duration: 60,
},
route: "/api/v1/timelines/public",
auth: {
required: false,
},
});
export default apiRoute<{
local?: boolean;
only_media?: boolean;
remote?: boolean;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
local?: boolean;
only_media?: boolean;
remote?: boolean;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const {
local,
limit = 20,
max_id,
min_id,
// only_media,
remote,
since_id,
} = extraData.parsedRequest;
const { user } = extraData.auth;
const {
local,
limit = 20,
max_id,
min_id,
// only_media,
remote,
since_id,
} = extraData.parsedRequest;
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (local && remote) {
return errorResponse("Cannot use both local and remote", 400);
}
if (local && remote) {
return errorResponse("Cannot use both local and remote", 400);
}
const objects = await client.status.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
instanceId: remote
? {
not: null,
}
: local
? null
: undefined,
},
include: statusAndUserRelations,
take: limit,
orderBy: {
id: "desc",
},
});
const objects = await client.status.findMany({
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
instanceId: remote
? {
not: null,
}
: local
? null
: undefined,
},
include: statusAndUserRelations,
take: limit,
orderBy: {
id: "desc",
},
});
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
// 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.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
);
}
return jsonResponse(
await Promise.all(
objects.map(async status => statusToAPI(status, user || undefined))
),
200,
{
Link: linkHeader.join(", "),
}
);
return jsonResponse(
await Promise.all(
objects.map(async (status) =>
statusToAPI(status, user || undefined),
),
),
200,
{
Link: linkHeader.join(", "),
},
);
});

View file

@ -1,148 +1,148 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { encode } from "blurhash";
import sharp from "sharp";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager";
import sharp from "sharp";
import { client } from "~database/datasource";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v2/media",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
allowedMethods: ["POST"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v2/media",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
});
/**
* Upload new media
*/
export default apiRoute<{
file: File;
thumbnail: File;
description: string;
// TODO: Implement focus storage
focus: string;
file: File;
thumbnail: File;
description: string;
// TODO: Implement focus storage
focus: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
if (!user) {
return errorResponse("Unauthorized", 401);
}
if (!user) {
return errorResponse("Unauthorized", 401);
}
const { file, thumbnail, description } = extraData.parsedRequest;
const { file, thumbnail, description } = extraData.parsedRequest;
if (!file) {
return errorResponse("No file provided", 400);
}
if (!file) {
return errorResponse("No file provided", 400);
}
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
if (file.size > config.validation.max_media_size) {
return errorResponse(
`File too large, max size is ${config.validation.max_media_size} bytes`,
413
);
}
if (file.size > config.validation.max_media_size) {
return errorResponse(
`File too large, max size is ${config.validation.max_media_size} bytes`,
413,
);
}
if (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return errorResponse("Invalid file type", 415);
}
if (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return errorResponse("Invalid file type", 415);
}
if (
description &&
description.length > config.validation.max_media_description_size
) {
return errorResponse(
`Description too long, max length is ${config.validation.max_media_description_size} characters`,
413
);
}
if (
description &&
description.length > config.validation.max_media_description_size
) {
return errorResponse(
`Description too long, max length is ${config.validation.max_media_description_size} characters`,
413,
);
}
const sha256 = new Bun.SHA256();
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
const isImage = file.type.startsWith("image/");
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const blurhash = isImage
? encode(
new Uint8ClampedArray(await file.arrayBuffer()),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4
)
: null;
const blurhash = isImage
? encode(
new Uint8ClampedArray(await file.arrayBuffer()),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
)
: null;
let url = "";
let url = "";
let mediaManager: MediaBackend;
let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
if (isImage) {
const { uploadedFile } = await mediaManager.addFile(file);
if (isImage) {
const { uploadedFile } = await mediaManager.addFile(file);
url = getUrl(uploadedFile.name, config);
}
url = getUrl(uploadedFile.name, config);
}
let thumbnailUrl = "";
let thumbnailUrl = "";
if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail);
if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(uploadedFile.name, config);
}
thumbnailUrl = getUrl(uploadedFile.name, config);
}
const newAttachment = await client.attachment.create({
data: {
url,
thumbnail_url: thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mime_type: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
},
});
const newAttachment = await client.attachment.create({
data: {
url,
thumbnail_url: thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mime_type: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
},
});
// TODO: Add job to process videos and other media
// TODO: Add job to process videos and other media
if (isImage) {
return jsonResponse(attachmentToAPI(newAttachment));
} else {
return jsonResponse(
{
...attachmentToAPI(newAttachment),
url: null,
},
202
);
}
if (isImage) {
return jsonResponse(attachmentToAPI(newAttachment));
}
return jsonResponse(
{
...attachmentToAPI(newAttachment),
url: null,
},
202,
);
});

View file

@ -5,139 +5,139 @@ import { client } from "~database/datasource";
import { statusToAPI } from "~database/entities/Status";
import { userToAPI } from "~database/entities/User";
import {
statusAndUserRelations,
userRelations,
statusAndUserRelations,
userRelations,
} from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v2/search",
auth: {
required: false,
oauthPermissions: ["read:search"],
},
allowedMethods: ["GET"],
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v2/search",
auth: {
required: false,
oauthPermissions: ["read:search"],
},
});
/**
* Upload new media
*/
export default apiRoute<{
q?: string;
type?: string;
resolve?: boolean;
following?: boolean;
account_id?: string;
max_id?: string;
min_id?: string;
limit?: number;
offset?: number;
q?: string;
type?: string;
resolve?: boolean;
following?: boolean;
account_id?: string;
max_id?: string;
min_id?: string;
limit?: number;
offset?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { user } = extraData.auth;
const {
q,
type,
resolve,
following,
account_id,
// max_id,
// min_id,
limit = 20,
offset,
} = extraData.parsedRequest;
const {
q,
type,
resolve,
following,
account_id,
// max_id,
// min_id,
limit = 20,
offset,
} = extraData.parsedRequest;
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
if (!config.meilisearch.enabled) {
return errorResponse("Meilisearch is not enabled", 501);
}
if (!config.meilisearch.enabled) {
return errorResponse("Meilisearch is not enabled", 501);
}
if (!user && (resolve || offset)) {
return errorResponse(
"Cannot use resolve or offset without being authenticated",
401
);
}
if (!user && (resolve || offset)) {
return errorResponse(
"Cannot use resolve or offset without being authenticated",
401,
);
}
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
let accountResults: { id: string }[] = [];
let statusResults: { id: string }[] = [];
let accountResults: { id: string }[] = [];
let statusResults: { id: string }[] = [];
if (!type || type === "accounts") {
accountResults = (
await meilisearch.index(MeiliIndexType.Accounts).search<{
id: string;
}>(q, {
limit: Number(limit) || 10,
offset: Number(offset) || 0,
sort: ["createdAt:desc"],
})
).hits;
}
if (!type || type === "accounts") {
accountResults = (
await meilisearch.index(MeiliIndexType.Accounts).search<{
id: string;
}>(q, {
limit: Number(limit) || 10,
offset: Number(offset) || 0,
sort: ["createdAt:desc"],
})
).hits;
}
if (!type || type === "statuses") {
statusResults = (
await meilisearch.index(MeiliIndexType.Statuses).search<{
id: string;
}>(q, {
limit: Number(limit) || 10,
offset: Number(offset) || 0,
sort: ["createdAt:desc"],
})
).hits;
}
if (!type || type === "statuses") {
statusResults = (
await meilisearch.index(MeiliIndexType.Statuses).search<{
id: string;
}>(q, {
limit: Number(limit) || 10,
offset: Number(offset) || 0,
sort: ["createdAt:desc"],
})
).hits;
}
const accounts = await client.user.findMany({
where: {
id: {
in: accountResults.map(hit => hit.id),
},
relationshipSubjects: {
some: {
subjectId: user?.id,
following: following ? true : undefined,
},
},
},
orderBy: {
createdAt: "desc",
},
include: userRelations,
});
const accounts = await client.user.findMany({
where: {
id: {
in: accountResults.map((hit) => hit.id),
},
relationshipSubjects: {
some: {
subjectId: user?.id,
following: following ? true : undefined,
},
},
},
orderBy: {
createdAt: "desc",
},
include: userRelations,
});
const statuses = await client.status.findMany({
where: {
id: {
in: statusResults.map(hit => hit.id),
},
author: {
relationshipSubjects: {
some: {
subjectId: user?.id,
following: following ? true : undefined,
},
},
},
authorId: account_id ? account_id : undefined,
},
orderBy: {
createdAt: "desc",
},
include: statusAndUserRelations,
});
const statuses = await client.status.findMany({
where: {
id: {
in: statusResults.map((hit) => hit.id),
},
author: {
relationshipSubjects: {
some: {
subjectId: user?.id,
following: following ? true : undefined,
},
},
},
authorId: account_id ? account_id : undefined,
},
orderBy: {
createdAt: "desc",
},
include: statusAndUserRelations,
});
return jsonResponse({
accounts: accounts.map(account => userToAPI(account)),
statuses: await Promise.all(
statuses.map(status => statusToAPI(status))
),
hashtags: [],
});
return jsonResponse({
accounts: accounts.map((account) => userToAPI(account)),
statuses: await Promise.all(
statuses.map((status) => statusToAPI(status)),
),
hashtags: [],
});
});

View file

@ -1,105 +1,103 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api";
import { randomBytes } from "crypto";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/auth/login",
auth: {
required: false,
},
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/auth/login",
auth: {
required: false,
},
});
/**
* OAuth Code flow
*/
export default apiRoute<{
email: string;
password: string;
email: string;
password: string;
}>(async (req, matchedRoute, extraData) => {
const scopes = (matchedRoute.query.scope || "")
.replaceAll("+", " ")
.split(" ");
const redirect_uri = matchedRoute.query.redirect_uri;
const response_type = matchedRoute.query.response_type;
const client_id = matchedRoute.query.client_id;
const scopes = (matchedRoute.query.scope || "")
.replaceAll("+", " ")
.split(" ");
const redirect_uri = matchedRoute.query.redirect_uri;
const response_type = matchedRoute.query.response_type;
const client_id = matchedRoute.query.client_id;
const { email, password } = extraData.parsedRequest;
const { email, password } = extraData.parsedRequest;
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?` +
new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString(),
302
);
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?${new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString()}`,
302,
);
if (response_type !== "code")
return redirectToLogin("Invalid response_type");
if (response_type !== "code")
return redirectToLogin("Invalid response_type");
if (!email || !password)
return redirectToLogin("Invalid username or password");
if (!email || !password)
return redirectToLogin("Invalid username or password");
// Get user
const user = await client.user.findFirst({
where: {
email,
},
include: userRelations,
});
// Get user
const user = await client.user.findFirst({
where: {
email,
},
include: userRelations,
});
if (!user || !(await Bun.password.verify(password, user.password || "")))
return redirectToLogin("Invalid username or password");
if (!user || !(await Bun.password.verify(password, user.password || "")))
return redirectToLogin("Invalid username or password");
// Get application
const application = await client.application.findFirst({
where: {
client_id,
},
});
// Get application
const application = await client.application.findFirst({
where: {
client_id,
},
});
if (!application) return redirectToLogin("Invalid client_id");
if (!application) return redirectToLogin("Invalid client_id");
const code = randomBytes(32).toString("hex");
const code = randomBytes(32).toString("hex");
await client.application.update({
where: { id: application.id },
data: {
tokens: {
create: {
access_token: randomBytes(64).toString("base64url"),
code: code,
scope: scopes.join(" "),
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
},
},
});
await client.application.update({
where: { id: application.id },
data: {
tokens: {
create: {
access_token: randomBytes(64).toString("base64url"),
code: code,
scope: scopes.join(" "),
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
},
},
});
// Redirect to OAuth confirmation screen
return Response.redirect(
`/oauth/redirect?` +
new URLSearchParams({
redirect_uri,
code,
client_id,
application: application.name,
website: application.website ?? "",
scope: scopes.join(" "),
}).toString(),
302
);
// Redirect to OAuth confirmation screen
return Response.redirect(
`/oauth/redirect?${new URLSearchParams({
redirect_uri,
code,
client_id,
application: application.name,
website: application.website ?? "",
scope: scopes.join(" "),
}).toString()}`,
302,
);
});

View file

@ -3,56 +3,55 @@ import { client } from "~database/datasource";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/auth/redirect",
auth: {
required: false,
},
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/auth/redirect",
auth: {
required: false,
},
});
/**
* OAuth Code flow
*/
export default apiRoute<{
email: string;
password: string;
email: string;
password: string;
}>(async (req, matchedRoute) => {
const redirect_uri = decodeURIComponent(matchedRoute.query.redirect_uri);
const client_id = matchedRoute.query.client_id;
const code = matchedRoute.query.code;
const redirect_uri = decodeURIComponent(matchedRoute.query.redirect_uri);
const client_id = matchedRoute.query.client_id;
const code = matchedRoute.query.code;
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?` +
new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString(),
302
);
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?${new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString()}`,
302,
);
// Get token
const token = await client.token.findFirst({
where: {
code,
application: {
client_id,
},
},
include: {
user: {
include: userRelations,
},
application: true,
},
});
// Get token
const token = await client.token.findFirst({
where: {
code,
application: {
client_id,
},
},
include: {
user: {
include: userRelations,
},
application: true,
},
});
if (!token) return redirectToLogin("Invalid code");
if (!token) return redirectToLogin("Invalid code");
// Redirect back to application
return Response.redirect(`${redirect_uri}?code=${code}`, 302);
// Redirect back to application
return Response.redirect(`${redirect_uri}?code=${code}`, 302);
});

View file

@ -1,45 +1,45 @@
import { errorResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { errorResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/media/:id",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: false,
},
allowedMethods: ["GET"],
route: "/media/:id",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: false,
},
});
export default apiRoute(async (req, matchedRoute) => {
// TODO: Add checks for disabled or not email verified accounts
// TODO: Add checks for disabled or not email verified accounts
const id = matchedRoute.params.id;
const id = matchedRoute.params.id;
// parse `Range` header
const [start = 0, end = Infinity] = (
(req.headers.get("Range") || "")
.split("=") // ["Range: bytes", "0-100"]
.at(-1) || ""
) // "0-100"
.split("-") // ["0", "100"]
.map(Number); // [0, 100]
// parse `Range` header
const [start = 0, end = Number.POSITIVE_INFINITY] = (
(req.headers.get("Range") || "")
.split("=") // ["Range: bytes", "0-100"]
.at(-1) || ""
) // "0-100"
.split("-") // ["0", "100"]
.map(Number); // [0, 100]
// Serve file from filesystem
const file = Bun.file(`./uploads/${id}`);
// Serve file from filesystem
const file = Bun.file(`./uploads/${id}`);
const buffer = await file.arrayBuffer();
const buffer = await file.arrayBuffer();
if (!(await file.exists())) return errorResponse("File not found", 404);
if (!(await file.exists())) return errorResponse("File not found", 404);
// Can't directly copy file into Response because this crashes Bun for now
return new Response(buffer, {
headers: {
"Content-Type": file.type || "application/octet-stream",
"Content-Length": `${file.size - start}`,
"Content-Range": `bytes ${start}-${end}/${file.size}`,
},
});
// Can't directly copy file into Response because this crashes Bun for now
return new Response(buffer, {
headers: {
"Content-Type": file.type || "application/octet-stream",
"Content-Length": `${file.size - start}`,
"Content-Range": `bytes ${start}-${end}/${file.size}`,
},
});
});

View file

@ -2,32 +2,32 @@ import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/nodeinfo/2.0",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/nodeinfo/2.0",
});
/**
* ActivityPub nodeinfo 2.0 endpoint
*/
export default apiRoute(() => {
// TODO: Implement this
return jsonResponse({
version: "2.0",
software: { name: "lysand", version: "0.0.1" },
protocols: ["activitypub"],
services: { outbound: [], inbound: [] },
usage: {
users: { total: 0, activeMonth: 0, activeHalfyear: 0 },
localPosts: 0,
},
openRegistrations: false,
metadata: {},
});
// TODO: Implement this
return jsonResponse({
version: "2.0",
software: { name: "lysand", version: "0.0.1" },
protocols: ["activitypub"],
services: { outbound: [], inbound: [] },
usage: {
users: { total: 0, activeMonth: 0, activeHalfyear: 0 },
localPosts: 0,
},
openRegistrations: false,
metadata: {},
});
});

View file

@ -1,95 +1,91 @@
import { apiRoute, applyConfig } from "@api";
import { oauthRedirectUri } from "@constants";
import {
calculatePKCECodeChallenge,
discoveryRequest,
generateRandomCodeVerifier,
processDiscoveryResponse,
calculatePKCECodeChallenge,
discoveryRequest,
generateRandomCodeVerifier,
processDiscoveryResponse,
} from "oauth4webapi";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/authorize-external",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/authorize-external",
});
/**
* Redirects the user to the external OAuth provider
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?` +
new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString(),
302
);
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?${new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString()}`,
302,
);
const issuerId = matchedRoute.query.issuer;
const issuerId = matchedRoute.query.issuer;
// This is the Lysand client's client_id, not the external OAuth provider's client_id
const clientId = matchedRoute.query.clientId;
// This is the Lysand client's client_id, not the external OAuth provider's client_id
const clientId = matchedRoute.query.clientId;
if (!clientId || clientId === "undefined") {
return redirectToLogin("Missing client_id");
}
if (!clientId || clientId === "undefined") {
return redirectToLogin("Missing client_id");
}
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
const issuer = config.oidc.providers.find(
provider => provider.id === issuerId
);
const issuer = config.oidc.providers.find(
(provider) => provider.id === issuerId,
);
if (!issuer) {
return redirectToLogin("Invalid issuer");
}
if (!issuer) {
return redirectToLogin("Invalid issuer");
}
const issuerUrl = new URL(issuer.url);
const issuerUrl = new URL(issuer.url);
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then(res => processDiscoveryResponse(issuerUrl, res));
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrl, res));
const codeVerifier = generateRandomCodeVerifier();
const codeVerifier = generateRandomCodeVerifier();
// Store into database
// Store into database
const newFlow = await client.openIdLoginFlow.create({
data: {
codeVerifier,
application: {
connect: {
client_id: clientId,
},
},
issuerId,
},
});
const newFlow = await client.openIdLoginFlow.create({
data: {
codeVerifier,
application: {
connect: {
client_id: clientId,
},
},
issuerId,
},
});
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
return Response.redirect(
authServer.authorization_endpoint +
"?" +
new URLSearchParams({
client_id: issuer.client_id,
redirect_uri:
oauthRedirectUri(issuerId) + `?flow=${newFlow.id}`,
response_type: "code",
scope: "openid profile email",
// PKCE
code_challenge_method: "S256",
code_challenge: codeChallenge,
}).toString(),
302
);
return Response.redirect(
`${authServer.authorization_endpoint}?${new URLSearchParams({
client_id: issuer.client_id,
redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${newFlow.id}`,
response_type: "code",
scope: "openid profile email",
// PKCE
code_challenge_method: "S256",
code_challenge: codeChallenge,
}).toString()}`,
302,
);
});

View file

@ -1,198 +1,196 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api";
import { oauthRedirectUri } from "@constants";
import { randomBytes } from "crypto";
import {
authorizationCodeGrantRequest,
discoveryRequest,
expectNoState,
isOAuth2Error,
processDiscoveryResponse,
validateAuthResponse,
userInfoRequest,
processAuthorizationCodeOpenIDResponse,
processUserInfoResponse,
getValidatedIdTokenClaims,
authorizationCodeGrantRequest,
discoveryRequest,
expectNoState,
getValidatedIdTokenClaims,
isOAuth2Error,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
processUserInfoResponse,
userInfoRequest,
validateAuthResponse,
} from "oauth4webapi";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/callback/:issuer",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/callback/:issuer",
});
/**
* Redirects the user to the external OAuth provider
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?` +
new URLSearchParams({
client_id: matchedRoute.query.clientId,
error: encodeURIComponent(error),
}).toString(),
302
);
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?${new URLSearchParams({
client_id: matchedRoute.query.clientId,
error: encodeURIComponent(error),
}).toString()}`,
302,
);
const currentUrl = new URL(req.url);
const currentUrl = new URL(req.url);
// Remove state query parameter from URL
currentUrl.searchParams.delete("state");
const issuerParam = matchedRoute.params.issuer;
const flow = await client.openIdLoginFlow.findFirst({
where: {
id: matchedRoute.query.flow,
},
include: {
application: true,
},
});
// Remove state query parameter from URL
currentUrl.searchParams.delete("state");
const issuerParam = matchedRoute.params.issuer;
const flow = await client.openIdLoginFlow.findFirst({
where: {
id: matchedRoute.query.flow,
},
include: {
application: true,
},
});
if (!flow) {
return redirectToLogin("Invalid flow");
}
if (!flow) {
return redirectToLogin("Invalid flow");
}
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
const issuer = config.oidc.providers.find(
provider => provider.id === issuerParam
);
const issuer = config.oidc.providers.find(
(provider) => provider.id === issuerParam,
);
if (!issuer) {
return redirectToLogin("Invalid issuer");
}
if (!issuer) {
return redirectToLogin("Invalid issuer");
}
const issuerUrl = new URL(issuer.url);
const issuerUrl = new URL(issuer.url);
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then(res => processDiscoveryResponse(issuerUrl, res));
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrl, res));
const parameters = validateAuthResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
currentUrl,
// Whether to expect state or not
expectNoState
);
const parameters = validateAuthResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
currentUrl,
// Whether to expect state or not
expectNoState,
);
if (isOAuth2Error(parameters)) {
return redirectToLogin(
parameters.error_description || parameters.error
);
}
if (isOAuth2Error(parameters)) {
return redirectToLogin(
parameters.error_description || parameters.error,
);
}
const response = await authorizationCodeGrantRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
parameters,
oauthRedirectUri(issuerParam) + `?flow=${flow.id}`,
flow.codeVerifier
);
const response = await authorizationCodeGrantRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
parameters,
`${oauthRedirectUri(issuerParam)}?flow=${flow.id}`,
flow.codeVerifier,
);
const result = await processAuthorizationCodeOpenIDResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
response
);
const result = await processAuthorizationCodeOpenIDResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
response,
);
if (isOAuth2Error(result)) {
return redirectToLogin(result.error_description || result.error);
}
if (isOAuth2Error(result)) {
return redirectToLogin(result.error_description || result.error);
}
const { access_token } = result;
const { access_token } = result;
const claims = getValidatedIdTokenClaims(result);
const { sub } = claims;
const claims = getValidatedIdTokenClaims(result);
const { sub } = claims;
// Validate `sub`
// Later, we'll use this to automatically set the user's data
await userInfoRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
access_token
).then(res =>
processUserInfoResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
sub,
res
)
);
// Validate `sub`
// Later, we'll use this to automatically set the user's data
await userInfoRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
access_token,
).then((res) =>
processUserInfoResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
sub,
res,
),
);
const user = await client.user.findFirst({
where: {
linkedOpenIdAccounts: {
some: {
serverId: sub,
issuerId: issuer.id,
},
},
},
});
const user = await client.user.findFirst({
where: {
linkedOpenIdAccounts: {
some: {
serverId: sub,
issuerId: issuer.id,
},
},
},
});
if (!user) {
return redirectToLogin("No user found with that account");
}
if (!user) {
return redirectToLogin("No user found with that account");
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!flow.application) return redirectToLogin("Invalid client_id");
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!flow.application) return redirectToLogin("Invalid client_id");
const code = randomBytes(32).toString("hex");
const code = randomBytes(32).toString("hex");
await client.application.update({
where: { id: flow.application.id },
data: {
tokens: {
create: {
access_token: randomBytes(64).toString("base64url"),
code: code,
scope: flow.application.scopes,
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
},
},
});
await client.application.update({
where: { id: flow.application.id },
data: {
tokens: {
create: {
access_token: randomBytes(64).toString("base64url"),
code: code,
scope: flow.application.scopes,
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
},
},
});
// Redirect back to application
return Response.redirect(
`/oauth/redirect?` +
new URLSearchParams({
redirect_uri: flow.application.redirect_uris,
code,
client_id: flow.application.client_id,
application: flow.application.name,
website: flow.application.website ?? "",
scope: flow.application.scopes,
}).toString(),
302
);
// Redirect back to application
return Response.redirect(
`/oauth/redirect?${new URLSearchParams({
redirect_uri: flow.application.redirect_uris,
code,
client_id: flow.application.client_id,
application: flow.application.name,
website: flow.application.website ?? "",
scope: flow.application.scopes,
}).toString()}`,
302,
);
});

View file

@ -2,28 +2,28 @@ import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 10,
},
route: "/oauth/providers",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 10,
},
route: "/oauth/providers",
});
/**
* Lists available OAuth providers
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
return jsonResponse(
config.oidc.providers.map(p => ({
name: p.name,
icon: p.icon,
id: p.id,
}))
);
return jsonResponse(
config.oidc.providers.map((p) => ({
name: p.name,
icon: p.icon,
id: p.id,
})),
);
});

View file

@ -3,61 +3,61 @@ import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 10,
},
route: "/oauth/token",
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 10,
},
route: "/oauth/token",
});
/**
* Allows getting token from OAuth code
*/
export default apiRoute<{
grant_type: string;
code: string;
redirect_uri: string;
client_id: string;
client_secret: string;
scope: string;
grant_type: string;
code: string;
redirect_uri: string;
client_id: string;
client_secret: string;
scope: string;
}>(async (req, matchedRoute, extraData) => {
const { grant_type, code, redirect_uri, client_id, client_secret, scope } =
extraData.parsedRequest;
const { grant_type, code, redirect_uri, client_id, client_secret, scope } =
extraData.parsedRequest;
if (grant_type !== "authorization_code")
return errorResponse(
"Invalid grant type (try 'authorization_code')",
400
);
if (grant_type !== "authorization_code")
return errorResponse(
"Invalid grant type (try 'authorization_code')",
400,
);
// Get associated token
const token = await client.token.findFirst({
where: {
code,
application: {
client_id,
secret: client_secret,
redirect_uris: redirect_uri,
scopes: scope?.replaceAll("+", " "),
},
scope: scope?.replaceAll("+", " "),
},
include: {
application: true,
},
});
// Get associated token
const token = await client.token.findFirst({
where: {
code,
application: {
client_id,
secret: client_secret,
redirect_uris: redirect_uri,
scopes: scope?.replaceAll("+", " "),
},
scope: scope?.replaceAll("+", " "),
},
include: {
application: true,
},
});
if (!token)
return errorResponse("Invalid access token or client credentials", 401);
if (!token)
return errorResponse("Invalid access token or client credentials", 401);
return jsonResponse({
access_token: token.access_token,
token_type: token.token_type,
scope: token.scope,
created_at: token.created_at,
});
return jsonResponse({
access_token: token.access_token,
token_type: token.token_type,
scope: token.scope,
created_at: token.created_at,
});
});

View file

@ -2,17 +2,17 @@ import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/object/:id",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/object/:id",
});
export default apiRoute(() => {
return jsonResponse({});
return jsonResponse({});
});

View file

@ -3,13 +3,13 @@ import type { Config } from "config-manager";
import type { AuthData } from "~database/entities/User";
export type RouteHandler<T> = (
req: Request,
matchedRoute: MatchedRoute,
extraData: {
auth: AuthData;
parsedRequest: Partial<T>;
configManager: {
getConfig: () => Promise<Config>;
};
}
req: Request,
matchedRoute: MatchedRoute,
extraData: {
auth: AuthData;
parsedRequest: Partial<T>;
configManager: {
getConfig: () => Promise<Config>;
};
},
) => Response | Promise<Response>;

View file

@ -9,394 +9,393 @@ import { createFromObject } from "~database/entities/Object";
import { createNewStatus, fetchFromRemote } from "~database/entities/Status";
import { parseMentionsUris } from "~database/entities/User";
import {
userRelations,
statusAndUserRelations,
statusAndUserRelations,
userRelations,
} from "~database/entities/relations";
import type {
Announce,
Like,
LysandAction,
LysandPublication,
Patch,
Undo,
Announce,
Like,
LysandAction,
LysandPublication,
Patch,
Undo,
} from "~types/lysand/Object";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/inbox",
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/inbox",
});
/**
* ActivityPub user inbox endpoint
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const username = matchedRoute.params.username;
const username = matchedRoute.params.username;
const config = await extraData.configManager.getConfig();
const config = await extraData.configManager.getConfig();
try {
if (
config.activitypub.reject_activities.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
/* try {
if (
config.activitypub.reject_activities.includes(
new URL(req.headers.get("Origin") ?? "").hostname,
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Activity from ${req.headers.get(
"Origin",
)}`,
);
console.error(e);
} */
// Process request body
const body = (await req.json()) as LysandPublication | LysandAction;
// Process request body
const body = (await req.json()) as LysandPublication | LysandAction;
const author = await client.user.findUnique({
where: {
username,
},
include: userRelations,
});
const author = await client.user.findUnique({
where: {
username,
},
include: userRelations,
});
if (!author) {
// TODO: Add new author to database
return errorResponse("Author not found", 404);
}
if (!author) {
// TODO: Add new author to database
return errorResponse("Author not found", 404);
}
// Verify HTTP signature
if (config.activitypub.authorized_fetch) {
// Check if date is older than 30 seconds
const origin = req.headers.get("Origin");
// Verify HTTP signature
/* if (config.activitypub.authorized_fetch) {
// Check if date is older than 30 seconds
const origin = req.headers.get("Origin");
if (!origin) {
return errorResponse("Origin header is required", 401);
}
if (!origin) {
return errorResponse("Origin header is required", 401);
}
const date = req.headers.get("Date");
const date = req.headers.get("Date");
if (!date) {
return errorResponse("Date header is required", 401);
}
if (!date) {
return errorResponse("Date header is required", 401);
}
if (new Date(date).getTime() < Date.now() - 30000) {
return errorResponse("Date is too old (max 30 seconds)", 401);
}
if (new Date(date).getTime() < Date.now() - 30000) {
return errorResponse("Date is too old (max 30 seconds)", 401);
}
const signatureHeader = req.headers.get("Signature");
const signatureHeader = req.headers.get("Signature");
if (!signatureHeader) {
return errorResponse("Signature header is required", 401);
}
if (!signatureHeader) {
return errorResponse("Signature header is required", 401);
}
const signature = signatureHeader
.split("signature=")[1]
.replace(/"/g, "");
const signature = signatureHeader
.split("signature=")[1]
.replace(/"/g, "");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(await req.text())
);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(await req.text()),
);
const expectedSignedString =
`(request-target): ${req.method.toLowerCase()} ${req.url}\n` +
`host: ${req.url}\n` +
`date: ${date}\n` +
`digest: SHA-256=${Buffer.from(digest).toString("base64")}`;
const expectedSignedString =
`(request-target): ${req.method.toLowerCase()} ${req.url}\n` +
`host: ${req.url}\n` +
`date: ${date}\n` +
`digest: SHA-256=${Buffer.from(digest).toString("base64")}`;
// author.public_key is base64 encoded raw public key
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(author.publicKey, "base64"),
"Ed25519",
false,
["verify"]
);
// author.public_key is base64 encoded raw public key
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(author.publicKey, "base64"),
"Ed25519",
false,
["verify"],
);
// Check if signed string is valid
const isValid = await crypto.subtle.verify(
"Ed25519",
publicKey,
Buffer.from(signature, "base64"),
new TextEncoder().encode(expectedSignedString)
);
// Check if signed string is valid
const isValid = await crypto.subtle.verify(
"Ed25519",
publicKey,
Buffer.from(signature, "base64"),
new TextEncoder().encode(expectedSignedString),
);
if (!isValid) {
return errorResponse("Invalid signature", 401);
}
}
if (!isValid) {
return errorResponse("Invalid signature", 401);
}
} */
// Get the object's ActivityPub type
const type = body.type;
// Get the object's ActivityPub type
const type = body.type;
switch (type) {
case "Note": {
// Store the object in the LysandObject table
await createFromObject(body);
switch (type) {
case "Note": {
// Store the object in the LysandObject table
await createFromObject(body);
const content = getBestContentType(body.contents);
const content = getBestContentType(body.contents);
const emojis = await parseEmojis(content?.content || "");
const emojis = await parseEmojis(content?.content || "");
const newStatus = await createNewStatus({
account: author,
content: content?.content || "",
content_type: content?.content_type,
application: null,
// TODO: Add visibility
visibility: "public",
spoiler_text: body.subject || "",
sensitive: body.is_sensitive,
uri: body.uri,
emojis: emojis,
mentions: await parseMentionsUris(body.mentions),
});
const newStatus = await createNewStatus({
account: author,
content: content?.content || "",
content_type: content?.content_type,
application: null,
// TODO: Add visibility
visibility: "public",
spoiler_text: body.subject || "",
sensitive: body.is_sensitive,
uri: body.uri,
emojis: emojis,
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.inReplyToPostId =
(await fetchFromRemote(body.replies_to[0]))?.id || null;
}
// If there is a reply, fetch all the reply parents and add them to the database
if (body.replies_to.length > 0) {
newStatus.inReplyToPostId =
(await fetchFromRemote(body.replies_to[0]))?.id || null;
}
// Same for quotes
if (body.quotes.length > 0) {
newStatus.quotingPostId =
(await fetchFromRemote(body.quotes[0]))?.id || null;
}
// Same for quotes
if (body.quotes.length > 0) {
newStatus.quotingPostId =
(await fetchFromRemote(body.quotes[0]))?.id || null;
}
await client.status.update({
where: {
id: newStatus.id,
},
data: {
inReplyToPostId: newStatus.inReplyToPostId,
quotingPostId: newStatus.quotingPostId,
},
});
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 createFromObject(patch);
break;
}
case "Patch": {
const patch = body as Patch;
// Store the object in the LysandObject table
await createFromObject(patch);
// Edit the status
// Edit the status
const content = getBestContentType(patch.contents);
const content = getBestContentType(patch.contents);
const emojis = await parseEmojis(content?.content || "");
const emojis = await parseEmojis(content?.content || "");
const status = await client.status.findUnique({
where: {
uri: patch.patched_id,
},
include: statusAndUserRelations,
});
const status = await client.status.findUnique({
where: {
uri: patch.patched_id,
},
include: statusAndUserRelations,
});
if (!status) {
return errorResponse("Status not found", 404);
}
if (!status) {
return errorResponse("Status not found", 404);
}
status.content = content?.content || "";
status.contentType = content?.content_type || "text/plain";
status.spoilerText = patch.subject || "";
status.sensitive = patch.is_sensitive;
status.emojis = emojis;
status.content = content?.content || "";
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.inReplyToPostId =
(await fetchFromRemote(body.replies_to[0]))?.id || null;
}
// If there is a reply, fetch all the reply parents and add them to the database
if (body.replies_to.length > 0) {
status.inReplyToPostId =
(await fetchFromRemote(body.replies_to[0]))?.id || null;
}
// Same for quotes
if (body.quotes.length > 0) {
status.quotingPostId =
(await fetchFromRemote(body.quotes[0]))?.id || null;
}
// Same for quotes
if (body.quotes.length > 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": {
const like = body as Like;
// Store the object in the LysandObject table
await createFromObject(body);
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": {
const like = body as Like;
// Store the object in the LysandObject table
await createFromObject(body);
const likedStatus = await client.status.findUnique({
where: {
uri: like.object,
},
include: statusAndUserRelations,
});
const likedStatus = await client.status.findUnique({
where: {
uri: like.object,
},
include: statusAndUserRelations,
});
if (!likedStatus) {
return errorResponse("Status not found", 404);
}
if (!likedStatus) {
return errorResponse("Status not found", 404);
}
await createLike(author, likedStatus);
await createLike(author, likedStatus);
break;
}
case "Dislike": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
case "Dislike": {
// Store the object in the LysandObject table
await createFromObject(body);
return jsonResponse({
info: "Dislikes are not supported by this software",
});
break;
}
case "Follow": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
case "FollowAccept": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
case "FollowReject": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
case "Announce": {
const announce = body as Announce;
// Store the object in the LysandObject table
await createFromObject(body);
return jsonResponse({
info: "Dislikes are not supported by this software",
});
}
case "Follow": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
case "FollowAccept": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
case "FollowReject": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
case "Announce": {
const announce = body as Announce;
// Store the object in the LysandObject table
await createFromObject(body);
const rebloggedStatus = await client.status.findUnique({
where: {
uri: announce.object,
},
include: statusAndUserRelations,
});
const rebloggedStatus = await client.status.findUnique({
where: {
uri: announce.object,
},
include: statusAndUserRelations,
});
if (!rebloggedStatus) {
return errorResponse("Status not found", 404);
}
if (!rebloggedStatus) {
return errorResponse("Status not found", 404);
}
// Create new reblog
await client.status.create({
data: {
authorId: author.id,
reblogId: rebloggedStatus.id,
isReblog: true,
uri: body.uri,
visibility: rebloggedStatus.visibility,
sensitive: false,
},
include: statusAndUserRelations,
});
// Create new reblog
await client.status.create({
data: {
authorId: author.id,
reblogId: rebloggedStatus.id,
isReblog: true,
uri: body.uri,
visibility: rebloggedStatus.visibility,
sensitive: false,
},
include: statusAndUserRelations,
});
// Create notification
await client.notification.create({
data: {
accountId: author.id,
notifiedId: rebloggedStatus.authorId,
type: "reblog",
statusId: rebloggedStatus.id,
},
});
break;
}
case "Undo": {
const undo = body as Undo;
// Store the object in the LysandObject table
await createFromObject(body);
// Create notification
await client.notification.create({
data: {
accountId: author.id,
notifiedId: rebloggedStatus.authorId,
type: "reblog",
statusId: rebloggedStatus.id,
},
});
break;
}
case "Undo": {
const undo = body as Undo;
// Store the object in the LysandObject table
await createFromObject(body);
const object = await client.lysandObject.findUnique({
where: {
uri: undo.object,
},
});
const object = await client.lysandObject.findUnique({
where: {
uri: undo.object,
},
});
if (!object) {
return errorResponse("Object not found", 404);
}
if (!object) {
return errorResponse("Object not found", 404);
}
switch (object.type) {
case "Like": {
const status = await client.status.findUnique({
where: {
uri: undo.object,
authorId: author.id,
},
include: statusAndUserRelations,
});
switch (object.type) {
case "Like": {
const status = await client.status.findUnique({
where: {
uri: undo.object,
authorId: author.id,
},
include: statusAndUserRelations,
});
if (!status) {
return errorResponse("Status not found", 404);
}
if (!status) {
return errorResponse("Status not found", 404);
}
await deleteLike(author, status);
break;
}
case "Announce": {
await client.status.delete({
where: {
uri: undo.object,
authorId: author.id,
},
include: statusAndUserRelations,
});
break;
}
case "Note": {
await client.status.delete({
where: {
uri: undo.object,
authorId: author.id,
},
include: statusAndUserRelations,
});
break;
}
default: {
return errorResponse("Invalid object type", 400);
}
}
break;
}
case "Extension": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
default: {
return errorResponse("Invalid type", 400);
}
}
await deleteLike(author, status);
break;
}
case "Announce": {
await client.status.delete({
where: {
uri: undo.object,
authorId: author.id,
},
include: statusAndUserRelations,
});
break;
}
case "Note": {
await client.status.delete({
where: {
uri: undo.object,
authorId: author.id,
},
include: statusAndUserRelations,
});
break;
}
default: {
return errorResponse("Invalid object type", 400);
}
}
break;
}
case "Extension": {
// Store the object in the LysandObject table
await createFromObject(body);
break;
}
default: {
return errorResponse("Invalid type", 400);
}
}
return jsonResponse({});
return jsonResponse({});
});

View file

@ -5,33 +5,33 @@ import { userToLysand } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid",
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid",
});
/**
* ActivityPub user inbox endpoint
*/
export default apiRoute(async (req, matchedRoute) => {
const uuid = matchedRoute.params.uuid;
const uuid = matchedRoute.params.uuid;
const user = await client.user.findUnique({
where: {
id: uuid,
},
include: userRelations,
});
const user = await client.user.findUnique({
where: {
id: uuid,
},
include: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(userToLysand(user));
return jsonResponse(userToLysand(user));
});

View file

@ -1,65 +1,65 @@
import { jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api";
import { statusToLysand } from "~database/entities/Status";
import { jsonResponse } from "@response";
import { client } from "~database/datasource";
import { statusToLysand } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid/outbox",
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid/outbox",
});
/**
* ActivityPub user outbox endpoint
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const uuid = matchedRoute.params.uuid;
const pageNumber = Number(matchedRoute.query.page) || 1;
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).hostname;
const uuid = matchedRoute.params.uuid;
const pageNumber = Number(matchedRoute.query.page) || 1;
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).hostname;
const statuses = await client.status.findMany({
where: {
authorId: uuid,
visibility: {
in: ["public", "unlisted"],
},
},
take: 20,
skip: 20 * (pageNumber - 1),
include: statusAndUserRelations,
});
const statuses = await client.status.findMany({
where: {
authorId: uuid,
visibility: {
in: ["public", "unlisted"],
},
},
take: 20,
skip: 20 * (pageNumber - 1),
include: statusAndUserRelations,
});
const totalStatuses = await client.status.count({
where: {
authorId: uuid,
visibility: {
in: ["public", "unlisted"],
},
},
});
const totalStatuses = await client.status.count({
where: {
authorId: uuid,
visibility: {
in: ["public", "unlisted"],
},
},
});
return jsonResponse({
first: `${host}/users/${uuid}/outbox?page=1`,
last: `${host}/users/${uuid}/outbox?page=1`,
total_items: totalStatuses,
// Server actor
author: `${config.http.base_url}/users/actor`,
next:
statuses.length === 20
? `${host}/users/${uuid}/outbox?page=${pageNumber + 1}`
: undefined,
prev:
pageNumber > 1
? `${host}/users/${uuid}/outbox?page=${pageNumber - 1}`
: undefined,
items: statuses.map(s => statusToLysand(s)),
});
return jsonResponse({
first: `${host}/users/${uuid}/outbox?page=1`,
last: `${host}/users/${uuid}/outbox?page=1`,
total_items: totalStatuses,
// Server actor
author: `${config.http.base_url}/users/actor`,
next:
statuses.length === 20
? `${host}/users/${uuid}/outbox?page=${pageNumber + 1}`
: undefined,
prev:
pageNumber > 1
? `${host}/users/${uuid}/outbox?page=${pageNumber - 1}`
: undefined,
items: statuses.map((s) => statusToLysand(s)),
});
});