From 0b1c1ba1283e6a52b5ce24d481587cba4a796196 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sun, 14 Apr 2024 00:36:25 -1000 Subject: [PATCH] Switch all routes to use Zod for strict validation --- bun.lockb | Bin 564740 -> 564788 bytes cli.ts | 4 +- database/entities/Status.ts | 2 +- drizzle/db.ts | 4 +- package.json | 3 +- .../tests/request-parser.test.ts | 4 +- packages/server-handler/index.ts | 4 +- packages/server-handler/tests.test.ts | 27 +- server/api/api/v1/accounts/[id]/block.ts | 5 +- server/api/api/v1/accounts/[id]/follow.ts | 67 ++-- server/api/api/v1/accounts/[id]/followers.ts | 87 +++-- server/api/api/v1/accounts/[id]/following.ts | 87 +++-- server/api/api/v1/accounts/[id]/index.ts | 11 +- server/api/api/v1/accounts/[id]/mute.ts | 75 ++-- server/api/api/v1/accounts/[id]/note.ts | 57 +-- server/api/api/v1/accounts/[id]/pin.ts | 5 +- .../v1/accounts/[id]/remove_from_followers.ts | 5 +- server/api/api/v1/accounts/[id]/statuses.ts | 133 ++++--- server/api/api/v1/accounts/[id]/unblock.ts | 5 +- server/api/api/v1/accounts/[id]/unfollow.ts | 5 +- server/api/api/v1/accounts/[id]/unmute.ts | 5 +- server/api/api/v1/accounts/[id]/unpin.ts | 5 +- .../v1/accounts/familiar_followers/index.ts | 112 +++--- server/api/api/v1/accounts/index.ts | 364 +++++++++--------- .../api/api/v1/accounts/lookup/index.test.ts | 39 -- server/api/api/v1/accounts/lookup/index.ts | 107 +++-- .../api/v1/accounts/relationships/index.ts | 70 ++-- .../api/api/v1/accounts/search/index.test.ts | 61 --- server/api/api/v1/accounts/search/index.ts | 136 ++++--- .../v1/accounts/update_credentials/index.ts | 357 ++++++++--------- server/api/api/v1/apps/index.ts | 74 ++-- .../api/v1/apps/verify_credentials/index.ts | 5 +- server/api/api/v1/blocks/index.ts | 82 ++-- server/api/api/v1/favourites/index.ts | 88 ++--- server/api/api/v1/follow_requests/index.ts | 85 ++-- server/api/api/v1/media/[id]/index.ts | 157 ++++---- server/api/api/v1/media/index.ts | 214 +++++----- server/api/api/v1/mutes/index.ts | 68 ++-- server/api/api/v1/notifications/index.ts | 161 +++++--- server/api/api/v1/statuses/[id]/context.ts | 5 +- server/api/api/v1/statuses/[id]/favourite.ts | 5 +- .../v1/statuses/[id]/favourited_by.test.ts | 37 -- .../api/api/v1/statuses/[id]/favourited_by.ts | 93 ++--- server/api/api/v1/statuses/[id]/index.ts | 337 ++++++++-------- server/api/api/v1/statuses/[id]/pin.ts | 17 +- server/api/api/v1/statuses/[id]/reblog.ts | 132 ++++--- .../api/v1/statuses/[id]/reblogged_by.test.ts | 37 -- .../api/api/v1/statuses/[id]/reblogged_by.ts | 98 ++--- server/api/api/v1/statuses/[id]/source.ts | 5 +- .../api/api/v1/statuses/[id]/unfavourite.ts | 5 +- server/api/api/v1/statuses/[id]/unpin.ts | 5 +- server/api/api/v1/statuses/[id]/unreblog.ts | 5 +- server/api/api/v1/statuses/index.test.ts | 12 +- server/api/api/v1/statuses/index.ts | 340 +++++++--------- server/api/api/v1/timelines/home.test.ts | 30 -- server/api/api/v1/timelines/home.ts | 109 +++--- server/api/api/v1/timelines/public.test.ts | 30 -- server/api/api/v1/timelines/public.ts | 122 +++--- server/api/api/v2/media/index.ts | 262 ++++++------- server/api/api/v2/search/index.ts | 300 ++++++++------- server/api/oauth/token/index.ts | 106 ++--- server/api/routes.type.ts | 15 - server/api/well-known/webfinger/index.ts | 112 +++--- tests/api.test.ts | 6 - tests/api/accounts.test.ts | 8 - tests/api/statuses.test.ts | 11 - utils/api.ts | 35 +- 67 files changed, 2459 insertions(+), 2600 deletions(-) delete mode 100644 server/api/routes.type.ts diff --git a/bun.lockb b/bun.lockb index 43ffe1b536723a9b25e32fb1774c5f27bb6e6c92..72a0961fcbf9c22f6c75e169f91f5678e7a9f3f2 100755 GIT binary patch delta 46453 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 delta 46024 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 ZP473O|Mae1Mt5Hwwwi9TR>yqu{{VLO_?G|x 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], +);