Switch all routes to use Zod for strict validation

This commit is contained in:
Jesse Wierzbinski 2024-04-14 00:36:25 -10:00
parent 53fa9ca545
commit 0b1c1ba128
No known key found for this signature in database
67 changed files with 2459 additions and 2600 deletions

BIN
bun.lockb

Binary file not shown.

4
cli.ts
View file

@ -5,7 +5,9 @@ import { Parser } from "@json2csv/plainjs";
import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch"; import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch";
import chalk from "chalk"; import chalk from "chalk";
import { CliBuilder, CliCommand } from "cli-parser"; import { CliBuilder, CliCommand } from "cli-parser";
import { CliParameterType } from "cli-parser/cli-builder.type";
import Table from "cli-table"; import Table from "cli-table";
import { config } from "config-manager";
import { type SQL, eq, inArray, isNotNull, isNull, like } from "drizzle-orm"; import { type SQL, eq, inArray, isNotNull, isNull, like } from "drizzle-orm";
import extract from "extract-zip"; import extract from "extract-zip";
import { MediaBackend } from "media-manager"; import { MediaBackend } from "media-manager";
@ -20,8 +22,6 @@ import {
} from "~database/entities/User"; } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { emoji, openIdAccount, status, user } from "~drizzle/schema"; import { emoji, openIdAccount, status, user } from "~drizzle/schema";
import { CliParameterType } from "cli-parser/cli-builder.type";
import { config } from "config-manager";
const args = process.argv; const args = process.argv;

View file

