Refactors, bugfixing

This commit is contained in:
Jesse Wierzbinski 2024-04-07 17:28:18 -10:00
parent 5812618170
commit e26d604a54
No known key found for this signature in database
42 changed files with 370 additions and 376 deletions

View file

@ -18,7 +18,7 @@ if (!token) {
}
const fetchTimeline = () =>
fetch(`${config.http.base_url}/api/v1/timelines/home`, {
fetch(new URL("/api/v1/timelines/home", config.http.base_url), {
headers: {
Authorization: `Bearer ${token}`,
},

1
cli.ts
View file

@ -388,7 +388,6 @@ const cliBuilder = new CliBuilder([
for (const key of keys) {
if (!args.fields.includes(key)) {
// @ts-expect-error This is fine
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete data[key];
}
}

View file

@ -58,13 +58,11 @@ export const attachmentToAPI = (
};
export const getUrl = (name: string, config: Config) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${name}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, @typescript-eslint/no-unnecessary-condition
return new URL(`/media/${name}`, config.http.base_url).toString();
}
if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${name}`;
return new URL(`/${name}`, config.s3.public_url).toString();
}
return "";
};

View file

@ -1,7 +1,6 @@
import type { Like, Prisma } from "@prisma/client";
import type { Like } from "@prisma/client";
import { config } from "config-manager";
import { client } from "~database/datasource";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Like as LysandLike } from "~types/lysand/Object";
import type { StatusWithRelations } from "./Status";
import type { UserWithRelations } from "./User";
@ -18,7 +17,7 @@ export const toLysand = (like: Like): LysandLike => {
created_at: new Date(like.createdAt).toISOString(),
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
object: (like as any).liked?.uri,
uri: `${config.http.base_url}/actions/${like.id}`,
uri: new URL(`/actions/${like.id}`, config.http.base_url).toString(),
};
};

View file

@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { LysandObject } from "@prisma/client";
import { client } from "~database/datasource";
import type { LysandObjectType } from "~types/lysand/Object";

View file

@ -149,7 +149,6 @@ export const federateStatusTo = async (
new TextEncoder().encode("request_body"),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const userInbox = new URL(user.endpoints.inbox);
const date = new Date();

View file

@ -21,7 +21,6 @@ import type { LysandPublication, Note } from "~types/lysand/Object";
import { applicationToAPI } from "./Application";
import { attachmentToAPI } from "./Attachment";
import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { UserWithRelations } from "./User";
import { fetchRemoteUser, parseMentionsUris, userToAPI } from "./User";
import { statusAndUserRelations, userRelations } from "./relations";
@ -118,7 +117,6 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
/**
* Return all the ancestors of this post,
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getAncestors = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null,
@ -154,7 +152,6 @@ export const getAncestors = async (
* Return all the descendants of this post (recursive)
* Temporary implementation, will be replaced with a recursive SQL query when Prisma adds support for it
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getDescendants = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null,
@ -295,7 +292,10 @@ export const createNewStatus = async (data: {
isReblog: false,
uri:
data.uri ||
`${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
new URL(
`/statuses/FAKE-${crypto.randomUUID()}`,
config.http.base_url,
).toString(),
mentions: {
connect: mentions.map((mention) => {
return {
@ -313,7 +313,12 @@ export const createNewStatus = async (data: {
id: status.id,
},
data: {
uri: data.uri || `${config.http.base_url}/statuses/${status.id}`,
uri:
data.uri ||
new URL(
`/statuses/${status.id}`,
config.http.base_url,
).toString(),
},
include: statusAndUserRelations,
});
@ -467,13 +472,10 @@ export const statusToAPI = async (
card: null,
content: status.content,
emojis: status.emojis.map((emoji) => emojiToAPI(emoji)),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourited: !!(status.likes ?? []).find(
(like) => like.likerId === user?.id,
),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourites_count: (status.likes ?? []).length,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
media_attachments: (status.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment,
),
@ -485,7 +487,7 @@ export const statusToAPI = async (
?.muting || false
: false,
pinned: status.pinnedBy.find((u) => u.id === user?.id) ? true : false,
// TODO: Add pols
// TODO: Add polls
poll: null,
reblog: status.reblog
? await statusToAPI(status.reblog as unknown as StatusWithRelations)
@ -501,9 +503,9 @@ export const statusToAPI = async (
sensitive: status.sensitive,
spoiler_text: status.spoilerText,
tags: [],
uri: `${config.http.base_url}/statuses/${status.id}`,
uri: new URL(`/statuses/${status.id}`, config.http.base_url).toString(),
visibility: "public",
url: `${config.http.base_url}/statuses/${status.id}`,
url: new URL(`/statuses/${status.id}`, config.http.base_url).toString(),
bookmarked: false,
quote: status.quotingPost
? await statusToAPI(
@ -567,7 +569,7 @@ export const statusToLysand = (status: StatusWithRelations): Note => {
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: status.authorId,
uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`,
uri: new URL(`/statuses/${status.id}`, config.http.base_url).toString(),
contents: [
{
content: status.content,

View file

@ -4,13 +4,13 @@ import { Prisma } from "@prisma/client";
import { type Config, config } from "config-manager";
import { htmlToText } from "html-to-text";
import { client } from "~database/datasource";
import { MediaBackendType } from "~packages/media-manager";
import type { APIAccount } from "~types/entities/account";
import type { APISource } from "~types/entities/source";
import type { LysandUser } from "~types/lysand/Object";
import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance";
import { userRelations } from "./relations";
import { getUrl } from "./Attachment";
export interface AuthData {
user: UserWithRelations | null;
@ -35,14 +35,7 @@ export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
*/
export const getAvatarUrl = (user: User, config: Config) => {
if (!user.avatar) return config.defaults.avatar;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.avatar}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
}
if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${user.avatar}`;
}
return "";
return getUrl(user.avatar, config);
};
/**
@ -52,14 +45,7 @@ export const getAvatarUrl = (user: User, config: Config) => {
*/
export const getHeaderUrl = (user: User, config: Config) => {
if (!user.header) return config.defaults.header;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.header}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
}
if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${user.header}`;
}
return "";
return getUrl(user.header, config);
};
export const getFromRequest = async (req: Request): Promise<AuthData> => {
@ -224,16 +210,7 @@ export const createNewLocalUser = async (data: {
id: user.id,
},
data: {
uri: `${config.http.base_url}/users/${user.id}`,
endpoints: {
disliked: `${config.http.base_url}/users/${user.id}/disliked`,
featured: `${config.http.base_url}/users/${user.id}/featured`,
liked: `${config.http.base_url}/users/${user.id}/liked`,
followers: `${config.http.base_url}/users/${user.id}/followers`,
following: `${config.http.base_url}/users/${user.id}/following`,
inbox: `${config.http.base_url}/users/${user.id}/inbox`,
outbox: `${config.http.base_url}/users/${user.id}/outbox`,
},
uri: new URL(`/users/${user.id}`, config.http.base_url).toString(),
},
include: userRelations,
});
@ -399,13 +376,35 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
},
],
created_at: new Date(user.createdAt).toISOString(),
disliked: `${user.uri}/disliked`,
featured: `${user.uri}/featured`,
liked: `${user.uri}/liked`,
followers: `${user.uri}/followers`,
following: `${user.uri}/following`,
inbox: `${user.uri}/inbox`,
outbox: `${user.uri}/outbox`,
disliked: new URL(
`/users/${user.id}/disliked`,
config.http.base_url,
).toString(),
featured: new URL(
`/users/${user.id}/featured`,
config.http.base_url,
).toString(),
liked: new URL(
`/users/${user.id}/liked`,
config.http.base_url,
).toString(),
followers: new URL(
`/users/${user.id}/followers`,
config.http.base_url,
).toString(),
following: new URL(
`/users/${user.id}/following`,
config.http.base_url,
).toString(),
inbox: new URL(
`/users/${user.id}/inbox`,
config.http.base_url,
).toString(),
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).toString(),
indexable: false,
username: user.username,
avatar: [
@ -444,7 +443,10 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
],
})),
public_key: {
actor: `${config.http.base_url}/users/${user.id}`,
actor: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
public_key: user.publicKey,
},
extensions: {

View file

@ -174,7 +174,6 @@ export class CliBuilder {
// Split the command into parts and iterate over them
for (const part of command.categories) {
// If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!currentLevel[part] && part !== "__proto__") {
// If this is the last part of the command, add the command itself
if (
@ -297,7 +296,6 @@ export class CliBuilder {
type ExecuteFunction<T> = (
instance: CliCommand,
args: Partial<T>,
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => Promise<number> | Promise<void> | number | void;
/**

View file

@ -64,9 +64,7 @@ export class MediaBackend {
* @returns The file as a File object
*/
public getFileByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
file: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
databaseHashFetcher: (sha256: string) => Promise<string>,
): Promise<File | null> {
return Promise.reject(
@ -79,7 +77,6 @@ export class MediaBackend {
* @param filename File name
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getFile(filename: string): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
@ -91,7 +88,6 @@ export class MediaBackend {
* @param file File to add
* @returns Metadata about the uploaded file
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public addFile(file: File): Promise<UploadedFileMetadata> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),

View file

@ -20,7 +20,6 @@ export default defineConfig({
},
},
define: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
__VERSION__: JSON.stringify(pkg.version),
},
ssr: {

View file

@ -1,4 +1,9 @@
import { errorResponse, jsonResponse } from "@response";
import {
clientResponse,
errorResponse,
jsonResponse,
response,
} from "@response";
import type { Config } from "config-manager";
import { matches } from "ip-matching";
import type { LogManager, MultiLogManager } from "log-manager";
@ -22,10 +27,7 @@ export const createServer = (
for (const ip of config.http.banned_ips) {
try {
if (matches(ip, request_ip)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
return errorResponse("Forbidden", 403);
}
} catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `);
@ -38,10 +40,7 @@ export const createServer = (
for (const agent of config.http.banned_user_agents) {
if (new RegExp(agent).test(ua)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
return errorResponse("Forbidden", 403);
}
}
@ -56,7 +55,7 @@ export const createServer = (
);
if (await file.exists()) {
return new Response(file);
return response(file);
}
await logger.log(
LogLevel.ERROR,
@ -81,7 +80,7 @@ export const createServer = (
);
if (await file.exists()) {
return new Response(file);
return response(file);
}
await logger.log(
LogLevel.ERROR,
@ -126,12 +125,12 @@ export const createServer = (
// Check for allowed requests
// @ts-expect-error Stupid error
if (!meta.allowedMethods.includes(req.method as string)) {
return new Response(undefined, {
status: 405,
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
return errorResponse(
`Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", ",
)}`,
});
405,
);
}
// TODO: Check for ratelimits
@ -140,20 +139,14 @@ export const createServer = (
// Check for authentication if required
if (meta.auth.required) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
return errorResponse("Unauthorized", 401);
}
} else if (
// @ts-expect-error Stupid error
(meta.auth.requiredOnMethods ?? []).includes(req.method)
) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
return errorResponse("Unauthorized", 401);
}
}
@ -167,10 +160,7 @@ export const createServer = (
"Server.RouteRequestParser",
e as Error,
);
return new Response(undefined, {
status: 400,
statusText: "Bad request",
});
return errorResponse("Bad request", 400);
}
return await file.default(req.clone(), matchedRoute, {
@ -196,7 +186,7 @@ export const createServer = (
// Serve from pages/dist/assets
if (await file.exists()) {
return new Response(file);
return clientResponse(file);
}
return errorResponse("Asset not found", 404);
}
@ -207,7 +197,7 @@ export const createServer = (
const file = Bun.file("./pages/dist/index.html");
// Serve from pages/dist
return new Response(file);
return clientResponse(file);
}
const proxy = await fetch(
req.url.replace(

View file

@ -19,7 +19,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
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}"/>
<Link rel="lrdd" template="${new URL(
"/.well-known/webfinger",
config.http.base_url,
).toString()}?resource={uri}"/>
</XRD>
`);
});

View file

@ -1,4 +1,5 @@
import { apiRoute, applyConfig } from "@api";
import { redirect } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -15,10 +16,8 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
return new Response("", {
status: 301,
headers: {
Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`,
},
});
return redirect(
new URL("/.well-known/nodeinfo/2.0", config.http.base_url),
301,
);
});

View file

@ -41,18 +41,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
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`,
type: "application/json",
href: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
},
],
});

View file

@ -1,5 +1,4 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";

View file

@ -1,5 +1,4 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";

View file

@ -33,7 +33,6 @@ export default apiRoute<{
if (!self) return errorResponse("Unauthorized", 401);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { notifications, duration } = extraData.parsedRequest;
const user = await client.user.findUnique({

View file

@ -1,6 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import type { Prisma, Status, User } from "@prisma/client";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";

View file

@ -1,5 +1,5 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { jsonResponse, response } from "@response";
import { tempmailDomains } from "@tempmail";
import ISO6391 from "iso-639-1";
import { client } from "~database/datasource";
@ -200,7 +200,5 @@ export default apiRoute<{
email: body.email ?? "",
});
return new Response("", {
status: 200,
});
return response(null, 200);
});

View file

@ -1,7 +1,8 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userToAPI, type UserWithRelations } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
@ -13,25 +14,48 @@ export const meta = applyConfig({
},
auth: {
required: true,
oauthPermissions: ["read:blocks"],
},
});
export default apiRoute(async (req, matchedRoute, extraData) => {
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
const blocks = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
blocking: true,
const { max_id, since_id, limit = 40 } = extraData.parsedRequest;
const { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
client.user,
{
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
blocking: true,
},
},
id: {
lt: max_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
},
include: userRelations,
});
req,
);
return jsonResponse(blocks.map((u) => userToAPI(u)));
return jsonResponse(
blocks.map((u) => userToAPI(u)),
200,
{
Link: link,
},
);
});

View file

@ -1,7 +1,11 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { statusToAPI } from "~database/entities/Status";
import {
statusToAPI,
type StatusWithRelations,
} from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({
@ -32,35 +36,29 @@ export default apiRoute<{
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,
const { objects, link } = await fetchTimeline<StatusWithRelations>(
client.status,
{
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
likes: {
some: {
likerId: user.id,
},
},
},
include: statusAndUserRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
},
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"`,
);
}
req,
);
return jsonResponse(
await Promise.all(
@ -68,7 +66,7 @@ export default apiRoute<{
),
200,
{
Link: linkHeader.join(", "),
Link: link,
},
);
});

View file

@ -1,7 +1,8 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userToAPI, type UserWithRelations } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
@ -32,42 +33,36 @@ export default apiRoute<{
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,
const { objects, link } = await fetchTimeline<UserWithRelations>(
client.user,
{
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
relationships: {
some: {
subjectId: user.id,
requested: true,
},
},
},
include: userRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
},
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"`,
);
}
req,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
Link: link,
},
);
});

