diff --git a/README.md b/README.md index 243e176d..898884f8 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ Working endpoints are: - `/api/v1/statuses/:id/reblogged_by` - `/api/v1/statuses/:id/reblog` - `/api/v1/statuses/:id/unreblog` +- `/api/v1/statuses/:id/pin` +- `/api/v1/statuses/:id/unpin` - `/api/v1/statuses` - `/api/v1/timelines/public` - `/api/v1/timelines/home` @@ -153,14 +155,13 @@ Working endpoints are: - `/api/v1/apps/verify_credentials` - `/oauth/authorize` - `/oauth/token` +- `/api/v1/blocks` +- `/api/v1/mutes` +- `/api/v2/media` Tests needed but completed: -- `/api/v2/media` - `/api/v1/media/:id` -- `/api/v1/blocks` -- `/api/v1/mutes` - Endpoints left: @@ -195,8 +196,6 @@ Endpoints left: - `/api/v1/statuses/:id/unbookmark` - `/api/v1/statuses/:id/mute` - `/api/v1/statuses/:id/unmute` -- `/api/v1/statuses/:id/pin` -- `/api/v1/statuses/:id/unpin` - `/api/v1/statuses/:id` (`PUT`) - `/api/v1/statuses/:id/history` - `/api/v1/statuses/:id/source` diff --git a/config/config.example.toml b/config/config.example.toml index 067bbd21..c95b70d4 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -123,6 +123,8 @@ url_scheme_whitelist = [ "ssb", "gemini", ] # NOT IMPLEMENTED + +enforce_mime_types = false allowed_mime_types = [ "image/jpeg", "image/png", @@ -152,7 +154,7 @@ allowed_mime_types = [ "audio/mp4", "audio/3gpp", "video/x-ms-asf", -] # MEDIA NOT IMPLEMENTED +] [defaults] # Default visibility for new notes diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 1934a002..10fec1b6 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -11,14 +11,13 @@ import { client } from "~database/datasource"; import type { LysandPublication, Note } from "~types/lysand/Object"; import { htmlToText } from "html-to-text"; import { getBestContentType } from "@content_types"; -import type { - Application, - Emoji, - Instance, - Like, - Relationship, - Status, - User, +import { + Prisma, + type Application, + type Emoji, + type Relationship, + type Status, + type User, } from "@prisma/client"; import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; import type { APIStatus } from "~types/entities/status"; @@ -26,7 +25,7 @@ import { applicationToAPI } from "./Application"; const config = getConfig(); -export const statusAndUserRelations = { +export const statusAndUserRelations: Prisma.StatusInclude = { author: { include: userRelations, }, @@ -54,6 +53,7 @@ export const statusAndUserRelations = { }, }, }, + attachments: true, instance: true, mentions: true, pinnedBy: true, @@ -115,64 +115,13 @@ export const statusAndUserRelations = { }, }; -export type StatusWithRelations = Status & { - author: UserWithRelations; - application: Application | null; - emojis: Emoji[]; - inReplyToPost: - | (Status & { - author: UserWithRelations; - application: Application | null; - emojis: Emoji[]; - inReplyToPost: Status | null; - instance: Instance | null; - mentions: User[]; - pinnedBy: User[]; - _count: { - replies: number; - }; - }) - | null; - instance: Instance | null; - mentions: User[]; - pinnedBy: User[]; - _count: { - replies: number; - likes: number; - reblogs: number; - }; - reblog: - | (Status & { - author: UserWithRelations; - application: Application | null; - emojis: Emoji[]; - inReplyToPost: Status | null; - instance: Instance | null; - mentions: User[]; - pinnedBy: User[]; - _count: { - replies: number; - }; - }) - | null; - quotingPost: - | (Status & { - author: UserWithRelations; - application: Application | null; - emojis: Emoji[]; - inReplyToPost: Status | null; - instance: Instance | null; - mentions: User[]; - pinnedBy: User[]; - _count: { - replies: number; - }; - }) - | null; - likes: (Like & { - liker: User; - })[]; -}; +const statusRelations = Prisma.validator()({ + include: statusAndUserRelations, +}); + +export type StatusWithRelations = Prisma.StatusGetPayload< + typeof statusRelations +>; /** * Represents a status (i.e. a post) @@ -494,7 +443,7 @@ export const statusToAPI = async ( ? user.relationships.find(r => r.subjectId == status.authorId) ?.muting || false : false, - pinned: status.author.pinnedNotes.some(note => note.id === status.id), + pinned: status.pinnedBy.find(u => u.id === user?.id) ? true : false, // TODO: Add pols poll: null, reblog: status.reblog diff --git a/database/entities/User.ts b/database/entities/User.ts index 66252007..462292ac 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -3,14 +3,8 @@ import { getConfig } from "@config"; import type { APIAccount } from "~types/entities/account"; import type { User as LysandUser } from "~types/lysand/Object"; import { htmlToText } from "html-to-text"; -import type { - Emoji, - Instance, - Like, - Relationship, - Status, - User, -} from "@prisma/client"; +import type { User } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { client } from "~database/datasource"; import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji"; import { addInstanceIfNotExists } from "./Instance"; @@ -26,7 +20,7 @@ export interface AuthData { * Stores local and remote users */ -export const userRelations = { +export const userRelations: Prisma.UserInclude = { emojis: true, instance: true, likes: true, @@ -41,18 +35,11 @@ export const userRelations = { }, }; -export type UserWithRelations = User & { - emojis: Emoji[]; - instance: Instance | null; - likes: Like[]; - relationships: Relationship[]; - relationshipSubjects: Relationship[]; - pinnedNotes: Status[]; - _count: { - statuses: number; - likes: number; - }; -}; +const userRelations2 = Prisma.validator()({ + include: userRelations, +}); + +export type UserWithRelations = Prisma.UserGetPayload; /** * Get the user's avatar in raw URL format diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 4082e856..5ba37ec3 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -32,13 +32,14 @@ export default async ( max_id, min_id, since_id, - limit, + limit = "20", exclude_reblogs, + pinned, }: { max_id?: string; since_id?: string; min_id?: string; - limit?: number; + limit?: string; only_media?: boolean; exclude_replies?: boolean; exclude_reblogs?: boolean; @@ -54,6 +55,48 @@ export default async ( if (!user) return errorResponse("User not found", 404); + if (pinned) { + const objects = await client.status.findMany({ + where: { + authorId: id, + isReblog: false, + pinnedBy: { + some: { + id: user.id, + }, + }, + id: { + lt: max_id, + gt: min_id, + gte: since_id, + }, + }, + include: statusAndUserRelations, + take: Number(limit), + orderBy: { + id: "desc", + }, + }); + + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` + ); + } + + return jsonResponse( + await Promise.all(objects.map(status => statusToAPI(status, user))), + 200, + { + Link: linkHeader.join(", "), + } + ); + } + const objects = await client.status.findMany({ where: { authorId: id, @@ -65,7 +108,7 @@ export default async ( }, }, include: statusAndUserRelations, - take: limit ?? 20, + take: Number(limit), orderBy: { id: "desc", }, @@ -76,11 +119,8 @@ export default async ( if (objects.length > 0) { const urlWithoutQuery = req.url.split("?")[0]; linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` - ); - linkHeader.push( - `<${urlWithoutQuery}?since_id=${objects.at(-1) - ?.id}&limit=${limit}>; rel="prev"` + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` ); } diff --git a/server/api/api/v1/blocks/index.ts b/server/api/api/v1/blocks/index.ts index 96a68b11..99ee967f 100644 --- a/server/api/api/v1/blocks/index.ts +++ b/server/api/api/v1/blocks/index.ts @@ -27,7 +27,7 @@ export default async (req: Request): Promise => { const blocks = await client.user.findMany({ where: { relationshipSubjects: { - every: { + some: { ownerId: user.id, blocking: true, }, diff --git a/server/api/api/v1/mutes/index.ts b/server/api/api/v1/mutes/index.ts index 5e32d4a2..dfa48057 100644 --- a/server/api/api/v1/mutes/index.ts +++ b/server/api/api/v1/mutes/index.ts @@ -27,7 +27,7 @@ export default async (req: Request): Promise => { const blocks = await client.user.findMany({ where: { relationshipSubjects: { - every: { + some: { ownerId: user.id, muting: true, }, diff --git a/server/api/api/v1/statuses/[id]/pin.ts b/server/api/api/v1/statuses/[id]/pin.ts new file mode 100644 index 00000000..72eceee6 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/pin.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import type { MatchedRoute } from "bun"; +import { client } from "~database/datasource"; +import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; +import type { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/pin", + auth: { + required: true, + }, +}); + +/** + * Pin a post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + let status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); + + // Check if status exists + if (!status) return errorResponse("Record not found", 404); + + // Check if status is user's + if (status.authorId !== user.id) return errorResponse("Unauthorized", 401); + + await client.user.update({ + where: { id: user.id }, + data: { + pinnedNotes: { + connect: { + id: status.id, + }, + }, + }, + }); + + status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); + + if (!status) return errorResponse("Record not found", 404); + + return jsonResponse(statusToAPI(status, user)); +}; diff --git a/server/api/api/v1/statuses/[id]/unpin.ts b/server/api/api/v1/statuses/[id]/unpin.ts new file mode 100644 index 00000000..af8f4a7a --- /dev/null +++ b/server/api/api/v1/statuses/[id]/unpin.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import type { MatchedRoute } from "bun"; +import { client } from "~database/datasource"; +import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; +import type { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unpin", + auth: { + required: true, + }, +}); + +/** + * Unpins a post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + let status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); + + // Check if status exists + if (!status) return errorResponse("Record not found", 404); + + // Check if status is user's + if (status.authorId !== user.id) return errorResponse("Unauthorized", 401); + + await client.user.update({ + where: { id: user.id }, + data: { + pinnedNotes: { + disconnect: { + id: status.id, + }, + }, + }, + }); + + status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); + + if (!status) return errorResponse("Record not found", 404); + + return jsonResponse(statusToAPI(status, user)); +}; diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index 2f209107..6c1e418e 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -58,8 +58,8 @@ export default async (req: Request): Promise => { not: null, } : local - ? null - : undefined, + ? null + : undefined, }, include: statusAndUserRelations, take: limit, @@ -73,12 +73,8 @@ export default async (req: Request): Promise => { if (objects.length > 0) { const urlWithoutQuery = req.url.split("?")[0]; linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` - ); - linkHeader.push( - `<${urlWithoutQuery}?since_id=${ - objects[objects.length - 1].id - }&limit=${limit}>; rel="prev"` + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` ); } diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index ed6140a8..51bc2997 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -54,7 +54,10 @@ export default async (req: Request): Promise => { ); } - if (!config.validation.allowed_mime_types.includes(file.type)) { + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { return errorResponse("Invalid file type", 415); } diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index 425d28b5..0c21f46c 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -21,6 +21,14 @@ let user2: UserWithRelations; describe("API Tests", () => { beforeAll(async () => { + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); + user = await createNewLocalUser({ email: "test@test.com", username: "test", @@ -291,6 +299,30 @@ describe("API Tests", () => { }); }); + describe("GET /api/v1/blocks", () => { + test("should return an array of APIAccount objects for the user's blocked accounts", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/blocks`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + const body = (await response.json()) as APIAccount[]; + + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0].id).toBe(user2.id); + }); + }); + describe("POST /api/v1/accounts/:id/unblock", () => { test("should unblock the specified user and return an APIRelationship object", async () => { const response = await fetch( @@ -369,6 +401,31 @@ describe("API Tests", () => { }); }); + describe("GET /api/v1/mutes", () => { + test("should return an array of APIAccount objects for the user's muted accounts", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/mutes`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const body = (await response.json()) as APIAccount[]; + + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0].id).toBe(user2.id); + }); + }); + describe("POST /api/v1/accounts/:id/unmute", () => { test("should unmute the specified user and return an APIRelationship object", async () => { const response = await fetch( diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 9169d8a8..3d9c1ea2 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -10,6 +10,7 @@ import { createNewLocalUser, } from "~database/entities/User"; import type { APIAccount } from "~types/entities/account"; +import type { APIAsyncAttachment } from "~types/entities/async_attachment"; import type { APIContext } from "~types/entities/context"; import type { APIStatus } from "~types/entities/status"; @@ -19,9 +20,18 @@ let token: Token; let user: UserWithRelations; let status: APIStatus | null = null; let status2: APIStatus | null = null; +let media1: APIAsyncAttachment | null = null; describe("API Tests", () => { beforeAll(async () => { + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); + user = await createNewLocalUser({ email: "test@test.com", username: "test", @@ -65,6 +75,36 @@ describe("API Tests", () => { }); }); + describe("POST /api/v2/media", () => { + test("should upload a file and return a MediaAttachment object", async () => { + const formData = new FormData(); + formData.append("file", new Blob(["test"], { type: "text/plain" })); + + // @ts-expect-error FormData is not iterable + const response = await fetch( + `${config.http.base_url}/api/v2/media`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + body: formData, + } + ); + + expect(response.status).toBe(202); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + media1 = (await response.json()) as APIAsyncAttachment; + + expect(media1.id).toBeDefined(); + expect(media1.type).toBe("unknown"); + expect(media1.url).toBeDefined(); + }); + }); + describe("POST /api/v1/statuses", () => { test("should create a new status and return an APIStatus object", async () => { const response = await fetch( @@ -78,6 +118,7 @@ describe("API Tests", () => { body: JSON.stringify({ status: "Hello, world!", visibility: "public", + media_ids: [media1?.id], }), } ); @@ -327,7 +368,7 @@ describe("API Tests", () => { expect(statuses.length).toBe(2); - const status1 = statuses[1]; + const status1 = statuses[0]; // Basic validation expect(status1.content).toBe("This is a reply!"); diff --git a/utils/config.ts b/utils/config.ts index 6d84b30a..f48498f4 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -51,6 +51,7 @@ export interface ConfigType { email_blacklist: string[]; url_scheme_whitelist: string[]; + enforce_mime_types: boolean; allowed_mime_types: string[]; }; @@ -245,6 +246,7 @@ export const configDefaults: ConfigType = { "ssb", ], + enforce_mime_types: false, allowed_mime_types: [], }, defaults: {