@ -25,7 +25,7 @@ import {
letter, letter,
maybe, maybe,
oneOrMore, oneOrMore,
} from "magic-regexp/further-magic"; } from "magic-regexp";
import { parse } from "marked"; import { parse } from "marked";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { import {

View file

@ -1,9 +1,9 @@
import { config } from "config-manager"; import { config } from "config-manager";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { Client } from "pg"; import { Client } from "pg";
import * as schema from "./schema"; import * as schema from "./schema";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { migrate } from "drizzle-orm/postgres-js/migrator";
export const client = new Client({ export const client = new Client({
host: config.database.host, host: config.database.host,

View file

@ -98,6 +98,7 @@
"oauth4webapi": "^2.4.0", "oauth4webapi": "^2.4.0",
"pg": "^8.11.5", "pg": "^8.11.5",
"request-parser": "workspace:*", "request-parser": "workspace:*",
"sharp": "^0.33.3" "sharp": "^0.33.3",
"zod": "^3.22.4"
} }
} }

View file

@ -58,11 +58,11 @@ describe("RequestParser", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "invalid json", body: "invalid json",
}); });
const result = await new RequestParser(request).toObject<{ const result = new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
}>(); }>();
expect(result).toEqual({}); expect(result).rejects.toThrow();
}); });
describe("should parse form data correctly", () => { describe("should parse form data correctly", () => {

View file

@ -108,8 +108,8 @@ export const processRoute = async (
} }
} }
// Check if Content-Type header is missing in POST, PUT and PATCH requests // Check if Content-Type header is missing if there is a body
if (["POST", "PUT", "PATCH"].includes(request.method)) { if (request.body) {
if (!request.headers.has("Content-Type")) { if (!request.headers.has("Content-Type")) {
return errorResponse( return errorResponse(
`Content-Type header is missing but required on method ${request.method}`, `Content-Type header is missing but required on method ${request.method}`,

View file

@ -120,7 +120,7 @@ describe("Route Processor", () => {
expect(output.status).toBe(401); expect(output.status).toBe(401);
}); });
it("should return a 400 when the Content-Type header is missing in POST, PUT and PATCH requests", async () => { it("should return a 400 when the Content-Type header is missing but there is a body", async () => {
mock.module( mock.module(
"./route", "./route",
() => () =>
@ -147,35 +147,12 @@ describe("Route Processor", () => {
} as MatchedRoute, } as MatchedRoute,
new Request("https://test.com/route", { new Request("https://test.com/route", {
method: "POST", method: "POST",
body: "test",
}), }),
new LogManager(Bun.file("/dev/null")), new LogManager(Bun.file("/dev/null")),
); );
expect(output.status).toBe(400); expect(output.status).toBe(400);
const output2 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PUT",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output2.status).toBe(400);
const output3 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PATCH",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output3.status).toBe(400);
}); });
it("should return a 400 when the request could not be parsed", async () => { it("should return a 400 when the request could not be parsed", async () => {

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;

View file

@ -1,5 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import {
findFirstUser, findFirstUser,
@ -20,41 +22,50 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
reblogs: z.coerce.boolean().optional(),
notify: z.coerce.boolean().optional(),
languages: z
.array(z.enum(ISO6391.getAllCodes() as [string, ...string[]]))
.optional(),
});
/** /**
* Follow a user * Follow a user
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
reblogs?: boolean; async (req, matchedRoute, extraData) => {
notify?: boolean; const id = matchedRoute.params.id;
languages?: string[]; if (!id.match(idValidator)) {
}>(async (req, matchedRoute, extraData) => { return errorResponse("Invalid ID, must be of type UUIDv7", 404);
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 otherUser = await findFirstUser({ const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id), where: (user, { eq }) => eq(user.id, id),
}); });
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, otherUser); let relationship = await getRelationshipToOtherUser(self, otherUser);
if (!relationship.following) { if (!relationship.following) {
relationship = await followRequestUser( relationship = await followRequestUser(
self, self,
otherUser, otherUser,
relationship.id, relationship.id,
reblogs, reblogs,
notify, notify,
languages, languages,
); );
} }
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); },
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type UserWithRelations, type UserWithRelations,
findFirstUser, findFirstUser,
@ -21,50 +22,56 @@ export const meta = applyConfig({
}, },
}); });
const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
});
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
max_id?: string; async (req, matchedRoute, extraData) => {
since_id?: string; const id = matchedRoute.params.id;
min_id?: string; if (!id.match(idValidator)) {
limit?: number; return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}>(async (req, matchedRoute, extraData) => { }
const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({ const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id), where: (user, { eq }) => eq(user.id, id),
}); });
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); if (!otherUser) return errorResponse("User not found", 404);
if (!otherUser) return errorResponse("User not found", 404); const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-ignore
where: (follower, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(follower.id, max_id) : undefined,
since_id ? gte(follower.id, since_id) : undefined,
min_id ? gt(follower.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${otherUser.id} AND "Relationship"."objectId" = ${follower.id} AND "Relationship"."following" = true)`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
const { objects, link } = await fetchTimeline<UserWithRelations>( return jsonResponse(
findManyUsers, await Promise.all(objects.map((object) => userToAPI(object))),
{ 200,
// @ts-ignore {
where: (follower, { and, lt, gt, gte, eq, sql }) => Link: link,
and( },
max_id ? lt(follower.id, max_id) : undefined, );
since_id ? gte(follower.id, since_id) : undefined, },
min_id ? gt(follower.id, min_id) : undefined, );
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${otherUser.id} AND "Relationship"."objectId" = ${follower.id} AND "Relationship"."following" = true)`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
},
req,
);
return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))),
200,
{
Link: link,
},
);
});

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type UserWithRelations, type UserWithRelations,
findFirstUser, findFirstUser,
@ -21,50 +22,56 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
});
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
max_id?: string; async (req, matchedRoute, extraData) => {
since_id?: string; const id = matchedRoute.params.id;
min_id?: string; if (!id.match(idValidator)) {
limit?: number; return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}>(async (req, matchedRoute, extraData) => { }
const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({ const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id), where: (user, { eq }) => eq(user.id, id),
}); });
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); if (!otherUser) return errorResponse("User not found", 404);
if (!otherUser) return errorResponse("User not found", 404); const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-ignore
where: (following, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(following.id, max_id) : undefined,
since_id ? gte(following.id, since_id) : undefined,
min_id ? gt(following.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${following.id} AND "Relationship"."objectId" = ${otherUser.id} AND "Relationship"."following" = true)`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
const { objects, link } = await fetchTimeline<UserWithRelations>( return jsonResponse(
findManyUsers, await Promise.all(objects.map((object) => userToAPI(object))),
{ 200,
// @ts-ignore {
where: (following, { and, lt, gt, gte, eq, sql }) => Link: link,
and( },
max_id ? lt(following.id, max_id) : undefined, );
since_id ? gte(following.id, since_id) : undefined, },
min_id ? gt(following.id, min_id) : undefined, );
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${following.id} AND "Relationship"."objectId" = ${otherUser.id} AND "Relationship"."following" = true)`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
},
req,
);
return jsonResponse(
await Promise.all(objects.map((object) => userToAPI(object))),
200,
{
Link: link,
},
);
});

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { findFirstUser, userToAPI } from "~database/entities/User"; import { findFirstUser, userToAPI } from "~database/entities/User";
@ -20,13 +20,8 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// Check if ID is valid UUIDv7 if (!id.match(idValidator)) {
if ( return errorResponse("Invalid ID, must be of type UUIDv7", 404);
!id.match(
/^[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
)
) {
return errorResponse("Invalid ID", 404);
} }
const { user } = extraData.auth; const { user } = extraData.auth;

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
import { import {
findFirstUser, findFirstUser,
@ -22,46 +23,58 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
notifications: z.coerce.boolean().optional(),
duration: z
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
});
/** /**
* Mute a user * Mute a user
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
notifications: boolean; async (req, matchedRoute, extraData) => {
duration: number; const id = matchedRoute.params.id;
}>(async (req, matchedRoute, extraData) => { if (!id.match(idValidator)) {
const id = matchedRoute.params.id; return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { notifications, duration } = extraData.parsedRequest; const { notifications, duration } = extraData.parsedRequest;
const user = await findFirstUser({ const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, id), where: (user, { eq }) => eq(user.id, id),
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
const foundRelationship = await getRelationshipToOtherUser(self, user); const foundRelationship = await getRelationshipToOtherUser(self, user);
if (!foundRelationship.muting) { if (!foundRelationship.muting) {
foundRelationship.muting = true; foundRelationship.muting = true;
} }
if (notifications ?? true) { if (notifications ?? true) {
foundRelationship.mutingNotifications = true; foundRelationship.mutingNotifications = true;
} }
await db await db
.update(relationship) .update(relationship)
.set({ .set({
muting: true, muting: true,
mutingNotifications: notifications ?? true, mutingNotifications: notifications ?? true,
}) })
.where(eq(relationship.id, foundRelationship.id)); .where(eq(relationship.id, foundRelationship.id));
// TODO: Implement duration // TODO: Implement duration
return jsonResponse(relationshipToAPI(foundRelationship)); return jsonResponse(relationshipToAPI(foundRelationship));
}); },
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -8,6 +8,7 @@ import {
} from "~database/entities/User"; } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { relationship } from "~drizzle/schema"; import { relationship } from "~drizzle/schema";
import { z } from "zod";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -22,37 +23,47 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
comment: z.string().min(0).max(5000).optional(),
});
/** /**
* Sets a user note * Sets a user note
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
comment: string; async (req, matchedRoute, extraData) => {
}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id;
const id = matchedRoute.params.id; if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
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 otherUser = await findFirstUser({ const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id), where: (user, { eq }) => eq(user.id, id),
}); });
if (!otherUser) return errorResponse("User not found", 404); if (!otherUser) return errorResponse("User not found", 404);
// Check if already following // Check if already following
const foundRelationship = await getRelationshipToOtherUser(self, otherUser); const foundRelationship = await getRelationshipToOtherUser(
self,
otherUser,
);
foundRelationship.note = comment ?? ""; foundRelationship.note = comment ?? "";
await db await db
.update(relationship) .update(relationship)
.set({ .set({
note: foundRelationship.note, note: foundRelationship.note,
}) })
.where(eq(relationship.id, foundRelationship.id)); .where(eq(relationship.id, foundRelationship.id));
return jsonResponse(relationshipToAPI(foundRelationship)); return jsonResponse(relationshipToAPI(foundRelationship));
}); },
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type StatusWithRelations, type StatusWithRelations,
findManyStatuses, findManyStatuses,
@ -21,41 +22,79 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
only_media: z.coerce.boolean().optional(),
exclude_replies: z.coerce.boolean().optional(),
exclude_reblogs: z.coerce.boolean().optional(),
pinned: z.coerce.boolean().optional(),
tagged: z.string().optional(),
});
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
max_id?: string; async (req, matchedRoute, extraData) => {
since_id?: string; const id = matchedRoute.params.id;
min_id?: string; if (!id.match(idValidator)) {
limit?: string; return errorResponse("Invalid ID, must be of type UUIDv7", 404);
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;
// TODO: Add pinned // TODO: Add pinned
const { const {
max_id, max_id,
min_id, min_id,
since_id, since_id,
limit = "20", limit,
exclude_reblogs, exclude_reblogs,
only_media = false, only_media,
pinned, pinned,
} = extraData.parsedRequest; } = extraData.parsedRequest;
const user = await findFirstUser({ const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, id), where: (user, { eq }) => eq(user.id, id),
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
if (pinned) {
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-ignore
where: (status, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
eq(status.authorId, id),
sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`,
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
);
return jsonResponse(
await Promise.all(
objects.map((status) => statusToAPI(status, user)),
),
200,
{
Link: link,
},
);
}
if (pinned) {
const { objects, link } = await fetchTimeline<StatusWithRelations>( const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses, findManyStatuses,
{ {
@ -66,13 +105,14 @@ export default apiRoute<{
since_id ? gte(status.id, since_id) : undefined, since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined, min_id ? gt(status.id, min_id) : undefined,
eq(status.authorId, id), eq(status.authorId, id),
sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`,
only_media only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})` ? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined, : undefined,
exclude_reblogs ? eq(status.reblogId, null) : undefined,
), ),
// @ts-expect-error Yes I KNOW the types are wrong // @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id), orderBy: (status, { desc }) => desc(status.id),
limit,
}, },
req, req,
); );
@ -86,34 +126,5 @@ export default apiRoute<{
Link: link, Link: link,
}, },
); );
} },
);
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-ignore
where: (status, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
eq(status.authorId, id),
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
exclude_reblogs ? eq(status.reblogId, null) : undefined,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
req,
);
return jsonResponse(
await Promise.all(objects.map((status) => statusToAPI(status, user))),
200,
{
Link: link,
},
);
});

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -24,6 +24,9 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship"; import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth; const { user: self } = extraData.auth;

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { findManyUsers, userToAPI } from "~database/entities/User"; import { findManyUsers, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
@ -16,69 +17,68 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
id: z.array(z.string().regex(idValidator)).min(1).max(10),
});
/** /**
* Find familiar followers (followers of a user that you also follow) * Find familiar followers (followers of a user that you also follow)
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
id: string[]; async (req, matchedRoute, extraData) => {
}>(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 const idFollowerRelationships = await db.query.relationship.findMany({
if (!ids || ids.length < 1 || ids.length > 10) { columns: {
return errorResponse("Number of ids must be between 1 and 10", 422); ownerId: true,
} },
where: (relationship, { inArray, and, eq }) =>
const idFollowerRelationships = await db.query.relationship.findMany({ and(
columns: { inArray(relationship.subjectId, ids),
ownerId: true, eq(relationship.following, true),
},
where: (relationship, { inArray, and, eq }) =>
and(
inArray(relationship.subjectId, ids),
eq(relationship.following, true),
),
});
if (idFollowerRelationships.length === 0) {
return jsonResponse([]);
}
// Find users that you follow in idFollowerRelationships
const relevantRelationships = await db.query.relationship.findMany({
columns: {
subjectId: true,
},
where: (relationship, { inArray, and, eq }) =>
and(
eq(relationship.ownerId, self.id),
inArray(
relationship.subjectId,
idFollowerRelationships.map((f) => f.ownerId),
), ),
eq(relationship.following, true), });
),
});
if (relevantRelationships.length === 0) { if (idFollowerRelationships.length === 0) {
return jsonResponse([]); return jsonResponse([]);
} }
const finalUsers = await findManyUsers({ // Find users that you follow in idFollowerRelationships
where: (user, { inArray }) => const relevantRelationships = await db.query.relationship.findMany({
inArray( columns: {
user.id, subjectId: true,
relevantRelationships.map((r) => r.subjectId), },
), where: (relationship, { inArray, and, eq }) =>
}); and(
eq(relationship.ownerId, self.id),
inArray(
relationship.subjectId,
idFollowerRelationships.map((f) => f.ownerId),
),
eq(relationship.following, true),
),
});
if (finalUsers.length === 0) { if (relevantRelationships.length === 0) {
return jsonResponse([]); return jsonResponse([]);
} }
return jsonResponse(finalUsers.map((o) => userToAPI(o))); const finalUsers = await findManyUsers({
}); where: (user, { inArray }) =>
inArray(
user.id,
relevantRelationships.map((r) => r.subjectId),
),
});
if (finalUsers.length === 0) {
return jsonResponse([]);
}
return jsonResponse(finalUsers.map((o) => userToAPI(o)));
},
);

View file

@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api";
import { jsonResponse, response } from "@response"; import { jsonResponse, response } from "@response";
import { tempmailDomains } from "@tempmail"; import { tempmailDomains } from "@tempmail";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod";
import { createNewLocalUser, findFirstUser } from "~database/entities/User"; import { createNewLocalUser, findFirstUser } from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
@ -17,193 +18,202 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ // No validation on the Zod side as we need to do custom validation
username: string; export const schema = z.object({
email: string; username: z.string(),
password: string; email: z.string(),
agreement: boolean; password: z.string(),
locale: string; agreement: z.boolean(),
reason: string; locale: z.string(),
}>(async (req, matchedRoute, extraData) => { reason: z.string(),
// TODO: Add Authorization check });
const body = extraData.parsedRequest; export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
// TODO: Add Authorization check
const config = await extraData.configManager.getConfig(); const body = extraData.parsedRequest;
if (!config.signups.registration) { const config = await extraData.configManager.getConfig();
return jsonResponse(
{ if (!config.signups.registration) {
error: "Registration is disabled", 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: [],
}, },
422, };
);
}
const errors: { // Check if fields are blank
details: Record< for (const value of [
string, "username",
{ "email",
error: "password",
| "ERR_BLANK" "agreement",
| "ERR_INVALID" "locale",
| "ERR_TOO_LONG" "reason",
| "ERR_TOO_SHORT" ]) {
| "ERR_BLOCKED" // @ts-expect-error We don't care about typing here
| "ERR_TAKEN" if (!body[value]) {
| "ERR_RESERVED" errors.details[value].push({
| "ERR_ACCEPTED" error: "ERR_BLANK",
| "ERR_INCLUSION"; description: `can't be blank`,
description: string; });
}[] }
>; }
} = {
details: {
password: [],
username: [],
email: [],
agreement: [],
locale: [],
reason: [],
},
};
// Check if fields are blank // Check if username is valid
for (const value of [ if (!body.username?.match(/^[a-z0-9_]+$/))
"username", errors.details.username.push({
"email", error: "ERR_INVALID",
"password", description:
"agreement", "must only contain lowercase letters, numbers, and underscores",
"locale", });
"reason",
]) { // Check if username doesnt match filters
// @ts-expect-error We don't care about typing here if (
if (!body[value]) { config.filters.username.some((filter) =>
errors.details[value].push({ 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 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 taken
if (
await findFirstUser({
where: (user, { eq }) => eq(user.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 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",
});
if (!body.locale)
errors.details.locale.push({
error: "ERR_BLANK", error: "ERR_BLANK",
description: `can't be blank`, description: `can't be blank`,
}); });
if (!ISO6391.validate(body.locale ?? ""))
errors.details.locale.push({
error: "ERR_INVALID",
description: "must be a valid ISO 639-1 code",
});
// If any errors are present, return them
if (Object.values(errors.details).some((value) => value.length > 0)) {
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
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,
);
} }
}
// Check if username is valid await createNewLocalUser({
if (!body.username?.match(/^[a-z0-9_]+$/)) username: body.username ?? "",
errors.details.username.push({ password: body.password ?? "",
error: "ERR_INVALID", email: body.email ?? "",
description:
"must only contain lowercase letters, numbers, and underscores",
}); });
// Check if username doesnt match filters return response(null, 200);
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 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 taken
if (
await findFirstUser({
where: (user, { eq }) => eq(user.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 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",
});
if (!body.locale)
errors.details.locale.push({
error: "ERR_BLANK",
description: `can't be blank`,
});
if (!ISO6391.validate(body.locale ?? ""))
errors.details.locale.push({
error: "ERR_INVALID",
description: "must be a valid ISO 639-1 code",
});
// If any errors are present, return them
if (Object.values(errors.details).some((value) => value.length > 0)) {
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
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 ?? "",
});
return response(null, 200);
});

View file

@ -21,45 +21,6 @@ afterAll(async () => {
// /api/v1/accounts/lookup // /api/v1/accounts/lookup
describe(meta.route, () => { describe(meta.route, () => {
test("should return 400 if acct is missing", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(400);
});
test("should return 400 if acct is empty", async () => {
const response = await sendTestRequest(
new Request(new URL(`${meta.route}?acct=`, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(400);
});
test("should return 404 if acct is invalid", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?acct=invalid`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(404);
});
test("should return 200 with users", async () => { test("should return 200 with users", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(

View file

@ -1,5 +1,17 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import {
anyOf,
charIn,
createRegExp,
digit,
exactly,
letter,
maybe,
oneOrMore,
global,
} from "magic-regexp";
import { z } from "zod";
import { import {
findFirstUser, findFirstUser,
resolveWebFinger, resolveWebFinger,
@ -19,52 +31,71 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
acct: string; acct: z.string().min(1).max(512),
}>(async (req, matchedRoute, extraData) => { });
const { acct } = extraData.parsedRequest;
if (!acct) { export default apiRoute<typeof meta, typeof schema>(
return errorResponse("Invalid acct parameter", 400); async (req, matchedRoute, extraData) => {
} const { acct } = extraData.parsedRequest;
// Check if acct is matching format username@domain.com or @username@domain.com if (!acct) {
const accountMatches = acct return errorResponse("Invalid acct parameter", 400);
?.trim()
.match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g);
if (accountMatches) {
// Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) {
accountMatches[0] = accountMatches[0].slice(1);
} }
const [username, domain] = accountMatches[0].split("@"); // Check if acct is matching format username@domain.com or @username@domain.com
const foundAccount = await resolveWebFinger(username, domain).catch( const accountMatches = acct?.trim().match(
(e) => { createRegExp(
console.error(e); maybe("@"),
return null; oneOrMore(
}, anyOf(letter.lowercase, digit, charIn("-")),
).groupedAs("username"),
exactly("@"),
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
"domain",
),
[global],
),
); );
if (foundAccount) { if (accountMatches) {
return jsonResponse(userToAPI(foundAccount)); // Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) {
accountMatches[0] = accountMatches[0].slice(1);
}
const [username, domain] = accountMatches[0].split("@");
const foundAccount = await resolveWebFinger(username, domain).catch(
(e) => {
console.error(e);
return null;
},
);
if (foundAccount) {
return jsonResponse(userToAPI(foundAccount));
}
return errorResponse("Account not found", 404);
} }
return errorResponse("Account not found", 404); let username = acct;
} if (username.startsWith("@")) {
username = username.slice(1);
}
let username = acct; const account = await findFirstUser({
if (username.startsWith("@")) { where: (user, { eq }) => eq(user.username, username),
username = username.slice(1); });
}
const account = await findFirstUser({ if (account) {
where: (user, { eq }) => eq(user.username, username), return jsonResponse(userToAPI(account));
}); }
if (account) { return errorResponse(
return jsonResponse(userToAPI(account)); `Account with username ${username} not found`,
} 404,
);
return errorResponse(`Account with username ${username} not found`, 404); },
}); );

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
@ -20,47 +21,48 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
id: z.array(z.string().regex(idValidator)).min(1).max(10),
});
/** /**
* Find relationships * Find relationships
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
id: string[]; async (req, matchedRoute, extraData) => {
}>(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 const relationships = await db.query.relationship.findMany({
if (!ids || ids.length < 1 || ids.length > 10) { where: (relationship, { inArray, and, eq }) =>
return errorResponse("Number of ids must be between 1 and 10", 422); and(
} inArray(relationship.subjectId, ids),
eq(relationship.ownerId, self.id),
),
});
const relationships = await db.query.relationship.findMany({ // Find IDs that dont have a relationship
where: (relationship, { inArray, and, eq }) => const missingIds = ids.filter(
and( (id) => !relationships.some((r) => r.subjectId === id),
inArray(relationship.subjectId, ids), );
eq(relationship.ownerId, self.id),
),
});
// Find IDs that dont have a relationship // Create the missing relationships
const missingIds = ids.filter( for (const id of missingIds) {
(id) => !relationships.some((r) => r.subjectId === id), const relationship = await createNewRelationship(self, {
); id,
} as User);
// Create the missing relationships relationships.push(relationship);
for (const id of missingIds) { }
const relationship = await createNewRelationship(self, { id } as User);
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 return jsonResponse(relationships.map((r) => relationshipToAPI(r)));
relationships.sort( },
(a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId), );
);
return jsonResponse(relationships.map((r) => relationshipToAPI(r)));
});

View file

@ -8,7 +8,6 @@ import {
sendTestRequest, sendTestRequest,
} from "~tests/utils"; } from "~tests/utils";
import type { APIAccount } from "~types/entities/account"; import type { APIAccount } from "~types/entities/account";
import type { APIStatus } from "~types/entities/status";
import { meta } from "./index"; import { meta } from "./index";
await deleteOldTestUsers(); await deleteOldTestUsers();
@ -21,66 +20,6 @@ afterAll(async () => {
// /api/v1/accounts/search // /api/v1/accounts/search
describe(meta.route, () => { describe(meta.route, () => {
test("should return 400 if q is missing", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(400);
});
test("should return 400 if q is empty", async () => {
const response = await sendTestRequest(
new Request(new URL(`${meta.route}?q=`, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?q=${users[0].username}&limit=0`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?q=${users[0].username}&limit=100`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 200 with users", async () => { test("should return 200 with users", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(

View file

@ -1,6 +1,18 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import {
createRegExp,
maybe,
oneOrMore,
anyOf,
letter,
digit,
charIn,
exactly,
global,
} from "magic-regexp";
import { z } from "zod";
import { import {
type UserWithRelations, type UserWithRelations,
findManyUsers, findManyUsers,
@ -22,59 +34,75 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
q?: string; q: z
limit?: number; .string()
offset?: number; .min(1)
resolve?: boolean; .max(512)
following?: boolean; .regex(
}>(async (req, matchedRoute, extraData) => { createRegExp(
// TODO: Add checks for disabled or not email verified accounts maybe("@"),
const { oneOrMore(
following = false, anyOf(letter.lowercase, digit, charIn("-")),
limit = 40, ).groupedAs("username"),
offset, maybe(
resolve, exactly("@"),
q, oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
} = extraData.parsedRequest; "domain",
const { user: self } = extraData.auth;
if (!self && following) return errorResponse("Unauthorized", 401);
if (limit < 1 || limit > 80) {
return errorResponse("Limit must be between 1 and 80", 400);
}
if (!q) {
return errorResponse("Query is required", 400);
}
const [username, host] = q?.split("@") || [];
const accounts: UserWithRelations[] = [];
if (resolve && username && host) {
const resolvedUser = await resolveWebFinger(username, host);
if (resolvedUser) {
accounts.push(resolvedUser);
}
} else {
accounts.push(
...(await findManyUsers({
where: (account, { or, like }) =>
or(
like(account.displayName, `%${q}%`),
like(account.username, `%${q}%`),
following
? sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${account.id} AND "Relationship"."following" = true)`
: undefined,
), ),
offset: Number(offset), ),
})), [global],
); ),
} ),
limit: z.coerce.number().int().min(1).max(80).default(40),
return jsonResponse(accounts.map((acct) => userToAPI(acct))); offset: z.coerce.number().int().optional(),
resolve: z.coerce.boolean().optional(),
following: z.coerce.boolean().optional(),
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts
const {
following = false,
limit,
offset,
resolve,
q,
} = extraData.parsedRequest;
const { user: self } = extraData.auth;
if (!self && following) return errorResponse("Unauthorized", 401);
// Remove any leading @
const [username, host] = q.replace(/^@/, "").split("@");
const accounts: UserWithRelations[] = [];
if (resolve && username && host) {
const resolvedUser = await resolveWebFinger(username, host);
if (resolvedUser) {
accounts.push(resolvedUser);
}
} else {
accounts.push(
...(await findManyUsers({
where: (account, { or, like }) =>
or(
like(account.displayName, `%${q}%`),
like(account.username, `%${q}%`),
following
? sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${account.id} AND "Relationship"."following" = true)`
: undefined,
),
offset,
limit,
})),
);
}
return jsonResponse(accounts.map((acct) => userToAPI(acct)));
},
);

View file

@ -7,11 +7,13 @@ import ISO6391 from "iso-639-1";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { z } from "zod";
import { getUrl } from "~database/entities/Attachment"; import { getUrl } from "~database/entities/Attachment";
import { parseEmojis } from "~database/entities/Emoji"; import { parseEmojis } from "~database/entities/Emoji";
import { findFirstUser, userToAPI } from "~database/entities/User"; import { findFirstUser, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { emojiToUser, user } from "~drizzle/schema"; import { emojiToUser, user } from "~drizzle/schema";
import { config } from "config-manager";
import type { APISource } from "~types/entities/source"; import type { APISource } from "~types/entities/source";
export const meta = applyConfig({ export const meta = applyConfig({
@ -27,45 +29,56 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
display_name: string; display_name: z
note: string; .string()
avatar: File; .min(3)
header: File; .max(config.validation.max_displayname_size)
locked: string; .optional(),
bot: string; note: z.string().min(0).max(config.validation.max_bio_size).optional(),
discoverable: string; avatar: z.instanceof(File).optional(),
"source[privacy]": string; header: z.instanceof(File).optional(),
"source[sensitive]": string; locked: z.boolean().optional(),
"source[language]": string; bot: z.boolean().optional(),
}>(async (req, matchedRoute, extraData) => { discoverable: z.boolean().optional(),
const { user: self } = extraData.auth; "source[privacy]": z
.enum(["public", "unlisted", "private", "direct"])
.optional(),
"source[sensitive]": z.boolean().optional(),
"source[language]": z
.enum(ISO6391.getAllCodes() as [string, ...string[]])
.optional(),
});
if (!self) return errorResponse("Unauthorized", 401); export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
const config = await extraData.configManager.getConfig(); if (!self) return errorResponse("Unauthorized", 401);
const { const config = await extraData.configManager.getConfig();
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 {
display_name,
note,
avatar,
header,
locked,
bot,
discoverable,
"source[privacy]": source_privacy,
"source[sensitive]": source_sensitive,
"source[language]": source_language,
} = extraData.parsedRequest;
const sanitizedDisplayName = display_name ?? ""; /* sanitize(display_name ?? "", { const sanitizedNote = await sanitizeHtml(note ?? "");
const sanitizedDisplayName = display_name ?? ""; /* sanitize(display_name ?? "", {
ALLOWED_TAGS: [], ALLOWED_TAGS: [],
ALLOWED_ATTR: [], ALLOWED_ATTR: [],
}); });
*/ */
/* if (!user.source) { /* if (!user.source) {
user.source = { user.source = {
privacy: "public", privacy: "public",
sensitive: false, sensitive: false,
@ -74,205 +87,153 @@ export default apiRoute<{
}; };
} */ } */
let mediaManager: MediaBackend; let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) { switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL: case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config); mediaManager = new LocalMediaBackend(config);
break; break;
case MediaBackendType.S3: case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config); mediaManager = new S3MediaBackend(config);
break; break;
default: default:
// TODO: Replace with logger // TODO: Replace with logger
throw new Error("Invalid media backend"); 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,
);
} }
// Check if display name doesnt match filters if (display_name) {
if ( // Check if display name doesnt match filters
config.filters.displayname.some((filter) => if (
sanitizedDisplayName.match(filter), config.filters.displayname.some((filter) =>
) sanitizedDisplayName.match(filter),
) { )
return errorResponse("Display name contains blocked words", 422); ) {
return errorResponse(
"Display name contains blocked words",
422,
);
}
self.displayName = sanitizedDisplayName;
} }
// Remove emojis if (note && self.source) {
self.emojis = []; // Check if bio doesnt match filters
if (
config.filters.bio.some((filter) => sanitizedNote.match(filter))
) {
return errorResponse("Bio contains blocked words", 422);
}
self.displayName = sanitizedDisplayName; (self.source as APISource).note = sanitizedNote;
} self.note = await convertTextToHtml(sanitizedNote);
if (note && self.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 (source_privacy && self.source) {
if (config.filters.bio.some((filter) => sanitizedNote.match(filter))) { (self.source as APISource).privacy = source_privacy;
return errorResponse("Bio contains blocked words", 422);
} }
(self.source as APISource).note = sanitizedNote; if (source_sensitive && self.source) {
// TODO: Convert note to HTML (self.source as APISource).sensitive = source_sensitive;
self.note = await convertTextToHtml(sanitizedNote);
}
if (source_privacy && self.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,
);
} }
(self.source as APISource).privacy = source_privacy; if (source_language && self.source) {
} (self.source as APISource).language = source_language;
if (source_sensitive && self.source) {
// Check if within allowed sensitive values
if (source_sensitive !== "true" && source_sensitive !== "false") {
return errorResponse("Sensitive must be a boolean", 422);
} }
(self.source as APISource).sensitive = source_sensitive === "true"; 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 (source_language && self.source) { const { path } = await mediaManager.addFile(avatar);
if (!ISO6391.validate(source_language)) {
return errorResponse( self.avatar = getUrl(path, config);
"Language must be a valid ISO 639-1 code",
422,
);
} }
(self.source as APISource).language = source_language; 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 (avatar) { const { path } = await mediaManager.addFile(header);
// Check if within allowed avatar length (avatar is an image)
if (avatar.size > config.validation.max_avatar_size) { self.header = getUrl(path, config);
return errorResponse(
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
422,
);
} }
const { path } = await mediaManager.addFile(avatar); if (locked) {
self.isLocked = locked;
self.avatar = getUrl(path, 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,
);
} }
const { path } = await mediaManager.addFile(header); if (bot) {
self.isBot = bot;
self.header = getUrl(path, config);
}
if (locked) {
// Check if locked is a boolean
if (locked !== "true" && locked !== "false") {
return errorResponse("Locked must be a boolean", 422);
} }
self.isLocked = locked === "true"; if (discoverable) {
} self.isDiscoverable = discoverable;
if (bot) {
// Check if bot is a boolean
if (bot !== "true" && bot !== "false") {
return errorResponse("Bot must be a boolean", 422);
} }
self.isBot = bot === "true"; // Parse emojis
} const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote);
if (discoverable) { self.emojis = [...displaynameEmojis, ...noteEmojis];
// Check if discoverable is a boolean
if (discoverable !== "true" && discoverable !== "false") {
return errorResponse("Discoverable must be a boolean", 422);
}
self.isDiscoverable = discoverable === "true"; // Deduplicate emojis
} self.emojis = self.emojis.filter(
(emoji, index, self) =>
// Parse emojis self.findIndex((e) => e.id === emoji.id) === index,
);
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote);
self.emojis = [...displaynameEmojis, ...noteEmojis];
// Deduplicate emojis
self.emojis = self.emojis.filter(
(emoji, index, self) =>
self.findIndex((e) => e.id === emoji.id) === index,
);
await db
.update(user)
.set({
displayName: self.displayName,
note: self.note,
avatar: self.avatar,
header: self.header,
isLocked: self.isLocked,
isBot: self.isBot,
isDiscoverable: self.isDiscoverable,
source: self.source || undefined,
})
.where(eq(user.id, self.id));
// Connect emojis, if any
for (const emoji of self.emojis) {
await db
.delete(emojiToUser)
.where(and(eq(emojiToUser.a, emoji.id), eq(emojiToUser.b, self.id)))
.execute();
await db await db
.insert(emojiToUser) .update(user)
.values({ .set({
a: emoji.id, displayName: self.displayName,
b: self.id, note: self.note,
avatar: self.avatar,
header: self.header,
isLocked: self.isLocked,
isBot: self.isBot,
isDiscoverable: self.isDiscoverable,
source: self.source || undefined,
}) })
.execute(); .where(eq(user.id, self.id));
}
const output = await findFirstUser({ // Connect emojis, if any
where: (user, { eq }) => eq(user.id, self.id), for (const emoji of self.emojis) {
}); await db
.delete(emojiToUser)
.where(
and(
eq(emojiToUser.emojiId, emoji.id),
eq(emojiToUser.userId, self.id),
),
)
.execute();
if (!output) return errorResponse("Couldn't edit user", 500); await db
.insert(emojiToUser)
.values({
emojiId: emoji.id,
userId: self.id,
})
.execute();
}
return jsonResponse(userToAPI(output)); const output = await findFirstUser({
}); where: (user, { eq }) => eq(user.id, self.id),
});
if (!output) return errorResponse("Couldn't edit user", 500);
return jsonResponse(userToAPI(output));
},
);