View file

@ -1,5 +1,5 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { errorResponse, jsonResponse, response } from "@response";
import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager";
import { client } from "~database/datasource";
@ -52,9 +52,7 @@ export default apiRoute<{
if (attachment.url) {
return jsonResponse(attachmentToAPI(attachment));
}
return new Response(null, {
status: 206,
});
return response(null, 206);
}
case "PUT": {
const { description, thumbnail } = extraData.parsedRequest;

View file

@ -1,7 +1,8 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userToAPI, type UserWithRelations } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({
@ -13,25 +14,40 @@ export const meta = applyConfig({
},
auth: {
required: true,
oauthPermissions: ["read:mutes"],
},
});
export default apiRoute(async (req, matchedRoute, extraData) => {
export default apiRoute<{
max_id?: string;
since_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { max_id, since_id, limit = 40 } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
const blocks = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
muting: true,
const { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
client.user,
{
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
muting: true,
},
},
id: {
lt: max_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
},
include: userRelations,
});
req,
);
return jsonResponse(blocks.map((u) => userToAPI(u)));
});

View file

@ -1,5 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import type { Prisma } from "@prisma/client";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { notificationToAPI } from "~database/entities/Notification";
import {
@ -50,53 +52,49 @@ export default apiRoute<{
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,
const { objects, link } = await fetchTimeline<
Prisma.NotificationGetPayload<{
include: {
account: {
include: typeof userRelations;
};
status: {
include: typeof statusAndUserRelations;
};
};
}>
>(
client.notification,
{
where: {
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
accountId: account_id,
},
type: {
in: types,
notIn: exclude_types,
include: {
account: {
include: userRelations,
},
status: {
include: statusAndUserRelations,
},
},
accountId: account_id,
orderBy: {
id: "desc",
},
take: Number(limit),
},
include: {
account: {
include: userRelations,
},
status: {
include: statusAndUserRelations,
},
},
orderBy: {
id: "desc",
},
take: Number(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"`,
);
}
req,
);
return jsonResponse(
await Promise.all(objects.map((n) => notificationToAPI(n))),
200,
{
Link: linkHeader.join(", "),
Link: link,
},
);
});

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";

