diff --git a/bun.lockb b/bun.lockb index 43ffe1b5..72a0961f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli.ts b/cli.ts index adcae235..e95de867 100644 --- a/cli.ts +++ b/cli.ts @@ -5,7 +5,9 @@ import { Parser } from "@json2csv/plainjs"; import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch"; import chalk from "chalk"; import { CliBuilder, CliCommand } from "cli-parser"; +import { CliParameterType } from "cli-parser/cli-builder.type"; import Table from "cli-table"; +import { config } from "config-manager"; import { type SQL, eq, inArray, isNotNull, isNull, like } from "drizzle-orm"; import extract from "extract-zip"; import { MediaBackend } from "media-manager"; @@ -20,8 +22,6 @@ import { } from "~database/entities/User"; import { db } from "~drizzle/db"; 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; diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 65d2f948..3fa93d29 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -25,7 +25,7 @@ import { letter, maybe, oneOrMore, -} from "magic-regexp/further-magic"; +} from "magic-regexp"; import { parse } from "marked"; import { db } from "~drizzle/db"; import { diff --git a/drizzle/db.ts b/drizzle/db.ts index 1a046e46..2de45337 100644 --- a/drizzle/db.ts +++ b/drizzle/db.ts @@ -1,9 +1,9 @@ import { config } from "config-manager"; 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 * 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({ host: config.database.host, diff --git a/package.json b/package.json index a03197fa..86ecb67c 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "oauth4webapi": "^2.4.0", "pg": "^8.11.5", "request-parser": "workspace:*", - "sharp": "^0.33.3" + "sharp": "^0.33.3", + "zod": "^3.22.4" } } diff --git a/packages/request-parser/tests/request-parser.test.ts b/packages/request-parser/tests/request-parser.test.ts index 9754b25a..bfa82ea4 100644 --- a/packages/request-parser/tests/request-parser.test.ts +++ b/packages/request-parser/tests/request-parser.test.ts @@ -58,11 +58,11 @@ describe("RequestParser", () => { headers: { "Content-Type": "application/json" }, body: "invalid json", }); - const result = await new RequestParser(request).toObject<{ + const result = new RequestParser(request).toObject<{ param1: string; param2: string; }>(); - expect(result).toEqual({}); + expect(result).rejects.toThrow(); }); describe("should parse form data correctly", () => { diff --git a/packages/server-handler/index.ts b/packages/server-handler/index.ts index 582ab420..15159f8e 100644 --- a/packages/server-handler/index.ts +++ b/packages/server-handler/index.ts @@ -108,8 +108,8 @@ export const processRoute = async ( } } - // Check if Content-Type header is missing in POST, PUT and PATCH requests - if (["POST", "PUT", "PATCH"].includes(request.method)) { + // Check if Content-Type header is missing if there is a body + if (request.body) { if (!request.headers.has("Content-Type")) { return errorResponse( `Content-Type header is missing but required on method ${request.method}`, diff --git a/packages/server-handler/tests.test.ts b/packages/server-handler/tests.test.ts index 6a312b8b..30ace68c 100644 --- a/packages/server-handler/tests.test.ts +++ b/packages/server-handler/tests.test.ts @@ -120,7 +120,7 @@ describe("Route Processor", () => { 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( "./route", () => @@ -147,35 +147,12 @@ describe("Route Processor", () => { } as MatchedRoute, new Request("https://test.com/route", { method: "POST", + body: "test", }), new LogManager(Bun.file("/dev/null")), ); 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 () => { diff --git a/server/api/api/v1/accounts/[id]/block.ts b/server/api/api/v1/accounts/[id]/block.ts index 280523a5..0e9a05dc 100644 --- a/server/api/api/v1/accounts/[id]/block.ts +++ b/server/api/api/v1/accounts/[id]/block.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -27,6 +27,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user: self } = extraData.auth; diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts index 12b38226..0cd114ab 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -1,5 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import ISO6391 from "iso-639-1"; +import { z } from "zod"; import { relationshipToAPI } from "~database/entities/Relationship"; import { 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 */ -export default apiRoute<{ - reblogs?: boolean; - notify?: boolean; - languages?: string[]; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + 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 { languages, notify, reblogs } = extraData.parsedRequest; + const { languages, notify, reblogs } = extraData.parsedRequest; - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await findFirstUser({ + 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 - let relationship = await getRelationshipToOtherUser(self, otherUser); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, otherUser); - if (!relationship.following) { - relationship = await followRequestUser( - self, - otherUser, - relationship.id, - reblogs, - notify, - languages, - ); - } + if (!relationship.following) { + relationship = await followRequestUser( + self, + otherUser, + relationship.id, + reblogs, + notify, + languages, + ); + } - return jsonResponse(relationshipToAPI(relationship)); -}); + return jsonResponse(relationshipToAPI(relationship)); + }, +); diff --git a/server/api/api/v1/accounts/[id]/followers.ts b/server/api/api/v1/accounts/[id]/followers.ts index d520c45f..f6fef88e 100644 --- a/server/api/api/v1/accounts/[id]/followers.ts +++ b/server/api/api/v1/accounts/[id]/followers.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type UserWithRelations, 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 */ -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } - // TODO: Add pinned - const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; + // TODO: Add pinned + const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await findFirstUser({ + 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( + 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( - 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), - }, - req, - ); - - return jsonResponse( - await Promise.all(objects.map((object) => userToAPI(object))), - 200, - { - Link: link, - }, - ); -}); + return jsonResponse( + await Promise.all(objects.map((object) => userToAPI(object))), + 200, + { + Link: link, + }, + ); + }, +); diff --git a/server/api/api/v1/accounts/[id]/following.ts b/server/api/api/v1/accounts/[id]/following.ts index 4fc5b8f4..342edf76 100644 --- a/server/api/api/v1/accounts/[id]/following.ts +++ b/server/api/api/v1/accounts/[id]/following.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type UserWithRelations, 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 */ -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } - // TODO: Add pinned - const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; + // TODO: Add pinned + const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await findFirstUser({ + 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( + 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( - 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), - }, - req, - ); - - return jsonResponse( - await Promise.all(objects.map((object) => userToAPI(object))), - 200, - { - Link: link, - }, - ); -}); + return jsonResponse( + await Promise.all(objects.map((object) => userToAPI(object))), + 200, + { + Link: link, + }, + ); + }, +); diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index 698e4634..a6bcb988 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { findFirstUser, userToAPI } from "~database/entities/User"; @@ -20,13 +20,8 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - // Check if ID is valid UUIDv7 - if ( - !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); + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); } const { user } = extraData.auth; diff --git a/server/api/api/v1/accounts/[id]/mute.ts b/server/api/api/v1/accounts/[id]/mute.ts index 90e7e799..37ed50cb 100644 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ b/server/api/api/v1/accounts/[id]/mute.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; +import { z } from "zod"; import { relationshipToAPI } from "~database/entities/Relationship"; import { 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 */ -export default apiRoute<{ - notifications: boolean; - duration: number; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + 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 { notifications, duration } = extraData.parsedRequest; + const { notifications, duration } = extraData.parsedRequest; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const user = await findFirstUser({ + 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 - const foundRelationship = await getRelationshipToOtherUser(self, user); + // Check if already following + const foundRelationship = await getRelationshipToOtherUser(self, user); - if (!foundRelationship.muting) { - foundRelationship.muting = true; - } - if (notifications ?? true) { - foundRelationship.mutingNotifications = true; - } + if (!foundRelationship.muting) { + foundRelationship.muting = true; + } + if (notifications ?? true) { + foundRelationship.mutingNotifications = true; + } - await db - .update(relationship) - .set({ - muting: true, - mutingNotifications: notifications ?? true, - }) - .where(eq(relationship.id, foundRelationship.id)); + await db + .update(relationship) + .set({ + muting: true, + mutingNotifications: notifications ?? true, + }) + .where(eq(relationship.id, foundRelationship.id)); - // TODO: Implement duration + // TODO: Implement duration - return jsonResponse(relationshipToAPI(foundRelationship)); -}); + return jsonResponse(relationshipToAPI(foundRelationship)); + }, +); diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts index e94fd270..a19a0509 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -8,6 +8,7 @@ import { } from "~database/entities/User"; import { db } from "~drizzle/db"; import { relationship } from "~drizzle/schema"; +import { z } from "zod"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - comment: string; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + 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({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await findFirstUser({ + 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 - const foundRelationship = await getRelationshipToOtherUser(self, otherUser); + // Check if already following + const foundRelationship = await getRelationshipToOtherUser( + self, + otherUser, + ); - foundRelationship.note = comment ?? ""; + foundRelationship.note = comment ?? ""; - await db - .update(relationship) - .set({ - note: foundRelationship.note, - }) - .where(eq(relationship.id, foundRelationship.id)); + await db + .update(relationship) + .set({ + note: foundRelationship.note, + }) + .where(eq(relationship.id, foundRelationship.id)); - return jsonResponse(relationshipToAPI(foundRelationship)); -}); + return jsonResponse(relationshipToAPI(foundRelationship)); + }, +); diff --git a/server/api/api/v1/accounts/[id]/pin.ts b/server/api/api/v1/accounts/[id]/pin.ts index d3fd975c..b561b264 100644 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ b/server/api/api/v1/accounts/[id]/pin.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -27,6 +27,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user: self } = extraData.auth; diff --git a/server/api/api/v1/accounts/[id]/remove_from_followers.ts b/server/api/api/v1/accounts/[id]/remove_from_followers.ts index dbd58055..bce07eda 100644 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ b/server/api/api/v1/accounts/[id]/remove_from_followers.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { and, eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -27,6 +27,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user: self } = extraData.auth; diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index c9768864..8091d39f 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type StatusWithRelations, 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 */ -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: string; - only_media?: boolean; - exclude_replies?: boolean; - exclude_reblogs?: boolean; - // TODO: Add with_muted - pinned?: boolean; - tagged?: string; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } - // TODO: Add pinned - const { - max_id, - min_id, - since_id, - limit = "20", - exclude_reblogs, - only_media = false, - pinned, - } = extraData.parsedRequest; + // TODO: Add pinned + const { + max_id, + min_id, + since_id, + limit, + exclude_reblogs, + only_media, + pinned, + } = extraData.parsedRequest; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const user = await findFirstUser({ + 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( + 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( findManyStatuses, { @@ -66,13 +105,14 @@ export default apiRoute<{ 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, + exclude_reblogs ? eq(status.reblogId, null) : undefined, ), // @ts-expect-error Yes I KNOW the types are wrong orderBy: (status, { desc }) => desc(status.id), + limit, }, req, ); @@ -86,34 +126,5 @@ export default apiRoute<{ Link: link, }, ); - } - - const { objects, link } = await fetchTimeline( - 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, - }, - ); -}); + }, +); diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts index dcf7791e..309970f0 100644 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ b/server/api/api/v1/accounts/[id]/unblock.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -24,6 +24,9 @@ export const meta = applyConfig({ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user: self } = extraData.auth; diff --git a/server/api/api/v1/accounts/[id]/unfollow.ts b/server/api/api/v1/accounts/[id]/unfollow.ts index 1fa32b81..0287aeaa 100644 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ b/server/api/api/v1/accounts/[id]/unfollow.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -27,6 +27,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user: self } = extraData.auth; diff --git a/server/api/api/v1/accounts/[id]/unmute.ts b/server/api/api/v1/accounts/[id]/unmute.ts index 7d1585a9..1e448329 100644 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ b/server/api/api/v1/accounts/[id]/unmute.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -27,6 +27,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user: self } = extraData.auth; diff --git a/server/api/api/v1/accounts/[id]/unpin.ts b/server/api/api/v1/accounts/[id]/unpin.ts index 8cc08ead..fb21d450 100644 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ b/server/api/api/v1/accounts/[id]/unpin.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; @@ -27,6 +27,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user: self } = extraData.auth; diff --git a/server/api/api/v1/accounts/familiar_followers/index.ts b/server/api/api/v1/accounts/familiar_followers/index.ts index 2763a108..70818a1e 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { z } from "zod"; import { findManyUsers, userToAPI } from "~database/entities/User"; 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) */ -export default apiRoute<{ - id: string[]; -}>(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { id: ids } = extraData.parsedRequest; + const { id: ids } = extraData.parsedRequest; - // Minimum id count 1, maximum 10 - if (!ids || ids.length < 1 || ids.length > 10) { - return errorResponse("Number of ids must be between 1 and 10", 422); - } - - const idFollowerRelationships = await db.query.relationship.findMany({ - columns: { - ownerId: 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), + const idFollowerRelationships = await db.query.relationship.findMany({ + columns: { + ownerId: true, + }, + where: (relationship, { inArray, and, eq }) => + and( + inArray(relationship.subjectId, ids), + eq(relationship.following, true), ), - eq(relationship.following, true), - ), - }); + }); - if (relevantRelationships.length === 0) { - return jsonResponse([]); - } + if (idFollowerRelationships.length === 0) { + return jsonResponse([]); + } - const finalUsers = await findManyUsers({ - where: (user, { inArray }) => - inArray( - user.id, - relevantRelationships.map((r) => r.subjectId), - ), - }); + // 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 (finalUsers.length === 0) { - return jsonResponse([]); - } + if (relevantRelationships.length === 0) { + 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))); + }, +); diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 668f9acc..6614c9b9 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse, response } from "@response"; import { tempmailDomains } from "@tempmail"; import ISO6391 from "iso-639-1"; +import { z } from "zod"; import { createNewLocalUser, findFirstUser } from "~database/entities/User"; export const meta = applyConfig({ @@ -17,193 +18,202 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - username: string; - email: string; - password: string; - agreement: boolean; - locale: string; - reason: string; -}>(async (req, matchedRoute, extraData) => { - // TODO: Add Authorization check +// No validation on the Zod side as we need to do custom validation +export const schema = z.object({ + username: z.string(), + email: z.string(), + password: z.string(), + agreement: z.boolean(), + locale: z.string(), + reason: z.string(), +}); - const body = extraData.parsedRequest; +export default apiRoute( + async (req, matchedRoute, extraData) => { + // TODO: Add Authorization check - const config = await extraData.configManager.getConfig(); + const body = extraData.parsedRequest; - if (!config.signups.registration) { - return jsonResponse( - { - error: "Registration is disabled", + const config = await extraData.configManager.getConfig(); + + if (!config.signups.registration) { + return jsonResponse( + { + error: "Registration is disabled", + }, + 422, + ); + } + + const errors: { + details: Record< + string, + { + error: + | "ERR_BLANK" + | "ERR_INVALID" + | "ERR_TOO_LONG" + | "ERR_TOO_SHORT" + | "ERR_BLOCKED" + | "ERR_TAKEN" + | "ERR_RESERVED" + | "ERR_ACCEPTED" + | "ERR_INCLUSION"; + description: string; + }[] + >; + } = { + details: { + password: [], + username: [], + email: [], + agreement: [], + locale: [], + reason: [], }, - 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: [], - }, - }; + // Check if fields are blank + for (const value of [ + "username", + "email", + "password", + "agreement", + "locale", + "reason", + ]) { + // @ts-expect-error We don't care about typing here + if (!body[value]) { + errors.details[value].push({ + error: "ERR_BLANK", + description: `can't be blank`, + }); + } + } - // Check if fields are blank - for (const value of [ - "username", - "email", - "password", - "agreement", - "locale", - "reason", - ]) { - // @ts-expect-error We don't care about typing here - if (!body[value]) { - errors.details[value].push({ + // Check if username is valid + if (!body.username?.match(/^[a-z0-9_]+$/)) + errors.details.username.push({ + error: "ERR_INVALID", + description: + "must only contain lowercase letters, numbers, and underscores", + }); + + // Check if username doesnt match filters + if ( + config.filters.username.some((filter) => + body.username?.match(filter), + ) + ) { + errors.details.username.push({ + error: "ERR_INVALID", + description: "contains blocked words", + }); + } + + // Check if username is too long + if ((body.username?.length ?? 0) > config.validation.max_username_size) + errors.details.username.push({ + error: "ERR_TOO_LONG", + description: `is too long (maximum is ${config.validation.max_username_size} characters)`, + }); + + // Check if username is too 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, + ); } - } - // Check if username is valid - if (!body.username?.match(/^[a-z0-9_]+$/)) - errors.details.username.push({ - error: "ERR_INVALID", - description: - "must only contain lowercase letters, numbers, and underscores", + await createNewLocalUser({ + username: body.username ?? "", + password: body.password ?? "", + email: body.email ?? "", }); - // Check if username doesnt match filters - if ( - config.filters.username.some((filter) => body.username?.match(filter)) - ) { - errors.details.username.push({ - error: "ERR_INVALID", - description: "contains blocked words", - }); - } - - // Check if username is too long - if ((body.username?.length ?? 0) > config.validation.max_username_size) - errors.details.username.push({ - error: "ERR_TOO_LONG", - description: `is too long (maximum is ${config.validation.max_username_size} characters)`, - }); - - // Check if username is too 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); -}); + return response(null, 200); + }, +); diff --git a/server/api/api/v1/accounts/lookup/index.test.ts b/server/api/api/v1/accounts/lookup/index.test.ts index 05e934dd..a80c1d21 100644 --- a/server/api/api/v1/accounts/lookup/index.test.ts +++ b/server/api/api/v1/accounts/lookup/index.test.ts @@ -21,45 +21,6 @@ afterAll(async () => { // /api/v1/accounts/lookup 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 () => { const response = await sendTestRequest( new Request( diff --git a/server/api/api/v1/accounts/lookup/index.ts b/server/api/api/v1/accounts/lookup/index.ts index 87098ba2..65d5e7af 100644 --- a/server/api/api/v1/accounts/lookup/index.ts +++ b/server/api/api/v1/accounts/lookup/index.ts @@ -1,5 +1,17 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { + anyOf, + charIn, + createRegExp, + digit, + exactly, + letter, + maybe, + oneOrMore, + global, +} from "magic-regexp"; +import { z } from "zod"; import { findFirstUser, resolveWebFinger, @@ -19,52 +31,71 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - acct: string; -}>(async (req, matchedRoute, extraData) => { - const { acct } = extraData.parsedRequest; +export const schema = z.object({ + acct: z.string().min(1).max(512), +}); - if (!acct) { - return errorResponse("Invalid acct parameter", 400); - } +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { acct } = extraData.parsedRequest; - // Check if acct is matching format username@domain.com or @username@domain.com - const accountMatches = acct - ?.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); + if (!acct) { + return errorResponse("Invalid acct parameter", 400); } - const [username, domain] = accountMatches[0].split("@"); - const foundAccount = await resolveWebFinger(username, domain).catch( - (e) => { - console.error(e); - return null; - }, + // Check if acct is matching format username@domain.com or @username@domain.com + const accountMatches = acct?.trim().match( + createRegExp( + maybe("@"), + oneOrMore( + anyOf(letter.lowercase, digit, charIn("-")), + ).groupedAs("username"), + exactly("@"), + oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs( + "domain", + ), + + [global], + ), ); - if (foundAccount) { - return jsonResponse(userToAPI(foundAccount)); + if (accountMatches) { + // 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; - if (username.startsWith("@")) { - username = username.slice(1); - } + const account = await findFirstUser({ + where: (user, { eq }) => eq(user.username, username), + }); - const account = await findFirstUser({ - where: (user, { eq }) => eq(user.username, username), - }); + if (account) { + return jsonResponse(userToAPI(account)); + } - if (account) { - return jsonResponse(userToAPI(account)); - } - - return errorResponse(`Account with username ${username} not found`, 404); -}); + return errorResponse( + `Account with username ${username} not found`, + 404, + ); + }, +); diff --git a/server/api/api/v1/accounts/relationships/index.ts b/server/api/api/v1/accounts/relationships/index.ts index 2a8802ab..d922a51d 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { z } from "zod"; import { createNewRelationship, 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 */ -export default apiRoute<{ - id: string[]; -}>(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { id: ids } = extraData.parsedRequest; + const { id: ids } = extraData.parsedRequest; - // Minimum id count 1, maximum 10 - if (!ids || ids.length < 1 || ids.length > 10) { - return errorResponse("Number of ids must be between 1 and 10", 422); - } + const relationships = await db.query.relationship.findMany({ + where: (relationship, { inArray, and, eq }) => + and( + inArray(relationship.subjectId, ids), + eq(relationship.ownerId, self.id), + ), + }); - const relationships = await db.query.relationship.findMany({ - where: (relationship, { inArray, and, eq }) => - and( - inArray(relationship.subjectId, ids), - eq(relationship.ownerId, self.id), - ), - }); + // Find IDs that dont have a relationship + const missingIds = ids.filter( + (id) => !relationships.some((r) => r.subjectId === id), + ); - // Find IDs that dont have a relationship - const missingIds = ids.filter( - (id) => !relationships.some((r) => r.subjectId === id), - ); + // Create the missing relationships + for (const id of missingIds) { + const relationship = await createNewRelationship(self, { + id, + } as User); - // Create the missing relationships - for (const id of missingIds) { - const relationship = await createNewRelationship(self, { id } as User); + relationships.push(relationship); + } - relationships.push(relationship); - } + // Order in the same order as ids + relationships.sort( + (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId), + ); - // Order in the same order as ids - relationships.sort( - (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId), - ); - - return jsonResponse(relationships.map((r) => relationshipToAPI(r))); -}); + return jsonResponse(relationships.map((r) => relationshipToAPI(r))); + }, +); diff --git a/server/api/api/v1/accounts/search/index.test.ts b/server/api/api/v1/accounts/search/index.test.ts index f56bf605..1f91497a 100644 --- a/server/api/api/v1/accounts/search/index.test.ts +++ b/server/api/api/v1/accounts/search/index.test.ts @@ -8,7 +8,6 @@ import { sendTestRequest, } from "~tests/utils"; import type { APIAccount } from "~types/entities/account"; -import type { APIStatus } from "~types/entities/status"; import { meta } from "./index"; await deleteOldTestUsers(); @@ -21,66 +20,6 @@ afterAll(async () => { // /api/v1/accounts/search 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 () => { const response = await sendTestRequest( new Request( diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts index f9ce8154..9345a14b 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,6 +1,18 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { sql } from "drizzle-orm"; +import { + createRegExp, + maybe, + oneOrMore, + anyOf, + letter, + digit, + charIn, + exactly, + global, +} from "magic-regexp"; +import { z } from "zod"; import { type UserWithRelations, findManyUsers, @@ -22,59 +34,75 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - q?: string; - limit?: number; - offset?: number; - resolve?: boolean; - following?: boolean; -}>(async (req, matchedRoute, extraData) => { - // TODO: Add checks for disabled or not email verified accounts - const { - following = false, - limit = 40, - offset, - resolve, - q, - } = extraData.parsedRequest; - - 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, +export const schema = z.object({ + q: z + .string() + .min(1) + .max(512) + .regex( + createRegExp( + maybe("@"), + oneOrMore( + anyOf(letter.lowercase, digit, charIn("-")), + ).groupedAs("username"), + maybe( + exactly("@"), + oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs( + "domain", ), - offset: Number(offset), - })), - ); - } - - return jsonResponse(accounts.map((acct) => userToAPI(acct))); + ), + [global], + ), + ), + limit: z.coerce.number().int().min(1).max(80).default(40), + offset: z.coerce.number().int().optional(), + resolve: z.coerce.boolean().optional(), + following: z.coerce.boolean().optional(), }); + +export default apiRoute( + 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))); + }, +); diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 814c00d0..25406975 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -7,11 +7,13 @@ import ISO6391 from "iso-639-1"; import { MediaBackendType } from "media-manager"; import type { MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { z } from "zod"; import { getUrl } from "~database/entities/Attachment"; import { parseEmojis } from "~database/entities/Emoji"; import { findFirstUser, userToAPI } from "~database/entities/User"; import { db } from "~drizzle/db"; import { emojiToUser, user } from "~drizzle/schema"; +import { config } from "config-manager"; import type { APISource } from "~types/entities/source"; export const meta = applyConfig({ @@ -27,45 +29,56 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - display_name: string; - note: string; - avatar: File; - header: File; - locked: string; - bot: string; - discoverable: string; - "source[privacy]": string; - "source[sensitive]": string; - "source[language]": string; -}>(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export const schema = z.object({ + display_name: z + .string() + .min(3) + .max(config.validation.max_displayname_size) + .optional(), + note: z.string().min(0).max(config.validation.max_bio_size).optional(), + avatar: z.instanceof(File).optional(), + header: z.instanceof(File).optional(), + locked: z.boolean().optional(), + bot: z.boolean().optional(), + discoverable: z.boolean().optional(), + "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( + async (req, matchedRoute, extraData) => { + const { user: self } = extraData.auth; - const config = await extraData.configManager.getConfig(); + if (!self) return errorResponse("Unauthorized", 401); - const { - display_name, - note, - avatar, - header, - locked, - bot, - discoverable, - "source[privacy]": source_privacy, - "source[sensitive]": source_sensitive, - "source[language]": source_language, - } = extraData.parsedRequest; + const config = await extraData.configManager.getConfig(); - 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_ATTR: [], }); */ - /* if (!user.source) { + /* if (!user.source) { user.source = { privacy: "public", sensitive: false, @@ -74,205 +87,153 @@ export default apiRoute<{ }; } */ - let mediaManager: MediaBackend; + let mediaManager: MediaBackend; - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } - - 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, - ); + 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"); } - // Check if display name doesnt match filters - if ( - config.filters.displayname.some((filter) => - sanitizedDisplayName.match(filter), - ) - ) { - return errorResponse("Display name contains blocked words", 422); + if (display_name) { + // Check if display name doesnt match filters + if ( + config.filters.displayname.some((filter) => + sanitizedDisplayName.match(filter), + ) + ) { + return errorResponse( + "Display name contains blocked words", + 422, + ); + } + + self.displayName = sanitizedDisplayName; } - // Remove emojis - self.emojis = []; + if (note && self.source) { + // 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; - } - - 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, - ); + (self.source as APISource).note = sanitizedNote; + self.note = await convertTextToHtml(sanitizedNote); } - // Check if bio doesnt match filters - if (config.filters.bio.some((filter) => sanitizedNote.match(filter))) { - return errorResponse("Bio contains blocked words", 422); + if (source_privacy && self.source) { + (self.source as APISource).privacy = source_privacy; } - (self.source as APISource).note = sanitizedNote; - // TODO: Convert note to HTML - 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, - ); + if (source_sensitive && self.source) { + (self.source as APISource).sensitive = source_sensitive; } - (self.source as APISource).privacy = source_privacy; - } - - 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); + if (source_language && self.source) { + (self.source as APISource).language = source_language; } - (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) { - if (!ISO6391.validate(source_language)) { - return errorResponse( - "Language must be a valid ISO 639-1 code", - 422, - ); + const { path } = await mediaManager.addFile(avatar); + + self.avatar = getUrl(path, config); } - (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) { - // Check if within allowed avatar length (avatar is an image) - if (avatar.size > config.validation.max_avatar_size) { - return errorResponse( - `Avatar must be less than ${config.validation.max_avatar_size} bytes`, - 422, - ); + const { path } = await mediaManager.addFile(header); + + self.header = getUrl(path, config); } - const { path } = await mediaManager.addFile(avatar); - - 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, - ); + if (locked) { + self.isLocked = locked; } - const { path } = await mediaManager.addFile(header); - - 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); + if (bot) { + self.isBot = bot; } - self.isLocked = locked === "true"; - } - - if (bot) { - // Check if bot is a boolean - if (bot !== "true" && bot !== "false") { - return errorResponse("Bot must be a boolean", 422); + if (discoverable) { + self.isDiscoverable = discoverable; } - self.isBot = bot === "true"; - } + // Parse emojis + const displaynameEmojis = await parseEmojis(sanitizedDisplayName); + const noteEmojis = await parseEmojis(sanitizedNote); - if (discoverable) { - // Check if discoverable is a boolean - if (discoverable !== "true" && discoverable !== "false") { - return errorResponse("Discoverable must be a boolean", 422); - } + self.emojis = [...displaynameEmojis, ...noteEmojis]; - self.isDiscoverable = discoverable === "true"; - } - - // Parse emojis - - 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(); + // Deduplicate emojis + self.emojis = self.emojis.filter( + (emoji, index, self) => + self.findIndex((e) => e.id === emoji.id) === index, + ); await db - .insert(emojiToUser) - .values({ - a: emoji.id, - b: self.id, + .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, }) - .execute(); - } + .where(eq(user.id, self.id)); - const output = await findFirstUser({ - where: (user, { eq }) => eq(user.id, self.id), - }); + // Connect emojis, if any + 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)); + }, +); diff --git a/server/api/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts index 532fa992..53a83c26 100644 --- a/server/api/api/v1/apps/index.ts +++ b/server/api/api/v1/apps/index.ts @@ -3,6 +3,7 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { db } from "~drizzle/db"; import { application } from "~drizzle/schema"; +import { z } from "zod"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - client_name: string; - redirect_uris: string; - scopes: string; - website: string; -}>(async (req, matchedRoute, extraData) => { - const { client_name, redirect_uris, scopes, website } = - extraData.parsedRequest; +export default apiRoute( + 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 - if (redirect_uris) { - if (!URL.canParse(redirect_uris)) { - return errorResponse("Redirect URI must be a valid URI", 422); - } - } + const app = ( + await db + .insert(application) + .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 = ( - await db - .insert(application) - .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]; - - 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, - }); -}); + 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, + }); + }, +); diff --git a/server/api/api/v1/apps/verify_credentials/index.ts b/server/api/api/v1/apps/verify_credentials/index.ts index 6ddc8fad..84f1d7bd 100644 --- a/server/api/api/v1/apps/verify_credentials/index.ts +++ b/server/api/api/v1/apps/verify_credentials/index.ts @@ -19,9 +19,12 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const { user, token } = extraData.auth; + + if (!token) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); + const application = await getFromToken(token); - if (!user) return errorResponse("Unauthorized", 401); if (!application) return errorResponse("Unauthorized", 401); return jsonResponse({ diff --git a/server/api/api/v1/blocks/index.ts b/server/api/api/v1/blocks/index.ts index bd7a3d08..bcf09bf2 100644 --- a/server/api/api/v1/blocks/index.ts +++ b/server/api/api/v1/blocks/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type UserWithRelations, findManyUsers, @@ -20,41 +21,46 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const { max_id, since_id, min_id, limit = 40 } = extraData.parsedRequest; - - const { objects: blocks, link } = await fetchTimeline( - 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 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).default(40), }); + +export default apiRoute( + 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( + 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, + }, + ); + }, +); diff --git a/server/api/api/v1/favourites/index.ts b/server/api/api/v1/favourites/index.ts index e3e132a8..ae8a9769 100644 --- a/server/api/api/v1/favourites/index.ts +++ b/server/api/api/v1/favourites/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type StatusWithRelations, findManyStatuses, @@ -19,46 +20,47 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(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( - 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 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).default(40), }); + +export default apiRoute( + 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( + 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, + }, + ); + }, +); diff --git a/server/api/api/v1/follow_requests/index.ts b/server/api/api/v1/follow_requests/index.ts index 4881044a..fdc089fe 100644 --- a/server/api/api/v1/follow_requests/index.ts +++ b/server/api/api/v1/follow_requests/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type UserWithRelations, findManyUsers, @@ -19,45 +20,45 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(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( - 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 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).default(20), }); + +export default apiRoute( + 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( + 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, + }, + ); + }, +); diff --git a/server/api/api/v1/media/[id]/index.ts b/server/api/api/v1/media/[id]/index.ts index 5e997b4c..07279bcc 100644 --- a/server/api/api/v1/media/[id]/index.ts +++ b/server/api/api/v1/media/[id]/index.ts @@ -1,12 +1,14 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse, response } from "@response"; import { eq } from "drizzle-orm"; import type { MediaBackend } 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 { db } from "~drizzle/db"; import { attachment } from "~drizzle/schema"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { config } from "config-manager"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - thumbnail?: File; - description?: string; - focus?: string; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; - if (!user) { - return errorResponse("Unauthorized", 401); - } + if (!user) { + return errorResponse("Unauthorized", 401); + } - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } - const foundAttachment = await db.query.attachment.findFirst({ - where: (attachment, { eq }) => eq(attachment.id, id), - }); + const foundAttachment = await db.query.attachment.findFirst({ + where: (attachment, { eq }) => eq(attachment.id, id), + }); - if (!foundAttachment) { - return errorResponse("Media not found", 404); - } + if (!foundAttachment) { + 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 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)); - } - - return jsonResponse(attachmentToAPI(foundAttachment)); - } - } - - return errorResponse("Method not allowed", 405); -}); + return errorResponse("Method not allowed", 405); + }, +); diff --git a/server/api/api/v1/media/index.ts b/server/api/api/v1/media/index.ts index bf4415fc..669e5819 100644 --- a/server/api/api/v1/media/index.ts +++ b/server/api/api/v1/media/index.ts @@ -3,11 +3,13 @@ import { errorResponse, jsonResponse } from "@response"; import { encode } from "blurhash"; import { MediaBackendType } from "media-manager"; import type { MediaBackend } from "media-manager"; +import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import sharp from "sharp"; +import { z } from "zod"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { db } from "~drizzle/db"; import { attachment } from "~drizzle/schema"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { config } from "config-manager"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - file: File; - thumbnail?: File; - description?: string; - // TODO: Add focus - focus?: string; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; - if (!user) { - return errorResponse("Unauthorized", 401); - } + if (!user) { + return errorResponse("Unauthorized", 401); + } - const { file, thumbnail, description } = extraData.parsedRequest; + const { file, thumbnail, description } = extraData.parsedRequest; - if (!file) { - return errorResponse("No file provided", 400); - } + const config = await extraData.configManager.getConfig(); - const config = await extraData.configManager.getConfig(); + if (file.size > config.validation.max_media_size) { + return errorResponse( + `File too large, max size is ${config.validation.max_media_size} bytes`, + 413, + ); + } - if (file.size > config.validation.max_media_size) { - return errorResponse( - `File too large, max size is ${config.validation.max_media_size} bytes`, - 413, - ); - } + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { + return errorResponse("Invalid file type", 415); + } - if ( - config.validation.enforce_mime_types && - !config.validation.allowed_mime_types.includes(file.type) - ) { - return errorResponse("Invalid file type", 415); - } + const sha256 = new Bun.SHA256(); - 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 isImage = file.type.startsWith("image/"); - 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((resolve) => { + (async () => + sharp(await file.arrayBuffer()) + .raw() + .ensureAlpha() + .toBuffer((err, buffer) => { + if (err) { + resolve(null); + return; + } - const metadata = isImage - ? await sharp(await file.arrayBuffer()).metadata() - : null; + try { + resolve( + encode( + new Uint8ClampedArray(buffer), + metadata?.width ?? 0, + metadata?.height ?? 0, + 4, + 4, + ) as string, + ); + } catch { + resolve(null); + } + }))(); + }); - const blurhash = await new Promise((resolve) => { - (async () => - sharp(await file.arrayBuffer()) - .raw() - .ensureAlpha() - .toBuffer((err, buffer) => { - if (err) { - resolve(null); - return; - } + let url = ""; - try { - resolve( - encode( - new Uint8ClampedArray(buffer), - metadata?.width ?? 0, - metadata?.height ?? 0, - 4, - 4, - ) as string, - ); - } catch { - resolve(null); - } - }))(); - }); + let mediaManager: MediaBackend; - 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) { - 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"); - } + url = getUrl(path, config); - 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 { path } = await mediaManager.addFile(thumbnail); + 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 - 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 - - return jsonResponse(attachmentToAPI(newAttachment)); -}); + return jsonResponse(attachmentToAPI(newAttachment)); + }, +); diff --git a/server/api/api/v1/mutes/index.ts b/server/api/api/v1/mutes/index.ts index 9d65a5b2..0135fa95 100644 --- a/server/api/api/v1/mutes/index.ts +++ b/server/api/api/v1/mutes/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type UserWithRelations, findManyUsers, @@ -20,34 +21,39 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(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( - 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 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).default(40), }); + +export default apiRoute( + 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( + 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))); + }, +); diff --git a/server/api/api/v1/notifications/index.ts b/server/api/api/v1/notifications/index.ts index 428389c5..4f66f1f0 100644 --- a/server/api/api/v1/notifications/index.ts +++ b/server/api/api/v1/notifications/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { findManyNotifications, notificationToAPI, @@ -19,64 +20,102 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - exclude_types?: string[]; - types?: string[]; - account_id?: string; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const { - account_id, - exclude_types, - limit = 15, - max_id, - min_id, - since_id, - types, - } = extraData.parsedRequest; - - if (limit > 80) return errorResponse("Limit too high", 400); - - if (limit <= 0) return errorResponse("Limit too low", 400); - - if (types && exclude_types) { - return errorResponse("Can't use both types and exclude_types", 400); - } - - const { objects, link } = await fetchTimeline( - findManyNotifications, - { - // @ts-expect-error Yes I KNOW the types are wrong - where: (notification, { 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), - ), - 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 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(15), + exclude_types: z + .enum([ + "mention", + "status", + "follow", + "follow_request", + "reblog", + "poll", + "favourite", + "update", + "admin.sign_up", + "admin.report", + ]) + .array() + .optional(), + types: z + .enum([ + "mention", + "status", + "follow", + "follow_request", + "reblog", + "poll", + "favourite", + "update", + "admin.sign_up", + "admin.report", + ]) + .array() + .optional(), + account_id: z.string().regex(idValidator).optional(), }); + +export default apiRoute( + 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( + 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, + }, + ); + }, +); diff --git a/server/api/api/v1/statuses/[id]/context.ts b/server/api/api/v1/statuses/[id]/context.ts index 1aa64fd9..0cbf01de 100644 --- a/server/api/api/v1/statuses/[id]/context.ts +++ b/server/api/api/v1/statuses/[id]/context.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import type { Relationship } from "~database/entities/Relationship"; 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. // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses. const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user } = extraData.auth; diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts index ae42914e..0b104f88 100644 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { createLike } from "~database/entities/Like"; import { @@ -26,6 +26,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user } = extraData.auth; diff --git a/server/api/api/v1/statuses/[id]/favourited_by.test.ts b/server/api/api/v1/statuses/[id]/favourited_by.test.ts index 113f8f0c..c76d6ce0 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.test.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.test.ts @@ -28,7 +28,6 @@ beforeAll(async () => { { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[1].accessToken}`, }, }, @@ -51,42 +50,6 @@ describe(meta.route, () => { 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 () => { const response = await sendTestRequest( new Request( diff --git a/server/api/api/v1/statuses/[id]/favourited_by.ts b/server/api/api/v1/statuses/[id]/favourited_by.ts index f7faa6fe..daa6b2df 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; import { type UserWithRelations, @@ -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 */ -export default apiRoute<{ - max_id?: string; - min_id?: string; - since_id?: string; - limit?: number; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + 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; - const status = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const status = await findFirstStatuses({ + where: (status, { eq }) => eq(status.id, id), + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - const { max_id, min_id, since_id, limit = 40 } = extraData.parsedRequest; + const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - // Check for limit limits - if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); - if (limit < 1) return errorResponse("Invalid limit", 400); + const { objects, link } = await fetchTimeline( + findManyUsers, + { + // @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( - findManyUsers, - { - // @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), - }, - req, - ); - - return jsonResponse( - objects.map((user) => userToAPI(user)), - 200, - { - Link: link, - }, - ); -}); + return jsonResponse( + objects.map((user) => userToAPI(user)), + 200, + { + Link: link, + }, + ); + }, +); diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index ab0e8033..55a16ebc 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -1,17 +1,19 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; import { eq } from "drizzle-orm"; import { parse } from "marked"; +import { z } from "zod"; import { editStatus, findFirstStatuses, isViewableByUser, statusToAPI, } from "~database/entities/Status"; -import { statusAndUserRelations } from "~database/entities/relations"; import { db } from "~drizzle/db"; import { status } from "~drizzle/schema"; +import { config } from "config-manager"; +import ISO6391 from "iso-639-1"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - status?: string; - spoiler_text?: string; - sensitive?: boolean; - language?: string; - content_type?: string; - media_ids?: string[]; - "poll[options]"?: string[]; - "poll[expires_in]"?: number; - "poll[multiple]"?: boolean; - "poll[hide_totals]"?: boolean; -}>(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); +export default apiRoute( + async (req, matchedRoute, extraData) => { + const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); } - // TODO: Implement delete and redraft functionality + const { user } = extraData.auth; - // 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, - ); - } - - // 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, + const foundStatus = await findFirstStatuses({ + where: (status, { eq }) => eq(status.id, id), }); - if (!newStatus) { - return errorResponse("Failed to update status", 500); + 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 + + // 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({}); + }, +); diff --git a/server/api/api/v1/statuses/[id]/pin.ts b/server/api/api/v1/statuses/[id]/pin.ts index d279755e..545609f4 100644 --- a/server/api/api/v1/statuses/[id]/pin.ts +++ b/server/api/api/v1/statuses/[id]/pin.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { findFirstStatuses, statusToAPI } from "~database/entities/Status"; import { db } from "~drizzle/db"; @@ -21,6 +21,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user } = extraData.auth; @@ -39,11 +42,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => { // Check if post is already pinned if ( - await db.query.statusToUser.findFirst({ - where: (statusToUser, { and, eq }) => + await db.query.userPinnedNotes.findFirst({ + where: (userPinnedNote, { and, eq }) => and( - eq(statusToUser.a, foundStatus.id), - eq(statusToUser.b, user.id), + eq(userPinnedNote.statusId, foundStatus.id), + eq(userPinnedNote.userId, user.id), ), }) ) { @@ -51,8 +54,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => { } await db.insert(statusToMentions).values({ - a: foundStatus.id, - b: user.id, + statusId: foundStatus.id, + userId: user.id, }); return jsonResponse(statusToAPI(foundStatus, user)); diff --git a/server/api/api/v1/statuses/[id]/reblog.ts b/server/api/api/v1/statuses/[id]/reblog.ts index fdf8f8d3..f4b3eb09 100644 --- a/server/api/api/v1/statuses/[id]/reblog.ts +++ b/server/api/api/v1/statuses/[id]/reblog.ts @@ -1,11 +1,11 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { z } from "zod"; import { findFirstStatuses, isViewableByUser, statusToAPI, } from "~database/entities/Status"; -import { statusAndUserRelations } from "~database/entities/relations"; import { db } from "~drizzle/db"; 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 */ -export default apiRoute<{ - visibility: "public" | "unlisted" | "private"; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + 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({ - 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, + const foundStatus = await findFirstStatuses({ + where: (status, { eq }) => eq(status.id, id), }); - } - 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)); + }, +); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.test.ts b/server/api/api/v1/statuses/[id]/reblogged_by.test.ts index 7c5f5833..49c824fd 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.test.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.test.ts @@ -29,7 +29,6 @@ beforeAll(async () => { { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[1].accessToken}`, }, }, @@ -52,42 +51,6 @@ describe(meta.route, () => { 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 () => { const response = await sendTestRequest( new Request( diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts index 99aaf04e..d5e19b35 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; import { type UserWithRelations, @@ -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 */ -export default apiRoute<{ - max_id?: string; - min_id?: string; - since_id?: string; - limit?: number; -}>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + 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; - const status = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const status = await findFirstStatuses({ + where: (status, { eq }) => eq(status.id, id), + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - const { - max_id = null, - min_id = null, - since_id = null, - limit = 40, - } = extraData.parsedRequest; + const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - // Check for limit limits - if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); - if (limit < 1) return errorResponse("Invalid limit", 400); + const { objects, link } = await fetchTimeline( + findManyUsers, + { + // @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( - findManyUsers, - { - // @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), - }, - req, - ); - - return jsonResponse( - objects.map((user) => userToAPI(user)), - 200, - { - Link: link, - }, - ); -}); + return jsonResponse( + objects.map((user) => userToAPI(user)), + 200, + { + Link: link, + }, + ); + }, +); diff --git a/server/api/api/v1/statuses/[id]/source.ts b/server/api/api/v1/statuses/[id]/source.ts index cf45318d..95e10c22 100644 --- a/server/api/api/v1/statuses/[id]/source.ts +++ b/server/api/api/v1/statuses/[id]/source.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse } from "@response"; import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; @@ -19,6 +19,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user } = extraData.auth; diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts index 572f3999..e1526ee8 100644 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { deleteLike } from "~database/entities/Like"; import { @@ -25,6 +25,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user } = extraData.auth; diff --git a/server/api/api/v1/statuses/[id]/unpin.ts b/server/api/api/v1/statuses/[id]/unpin.ts index e0da0fe9..8ffb88f4 100644 --- a/server/api/api/v1/statuses/[id]/unpin.ts +++ b/server/api/api/v1/statuses/[id]/unpin.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { and, eq } from "drizzle-orm"; import { findFirstStatuses, statusToAPI } from "~database/entities/Status"; @@ -22,6 +22,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user } = extraData.auth; diff --git a/server/api/api/v1/statuses/[id]/unreblog.ts b/server/api/api/v1/statuses/[id]/unreblog.ts index 6b3ad75f..51b065c1 100644 --- a/server/api/api/v1/statuses/[id]/unreblog.ts +++ b/server/api/api/v1/statuses/[id]/unreblog.ts @@ -1,4 +1,4 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { @@ -27,6 +27,9 @@ export const meta = applyConfig({ */ export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } const { user } = extraData.auth; diff --git a/server/api/api/v1/statuses/index.test.ts b/server/api/api/v1/statuses/index.test.ts index ad43ef2c..dfb4b1a6 100644 --- a/server/api/api/v1/statuses/index.test.ts +++ b/server/api/api/v1/statuses/index.test.ts @@ -52,7 +52,7 @@ describe(meta.route, () => { 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( new Request(new URL(meta.route, config.http.base_url), { 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 () => { @@ -108,7 +108,7 @@ describe(meta.route, () => { 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( new Request(new URL(meta.route, config.http.base_url), { 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( new Request(new URL(meta.route, config.http.base_url), { 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 () => { diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 72a6f538..21c2bd26 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -1,7 +1,8 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; import { parse } from "marked"; +import { z } from "zod"; import type { StatusWithRelations } from "~database/entities/Status"; import { createNewStatus, @@ -11,6 +12,8 @@ import { statusToAPI, } from "~database/entities/Status"; import { db } from "~drizzle/db"; +import { config } from "config-manager"; +import ISO6391 from "iso-639-1"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - status: string; - media_ids?: string[]; - "poll[options]"?: string[]; - "poll[expires_in]"?: number; - "poll[multiple]"?: boolean; - "poll[hide_totals]"?: boolean; - in_reply_to_id?: string; - quote_id?: string; - sensitive?: boolean; - spoiler_text?: string; - visibility?: "public" | "unlisted" | "private" | "direct"; - language?: string; - scheduled_at?: string; - local_only?: boolean; - content_type?: string; - federate?: boolean; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default apiRoute( + 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 { - status, - media_ids, - "poll[expires_in]": expires_in, - // "poll[hide_totals]": hide_totals, - // "poll[multiple]": multiple, - "poll[options]": options, - in_reply_to_id, - quote_id, - // language, - scheduled_at, - sensitive, - spoiler_text, - visibility, - content_type, - federate = true, - } = extraData.parsedRequest; + const { + status, + media_ids, + "poll[expires_in]": expires_in, + "poll[options]": options, + in_reply_to_id, + quote_id, + scheduled_at, + sensitive, + spoiler_text, + visibility, + content_type, + federate, + } = extraData.parsedRequest; - // Validate status - if (!status && !(media_ids && media_ids.length > 0)) { - return errorResponse( - "Status is required unless media is attached", - 422, - ); - } + // Validate status + if (!status && !(media_ids && media_ids.length > 0)) { + return errorResponse( + "Status is required unless media is attached", + 422, + ); + } - // Validate media_ids - if (media_ids && !Array.isArray(media_ids)) { - return errorResponse("Media IDs must be an array", 422); - } - - // Validate 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) { + if (media_ids && media_ids.length > 0 && options) { + // Disallow poll 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) { - return errorResponse( - `Poll options must be less than ${config.validation.max_poll_options}`, - 422, - ); - } + let sanitizedStatus: string; - 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 (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 (expires_in && expires_in < config.validation.min_poll_duration) { - return errorResponse( - `Poll duration must be greater than ${config.validation.min_poll_duration} seconds`, - 422, - ); - } + // Get reply account and status if exists + let replyStatus: StatusWithRelations | null = null; + let quote: StatusWithRelations | null = null; - if (expires_in && expires_in > config.validation.max_poll_duration) { - return errorResponse( - `Poll duration must be less than ${config.validation.max_poll_duration} seconds`, - 422, - ); - } + if (in_reply_to_id) { + replyStatus = await findFirstStatuses({ + where: (status, { eq }) => eq(status.id, in_reply_to_id), + }).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 ( - Number.isNaN(new Date(scheduled_at).getTime()) || - new Date(scheduled_at).getTime() < Date.now() + config.filters.note_content.some((filter) => status?.match(filter)) ) { - return errorResponse("Scheduled time must be in the future", 422); + return errorResponse("Status contains blocked words", 422); } - } - // Validate visibility - if ( - visibility && - !["public", "unlisted", "private", "direct"].includes(visibility) - ) { - return errorResponse("Invalid visibility", 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(() => []); - let sanitizedStatus: string; - - 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 (foundAttachments.length !== (media_ids ?? []).length) { + return errorResponse("Invalid media IDs", 422); + } } - } - if (quote_id) { - quote = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, quote_id), - }).catch(() => null); + const mentions = await parseTextMentions(sanitizedStatus); - if (!quote) { - return errorResponse("Quote status not found", 404); - } - } - - // Check if status body doesnt match filters - if (config.filters.note_content.some((filter) => status?.match(filter))) { - return errorResponse("Status contains blocked words", 422); - } - - // Check if 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 ?? "", + const newStatus = await createNewStatus( + user, + { + [content_type]: { + content: sanitizedStatus ?? "", + }, }, - }, - visibility ?? "public", - sensitive ?? false, - spoiler_text ?? "", - [], - undefined, - mentions, - media_ids, - replyStatus ?? undefined, - quote ?? undefined, - ); + visibility, + sensitive ?? false, + spoiler_text ?? "", + [], + undefined, + mentions, + media_ids, + replyStatus ?? undefined, + quote ?? undefined, + ); - if (!newStatus) { - return errorResponse("Failed to create status", 500); - } + if (!newStatus) { + return errorResponse("Failed to create status", 500); + } - if (federate) { - await federateStatus(newStatus); - } + if (federate) { + await federateStatus(newStatus); + } - return jsonResponse(await statusToAPI(newStatus, user)); -}); + return jsonResponse(await statusToAPI(newStatus, user)); + }, +); diff --git a/server/api/api/v1/timelines/home.test.ts b/server/api/api/v1/timelines/home.test.ts index 0d45fda2..8b472da8 100644 --- a/server/api/api/v1/timelines/home.test.ts +++ b/server/api/api/v1/timelines/home.test.ts @@ -27,36 +27,6 @@ describe(meta.route, () => { 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 () => { const response = await sendTestRequest( new Request( diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index 7a3aa27b..b8922540 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { z } from "zod"; import { type StatusWithRelations, 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 */ -export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default apiRoute( + 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) { - return errorResponse("Limit must be between 1 and 40", 400); - } + if (!user) return errorResponse("Unauthorized", 401); - if (!user) return errorResponse("Unauthorized", 401); - - const followers = await db.query.relationship.findMany({ - where: (relationship, { eq, and }) => - and( - eq(relationship.subjectId, user.id), - eq(relationship.following, true), - ), - }); - - const { objects, link } = await fetchTimeline( - findManyStatuses, - { - // @ts-expect-error Yes I KNOW the types are wrong - where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) => - and( + const { objects, link } = await fetchTimeline( + findManyStatuses, + { + // @ts-expect-error Yes I KNOW the types are wrong + where: (status, { lt, gte, gt, and, or, eq, inArray, 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, - ), - or( - eq(status.authorId, user.id), - /* inArray( + and( + max_id ? lt(status.id, max_id) : undefined, + since_id ? gte(status.id, since_id) : undefined, + min_id ? gt(status.id, min_id) : undefined, + ), + or( + eq(status.authorId, user.id), + /* inArray( status.authorId, followers.map((f) => f.ownerId), ), */ - // All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id - // WHERE format (... = ...) - sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`, - // All statuses from users that the user is following - // WHERE format (... = ...) - sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`, + // All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id + // WHERE format (... = ...) + sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`, + // All statuses from users that the user is following + // WHERE format (... = ...) + sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`, + ), ), - ), - limit: Number(limit), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (status, { desc }) => desc(status.id), - }, - req, - ); + 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)), - ), - 200, - { - Link: link, - }, - ); -}); + return jsonResponse( + await Promise.all( + objects.map(async (status) => statusToAPI(status, user)), + ), + 200, + { + Link: link, + }, + ); + }, +); diff --git a/server/api/api/v1/timelines/public.test.ts b/server/api/api/v1/timelines/public.test.ts index 85f6c630..8de8f0e8 100644 --- a/server/api/api/v1/timelines/public.test.ts +++ b/server/api/api/v1/timelines/public.test.ts @@ -19,36 +19,6 @@ afterAll(async () => { }); 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 () => { const response = await sendTestRequest( new Request( diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index 7364e101..58bda3a4 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { sql } from "drizzle-orm"; +import { z } from "zod"; import { type StatusWithRelations, findManyStatuses, @@ -19,65 +21,61 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - local?: boolean; - only_media?: boolean; - remote?: boolean; - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - const { - local, - limit = 20, - max_id, - min_id, - // only_media, - remote, - since_id, - } = extraData.parsedRequest; - - 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( - 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 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), + local: z.coerce.boolean().optional(), + remote: z.coerce.boolean().optional(), + only_media: z.coerce.boolean().optional(), }); + +export default apiRoute( + 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( + 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, + }, + ); + }, +); diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index 0f7dbbd6..88dd7dce 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -1,13 +1,15 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { encode } from "blurhash"; +import { config } from "config-manager"; import type { MediaBackend } from "media-manager"; import { MediaBackendType } from "media-manager"; +import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import sharp from "sharp"; +import { z } from "zod"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { db } from "~drizzle/db"; import { attachment } from "~drizzle/schema"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - file: File; - thumbnail: File; - description: string; - // TODO: Implement focus storage - focus: string; -}>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { file, thumbnail, description } = extraData.parsedRequest; - if (!user) { - return errorResponse("Unauthorized", 401); - } + const config = await extraData.configManager.getConfig(); - 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) { - return errorResponse("No file provided", 400); - } + if ( + 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) { - return errorResponse( - `File too large, max size is ${config.validation.max_media_size} bytes`, - 413, + const isImage = file.type.startsWith("image/"); + + const metadata = isImage + ? await sharp(await file.arrayBuffer()).metadata() + : null; + + const blurhash = await new Promise((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((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, - ); -}); + }, +); diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index 6271d0c3..9487dcc8 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api"; import { MeiliIndexType, meilisearch } from "@meilisearch"; import { errorResponse, jsonResponse } from "@response"; import { and, eq, sql } from "drizzle-orm"; +import { z } from "zod"; import { findManyStatuses, statusToAPI } from "~database/entities/Status"; import { findFirstUser, @@ -25,176 +26,177 @@ export const meta = applyConfig({ }, }); -export default apiRoute<{ - q?: string; - type?: string; - resolve?: boolean; - following?: boolean; - account_id?: string; - max_id?: string; - min_id?: string; - limit?: number; - offset?: number; -}>(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export const schema = z.object({ + q: z.string().optional(), + type: z.string().optional(), + resolve: z.coerce.boolean().optional(), + following: z.coerce.boolean().optional(), + account_id: z.string().optional(), + max_id: z.string().optional(), + min_id: z.string().optional(), + limit: z.coerce.number().int().min(1).max(40).optional(), + offset: z.coerce.number().int().optional(), +}); - const { - q, - type, - resolve, - following, - account_id, - // max_id, - // min_id, - limit = 20, - offset, - } = extraData.parsedRequest; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { user: self } = extraData.auth; - 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) { - return errorResponse("Meilisearch is not enabled", 501); - } + const config = await extraData.configManager.getConfig(); - if (!self && (resolve || offset)) { - return errorResponse( - "Cannot use resolve or offset without being authenticated", - 401, - ); - } + if (!config.meilisearch.enabled) { + return errorResponse("Meilisearch is not enabled", 501); + } - if (limit < 1 || limit > 40) { - return errorResponse("Limit must be between 1 and 40", 400); - } + if (!self && (resolve || offset)) { + return errorResponse( + "Cannot use resolve or offset without being authenticated", + 401, + ); + } - let accountResults: { id: string }[] = []; - let statusResults: { id: string }[] = []; + let accountResults: { id: string }[] = []; + let statusResults: { id: string }[] = []; - if (!type || type === "accounts") { - // Check if q is matching format username@domain.com or @username@domain.com - const accountMatches = q - ?.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); - } + if (!type || type === "accounts") { + // Check if q is matching format username@domain.com or @username@domain.com + const accountMatches = q + ?.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("@"); + const [username, domain] = accountMatches[0].split("@"); - const accountId = ( - await db - .select({ - id: user.id, - }) - .from(user) - .leftJoin(instance, eq(user.instanceId, instance.id)) - .where( - and( - eq(user.username, username), - eq(instance.baseUrl, domain), - ), - ) - )[0]?.id; + const accountId = ( + await db + .select({ + id: user.id, + }) + .from(user) + .leftJoin(instance, eq(user.instanceId, instance.id)) + .where( + and( + eq(user.username, username), + eq(instance.baseUrl, domain), + ), + ) + )[0]?.id; - const account = accountId - ? await findFirstUser({ - where: (user, { eq }) => eq(user.id, accountId), - }) - : null; + const account = accountId + ? await findFirstUser({ + where: (user, { eq }) => eq(user.id, accountId), + }) + : null; - 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) { + if (account) { return jsonResponse({ - accounts: [userToAPI(newUser)], + accounts: [userToAPI(account)], statuses: [], 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 = ( - await meilisearch.index(MeiliIndexType.Accounts).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; - } + if (!type || type === "statuses") { + statusResults = ( + await meilisearch.index(MeiliIndexType.Statuses).search<{ + id: string; + }>(q, { + limit: Number(limit) || 10, + offset: Number(offset) || 0, + sort: ["createdAt:desc"], + }) + ).hits; + } - if (!type || type === "statuses") { - statusResults = ( - await meilisearch.index(MeiliIndexType.Statuses).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; - } - - const accounts = await findManyUsers({ - where: (user, { and, eq, inArray }) => - and( - inArray( - user.id, - accountResults.map((hit) => hit.id), + const accounts = await findManyUsers({ + where: (user, { and, eq, inArray }) => + and( + inArray( + user.id, + accountResults.map((hit) => hit.id), + ), + self + ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ + self?.id + } AND Relationships.following = ${ + following ? true : false + } AND Relationships.objectId = ${user.id})` + : undefined, ), - self - ? 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), - }); + orderBy: (user, { desc }) => desc(user.createdAt), + }); - const statuses = await findManyStatuses({ - where: (status, { and, eq, inArray }) => - and( - inArray( - status.id, - statusResults.map((hit) => hit.id), + const statuses = await findManyStatuses({ + where: (status, { and, eq, inArray }) => + and( + inArray( + status.id, + statusResults.map((hit) => hit.id), + ), + account_id ? eq(status.authorId, account_id) : undefined, + self + ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ + self?.id + } AND Relationships.following = ${ + following ? true : false + } AND Relationships.objectId = ${status.authorId})` + : undefined, ), - 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, - ), - orderBy: (status, { desc }) => desc(status.createdAt), - }); + orderBy: (status, { desc }) => desc(status.createdAt), + }); - return jsonResponse({ - accounts: accounts.map((account) => userToAPI(account)), - statuses: await Promise.all( - statuses.map((status) => statusToAPI(status)), - ), - hashtags: [], - }); -}); + return jsonResponse({ + accounts: accounts.map((account) => userToAPI(account)), + statuses: await Promise.all( + statuses.map((status) => statusToAPI(status)), + ), + hashtags: [], + }); + }, +); diff --git a/server/api/oauth/token/index.ts b/server/api/oauth/token/index.ts index 6618c283..aaf7f1d1 100644 --- a/server/api/oauth/token/index.ts +++ b/server/api/oauth/token/index.ts @@ -1,5 +1,6 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { z } from "zod"; import { db } from "~drizzle/db"; export const meta = applyConfig({ @@ -14,61 +15,68 @@ export const meta = applyConfig({ 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 */ -export default apiRoute<{ - grant_type: string; - code: string; - redirect_uri: string; - client_id: string; - client_secret: string; - scope: string; -}>(async (req, matchedRoute, extraData) => { - const { grant_type, code, redirect_uri, client_id, client_secret, scope } = - extraData.parsedRequest; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { + grant_type, + code, + redirect_uri, + client_id, + client_secret, + scope, + } = extraData.parsedRequest; - if (grant_type !== "authorization_code") - return errorResponse( - "Invalid grant type (try 'authorization_code')", - 400, - ); + if (grant_type !== "authorization_code") + return errorResponse( + "Invalid grant type (try 'authorization_code')", + 422, + ); - if (!code || !redirect_uri || !client_id || !client_secret || !scope) - return errorResponse( - "Missing required parameters code, redirect_uri, client_id, client_secret, scope", - 400, - ); + // Get associated token + const application = await db.query.application.findFirst({ + where: (application, { eq, and }) => + and( + eq(application.clientId, client_id), + eq(application.secret, client_secret), + eq(application.redirectUris, redirect_uri), + eq(application.scopes, scope?.replaceAll("+", " ")), + ), + }); - // Get associated token - const application = await db.query.application.findFirst({ - where: (application, { eq, and }) => - and( - eq(application.clientId, client_id), - eq(application.secret, client_secret), - eq(application.redirectUris, redirect_uri), - eq(application.scopes, scope?.replaceAll("+", " ")), - ), - }); + if (!application) + return errorResponse( + "Invalid client credentials (missing application)", + 401, + ); - if (!application) - return errorResponse( - "Invalid client credentials (missing applicaiton)", - 401, - ); + const token = await db.query.token.findFirst({ + where: (token, { eq }) => + eq(token.code, code) && eq(token.applicationId, application.id), + }); - const token = await db.query.token.findFirst({ - where: (token, { eq }) => - eq(token.code, code) && eq(token.applicationId, application.id), - }); + if (!token) + return errorResponse( + "Invalid access token or client credentials", + 401, + ); - if (!token) - return errorResponse("Invalid access token or client credentials", 401); - - return jsonResponse({ - access_token: token.accessToken, - token_type: token.tokenType, - scope: token.scope, - created_at: new Date(token.createdAt).getTime(), - }); -}); + return jsonResponse({ + access_token: token.accessToken, + token_type: token.tokenType, + scope: token.scope, + created_at: new Date(token.createdAt).getTime(), + }); + }, +); diff --git a/server/api/routes.type.ts b/server/api/routes.type.ts deleted file mode 100644 index df2f6de8..00000000 --- a/server/api/routes.type.ts +++ /dev/null @@ -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 = ( - req: Request, - matchedRoute: MatchedRoute, - extraData: { - auth: AuthData; - parsedRequest: Partial; - configManager: { - getConfig: () => Promise; - }; - }, -) => Response | Promise; diff --git a/server/api/well-known/webfinger/index.ts b/server/api/well-known/webfinger/index.ts index fb59e785..34e70fa3 100644 --- a/server/api/well-known/webfinger/index.ts +++ b/server/api/well-known/webfinger/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { z } from "zod"; import { findFirstUser } from "~database/entities/User"; export const meta = applyConfig({ @@ -14,58 +15,59 @@ export const meta = applyConfig({ route: "/.well-known/webfinger", }); -export default apiRoute<{ - resource: string; -}>(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 const schema = z.object({ + resource: z.string().min(1).max(512), }); + +export default apiRoute( + 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(), + }, + ], + }); + }, +); diff --git a/tests/api.test.ts b/tests/api.test.ts index e722d4bd..0df4b782 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -21,12 +21,6 @@ describe("API Tests", () => { const response = await sendTestRequest( new Request( wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url), - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }, ), ); diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index 0f7b8efb..caebb763 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -23,10 +23,8 @@ describe("API Tests", () => { new Request( wrapRelativeUrl("/api/v1/accounts/999999", base_url), { - method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -80,10 +78,8 @@ describe("API Tests", () => { base_url, ), { - method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -130,10 +126,8 @@ describe("API Tests", () => { base_url, ), { - method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -585,7 +579,6 @@ describe("API Tests", () => { method: "DELETE", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -612,7 +605,6 @@ describe("API Tests", () => { method: "DELETE", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index f90cb4aa..266acd3c 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -164,10 +164,8 @@ describe("API Tests", () => { base_url, ), { - method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -218,7 +216,6 @@ describe("API Tests", () => { method: "POST", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -249,7 +246,6 @@ describe("API Tests", () => { method: "POST", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -276,10 +272,8 @@ describe("API Tests", () => { base_url, ), { - method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -312,7 +306,6 @@ describe("API Tests", () => { method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -341,7 +334,6 @@ describe("API Tests", () => { method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -377,7 +369,6 @@ describe("API Tests", () => { method: "POST", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -399,7 +390,6 @@ describe("API Tests", () => { method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), @@ -430,7 +420,6 @@ describe("API Tests", () => { method: "POST", headers: { Authorization: `Bearer ${token.accessToken}`, - "Content-Type": "application/json", }, }, ), diff --git a/utils/api.ts b/utils/api.ts index 70a378f8..08938ba9 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,8 +1,15 @@ import { config } from "config-manager"; -import type { RouteHandler } from "~server/api/routes.type"; -import type { APIRouteMeta } from "~types/api"; +import { + 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; // Apply ratelimits from config @@ -16,6 +23,26 @@ export const applyConfig = (routeMeta: APIRouteMeta) => { return newMeta; }; -export const apiRoute = (routeFunction: RouteHandler) => { +export const apiRoute = < + Metadata extends APIRouteMetadata, + ZodSchema extends Zod.AnyZodObject, +>( + routeFunction: RouteHandler, +) => { 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], +);