View file

@ -3,6 +3,7 @@ import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { application } from "~drizzle/schema"; import { application } from "~drizzle/schema";
import { z } from "zod";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -16,46 +17,43 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
client_name: z.string().min(1).max(100),
redirect_uris: z.string().min(0).max(2000).url(),
scopes: z.string().min(1).max(200),
website: z.string().min(0).max(2000).url().optional(),
});
/** /**
* Creates a new application to obtain OAuth 2 credentials * Creates a new application to obtain OAuth 2 credentials
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
client_name: string; async (req, matchedRoute, extraData) => {
redirect_uris: string; const { client_name, redirect_uris, scopes, website } =
scopes: string; extraData.parsedRequest;
website: string;
}>(async (req, matchedRoute, extraData) => {
const { client_name, redirect_uris, scopes, website } =
extraData.parsedRequest;
// Check if redirect URI is a valid URI, and also an absolute URI const app = (
if (redirect_uris) { await db
if (!URL.canParse(redirect_uris)) { .insert(application)
return errorResponse("Redirect URI must be a valid URI", 422); .values({
} name: client_name || "",
} redirectUris: redirect_uris || "",
scopes: scopes || "read",
website: website || null,
clientId: randomBytes(32).toString("base64url"),
secret: randomBytes(64).toString("base64url"),
})
.returning()
)[0];
const app = ( return jsonResponse({
await db id: app.id,
.insert(application) name: app.name,
.values({ website: app.website,
name: client_name || "", client_id: app.clientId,
redirectUris: redirect_uris || "", client_secret: app.secret,
scopes: scopes || "read", redirect_uri: app.redirectUris,
website: website || null, vapid_link: app.vapidKey,
clientId: randomBytes(32).toString("base64url"), });
secret: randomBytes(64).toString("base64url"), },
}) );
.returning()
)[0];
return jsonResponse({
id: app.id,
name: app.name,
website: app.website,
client_id: app.clientId,
client_secret: app.secret,
redirect_uri: app.redirectUris,
vapid_link: app.vapidKey,
});
});

View file

@ -19,9 +19,12 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user, token } = extraData.auth; const { user, token } = extraData.auth;
if (!token) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const application = await getFromToken(token); const application = await getFromToken(token);
if (!user) return errorResponse("Unauthorized", 401);
if (!application) return errorResponse("Unauthorized", 401); if (!application) return errorResponse("Unauthorized", 401);
return jsonResponse({ return jsonResponse({

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type UserWithRelations, type UserWithRelations,
findManyUsers, findManyUsers,
@ -20,41 +21,46 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
max_id?: string; max_id: z.string().regex(idValidator).optional(),
since_id?: string; since_id: z.string().regex(idValidator).optional(),
min_id?: string; min_id: z.string().regex(idValidator).optional(),
limit?: number; limit: z.coerce.number().int().min(1).max(80).default(40),
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
const { max_id, since_id, min_id, limit = 40 } = extraData.parsedRequest;
const { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (subject, { lt, gte, gt, and, sql }) =>
and(
max_id ? lt(subject.id, max_id) : undefined,
since_id ? gte(subject.id, since_id) : undefined,
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${subject.id} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."blocking" = true)`,
),
limit: Number(limit),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse(
blocks.map((u) => userToAPI(u)),
200,
{
Link: link,
},
);
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
const { max_id, since_id, min_id, limit } = extraData.parsedRequest;
const { objects: blocks, link } =
await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (subject, { lt, gte, gt, and, sql }) =>
and(
max_id ? lt(subject.id, max_id) : undefined,
since_id ? gte(subject.id, since_id) : undefined,
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${subject.id} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."blocking" = true)`,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse(
blocks.map((u) => userToAPI(u)),
200,
{
Link: link,
},
);
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type StatusWithRelations, type StatusWithRelations,
findManyStatuses, findManyStatuses,
@ -19,46 +20,47 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
max_id?: string; max_id: z.string().regex(idValidator).optional(),
since_id?: string; since_id: z.string().regex(idValidator).optional(),
min_id?: string; min_id: z.string().regex(idValidator).optional(),
limit?: number; limit: z.coerce.number().int().min(1).max(80).default(40),
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
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 (!user) return errorResponse("Unauthorized", 401);
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-ignore
where: (status, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
req,
);
return jsonResponse(
await Promise.all(
objects.map(async (status) => statusToAPI(status, user)),
),
200,
{
Link: link,
},
);
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-ignore
where: (status, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
);
return jsonResponse(
await Promise.all(
objects.map(async (status) => statusToAPI(status, user)),
),
200,
{
Link: link,
},
);
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type UserWithRelations, type UserWithRelations,
findManyUsers, findManyUsers,
@ -19,45 +20,45 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
max_id?: string; max_id: z.string().regex(idValidator).optional(),
since_id?: string; since_id: z.string().regex(idValidator).optional(),
min_id?: string; min_id: z.string().regex(idValidator).optional(),
limit?: number; limit: z.coerce.number().int().min(1).max(80).default(20),
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
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 (!user) return errorResponse("Unauthorized", 401);
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (subject, { lt, gte, gt, and, sql }) =>
and(
max_id ? lt(subject.id, max_id) : undefined,
since_id ? gte(subject.id, since_id) : undefined,
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${subject.id} AND "Relationship"."requested" = true)`,
),
limit: Number(limit),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: link,
},
);
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (subject, { lt, gte, gt, and, sql }) =>
and(
max_id ? lt(subject.id, max_id) : undefined,
since_id ? gte(subject.id, since_id) : undefined,
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${subject.id} AND "Relationship"."requested" = true)`,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: link,
},
);
},
);

View file

@ -1,12 +1,14 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse, response } from "@response"; import { errorResponse, jsonResponse, response } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { z } from "zod";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { attachment } from "~drizzle/schema"; import { attachment } from "~drizzle/schema";
import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { config } from "config-manager";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET", "PUT"], allowedMethods: ["GET", "PUT"],
@ -21,86 +23,97 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
});
/** /**
* Get media information * Get media information
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
thumbnail?: File; async (req, matchedRoute, extraData) => {
description?: string; const { user } = extraData.auth;
focus?: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) { if (!user) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
} }
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const foundAttachment = await db.query.attachment.findFirst({ const foundAttachment = await db.query.attachment.findFirst({
where: (attachment, { eq }) => eq(attachment.id, id), where: (attachment, { eq }) => eq(attachment.id, id),
}); });
if (!foundAttachment) { if (!foundAttachment) {
return errorResponse("Media not found", 404); return errorResponse("Media not found", 404);
} }
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
switch (req.method) {
case "GET": {
if (foundAttachment.url) {
return jsonResponse(attachmentToAPI(foundAttachment));
}
return response(null, 206);
}
case "PUT": {
const { description, thumbnail } = extraData.parsedRequest;
let thumbnailUrl = foundAttachment.thumbnailUrl;
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");
}
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(path, config);
}
const descriptionText =
description || foundAttachment.description;
if (
descriptionText !== foundAttachment.description ||
thumbnailUrl !== foundAttachment.thumbnailUrl
) {
const newAttachment = (
await db
.update(attachment)
.set({
description: descriptionText,
thumbnailUrl,
})
.where(eq(attachment.id, id))
.returning()
)[0];
return jsonResponse(attachmentToAPI(newAttachment));
}
switch (req.method) {
case "GET": {
if (foundAttachment.url) {
return jsonResponse(attachmentToAPI(foundAttachment)); return jsonResponse(attachmentToAPI(foundAttachment));
} }
return response(null, 206);
} }
case "PUT": {
const { description, thumbnail } = extraData.parsedRequest;
let thumbnailUrl = foundAttachment.thumbnailUrl; return errorResponse("Method not allowed", 405);
},
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");
}
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(path, config);
}
const descriptionText = description || foundAttachment.description;
if (
descriptionText !== foundAttachment.description ||
thumbnailUrl !== foundAttachment.thumbnailUrl
) {
const newAttachment = (
await db
.update(attachment)
.set({
description: descriptionText,
thumbnailUrl,
})
.where(eq(attachment.id, id))
.returning()
)[0];
return jsonResponse(attachmentToAPI(newAttachment));
}
return jsonResponse(attachmentToAPI(foundAttachment));
}
}
return errorResponse("Method not allowed", 405);
});

View file

@ -3,11 +3,13 @@ import { errorResponse, jsonResponse } from "@response";
import { encode } from "blurhash"; import { encode } from "blurhash";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { attachment } from "~drizzle/schema"; import { attachment } from "~drizzle/schema";
import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { config } from "config-manager";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -22,134 +24,128 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
file: z.instanceof(File),
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
});
/** /**
* Upload new media * Upload new media
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
file: File; async (req, matchedRoute, extraData) => {
thumbnail?: File; const { user } = extraData.auth;
description?: string;
// TODO: Add focus
focus?: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) { if (!user) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
} }
const { file, thumbnail, description } = extraData.parsedRequest; const { file, thumbnail, description } = extraData.parsedRequest;
if (!file) { const config = await extraData.configManager.getConfig();
return errorResponse("No file provided", 400);
}
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) { if (
return errorResponse( config.validation.enforce_mime_types &&
`File too large, max size is ${config.validation.max_media_size} bytes`, !config.validation.allowed_mime_types.includes(file.type)
413, ) {
); return errorResponse("Invalid file type", 415);
} }
if ( const sha256 = new Bun.SHA256();
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return errorResponse("Invalid file type", 415);
}
if ( const isImage = file.type.startsWith("image/");
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 metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const isImage = file.type.startsWith("image/"); const blurhash = await new Promise<string | null>((resolve) => {
(async () =>
sharp(await file.arrayBuffer())
.raw()
.ensureAlpha()
.toBuffer((err, buffer) => {
if (err) {
resolve(null);
return;
}
const metadata = isImage try {
? await sharp(await file.arrayBuffer()).metadata() resolve(
: null; encode(
new Uint8ClampedArray(buffer),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
) as string,
);
} catch {
resolve(null);
}
}))();
});
const blurhash = await new Promise<string | null>((resolve) => { let url = "";
(async () =>
sharp(await file.arrayBuffer())
.raw()
.ensureAlpha()
.toBuffer((err, buffer) => {
if (err) {
resolve(null);
return;
}
try { let mediaManager: MediaBackend;
resolve(
encode(
new Uint8ClampedArray(buffer),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
) as string,
);
} catch {
resolve(null);
}
}))();
});
let url = ""; 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");
}
let mediaManager: MediaBackend; const { path } = await mediaManager.addFile(file);
switch (config.media.backend as MediaBackendType) { url = getUrl(path, config);
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 { path } = await mediaManager.addFile(file); let thumbnailUrl = "";
url = getUrl(path, config); if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
let thumbnailUrl = ""; thumbnailUrl = getUrl(path, config);
}
if (thumbnail) { const newAttachment = (
const { path } = await mediaManager.addFile(thumbnail); await db
.insert(attachment)
.values({
url,
thumbnailUrl,
sha256: sha256
.update(await file.arrayBuffer())
.digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
})
.returning()
)[0];
// TODO: Add job to process videos and other media
thumbnailUrl = getUrl(path, config); return jsonResponse(attachmentToAPI(newAttachment));
} },
);
const newAttachment = (
await db
.insert(attachment)
.values({
url,
thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
})
.returning()
)[0];
// TODO: Add job to process videos and other media
return jsonResponse(attachmentToAPI(newAttachment));
});

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type UserWithRelations, type UserWithRelations,
findManyUsers, findManyUsers,
@ -20,34 +21,39 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
max_id?: string; max_id: z.string().regex(idValidator).optional(),
since_id?: string; since_id: z.string().regex(idValidator).optional(),
min_id?: string; min_id: z.string().regex(idValidator).optional(),
limit?: number; limit: z.coerce.number().int().min(1).max(80).default(40),
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { max_id, since_id, limit = 40, min_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
const { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (subject, { lt, gte, gt, and, sql }) =>
and(
max_id ? lt(subject.id, max_id) : undefined,
since_id ? gte(subject.id, since_id) : undefined,
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${subject.id} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."muting" = true)`,
),
limit: Number(limit),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse(blocks.map((u) => userToAPI(u)));
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { max_id, since_id, limit, min_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
const { objects: blocks, link } =
await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (subject, { lt, gte, gt, and, sql }) =>
and(
max_id ? lt(subject.id, max_id) : undefined,
since_id ? gte(subject.id, since_id) : undefined,
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${subject.id} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."muting" = true)`,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
req,
);
return jsonResponse(blocks.map((u) => userToAPI(u)));
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
findManyNotifications, findManyNotifications,
notificationToAPI, notificationToAPI,
@ -19,64 +20,102 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
max_id?: string; max_id: z.string().regex(idValidator).optional(),
since_id?: string; since_id: z.string().regex(idValidator).optional(),
min_id?: string; min_id: z.string().regex(idValidator).optional(),
limit?: number; limit: z.coerce.number().int().min(1).max(80).optional().default(15),
exclude_types?: string[]; exclude_types: z
types?: string[]; .enum([
account_id?: string; "mention",
}>(async (req, matchedRoute, extraData) => { "status",
const { user } = extraData.auth; "follow",
"follow_request",
if (!user) return errorResponse("Unauthorized", 401); "reblog",
"poll",
const { "favourite",
account_id, "update",
exclude_types, "admin.sign_up",
limit = 15, "admin.report",
max_id, ])
min_id, .array()
since_id, .optional(),
types, types: z
} = extraData.parsedRequest; .enum([
"mention",
if (limit > 80) return errorResponse("Limit too high", 400); "status",
"follow",
if (limit <= 0) return errorResponse("Limit too low", 400); "follow_request",
"reblog",
if (types && exclude_types) { "poll",
return errorResponse("Can't use both types and exclude_types", 400); "favourite",
} "update",
"admin.sign_up",
const { objects, link } = await fetchTimeline<NotificationWithRelations>( "admin.report",
findManyNotifications, ])
{ .array()
// @ts-expect-error Yes I KNOW the types are wrong .optional(),
where: (notification, { lt, gte, gt, and, or, eq, inArray, sql }) => account_id: z.string().regex(idValidator).optional(),
or(
and(
max_id ? lt(notification.id, max_id) : undefined,
since_id ? gte(notification.id, since_id) : undefined,
min_id ? gt(notification.id, min_id) : undefined,
),
eq(notification.notifiedId, user.id),
eq(notification.accountId, account_id),
),
with: {},
limit: Number(limit),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (notification, { desc }) => desc(notification.id),
},
req,
);
return jsonResponse(
await Promise.all(objects.map((n) => notificationToAPI(n))),
200,
{
Link: link,
},
);
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
const {
account_id,
exclude_types,
limit,
max_id,
min_id,
since_id,
types,
} = extraData.parsedRequest;
if (types && exclude_types) {
return errorResponse("Can't use both types and exclude_types", 400);
}
const { objects, link } =
await fetchTimeline<NotificationWithRelations>(
findManyNotifications,
{
where: (
// @ts-expect-error Yes I KNOW the types are wrong
notification,
// @ts-expect-error Yes I KNOW the types are wrong
{ lt, gte, gt, and, or, eq, inArray, sql },
) =>
or(
and(
max_id
? lt(notification.id, max_id)
: undefined,
since_id
? gte(notification.id, since_id)
: undefined,
min_id
? gt(notification.id, min_id)
: undefined,
),
eq(notification.notifiedId, user.id),
eq(notification.accountId, account_id),
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (notification, { desc }) => desc(notification.id),
},
req,
);
return jsonResponse(
await Promise.all(objects.map((n) => notificationToAPI(n))),
200,
{
Link: link,
},
);
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { Relationship } from "~database/entities/Relationship"; import type { Relationship } from "~database/entities/Relationship";
import { import {
@ -28,6 +28,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
// Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20. // 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. // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses.
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth; const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { createLike } from "~database/entities/Like"; import { createLike } from "~database/entities/Like";
import { import {
@ -26,6 +26,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth; const { user } = extraData.auth;

View file

@ -28,7 +28,6 @@ beforeAll(async () => {
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens[1].accessToken}`, Authorization: `Bearer ${tokens[1].accessToken}`,
}, },
}, },
@ -51,42 +50,6 @@ describe(meta.route, () => {
expect(response.status).toBe(401); expect(response.status).toBe(401);
}); });
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=0`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=100`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 200 with users", async () => { test("should return 200 with users", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
import { import {
type UserWithRelations, type UserWithRelations,
@ -20,55 +21,59 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(40),
});
/** /**
* Fetch users who favourited the post * Fetch users who favourited the post
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
max_id?: string; async (req, matchedRoute, extraData) => {
min_id?: string; const id = matchedRoute.params.id;
since_id?: string; if (!id.match(idValidator)) {
limit?: number; return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}>(async (req, matchedRoute, extraData) => { }
const id = matchedRoute.params.id;
const { user } = extraData.auth; const { user } = extraData.auth;
const status = await findFirstStatuses({ const status = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id), where: (status, { eq }) => eq(status.id, id),
}); });
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user)) if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);
const { max_id, min_id, since_id, limit = 40 } = extraData.parsedRequest; const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
// Check for limit limits const { objects, link } = await fetchTimeline<UserWithRelations>(
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); findManyUsers,
if (limit < 1) return errorResponse("Invalid limit", 400); {
// @ts-ignore
where: (liker, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(liker.id, max_id) : undefined,
since_id ? gte(liker.id, since_id) : undefined,
min_id ? gt(liker.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${liker.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
const { objects, link } = await fetchTimeline<UserWithRelations>( return jsonResponse(
findManyUsers, objects.map((user) => userToAPI(user)),
{ 200,
// @ts-ignore {
where: (liker, { and, lt, gt, gte, eq, sql }) => Link: link,
and( },
max_id ? lt(liker.id, max_id) : undefined, );
since_id ? gte(liker.id, since_id) : undefined, },
min_id ? gt(liker.id, min_id) : undefined, );
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${liker.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
},
req,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: link,
},
);
});

View file

@ -1,17 +1,19 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization"; import { sanitizeHtml } from "@sanitization";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { parse } from "marked"; import { parse } from "marked";
import { z } from "zod";
import { import {
editStatus, editStatus,
findFirstStatuses, findFirstStatuses,
isViewableByUser, isViewableByUser,
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { status } from "~drizzle/schema"; import { status } from "~drizzle/schema";
import { config } from "config-manager";
import ISO6391 from "iso-639-1";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET", "DELETE", "PUT"], allowedMethods: ["GET", "DELETE", "PUT"],
@ -26,195 +28,162 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
status: z.string().max(config.validation.max_note_size).optional(),
// TODO: Add regex to validate
content_type: z.string().optional(),
media_ids: z
.array(z.string().regex(idValidator))
.max(config.validation.max_media_attachments)
.optional(),
spoiler_text: z.string().max(255).optional(),
sensitive: z.boolean().optional(),
language: z.enum(ISO6391.getAllCodes() as [string, ...string[]]).optional(),
"poll[options]": z
.array(z.string().max(config.validation.max_poll_option_size))
.max(config.validation.max_poll_options)
.optional(),
"poll[expires_in]": z
.number()
.int()
.min(config.validation.min_poll_duration)
.max(config.validation.max_poll_duration)
.optional(),
"poll[multiple]": z.boolean().optional(),
"poll[hide_totals]": z.boolean().optional(),
});
/** /**
* Fetch a user * Fetch a user
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
status?: string; async (req, matchedRoute, extraData) => {
spoiler_text?: string; const id = matchedRoute.params.id;
sensitive?: boolean; if (!id.match(idValidator)) {
language?: string; return errorResponse("Invalid ID, must be of type UUIDv7", 404);
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 { user } = extraData.auth;
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
});
const config = await extraData.configManager.getConfig();
// Check if user is authorized to view this status (if it's private)
if (!foundStatus || !isViewableByUser(foundStatus, user))
return errorResponse("Record not found", 404);
if (req.method === "GET") {
return jsonResponse(await statusToAPI(foundStatus));
}
if (req.method === "DELETE") {
if (foundStatus.authorId !== user?.id) {
return errorResponse("Unauthorized", 401);
} }
// TODO: Implement delete and redraft functionality const { user } = extraData.auth;
// Delete status and all associated objects const foundStatus = await findFirstStatuses({
await db.delete(status).where(eq(status.id, id)); where: (status, { eq }) => eq(status.id, id),
return jsonResponse(
{
...(await statusToAPI(foundStatus, user)),
// TODO: Add
// text: Add source text
// poll: Add source poll
// media_attachments
},
200,
);
}
if (req.method === "PUT") {
if (foundStatus.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;
// 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 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 (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?.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.max_poll_duration) {
return errorResponse(
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
422,
);
}
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 (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 media attachments are all valid
if (media_ids && media_ids.length > 0) {
const foundAttachments = await db.query.attachment.findMany({
where: (attachment, { inArray }) =>
inArray(attachment.id, media_ids),
});
if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
}
}
// Update status
const newStatus = await editStatus(foundStatus, {
content: sanitizedStatus,
content_type,
media_attachments: media_ids,
spoiler_text: spoiler_text ?? "",
sensitive: sensitive ?? false,
}); });
if (!newStatus) { const config = await extraData.configManager.getConfig();
return errorResponse("Failed to update status", 500);
// Check if user is authorized to view this status (if it's private)
if (!foundStatus || !isViewableByUser(foundStatus, user))
return errorResponse("Record not found", 404);
if (req.method === "GET") {
return jsonResponse(await statusToAPI(foundStatus));
}
if (req.method === "DELETE") {
if (foundStatus.authorId !== user?.id) {
return errorResponse("Unauthorized", 401);
}
// TODO: Implement delete and redraft functionality
// Delete status and all associated objects
await db.delete(status).where(eq(status.id, id));
return jsonResponse(
{
...(await statusToAPI(foundStatus, user)),
// TODO: Add
// text: Add source text
// poll: Add source poll
// media_attachments
},
200,
);
}
if (req.method === "PUT") {
if (foundStatus.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;
// TODO: Add Poll support
// Validate status
if (!statusText && !(media_ids && media_ids.length > 0)) {
return errorResponse(
"Status is required unless media is attached",
422,
);
}
if (media_ids && media_ids.length > 0 && options) {
// Disallow poll
return errorResponse(
"Cannot attach poll to post with media",
422,
);
}
let sanitizedStatus: string;
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(
await parse(statusText ?? ""),
);
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(
await parse(statusText ?? ""),
);
} else {
sanitizedStatus = await sanitizeHtml(statusText ?? "");
}
// Check if status body doesnt match filters
if (
config.filters.note_content.some((filter) =>
statusText?.match(filter),
)
) {
return errorResponse("Status contains blocked words", 422);
}
// Check if media attachments are all valid
if (media_ids && media_ids.length > 0) {
const foundAttachments = await db.query.attachment.findMany({
where: (attachment, { inArray }) =>
inArray(attachment.id, media_ids),
});
if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
}
}
// Update status
const newStatus = await editStatus(foundStatus, {
content: sanitizedStatus,
content_type,
media_attachments: media_ids,
spoiler_text: spoiler_text ?? "",
sensitive: sensitive ?? false,
});
if (!newStatus) {
return errorResponse("Failed to update status", 500);
}
return jsonResponse(await statusToAPI(newStatus, user));
} }
return jsonResponse(await statusToAPI(newStatus, user)); return jsonResponse({});
} },
);
return jsonResponse({});
});

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { findFirstStatuses, statusToAPI } from "~database/entities/Status"; import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
@ -21,6 +21,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth; const { user } = extraData.auth;
@ -39,11 +42,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
// Check if post is already pinned // Check if post is already pinned
if ( if (
await db.query.statusToUser.findFirst({ await db.query.userPinnedNotes.findFirst({
where: (statusToUser, { and, eq }) => where: (userPinnedNote, { and, eq }) =>
and( and(
eq(statusToUser.a, foundStatus.id), eq(userPinnedNote.statusId, foundStatus.id),
eq(statusToUser.b, user.id), eq(userPinnedNote.userId, user.id),
), ),
}) })
) { ) {
@ -51,8 +54,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
} }
await db.insert(statusToMentions).values({ await db.insert(statusToMentions).values({
a: foundStatus.id, statusId: foundStatus.id,
b: user.id, userId: user.id,
}); });
return jsonResponse(statusToAPI(foundStatus, user)); return jsonResponse(statusToAPI(foundStatus, user));

View file

@ -1,11 +1,11 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { import {
findFirstStatuses, findFirstStatuses,
isViewableByUser, isViewableByUser,
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { notification, status } from "~drizzle/schema"; import { notification, status } from "~drizzle/schema";
@ -21,71 +21,81 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
visibility: z.enum(["public", "unlisted", "private"]).default("public"),
});
/** /**
* Reblogs a post * Reblogs a post
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
visibility: "public" | "unlisted" | "private"; async (req, matchedRoute, extraData) => {
}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id;
const id = matchedRoute.params.id; if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
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 } = extraData.parsedRequest;
const foundStatus = await findFirstStatuses({ const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id), where: (status, { eq }) => eq(status.id, id),
});
// Check if user is authorized to view this status (if it's private)
if (!foundStatus || !isViewableByUser(foundStatus, user))
return errorResponse("Record not found", 404);
const existingReblog = await db.query.status.findFirst({
where: (status, { and, eq }) =>
and(eq(status.authorId, user.id), eq(status.reblogId, status.id)),
});
if (existingReblog) {
return errorResponse("Already reblogged", 422);
}
const newReblog = (
await db
.insert(status)
.values({
authorId: user.id,
reblogId: foundStatus.id,
visibility,
sensitive: false,
updatedAt: new Date().toISOString(),
})
.returning()
)[0];
if (!newReblog) {
return errorResponse("Failed to reblog", 500);
}
const finalNewReblog = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, newReblog.id),
});
if (!finalNewReblog) {
return errorResponse("Failed to reblog", 500);
}
// Create notification for reblog if reblogged user is on the same instance
if (foundStatus.author.instanceId === user.instanceId) {
await db.insert(notification).values({
accountId: user.id,
notifiedId: foundStatus.authorId,
type: "reblog",
statusId: foundStatus.reblogId,
}); });
}
return jsonResponse(await statusToAPI(finalNewReblog, user)); // Check if user is authorized to view this status (if it's private)
}); if (!foundStatus || !isViewableByUser(foundStatus, user))
return errorResponse("Record not found", 404);
const existingReblog = await db.query.status.findFirst({
where: (status, { and, eq }) =>
and(
eq(status.authorId, user.id),
eq(status.reblogId, status.id),
),
});
if (existingReblog) {
return errorResponse("Already reblogged", 422);
}
const newReblog = (
await db
.insert(status)
.values({
authorId: user.id,
reblogId: foundStatus.id,
visibility,
sensitive: false,
updatedAt: new Date().toISOString(),
})
.returning()
)[0];
if (!newReblog) {
return errorResponse("Failed to reblog", 500);
}
const finalNewReblog = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, newReblog.id),
});
if (!finalNewReblog) {
return errorResponse("Failed to reblog", 500);
}
// Create notification for reblog if reblogged user is on the same instance
if (foundStatus.author.instanceId === user.instanceId) {
await db.insert(notification).values({
accountId: user.id,
notifiedId: foundStatus.authorId,
type: "reblog",
statusId: foundStatus.reblogId,
});
}
return jsonResponse(await statusToAPI(finalNewReblog, user));
},
);

View file

@ -29,7 +29,6 @@ beforeAll(async () => {
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens[1].accessToken}`, Authorization: `Bearer ${tokens[1].accessToken}`,
}, },
}, },
@ -52,42 +51,6 @@ describe(meta.route, () => {
expect(response.status).toBe(401); expect(response.status).toBe(401);
}); });
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=0`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=100`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 200 with users", async () => { test("should return 200 with users", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
import { import {
type UserWithRelations, type UserWithRelations,
@ -20,60 +21,59 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(40),
});
/** /**
* Fetch users who reblogged the post * Fetch users who reblogged the post
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
max_id?: string; async (req, matchedRoute, extraData) => {
min_id?: string; const id = matchedRoute.params.id;
since_id?: string; if (!id.match(idValidator)) {
limit?: number; return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}>(async (req, matchedRoute, extraData) => { }
const id = matchedRoute.params.id;
const { user } = extraData.auth; const { user } = extraData.auth;
const status = await findFirstStatuses({ const status = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id), where: (status, { eq }) => eq(status.id, id),
}); });
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user)) if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);
const { const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = extraData.parsedRequest;
// Check for limit limits const { objects, link } = await fetchTimeline<UserWithRelations>(
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); findManyUsers,
if (limit < 1) return errorResponse("Invalid limit", 400); {
// @ts-ignore
where: (reblogger, { and, lt, gt, gte, eq, sql }) =>
and(
max_id ? lt(reblogger.id, max_id) : undefined,
since_id ? gte(reblogger.id, since_id) : undefined,
min_id ? gt(reblogger.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${status.id} AND "Status"."authorId" = ${reblogger.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
const { objects, link } = await fetchTimeline<UserWithRelations>( return jsonResponse(
findManyUsers, objects.map((user) => userToAPI(user)),
{ 200,
// @ts-ignore {
where: (reblogger, { and, lt, gt, gte, eq, sql }) => Link: link,
and( },
max_id ? lt(reblogger.id, max_id) : undefined, );
since_id ? gte(reblogger.id, since_id) : undefined, },
min_id ? gt(reblogger.id, min_id) : undefined, );
sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${status.id} AND "Status"."authorId" = ${reblogger.id})`,
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
},
req,
);
return jsonResponse(
objects.map((user) => userToAPI(user)),
200,
{
Link: link,
},
);
});

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse } from "@response"; import { errorResponse } from "@response";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
@ -19,6 +19,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth; const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { deleteLike } from "~database/entities/Like"; import { deleteLike } from "~database/entities/Like";
import { import {
@ -25,6 +25,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth; const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { findFirstStatuses, statusToAPI } from "~database/entities/Status"; import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
@ -22,6 +22,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth; const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { import {
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth; const { user } = extraData.auth;

View file

@ -52,7 +52,7 @@ describe(meta.route, () => {
expect(response.status).toBe(422); expect(response.status).toBe(422);
}); });
test("should return 400 is status is too long", async () => { test("should return 422 is status is too long", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), { new Request(new URL(meta.route, config.http.base_url), {
method: "POST", method: "POST",
@ -67,7 +67,7 @@ describe(meta.route, () => {
}), }),
); );
expect(response.status).toBe(400); expect(response.status).toBe(422);
}); });
test("should return 422 is visibility is invalid", async () => { test("should return 422 is visibility is invalid", async () => {
@ -108,7 +108,7 @@ describe(meta.route, () => {
expect(response.status).toBe(422); expect(response.status).toBe(422);
}); });
test("should return 404 is in_reply_to_id is invalid", async () => { test("should return 422 is in_reply_to_id is invalid", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), { new Request(new URL(meta.route, config.http.base_url), {
method: "POST", method: "POST",
@ -124,10 +124,10 @@ describe(meta.route, () => {
}), }),
); );
expect(response.status).toBe(404); expect(response.status).toBe(422);
}); });
test("should return 404 is quote_id is invalid", async () => { test("should return 422 is quote_id is invalid", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), { new Request(new URL(meta.route, config.http.base_url), {
method: "POST", method: "POST",
@ -143,7 +143,7 @@ describe(meta.route, () => {
}), }),
); );
expect(response.status).toBe(404); expect(response.status).toBe(422);
}); });
test("should return 422 is media_ids is invalid", async () => { test("should return 422 is media_ids is invalid", async () => {

View file

@ -1,7 +1,8 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization"; import { sanitizeHtml } from "@sanitization";
import { parse } from "marked"; import { parse } from "marked";
import { z } from "zod";
import type { StatusWithRelations } from "~database/entities/Status"; import type { StatusWithRelations } from "~database/entities/Status";
import { import {
createNewStatus, createNewStatus,
@ -11,6 +12,8 @@ import {
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { config } from "config-manager";
import ISO6391 from "iso-639-1";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -24,221 +27,176 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
status: z.string().max(config.validation.max_note_size).optional(),
// TODO: Add regex to validate
content_type: z.string().optional().default("text/plain"),
media_ids: z
.array(z.string().regex(idValidator))
.max(config.validation.max_media_attachments)
.optional(),
spoiler_text: z.string().max(255).optional(),
sensitive: z.boolean().optional(),
language: z.enum(ISO6391.getAllCodes() as [string, ...string[]]).optional(),
"poll[options]": z
.array(z.string().max(config.validation.max_poll_option_size))
.max(config.validation.max_poll_options)
.optional(),
"poll[expires_in]": z
.number()
.int()
.min(config.validation.min_poll_duration)
.max(config.validation.max_poll_duration)
.optional(),
"poll[multiple]": z.boolean().optional(),
"poll[hide_totals]": z.boolean().optional(),
in_reply_to_id: z.string().regex(idValidator).optional(),
quote_id: z.string().regex(idValidator).optional(),
visibility: z
.enum(["public", "unlisted", "private", "direct"])
.optional()
.default("public"),
scheduled_at: z.string().optional(),
local_only: z.boolean().optional(),
federate: z.boolean().optional().default(true),
});
/** /**
* Post new status * Post new status
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
status: string; async (req, matchedRoute, extraData) => {
media_ids?: string[]; const { user } = extraData.auth;
"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;
federate?: boolean;
}>(async (req, matchedRoute, extraData) => {
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 { const {
status, status,
media_ids, media_ids,
"poll[expires_in]": expires_in, "poll[expires_in]": expires_in,
// "poll[hide_totals]": hide_totals, "poll[options]": options,
// "poll[multiple]": multiple, in_reply_to_id,
"poll[options]": options, quote_id,
in_reply_to_id, scheduled_at,
quote_id, sensitive,
// language, spoiler_text,
scheduled_at, visibility,
sensitive, content_type,
spoiler_text, federate,
visibility, } = extraData.parsedRequest;
content_type,
federate = true,
} = extraData.parsedRequest;
// Validate status // Validate status
if (!status && !(media_ids && media_ids.length > 0)) { if (!status && !(media_ids && media_ids.length > 0)) {
return errorResponse( return errorResponse(
"Status is required unless media is attached", "Status is required unless media is attached",
422, 422,
); );
} }
// Validate media_ids if (media_ids && media_ids.length > 0 && options) {
if (media_ids && !Array.isArray(media_ids)) { // Disallow poll
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);
}
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); return errorResponse("Cannot attach poll to media", 422);
} }
if (media_ids.length > 4) {
return errorResponse("Media IDs must be less than 5", 422); if (scheduled_at) {
if (
Number.isNaN(new Date(scheduled_at).getTime()) ||
new Date(scheduled_at).getTime() < Date.now()
) {
return errorResponse(
"Scheduled time must be in the future",
422,
);
}
} }
}
if (options && options.length > config.validation.max_poll_options) { let sanitizedStatus: string;
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_options}`,
422,
);
}
if ( if (content_type === "text/markdown") {
options?.some( sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
(option) => option.length > config.validation.max_poll_option_size, } else if (content_type === "text/x.misskeymarkdown") {
) // Parse as MFM
) { // TODO: Parse as MFM
return errorResponse( sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
`Poll options must be less than ${config.validation.max_poll_option_size} characters`, } else {
422, sanitizedStatus = await sanitizeHtml(status ?? "");
); }
}
if (expires_in && expires_in < config.validation.min_poll_duration) { // Get reply account and status if exists
return errorResponse( let replyStatus: StatusWithRelations | null = null;
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`, let quote: StatusWithRelations | null = null;
422,
);
}
if (expires_in && expires_in > config.validation.max_poll_duration) { if (in_reply_to_id) {
return errorResponse( replyStatus = await findFirstStatuses({
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`, where: (status, { eq }) => eq(status.id, in_reply_to_id),
422, }).catch(() => null);
);
}
if (scheduled_at) { if (!replyStatus) {
return errorResponse("Reply status not found", 404);
}
}
if (quote_id) {
quote = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, quote_id),
}).catch(() => null);
if (!quote) {
return errorResponse("Quote status not found", 404);
}
}
// Check if status body doesnt match filters
if ( if (
Number.isNaN(new Date(scheduled_at).getTime()) || config.filters.note_content.some((filter) => status?.match(filter))
new Date(scheduled_at).getTime() < Date.now()
) { ) {
return errorResponse("Scheduled time must be in the future", 422); return errorResponse("Status contains blocked words", 422);
} }
}
// Validate visibility // Check if media attachments are all valid
if ( if (media_ids && media_ids.length > 0) {
visibility && const foundAttachments = await db.query.attachment
!["public", "unlisted", "private", "direct"].includes(visibility) .findMany({
) { where: (attachment, { inArray }) =>
return errorResponse("Invalid visibility", 422); inArray(attachment.id, media_ids),
} })
.catch(() => []);
let sanitizedStatus: string; if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
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,
);
}
// Get reply account and status if exists
let replyStatus: StatusWithRelations | null = null;
let quote: StatusWithRelations | null = null;
if (in_reply_to_id) {
replyStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, in_reply_to_id),
}).catch(() => null);
if (!replyStatus) {
return errorResponse("Reply status not found", 404);
} }
}
if (quote_id) { const mentions = await parseTextMentions(sanitizedStatus);
quote = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, quote_id),
}).catch(() => null);
if (!quote) { const newStatus = await createNewStatus(
return errorResponse("Quote status not found", 404); user,
} {
} [content_type]: {
content: sanitizedStatus ?? "",
// 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
if (media_ids && media_ids.length > 0) {
const foundAttachments = await db.query.attachment
.findMany({
where: (attachment, { inArray }) =>
inArray(attachment.id, media_ids),
})
.catch(() => []);
if (foundAttachments.length !== (media_ids ?? []).length) {
return errorResponse("Invalid media IDs", 422);
}
}
const mentions = await parseTextMentions(sanitizedStatus);
const newStatus = await createNewStatus(
user,
{
[content_type ?? "text/plain"]: {
content: sanitizedStatus ?? "",
}, },
}, visibility,
visibility ?? "public", sensitive ?? false,
sensitive ?? false, spoiler_text ?? "",
spoiler_text ?? "", [],
[], undefined,
undefined, mentions,
mentions, media_ids,
media_ids, replyStatus ?? undefined,
replyStatus ?? undefined, quote ?? undefined,
quote ?? undefined, );
);
if (!newStatus) { if (!newStatus) {
return errorResponse("Failed to create status", 500); return errorResponse("Failed to create status", 500);
} }
if (federate) { if (federate) {
await federateStatus(newStatus); await federateStatus(newStatus);
} }
return jsonResponse(await statusToAPI(newStatus, user)); return jsonResponse(await statusToAPI(newStatus, user));
}); },
);

View file

@ -27,36 +27,6 @@ describe(meta.route, () => {
expect(response.status).toBe(401); expect(response.status).toBe(401);
}); });
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=0`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=100`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should correctly parse limit", async () => { test("should correctly parse limit", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { import {
type StatusWithRelations, type StatusWithRelations,
findManyStatuses, findManyStatuses,
@ -20,72 +21,64 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(20),
});
/** /**
* Fetch home timeline statuses * Fetch home timeline statuses
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
max_id?: string; async (req, matchedRoute, extraData) => {
since_id?: string; const { user } = extraData.auth;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 80) { if (!user) return errorResponse("Unauthorized", 401);
return errorResponse("Limit must be between 1 and 40", 400);
}
if (!user) return errorResponse("Unauthorized", 401); const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
const followers = await db.query.relationship.findMany({ {
where: (relationship, { eq, and }) => // @ts-expect-error Yes I KNOW the types are wrong
and( where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) =>
eq(relationship.subjectId, user.id),
eq(relationship.following, true),
),
});
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) =>
and(
and( and(
max_id ? lt(status.id, max_id) : undefined, and(
since_id ? gte(status.id, since_id) : undefined, max_id ? lt(status.id, max_id) : undefined,
min_id ? gt(status.id, min_id) : undefined, since_id ? gte(status.id, since_id) : undefined,
), min_id ? gt(status.id, min_id) : undefined,
or( ),
eq(status.authorId, user.id), or(
/* inArray( eq(status.authorId, user.id),
/* inArray(
status.authorId, status.authorId,
followers.map((f) => f.ownerId), followers.map((f) => f.ownerId),
), */ ), */
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id // All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
// WHERE format (... = ...) // WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`, sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`,
// All statuses from users that the user is following // All statuses from users that the user is following
// WHERE format (... = ...) // WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`, sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
),
), ),
), limit,
limit: Number(limit), // @ts-expect-error Yes I KNOW the types are wrong
// @ts-expect-error Yes I KNOW the types are wrong orderBy: (status, { desc }) => desc(status.id),
orderBy: (status, { desc }) => desc(status.id), },
}, req,
req, );
);
return jsonResponse( return jsonResponse(
await Promise.all( await Promise.all(
objects.map(async (status) => statusToAPI(status, user)), objects.map(async (status) => statusToAPI(status, user)),
), ),
200, 200,
{ {
Link: link, Link: link,
}, },
); );
}); },
);

