Add status pinning and unpinning, fix bugs

This commit is contained in:
Jesse Wierzbinski 2023-11-26 14:56:16 -10:00
parent 0a74bbfe93
commit f51476e810
No known key found for this signature in database
14 changed files with 322 additions and 116 deletions

View file

@ -144,6 +144,8 @@ Working endpoints are:
- `/api/v1/statuses/:id/reblogged_by` - `/api/v1/statuses/:id/reblogged_by`
- `/api/v1/statuses/:id/reblog` - `/api/v1/statuses/:id/reblog`
- `/api/v1/statuses/:id/unreblog` - `/api/v1/statuses/:id/unreblog`
- `/api/v1/statuses/:id/pin`
- `/api/v1/statuses/:id/unpin`
- `/api/v1/statuses` - `/api/v1/statuses`
- `/api/v1/timelines/public` - `/api/v1/timelines/public`
- `/api/v1/timelines/home` - `/api/v1/timelines/home`
@ -153,14 +155,13 @@ Working endpoints are:
- `/api/v1/apps/verify_credentials` - `/api/v1/apps/verify_credentials`
- `/oauth/authorize` - `/oauth/authorize`
- `/oauth/token` - `/oauth/token`
- `/api/v1/blocks`
- `/api/v1/mutes`
- `/api/v2/media`
Tests needed but completed: Tests needed but completed:
- `/api/v2/media`
- `/api/v1/media/:id` - `/api/v1/media/:id`
- `/api/v1/blocks`
- `/api/v1/mutes`
Endpoints left: Endpoints left:
@ -195,8 +196,6 @@ Endpoints left:
- `/api/v1/statuses/:id/unbookmark` - `/api/v1/statuses/:id/unbookmark`
- `/api/v1/statuses/:id/mute` - `/api/v1/statuses/:id/mute`
- `/api/v1/statuses/:id/unmute` - `/api/v1/statuses/:id/unmute`
- `/api/v1/statuses/:id/pin`
- `/api/v1/statuses/:id/unpin`
- `/api/v1/statuses/:id` (`PUT`) - `/api/v1/statuses/:id` (`PUT`)
- `/api/v1/statuses/:id/history` - `/api/v1/statuses/:id/history`
- `/api/v1/statuses/:id/source` - `/api/v1/statuses/:id/source`

View file