View file

@ -1,8 +1,9 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { isViewableByUser } from "~database/entities/Status";
import { userToAPI } from "~database/entities/User";
import { userToAPI, type UserWithRelations } from "~database/entities/User";
import {
statusAndUserRelations,
userRelations,
@ -42,63 +43,48 @@ export default apiRoute<{
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, min_id, since_id, 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);
const objects = await client.user.findMany({
where: {
likes: {
some: {
likedId: status.id,
const { objects, link } = await fetchTimeline<UserWithRelations>(
client.user,
{
where: {
likes: {
some: {
likedId: status.id,
},
},
id: {
lt: max_id,
gte: since_id,
gt: min_id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
include: {
...userRelations,
likes: {
where: {
likedId: status.id,
include: {
...userRelations,
likes: {
where: {
likedId: status.id,
},
},
},
take: Number(limit),
orderBy: {
id: "desc",
},
},
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[0].id}&limit=${limit}>; rel="next"`,
);
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`,
);
}
req,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
Link: link,
},
);
});

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
@ -58,7 +57,10 @@ export default apiRoute<{
authorId: user.id,
reblogId: status.id,
isReblog: true,
uri: `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
uri: new URL(
`/statuses/FAKE-${crypto.randomUUID()}`,
config.http.base_url,
).toString(),
visibility,
sensitive: false,
},
@ -68,7 +70,10 @@ export default apiRoute<{
await client.status.update({
where: { id: newReblog.id },
data: {
uri: `${config.http.base_url}/statuses/${newReblog.id}`,
uri: new URL(
`/statuses/${newReblog.id}`,
config.http.base_url,
).toString(),
},
include: statusAndUserRelations,
});
@ -89,7 +94,10 @@ export default apiRoute<{
await statusToAPI(
{
...newReblog,
uri: `${config.http.base_url}/statuses/${newReblog.id}`,
uri: new URL(
`/statuses/${newReblog.id}`,
config.http.base_url,
).toString(),
},
user,
),

View file

@ -1,8 +1,9 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { isViewableByUser } from "~database/entities/Status";
import { userToAPI } from "~database/entities/User";
import { type UserWithRelations, userToAPI } from "~database/entities/User";
import {
statusAndUserRelations,
userRelations,
@ -53,53 +54,43 @@ export default apiRoute<{
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,
const { objects, link } = await fetchTimeline<UserWithRelations>(
client.user,
{
where: {
statuses: {
some: {
reblogId: status.id,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
id: {
lt: max_id ?? undefined,
gte: since_id ?? undefined,
gt: min_id ?? undefined,
},
},
include: {
...userRelations,
statuses: {
where: {
reblogId: status.id,
include: {
...userRelations,
statuses: {
where: {
reblogId: status.id,
},
include: statusAndUserRelations,
},
include: statusAndUserRelations,
},
take: Number(limit),
orderBy: {
id: "desc",
},
},
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[0].id}&limit=${limit}>; rel="next"`,
);
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`,
);
}
req,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: linkHeader.join(", "),
Link: link,
},
);
});

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";

View file

@ -1,9 +1,9 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse } from "@response";
import { errorResponse, response } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/media/:id",
route: "/api/v1/media/:id",
ratelimits: {
max: 100,
duration: 60,
@ -35,11 +35,9 @@ export default apiRoute(async (req, matchedRoute) => {
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}`,
},
return response(buffer, 200, {
"Content-Type": file.type || "application/octet-stream",
"Content-Length": `${file.size - start}`,
"Content-Range": `bytes ${start}-${end}/${file.size}`,
});
});

View file

@ -157,7 +157,6 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
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");
const code = randomBytes(32).toString("hex");

View file

@ -51,14 +51,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
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}`
author: new URL("/users/actor", config.http.base_url).toString(),
next: statuses.length === 20
? new URL(`/users/${uuid}/outbox?page=${pageNumber + 1}`, config.http.base_url).toString(),
prev: pageNumber > 1
? new URL(`/users/${uuid}/outbox?page=${pageNumber - 1}`, config.http.base_url).toString()
: undefined,
items: statuses.map((s) => statusToLysand(s)),
});

View file

@ -172,7 +172,7 @@ describe("API Tests", () => {
expect(account.statuses_count).toBe(0);
expect(account.note).toBe("");
expect(account.url).toBe(
`${config.http.base_url}/users/${user.id}`,
new URL(`/users/${user.id}`, config.http.base_url).toString(),
);
expect(account.avatar).toBeDefined();
expect(account.avatar_static).toBeDefined();

View file

@ -136,7 +136,6 @@ describe("API Tests", () => {
"application/json",
);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
status = (await response.json()) as APIStatus;
expect(status.content).toContain("Hello, world!");
expect(status.visibility).toBe("public");
@ -184,7 +183,6 @@ describe("API Tests", () => {
"application/json",
);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
status2 = (await response.json()) as APIStatus;
expect(status2.content).toContain("This is a reply!");
expect(status2.visibility).toBe("public");

View file

@ -40,7 +40,6 @@ describe("POST /api/v1/apps/", () => {
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json = await response.json();
expect(json).toEqual({
@ -53,9 +52,7 @@ describe("POST /api/v1/apps/", () => {
vapid_link: null,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
client_id = json.client_id;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
client_secret = json.client_secret;
});
});
@ -111,7 +108,6 @@ describe("POST /oauth/token/", () => {
}),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json = await response.json();
expect(response.status).toBe(200);
@ -123,7 +119,6 @@ describe("POST /oauth/token/", () => {
created_at: expect.any(Number),
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
token = json;
});
});

View file

@ -9,7 +9,6 @@ export const applyConfig = (routeMeta: APIRouteMeta) => {
newMeta.ratelimits.duration *= config.ratelimits.duration_coeff;
newMeta.ratelimits.max *= config.ratelimits.max_coeff;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (config.custom_ratelimits[routeMeta.route]) {
newMeta.ratelimits = config.custom_ratelimits[routeMeta.route];
}

View file

@ -1,4 +1,4 @@
import { config } from "config-manager";
export const oauthRedirectUri = (issuer: string) =>
`${config.http.base_url}/oauth/callback/${issuer}`;
new URL(`/oauth/callback/${issuer}`, config.http.base_url).toString();

View file

@ -1,12 +1,12 @@
import type { APActivity, APObject } from "activitypub-types";
import type { NodeObject } from "jsonld";
export const jsonResponse = (
data: object,
export const response = (
data: BodyInit | null = null,
status = 200,
headers: Record<string, string> = {},
) => {
return new Response(JSON.stringify(data), {
return new Response(data, {
headers: {
"Content-Type": "application/json",
"X-Frame-Options": "DENY",
@ -27,12 +27,32 @@ export const jsonResponse = (
});
};
export const clientResponse = (
data: BodyInit | null = null,
status = 200,
headers: Record<string, string> = {},
) => {
return response(data, status, {
...headers,
"Content-Security-Policy":
"default-src 'none'; frame-ancestors 'none'; form-action 'none'; connect-src 'self' blob: https: wss:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; media-src 'self'; frame-src 'none'; worker-src 'self'; manifest-src 'self'; prefetch-src 'self'; base-uri 'none';",
});
};
export const jsonResponse = (
data: object,
status = 200,
headers: Record<string, string> = {},
) => {
return response(JSON.stringify(data), status, {
"Content-Type": "application/json",
...headers,
});
};
export const xmlResponse = (data: string, status = 200) => {
return new Response(data, {
headers: {
"Content-Type": "application/xml",
},
status,
return response(data, status, {
"Content-Type": "application/xml",
});
};
@ -40,11 +60,8 @@ export const jsonLdResponse = (
data: NodeObject | APActivity | APObject,
status = 200,
) => {
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/activity+json",
},
status,
return response(JSON.stringify(data), status, {
"Content-Type": "application/activity+json",
});
};
@ -56,3 +73,9 @@ export const errorResponse = (error: string, status = 500) => {
status,
);
};
export const redirect = (url: string | URL, status = 302) => {
return response(null, status, {
Location: url.toString(),
});
};

View file

@ -1,8 +1,14 @@
import type { Status, User, Prisma } from "@prisma/client";
import type { Status, User, Prisma, Notification } from "@prisma/client";
export async function fetchTimeline<T extends User | Status>(
model: Prisma.StatusDelegate | Prisma.UserDelegate,
args: Prisma.StatusFindManyArgs | Prisma.UserFindManyArgs,
export async function fetchTimeline<T extends User | Status | Notification>(
model:
| Prisma.StatusDelegate
| Prisma.UserDelegate
| Prisma.NotificationDelegate,
args:
| Prisma.StatusFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.NotificationFindManyArgs,
req: Request,
) {
// BEFORE: Before in a top-to-bottom order, so the most recent posts