View file

@ -19,36 +19,6 @@ afterAll(async () => {
}); });
describe(meta.route, () => { describe(meta.route, () => {
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=0`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=100`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should correctly parse limit", async () => { test("should correctly parse limit", async () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(

View file

@ -1,6 +1,8 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { sql } from "drizzle-orm";
import { z } from "zod";
import { import {
type StatusWithRelations, type StatusWithRelations,
findManyStatuses, findManyStatuses,
@ -19,65 +21,61 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
local?: boolean; max_id: z.string().regex(idValidator).optional(),
only_media?: boolean; since_id: z.string().regex(idValidator).optional(),
remote?: boolean; min_id: z.string().regex(idValidator).optional(),
max_id?: string; limit: z.coerce.number().int().min(1).max(80).optional().default(20),
since_id?: string; local: z.coerce.boolean().optional(),
min_id?: string; remote: z.coerce.boolean().optional(),
limit?: number; only_media: z.coerce.boolean().optional(),
}>(async (req, matchedRoute, extraData) => {
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 (local && remote) {
return errorResponse("Cannot use both local and remote", 400);
}
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (status, { lt, gte, gt, and, isNull, isNotNull }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
remote
? isNotNull(status.instanceId)
: local
? isNull(status.instanceId)
: undefined,
),
limit: Number(limit),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
req,
);
return jsonResponse(
await Promise.all(
objects.map(async (status) =>
statusToAPI(status, user || undefined),
),
),
200,
{
Link: link,
},
);
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { local, limit, max_id, min_id, only_media, remote, since_id } =
extraData.parsedRequest;
if (local && remote) {
return errorResponse("Cannot use both local and remote", 400);
}
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (status, { lt, gte, gt, and, isNull, isNotNull }) =>
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
remote
? isNotNull(status.instanceId)
: local
? isNull(status.instanceId)
: undefined,
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
req,
);
return jsonResponse(
await Promise.all(
objects.map(async (status) =>
statusToAPI(status, user || undefined),
),
),
200,
{
Link: link,
},
);
},
);

View file

@ -1,13 +1,15 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { encode } from "blurhash"; import { encode } from "blurhash";
import { config } from "config-manager";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { attachment } from "~drizzle/schema"; import { attachment } from "~drizzle/schema";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -22,147 +24,135 @@ export const meta = applyConfig({
}, },
}); });
export const schema = z.object({
file: z.instanceof(File),
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
});
/** /**
* Upload new media * Upload new media
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
file: File; async (req, matchedRoute, extraData) => {
thumbnail: File; const { file, thumbnail, description } = extraData.parsedRequest;
description: string;
// TODO: Implement focus storage
focus: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) { const config = await extraData.configManager.getConfig();
return errorResponse("Unauthorized", 401);
}
const { file, thumbnail, description } = extraData.parsedRequest; 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) { if (
return errorResponse("No file provided", 400); config.validation.enforce_mime_types &&
} !config.validation.allowed_mime_types.includes(file.type)
) {
return errorResponse("Invalid file type", 415);
}
const config = await extraData.configManager.getConfig(); const sha256 = new Bun.SHA256();
if (file.size > config.validation.max_media_size) { const isImage = file.type.startsWith("image/");
return errorResponse(
`File too large, max size is ${config.validation.max_media_size} bytes`, const metadata = isImage
413, ? await sharp(await file.arrayBuffer()).metadata()
: null;
const blurhash = await new Promise<string | null>((resolve) => {
(async () =>
sharp(await file.arrayBuffer())
.raw()
.ensureAlpha()
.toBuffer((err, buffer) => {
if (err) {
resolve(null);
return;
}
try {
resolve(
encode(
new Uint8ClampedArray(buffer),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
) as string,
);
} catch {
resolve(null);
}
}))();
});
let url = "";
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");
}
if (isImage) {
const { path } = await mediaManager.addFile(file);
url = getUrl(path, config);
}
let thumbnailUrl = "";
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(path, config);
}
const newAttachment = (
await db
.insert(attachment)
.values({
url,
thumbnailUrl,
sha256: sha256
.update(await file.arrayBuffer())
.digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
})
.returning()
)[0];
// TODO: Add job to process videos and other media
if (isImage) {
return jsonResponse(attachmentToAPI(newAttachment));
}
return jsonResponse(
{
...attachmentToAPI(newAttachment),
url: null,
},
202,
); );
} },
);
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,
);
}
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const blurhash = await new Promise<string | null>((resolve) => {
(async () =>
sharp(await file.arrayBuffer())
.raw()
.ensureAlpha()
.toBuffer((err, buffer) => {
if (err) {
resolve(null);
return;
}
try {
resolve(
encode(
new Uint8ClampedArray(buffer),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
) as string,
);
} catch {
resolve(null);
}
}))();
});
let url = "";
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");
}
if (isImage) {
const { path } = await mediaManager.addFile(file);
url = getUrl(path, config);
}
let thumbnailUrl = "";
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(path, config);
}
const newAttachment = (
await db
.insert(attachment)
.values({
url,
thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
})
.returning()
)[0];
// TODO: Add job to process videos and other media
if (isImage) {
return jsonResponse(attachmentToAPI(newAttachment));
}
return jsonResponse(
{
...attachmentToAPI(newAttachment),
url: null,
},
202,
);
});

View file

@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api";
import { MeiliIndexType, meilisearch } from "@meilisearch"; import { MeiliIndexType, meilisearch } from "@meilisearch";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
import { findManyStatuses, statusToAPI } from "~database/entities/Status"; import { findManyStatuses, statusToAPI } from "~database/entities/Status";
import { import {
findFirstUser, findFirstUser,
@ -25,176 +26,177 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute<{ export const schema = z.object({
q?: string; q: z.string().optional(),
type?: string; type: z.string().optional(),
resolve?: boolean; resolve: z.coerce.boolean().optional(),
following?: boolean; following: z.coerce.boolean().optional(),
account_id?: string; account_id: z.string().optional(),
max_id?: string; max_id: z.string().optional(),
min_id?: string; min_id: z.string().optional(),
limit?: number; limit: z.coerce.number().int().min(1).max(40).optional(),
offset?: number; offset: z.coerce.number().int().optional(),
}>(async (req, matchedRoute, extraData) => { });
const { user: self } = extraData.auth;
const { export default apiRoute<typeof meta, typeof schema>(
q, async (req, matchedRoute, extraData) => {
type, const { user: self } = extraData.auth;
resolve,
following,
account_id,
// max_id,
// min_id,
limit = 20,
offset,
} = extraData.parsedRequest;
const config = await extraData.configManager.getConfig(); const {
q,
type,
resolve,
following,
account_id,
// max_id,
// min_id,
limit = 20,
offset,
} = extraData.parsedRequest;
if (!config.meilisearch.enabled) { const config = await extraData.configManager.getConfig();
return errorResponse("Meilisearch is not enabled", 501);
}
if (!self && (resolve || offset)) { if (!config.meilisearch.enabled) {
return errorResponse( return errorResponse("Meilisearch is not enabled", 501);
"Cannot use resolve or offset without being authenticated", }
401,
);
}
if (limit < 1 || limit > 40) { if (!self && (resolve || offset)) {
return errorResponse("Limit must be between 1 and 40", 400); return errorResponse(
} "Cannot use resolve or offset without being authenticated",
401,
);
}
let accountResults: { id: string }[] = []; let accountResults: { id: string }[] = [];
let statusResults: { id: string }[] = []; let statusResults: { id: string }[] = [];
if (!type || type === "accounts") { if (!type || type === "accounts") {
// Check if q is matching format username@domain.com or @username@domain.com // Check if q is matching format username@domain.com or @username@domain.com
const accountMatches = q const accountMatches = q
?.trim() ?.trim()
.match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g); .match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g);
if (accountMatches) { if (accountMatches) {
// Remove leading @ if it exists // Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) { if (accountMatches[0].startsWith("@")) {
accountMatches[0] = accountMatches[0].slice(1); accountMatches[0] = accountMatches[0].slice(1);
} }
const [username, domain] = accountMatches[0].split("@"); const [username, domain] = accountMatches[0].split("@");
const accountId = ( const accountId = (
await db await db
.select({ .select({
id: user.id, id: user.id,
}) })
.from(user) .from(user)
.leftJoin(instance, eq(user.instanceId, instance.id)) .leftJoin(instance, eq(user.instanceId, instance.id))
.where( .where(
and( and(
eq(user.username, username), eq(user.username, username),
eq(instance.baseUrl, domain), eq(instance.baseUrl, domain),
), ),
) )
)[0]?.id; )[0]?.id;
const account = accountId const account = accountId
? await findFirstUser({ ? await findFirstUser({
where: (user, { eq }) => eq(user.id, accountId), where: (user, { eq }) => eq(user.id, accountId),
}) })
: null; : null;
if (account) { if (account) {
return jsonResponse({
accounts: [userToAPI(account)],
statuses: [],
hashtags: [],
});
}
if (resolve) {
const newUser = await resolveWebFinger(username, domain).catch(
(e) => {
console.error(e);
return null;
},
);
if (newUser) {
return jsonResponse({ return jsonResponse({
accounts: [userToAPI(newUser)], accounts: [userToAPI(account)],
statuses: [], statuses: [],
hashtags: [], hashtags: [],
}); });
} }
if (resolve) {
const newUser = await resolveWebFinger(
username,
domain,
).catch((e) => {
console.error(e);
return null;
});
if (newUser) {
return jsonResponse({
accounts: [userToAPI(newUser)],
statuses: [],
hashtags: [],
});
}
}
} }
accountResults = (
await meilisearch.index(MeiliIndexType.Accounts).search<{
id: string;
}>(q, {
limit: Number(limit) || 10,
offset: Number(offset) || 0,
sort: ["createdAt:desc"],
})
).hits;
} }
accountResults = ( if (!type || type === "statuses") {
await meilisearch.index(MeiliIndexType.Accounts).search<{ statusResults = (
id: string; await meilisearch.index(MeiliIndexType.Statuses).search<{
}>(q, { id: string;
limit: Number(limit) || 10, }>(q, {
offset: Number(offset) || 0, limit: Number(limit) || 10,
sort: ["createdAt:desc"], offset: Number(offset) || 0,
}) sort: ["createdAt:desc"],
).hits; })
} ).hits;
}
if (!type || type === "statuses") { const accounts = await findManyUsers({
statusResults = ( where: (user, { and, eq, inArray }) =>
await meilisearch.index(MeiliIndexType.Statuses).search<{ and(
id: string; inArray(
}>(q, { user.id,
limit: Number(limit) || 10, accountResults.map((hit) => hit.id),
offset: Number(offset) || 0, ),
sort: ["createdAt:desc"], self
}) ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
).hits; self?.id
} } AND Relationships.following = ${
following ? true : false
const accounts = await findManyUsers({ } AND Relationships.objectId = ${user.id})`
where: (user, { and, eq, inArray }) => : undefined,
and(
inArray(
user.id,
accountResults.map((hit) => hit.id),
), ),
self orderBy: (user, { desc }) => desc(user.createdAt),
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ });
self?.id
} AND Relationships.following = ${
following ? true : false
} AND Relationships.objectId = ${user.id})`
: undefined,
),
orderBy: (user, { desc }) => desc(user.createdAt),
});
const statuses = await findManyStatuses({ const statuses = await findManyStatuses({
where: (status, { and, eq, inArray }) => where: (status, { and, eq, inArray }) =>
and( and(
inArray( inArray(
status.id, status.id,
statusResults.map((hit) => hit.id), statusResults.map((hit) => hit.id),
),
account_id ? eq(status.authorId, account_id) : undefined,
self
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
self?.id
} AND Relationships.following = ${
following ? true : false
} AND Relationships.objectId = ${status.authorId})`
: undefined,
), ),
account_id ? eq(status.authorId, account_id) : undefined, orderBy: (status, { desc }) => desc(status.createdAt),
self });
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
self?.id
} AND Relationships.following = ${
following ? true : false
} AND Relationships.objectId = ${status.authorId})`
: undefined,
),
orderBy: (status, { desc }) => desc(status.createdAt),
});
return jsonResponse({ return jsonResponse({
accounts: accounts.map((account) => userToAPI(account)), accounts: accounts.map((account) => userToAPI(account)),
statuses: await Promise.all( statuses: await Promise.all(
statuses.map((status) => statusToAPI(status)), statuses.map((status) => statusToAPI(status)),
), ),
hashtags: [], hashtags: [],
}); });
}); },
);

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
export const meta = applyConfig({ export const meta = applyConfig({
@ -14,61 +15,68 @@ export const meta = applyConfig({
route: "/oauth/token", route: "/oauth/token",
}); });
export const schema = z.object({
grant_type: z.string(),
code: z.string(),
redirect_uri: z.string().url(),
client_id: z.string(),
client_secret: z.string(),
scope: z.string(),
});
/** /**
* Allows getting token from OAuth code * Allows getting token from OAuth code
*/ */
export default apiRoute<{ export default apiRoute<typeof meta, typeof schema>(
grant_type: string; async (req, matchedRoute, extraData) => {
code: string; const {
redirect_uri: string; grant_type,
client_id: string; code,
client_secret: string; redirect_uri,
scope: string; client_id,
}>(async (req, matchedRoute, extraData) => { client_secret,
const { grant_type, code, redirect_uri, client_id, client_secret, scope } = scope,
extraData.parsedRequest; } = extraData.parsedRequest;
if (grant_type !== "authorization_code") if (grant_type !== "authorization_code")
return errorResponse( return errorResponse(
"Invalid grant type (try 'authorization_code')", "Invalid grant type (try 'authorization_code')",
400, 422,
); );
if (!code || !redirect_uri || !client_id || !client_secret || !scope) // Get associated token
return errorResponse( const application = await db.query.application.findFirst({
"Missing required parameters code, redirect_uri, client_id, client_secret, scope", where: (application, { eq, and }) =>
400, and(
); eq(application.clientId, client_id),
eq(application.secret, client_secret),
eq(application.redirectUris, redirect_uri),
eq(application.scopes, scope?.replaceAll("+", " ")),
),
});
// Get associated token if (!application)
const application = await db.query.application.findFirst({ return errorResponse(
where: (application, { eq, and }) => "Invalid client credentials (missing application)",
and( 401,
eq(application.clientId, client_id), );
eq(application.secret, client_secret),
eq(application.redirectUris, redirect_uri),
eq(application.scopes, scope?.replaceAll("+", " ")),
),
});
if (!application) const token = await db.query.token.findFirst({
return errorResponse( where: (token, { eq }) =>
"Invalid client credentials (missing applicaiton)", eq(token.code, code) && eq(token.applicationId, application.id),
401, });
);
const token = await db.query.token.findFirst({ if (!token)
where: (token, { eq }) => return errorResponse(
eq(token.code, code) && eq(token.applicationId, application.id), "Invalid access token or client credentials",
}); 401,
);
if (!token) return jsonResponse({
return errorResponse("Invalid access token or client credentials", 401); access_token: token.accessToken,
token_type: token.tokenType,
return jsonResponse({ scope: token.scope,
access_token: token.accessToken, created_at: new Date(token.createdAt).getTime(),
token_type: token.tokenType, });
scope: token.scope, },
created_at: new Date(token.createdAt).getTime(), );
});
});