@ -123,6 +123,8 @@ url_scheme_whitelist = [
"ssb", "ssb",
"gemini", "gemini",
] # NOT IMPLEMENTED ] # NOT IMPLEMENTED
enforce_mime_types = false
allowed_mime_types = [ allowed_mime_types = [
"image/jpeg", "image/jpeg",
"image/png", "image/png",
@ -152,7 +154,7 @@ allowed_mime_types = [
"audio/mp4", "audio/mp4",
"audio/3gpp", "audio/3gpp",
"video/x-ms-asf", "video/x-ms-asf",
] # MEDIA NOT IMPLEMENTED ]
[defaults] [defaults]
# Default visibility for new notes # Default visibility for new notes

View file

@ -11,14 +11,13 @@ import { client } from "~database/datasource";
import type { LysandPublication, Note } from "~types/lysand/Object"; import type { LysandPublication, Note } from "~types/lysand/Object";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import { getBestContentType } from "@content_types"; import { getBestContentType } from "@content_types";
import type { import {
Application, Prisma,
Emoji, type Application,
Instance, type Emoji,
Like, type Relationship,
Relationship, type Status,
Status, type User,
User,
} from "@prisma/client"; } from "@prisma/client";
import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
import type { APIStatus } from "~types/entities/status"; import type { APIStatus } from "~types/entities/status";
@ -26,7 +25,7 @@ import { applicationToAPI } from "./Application";
const config = getConfig(); const config = getConfig();
export const statusAndUserRelations = { export const statusAndUserRelations: Prisma.StatusInclude = {
author: { author: {
include: userRelations, include: userRelations,
}, },
@ -54,6 +53,7 @@ export const statusAndUserRelations = {
}, },
}, },
}, },
attachments: true,
instance: true, instance: true,
mentions: true, mentions: true,
pinnedBy: true, pinnedBy: true,
@ -115,64 +115,13 @@ export const statusAndUserRelations = {
}, },
}; };
export type StatusWithRelations = Status & { const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({
author: UserWithRelations; include: statusAndUserRelations,
application: Application | null; });
emojis: Emoji[];
inReplyToPost: export type StatusWithRelations = Prisma.StatusGetPayload<
| (Status & { typeof statusRelations
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;
})[];
};
/** /**
* Represents a status (i.e. a post) * Represents a status (i.e. a post)
@ -494,7 +443,7 @@ export const statusToAPI = async (
? user.relationships.find(r => r.subjectId == status.authorId) ? user.relationships.find(r => r.subjectId == status.authorId)
?.muting || false ?.muting || false
: 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 // TODO: Add pols
poll: null, poll: null,
reblog: status.reblog reblog: status.reblog

View file

@ -3,14 +3,8 @@ import { getConfig } from "@config";
import type { APIAccount } from "~types/entities/account"; import type { APIAccount } from "~types/entities/account";
import type { User as LysandUser } from "~types/lysand/Object"; import type { User as LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import type { import type { User } from "@prisma/client";
Emoji, import { Prisma } from "@prisma/client";
Instance,
Like,
Relationship,
Status,
User,
} from "@prisma/client";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji"; import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance"; import { addInstanceIfNotExists } from "./Instance";
@ -26,7 +20,7 @@ export interface AuthData {
* Stores local and remote users * Stores local and remote users
*/ */
export const userRelations = { export const userRelations: Prisma.UserInclude = {
emojis: true, emojis: true,
instance: true, instance: true,
likes: true, likes: true,
@ -41,18 +35,11 @@ export const userRelations = {
}, },
}; };
export type UserWithRelations = User & { const userRelations2 = Prisma.validator<Prisma.UserDefaultArgs>()({
emojis: Emoji[]; include: userRelations,
instance: Instance | null; });
likes: Like[];
relationships: Relationship[]; export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
relationshipSubjects: Relationship[];
pinnedNotes: Status[];
_count: {
statuses: number;
likes: number;
};
};
/** /**
* Get the user's avatar in raw URL format * Get the user's avatar in raw URL format

View file

@ -32,13 +32,14 @@ export default async (
max_id, max_id,
min_id, min_id,
since_id, since_id,
limit, limit = "20",
exclude_reblogs, exclude_reblogs,
pinned,
}: { }: {
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
min_id?: string; min_id?: string;
limit?: number; limit?: string;
only_media?: boolean; only_media?: boolean;
exclude_replies?: boolean; exclude_replies?: boolean;
exclude_reblogs?: boolean; exclude_reblogs?: boolean;
@ -54,6 +55,48 @@ export default async (
if (!user) return errorResponse("User not found", 404); 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({ const objects = await client.status.findMany({
where: { where: {
authorId: id, authorId: id,
@ -65,7 +108,7 @@ export default async (
}, },
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
take: limit ?? 20, take: Number(limit),
orderBy: { orderBy: {
id: "desc", id: "desc",
}, },
@ -76,11 +119,8 @@ export default async (
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
); `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
linkHeader.push(
`<${urlWithoutQuery}?since_id=${objects.at(-1)
?.id}&limit=${limit}>; rel="prev"`
); );
} }

View file

@ -27,7 +27,7 @@ export default async (req: Request): Promise<Response> => {
const blocks = await client.user.findMany({ const blocks = await client.user.findMany({
where: { where: {
relationshipSubjects: { relationshipSubjects: {
every: { some: {
ownerId: user.id, ownerId: user.id,
blocking: true, blocking: true,
}, },

View file

@ -27,7 +27,7 @@ export default async (req: Request): Promise<Response> => {
const blocks = await client.user.findMany({ const blocks = await client.user.findMany({
where: { where: {
relationshipSubjects: { relationshipSubjects: {
every: { some: {
ownerId: user.id, ownerId: user.id,
muting: true, muting: true,
}, },

View file

@ -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<Response> => {
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));
};

View file

@ -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<Response> => {
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));
};

View file

@ -58,8 +58,8 @@ export default async (req: Request): Promise<Response> => {
not: null, not: null,
} }
: local : local
? null ? null
: undefined, : undefined,
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
take: limit, take: limit,
@ -73,12 +73,8 @@ export default async (req: Request): Promise<Response> => {
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
); `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`
); );
} }

View file

@ -54,7 +54,10 @@ export default async (req: Request): Promise<Response> => {
); );
} }
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); return errorResponse("Invalid file type", 415);
} }

View file

@ -21,6 +21,14 @@ let user2: UserWithRelations;
describe("API Tests", () => { describe("API Tests", () => {
beforeAll(async () => { beforeAll(async () => {
await client.user.deleteMany({
where: {
username: {
in: ["test", "test2"],
},
},
});
user = await createNewLocalUser({ user = await createNewLocalUser({
email: "test@test.com", email: "test@test.com",
username: "test", 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", () => { describe("POST /api/v1/accounts/:id/unblock", () => {
test("should unblock the specified user and return an APIRelationship object", async () => { test("should unblock the specified user and return an APIRelationship object", async () => {
const response = await fetch( 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", () => { describe("POST /api/v1/accounts/:id/unmute", () => {
test("should unmute the specified user and return an APIRelationship object", async () => { test("should unmute the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await fetch(

View file

@ -10,6 +10,7 @@ import {
createNewLocalUser, createNewLocalUser,
} from "~database/entities/User"; } from "~database/entities/User";
import type { APIAccount } from "~types/entities/account"; import type { APIAccount } from "~types/entities/account";
import type { APIAsyncAttachment } from "~types/entities/async_attachment";
import type { APIContext } from "~types/entities/context"; import type { APIContext } from "~types/entities/context";
import type { APIStatus } from "~types/entities/status"; import type { APIStatus } from "~types/entities/status";
@ -19,9 +20,18 @@ let token: Token;
let user: UserWithRelations; let user: UserWithRelations;
let status: APIStatus | null = null; let status: APIStatus | null = null;
let status2: APIStatus | null = null; let status2: APIStatus | null = null;
let media1: APIAsyncAttachment | null = null;
describe("API Tests", () => { describe("API Tests", () => {
beforeAll(async () => { beforeAll(async () => {
await client.user.deleteMany({
where: {
username: {
in: ["test", "test2"],
},
},
});
user = await createNewLocalUser({ user = await createNewLocalUser({
email: "test@test.com", email: "test@test.com",
username: "test", 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", () => { describe("POST /api/v1/statuses", () => {
test("should create a new status and return an APIStatus object", async () => { test("should create a new status and return an APIStatus object", async () => {
const response = await fetch( const response = await fetch(
@ -78,6 +118,7 @@ describe("API Tests", () => {
body: JSON.stringify({ body: JSON.stringify({
status: "Hello, world!", status: "Hello, world!",
visibility: "public", visibility: "public",
media_ids: [media1?.id],
}), }),
} }
); );
@ -327,7 +368,7 @@ describe("API Tests", () => {
expect(statuses.length).toBe(2); expect(statuses.length).toBe(2);
const status1 = statuses[1]; const status1 = statuses[0];
// Basic validation // Basic validation
expect(status1.content).toBe("This is a reply!"); expect(status1.content).toBe("This is a reply!");

View file

@ -51,6 +51,7 @@ export interface ConfigType {
email_blacklist: string[]; email_blacklist: string[];
url_scheme_whitelist: string[]; url_scheme_whitelist: string[];
enforce_mime_types: boolean;
allowed_mime_types: string[]; allowed_mime_types: string[];
}; };
@ -245,6 +246,7 @@ export const configDefaults: ConfigType = {
"ssb", "ssb",
], ],
enforce_mime_types: false,
allowed_mime_types: [], allowed_mime_types: [],
}, },
defaults: { defaults: {