View file

@ -1,15 +0,0 @@
import type { MatchedRoute } from "bun";
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>;
};
},
) => Response | Promise<Response>;

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { findFirstUser } from "~database/entities/User"; import { findFirstUser } from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
@ -14,58 +15,59 @@ export const meta = applyConfig({
route: "/.well-known/webfinger", route: "/.well-known/webfinger",
}); });
export default apiRoute<{ export const schema = z.object({
resource: string; resource: z.string().min(1).max(512),
}>(async (req, matchedRoute, extraData) => {
const { resource } = extraData.parsedRequest;
if (!resource) return errorResponse("No resource provided", 400);
// Check if resource is in the correct format (acct:uuid/username@domain)
if (!resource.match(/^acct:[a-zA-Z0-9-]+@[a-zA-Z0-9.-:]+$/)) {
return errorResponse(
"Invalid resource (should be acct:(id or username)@domain)",
400,
);
}
const requestedUser = resource.split("acct:")[1];
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).host;
// Check if user is a local user
if (requestedUser.split("@")[1] !== host) {
return errorResponse("User is a remote user", 404);
}
const isUuid = requestedUser
.split("@")[0]
.match(
/[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/i,
);
const user = await findFirstUser({
where: (user, { eq }) =>
eq(isUuid ? user.id : user.username, requestedUser.split("@")[0]),
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse({
subject: `acct:${isUuid ? user.id : user.username}@${host}`,
links: [
{
rel: "self",
type: "application/json",
href: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
},
],
});
}); });
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { resource } = extraData.parsedRequest;
// Check if resource is in the correct format (acct:uuid/username@domain)
if (!resource.match(/^acct:[a-zA-Z0-9-]+@[a-zA-Z0-9.-:]+$/)) {
return errorResponse(
"Invalid resource (should be acct:(id or username)@domain)",
400,
);
}
const requestedUser = resource.split("acct:")[1];
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).host;
// Check if user is a local user
if (requestedUser.split("@")[1] !== host) {
return errorResponse("User is a remote user", 404);
}
const isUuid = requestedUser.split("@")[0].match(idValidator);
const user = await findFirstUser({
where: (user, { eq }) =>
eq(
isUuid ? user.id : user.username,
requestedUser.split("@")[0],
),
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse({
subject: `acct:${isUuid ? user.id : user.username}@${host}`,
links: [
{
rel: "self",
type: "application/json",
href: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
},
],
});
},
);

View file

@ -21,12 +21,6 @@ describe("API Tests", () => {
const response = await sendTestRequest( const response = await sendTestRequest(
new Request( new Request(
wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url), wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url),
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
), ),
); );

View file

@ -23,10 +23,8 @@ describe("API Tests", () => {
new Request( new Request(
wrapRelativeUrl("/api/v1/accounts/999999", base_url), wrapRelativeUrl("/api/v1/accounts/999999", base_url),
{ {
method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -80,10 +78,8 @@ describe("API Tests", () => {
base_url, base_url,
), ),
{ {
method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -130,10 +126,8 @@ describe("API Tests", () => {
base_url, base_url,
), ),
{ {
method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -585,7 +579,6 @@ describe("API Tests", () => {
method: "DELETE", method: "DELETE",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -612,7 +605,6 @@ describe("API Tests", () => {
method: "DELETE", method: "DELETE",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),

View file

@ -164,10 +164,8 @@ describe("API Tests", () => {
base_url, base_url,
), ),
{ {
method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -218,7 +216,6 @@ describe("API Tests", () => {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -249,7 +246,6 @@ describe("API Tests", () => {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -276,10 +272,8 @@ describe("API Tests", () => {
base_url, base_url,
), ),
{ {
method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -312,7 +306,6 @@ describe("API Tests", () => {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -341,7 +334,6 @@ describe("API Tests", () => {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -377,7 +369,6 @@ describe("API Tests", () => {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -399,7 +390,6 @@ describe("API Tests", () => {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),
@ -430,7 +420,6 @@ describe("API Tests", () => {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
}, },
}, },
), ),

View file

@ -1,8 +1,15 @@
import { config } from "config-manager"; import { config } from "config-manager";
import type { RouteHandler } from "~server/api/routes.type"; import {
import type { APIRouteMeta } from "~types/api"; anyOf,
caseInsensitive,
charIn,
createRegExp,
digit,
exactly,
} from "magic-regexp";
import type { APIRouteMetadata, RouteHandler } from "server-handler";
export const applyConfig = (routeMeta: APIRouteMeta) => { export const applyConfig = (routeMeta: APIRouteMetadata) => {
const newMeta = routeMeta; const newMeta = routeMeta;
// Apply ratelimits from config // Apply ratelimits from config
@ -16,6 +23,26 @@ export const applyConfig = (routeMeta: APIRouteMeta) => {
return newMeta; return newMeta;
}; };
export const apiRoute = <T>(routeFunction: RouteHandler<T>) => { export const apiRoute = <
Metadata extends APIRouteMetadata,
ZodSchema extends Zod.AnyZodObject,
>(
routeFunction: RouteHandler<Metadata, ZodSchema>,
) => {
return routeFunction; return routeFunction;
}; };
export const idValidator = createRegExp(
anyOf(digit, charIn("ABCDEF")).times(8),
exactly("-"),
anyOf(digit, charIn("ABCDEF")).times(4),
exactly("-"),
exactly("7"),
anyOf(digit, charIn("ABCDEF")).times(3),
exactly("-"),
anyOf("8", "9", "A", "B").times(1),
anyOf(digit, charIn("ABCDEF")).times(3),
exactly("-"),
anyOf(digit, charIn("ABCDEF")).times(12),
[caseInsensitive],
);