feat(api): Implement filters API v2 (with some routes missing)

This commit is contained in:
Jesse Wierzbinski 2024-04-17 13:47:03 -10:00
parent ce082f8e6a
commit a37e8e92c5
No known key found for this signature in database
21 changed files with 3087 additions and 154 deletions

View file

@ -233,10 +233,10 @@ Working endpoints are:
- [ ] `/api/v1/trends/tags`
- [ ] `/api/v2/filters/:filter_id/keywords` (`GET`, `POST`)
- [ ] `/api/v2/filters/:filter_id/statuses` (`GET`, `POST`)
- [ ] `/api/v2/filters/:id` (`GET`, `PUT`, `DELETE`)
- [x] `/api/v2/filters/:id` (`GET`, `PUT`, `DELETE`)
- [ ] `/api/v2/filters/keywords/:id` (`GET`, `PUT`, `DELETE`)
- [ ] `/api/v2/filters/statuses/:id` (`GET`, `DELETE`)
- [ ] `/api/v2/filters` (`GET`, `POST`)
- [x] `/api/v2/filters` (`GET`, `POST`)
- [x] `/api/v2/instance`
- [x] `/api/v2/media`
- [x] `/api/v2/search`
@ -248,7 +248,6 @@ Working endpoints are:
### Main work to do
- [ ] Announcements
- [ ] Filters
- [ ] Polls
- [ ] Tags
- [ ] Lists

BIN
bun.lockb

Binary file not shown.

View file

@ -6,16 +6,16 @@ export default {
out: "./drizzle",
schema: "./drizzle/schema.ts",
dbCredentials: {
/* host: "localhost",
host: "localhost",
port: 40000,
user: "lysand",
password: "lysand",
database: "lysand", */
host: config.database.host,
database: "lysand",
/* host: config.database.host,
port: Number(config.database.port),
user: config.database.username,
password: config.database.password,
database: config.database.database,
database: config.database.database, */
},
// Print all statements
verbose: true,

View file

@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS "FilterKeywords" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL,
"filterId" uuid NOT NULL,
"keyword" text NOT NULL,
"whole_word" boolean NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "Filters" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL,
"userId" uuid NOT NULL,
"context" text[],
"title" text NOT NULL,
"filter_action" text NOT NULL,
"expires_at" timestamp(3),
"created_at" timestamp(3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "FilterKeywords" ADD CONSTRAINT "FilterKeywords_filterId_Filters_id_fk" FOREIGN KEY ("filterId") REFERENCES "Filters"("id") ON DELETE cascade ON UPDATE cascade;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Filters" ADD CONSTRAINT "Filters_userId_Users_id_fk" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE cascade ON UPDATE cascade;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,13 @@
"when": 1713336611301,
"tag": "0013_wandering_celestials",
"breakpoints": true
},
{
"idx": 14,
"version": "5",
"when": 1713389937821,
"tag": "0014_wonderful_sandman",
"breakpoints": true
}
]
}

View file

@ -26,6 +26,50 @@ export const Emojis = pgTable("Emojis", {
}),
});
export const Filters = pgTable("Filters", {
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
userId: uuid("userId")
.notNull()
.references(() => Users.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
context: text("context")
.array()
.$type<
("home" | "notifications" | "public" | "thread" | "account")[]
>(),
title: text("title").notNull(),
filterAction: text("filter_action").notNull().$type<"warn" | "hide">(),
expireAt: timestamp("expires_at", { precision: 3, mode: "string" }),
createdAt: timestamp("created_at", { precision: 3, mode: "string" })
.defaultNow()
.notNull(),
});
export const FilterKeywords = pgTable("FilterKeywords", {
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
filterId: uuid("filterId")
.notNull()
.references(() => Filters.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
keyword: text("keyword").notNull(),
wholeWord: boolean("whole_word").notNull(),
});
export const FilterRelations = relations(Filters, ({ many }) => ({
keywords: many(FilterKeywords),
}));
export const FilterKeywordsRelations = relations(FilterKeywords, ({ one }) => ({
filter: one(Filters, {
fields: [FilterKeywords.filterId],
references: [Filters.id],
}),
}));
export const Markers = pgTable("Markers", {
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
noteId: uuid("noteId").references(() => Notes.id, {

View file

@ -1,9 +1,4 @@
/**
* RequestParser
* @file index.ts
* @module request-parser
* @description Parses Request object into a JavaScript object based on the content type
*/
import { parse } from "qs";
/**
* RequestParser
@ -98,6 +93,10 @@ export class RequestParser {
const formData = await this.request.formData();
const result: Partial<T> = {};
// Check if there are any files in the FormData
if (
Array.from(formData.values()).some((value) => value instanceof Blob)
) {
for (const [key, value] of formData.entries()) {
if (value instanceof Blob) {
result[key as keyof T] = value as T[keyof T];
@ -112,6 +111,21 @@ export class RequestParser {
result[key as keyof T] = value as T[keyof T];
}
}
} else {
// Convert to URLSearchParams and parse as query
const searchParams = new URLSearchParams([
...formData.entries(),
] as [string, string][]);
const parsed = parse(searchParams.toString(), {
parseArrays: true,
interpretNumericEntities: true,
});
return castBooleanObject(
parsed as PossiblyRecursiveObject,
) as Partial<T>;
}
return result;
}
@ -159,29 +173,49 @@ export class RequestParser {
* @returns JavaScript object of type T
*/
private parseQuery<T>(): Partial<T> {
const result: Partial<T> = {};
const url = new URL(this.request.url);
const parsed = parse(
new URL(this.request.url).searchParams.toString(),
{
parseArrays: true,
interpretNumericEntities: true,
},
);
for (const [key, value] of url.searchParams.entries()) {
if (decodeURIComponent(key).endsWith("[]")) {
const arrayKey = decodeURIComponent(key).slice(
0,
-2,
) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as string[]).push(decodeURIComponent(value));
} else {
result[key as keyof T] = castBoolean(
decodeURIComponent(value),
) as T[keyof T];
}
}
return result;
return castBooleanObject(
parsed as PossiblyRecursiveObject,
) as Partial<T>;
}
}
interface PossiblyRecursiveObject {
[key: string]:
| PossiblyRecursiveObject[]
| PossiblyRecursiveObject
| string
| string[]
| boolean;
}
// Recursive
const castBooleanObject = (value: PossiblyRecursiveObject | string) => {
if (typeof value === "string") {
return castBoolean(value);
}
for (const key in value) {
const child = value[key];
if (Array.isArray(child)) {
value[key] = child.map((v) => castBooleanObject(v)) as string[];
} else if (typeof child === "object") {
value[key] = castBooleanObject(child);
} else {
value[key] = castBoolean(child as string);
}
}
return value;
};
const castBoolean = (value: string) => {
if (["true"].includes(value)) {
return true;

View file

@ -2,5 +2,8 @@
"name": "request-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
"dependencies": { "qs": "^6.12.1" },
"devDependencies": {
"@types/qs": "^6.9.15"
}
}

View file

@ -24,6 +24,28 @@ describe("RequestParser", () => {
expect(result.test).toEqual(["value1", "value2"]);
});
test("With Array of objects", async () => {
const request = new Request(
"http://localhost?test[][key]=value1&test[][value]=value2",
);
const result = await new RequestParser(request).toObject<{
test: { key: string; value: string }[];
}>();
expect(result.test).toEqual([{ key: "value1", value: "value2" }]);
});
test("With Array of multiple objects", async () => {
const request = new Request(
"http://localhost?test[][key]=value1&test[][value]=value2&test[][key]=value3&test[][value]=value4",
);
const result = await new RequestParser(request).toObject<{
test: { key: string[]; value: string[] }[];
}>();
expect(result.test).toEqual([
{ key: ["value1", "value3"], value: ["value2", "value4"] },
]);
});
test("With both at once", async () => {
const request = new Request(
"http://localhost?param1=value1&param2=value2&test[]=value1&test[]=value2",

View file

@ -86,14 +86,12 @@ export const processRoute = async (
return errorResponse("Method not allowed", 405);
}
let auth: AuthData | null = null;
const auth: AuthData = await getFromRequest(request);
if (
route.meta.auth.required ||
route.meta.auth.requiredOnMethods?.includes(request.method as HttpVerb)
) {
auth = await getFromRequest(request);
if (!auth.user) {
return errorResponse(
"Unauthorized: access to this method requires an authenticated user",
@ -112,7 +110,7 @@ export const processRoute = async (
}
}
const parsedRequest = await new RequestParser(request)
const parsedRequest = await new RequestParser(request.clone())
.toObject()
.catch(async (err) => {
await logger.logError(

View file

@ -107,4 +107,71 @@ describe(meta.route, () => {
);
}
});
test("should not return notifications with filtered keywords", async () => {
const formData = new FormData();
formData.append("title", "Test Filter");
formData.append("context[]", "notifications");
formData.append("filter_action", "hide");
formData.append(
"keywords_attributes[0][keyword]",
timeline[0].content.slice(4, 20),
);
formData.append("keywords_attributes[0][whole_word]", "false");
const filterResponse = await sendTestRequest(
new Request(new URL("/api/v2/filters", config.http.base_url), {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
}),
);
expect(filterResponse.status).toBe(200);
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=20`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const objects = (await response.json()) as APINotification[];
expect(objects.length).toBe(3);
// There should be no element with a status with id of timeline[0].id
expect(objects).not.toContainEqual(
expect.objectContaining({
status: expect.objectContaining({ id: timeline[0].id }),
}),
);
// Delete filter
const filterDeleteResponse = await sendTestRequest(
new Request(
new URL(
`/api/v2/filters/${(await filterResponse.json()).id}`,
config.http.base_url,
),
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(filterDeleteResponse.status).toBe(200);
});
});

View file

@ -1,6 +1,7 @@
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 {
findManyNotifications,
@ -127,6 +128,24 @@ export default apiRoute<typeof meta, typeof schema>(
exclude_types
? not(inArray(notification.type, exclude_types))
: undefined,
// Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
// Filters table has a userId and a context which is an array
sql`NOT EXISTS (
SELECT 1
FROM "Filters"
WHERE "Filters"."userId" = ${user.id}
AND "Filters"."filter_action" = 'hide'
AND EXISTS (
SELECT 1
FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
WHERE "FilterKeywords"."filterId" = "Filters"."id"
AND "n_inner"."noteId" = "Notes"."id"
AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%'
AND "n_inner"."id" = "Notifications"."id"
)
AND "Filters"."context" @> ARRAY['notifications']
)`,
),
limit,
// @ts-expect-error Yes I KNOW the types are wrong

View file

@ -172,5 +172,72 @@ describe(meta.route, () => {
expect(status.id).toBe(timeline[index].id);
}
});
test("should not return statuses with filtered keywords", async () => {
const formData = new FormData();
formData.append("title", "Test Filter");
formData.append("context[]", "home");
formData.append("filter_action", "hide");
formData.append(
"keywords_attributes[0][keyword]",
timeline[0].content.slice(4, 20),
);
formData.append("keywords_attributes[0][whole_word]", "false");
const filterResponse = await sendTestRequest(
new Request(new URL("/api/v2/filters", config.http.base_url), {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
}),
);
expect(filterResponse.status).toBe(200);
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=20`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"application/json",
);
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
// There should be no element with id of timeline[0].id
expect(objects).not.toContainEqual(
expect.objectContaining({ id: timeline[0].id }),
);
// Delete filter
const filterDeleteResponse = await sendTestRequest(
new Request(
new URL(
`/api/v2/filters/${(await filterResponse.json()).id}`,
config.http.base_url,
),
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(filterDeleteResponse.status).toBe(200);
});
});
});

View file

@ -49,6 +49,10 @@ export default apiRoute<typeof meta, typeof schema>(
// WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`,
),
// Don't show statuses that have filtered words in them
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
// Filters table has a userId and a context which is an array
sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`,
),
limit,
req.url,

View file

@ -218,4 +218,69 @@ describe(meta.route, () => {
}
});
});
test("should not return statuses with filtered keywords", async () => {
const formData = new FormData();
formData.append("title", "Test Filter");
formData.append("context[]", "public");
formData.append("filter_action", "hide");
formData.append(
"keywords_attributes[0][keyword]",
timeline[0].content.slice(4, 20),
);
formData.append("keywords_attributes[0][whole_word]", "false");
const filterResponse = await sendTestRequest(
new Request(new URL("/api/v2/filters", config.http.base_url), {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
}),
);
expect(filterResponse.status).toBe(200);
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=20`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
// There should be no element with id of timeline[0].id
expect(objects).not.toContainEqual(
expect.objectContaining({ id: timeline[0].id }),
);
// Delete filter
const filterDeleteResponse = await sendTestRequest(
new Request(
new URL(
`/api/v2/filters/${(await filterResponse.json()).id}`,
config.http.base_url,
),
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(filterDeleteResponse.status).toBe(200);
});
});

View file

@ -52,6 +52,9 @@ export default apiRoute<typeof meta, typeof schema>(
only_media
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
: undefined,
user
? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])`
: undefined,
),
limit,
req.url,

View file

@ -0,0 +1,203 @@
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { getTestUsers, sendTestRequest } from "~tests/utils";
import { meta } from "./index";
const { users, tokens, deleteUsers } = await getTestUsers(2);
const formData = new FormData();
formData.append("title", "Test Filter");
formData.append("context[]", "home");
formData.append("filter_action", "warn");
formData.append("expires_in", "86400");
formData.append("keywords_attributes[0][keyword]", "test");
formData.append("keywords_attributes[0][whole_word]", "true");
const response = await sendTestRequest(
new Request(new URL("/api/v2/filters", config.http.base_url), {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
}),
);
expect(response.status).toBe(200);
const filter = await response.json();
expect(filter).toBeObject();
afterAll(async () => {
await deleteUsers();
});
// /api/v2/filters/:id
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url)),
);
expect(response.status).toBe(401);
});
test("should get that filter", async () => {
const response = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", filter.id),
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toBeObject();
expect(json).toContainKeys(["id", "title"]);
expect(json.title).toBe("Test Filter");
expect(json.context).toEqual(["home"]);
expect(json.filter_action).toBe("warn");
expect(json.expires_at).toBeString();
expect(json.keywords).toBeArray();
expect(json.keywords).not.toBeEmpty();
expect(json.keywords[0]).toContainKeys(["keyword", "whole_word"]);
expect(json.keywords[0].keyword).toEqual("test");
});
test("should edit that filter", async () => {
const formData = new FormData();
formData.append("title", "New Filter");
formData.append("context[]", "notifications");
formData.append("filter_action", "hide");
formData.append("expires_in", "86400");
formData.append("keywords_attributes[0][keyword]", "new");
formData.append("keywords_attributes[0][id]", filter.keywords[0].id);
formData.append("keywords_attributes[0][whole_word]", "false");
const response = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", filter.id),
config.http.base_url,
),
{
method: "PUT",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
},
),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toBeObject();
expect(json).toContainKeys(["id", "title"]);
expect(json.title).toBe("New Filter");
expect(json.context).toEqual(["notifications"]);
expect(json.filter_action).toBe("hide");
expect(json.expires_at).toBeString();
expect(json.keywords).toBeArray();
expect(json.keywords).not.toBeEmpty();
expect(json.keywords[0]).toContainKeys(["keyword", "whole_word"]);
expect(json.keywords[0].keyword).toEqual("new");
});
test("should delete keyword", async () => {
const formData = new FormData();
formData.append("keywords_attributes[0][id]", filter.keywords[0].id);
formData.append("keywords_attributes[0][_destroy]", "true");
const response = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", filter.id),
config.http.base_url,
),
{
method: "PUT",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
},
),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toBeObject();
expect(json.keywords).toBeEmpty();
// Get the filter again and check
const getResponse = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", filter.id),
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(getResponse.status).toBe(200);
expect((await getResponse.json()).keywords).toBeEmpty();
});
test("should delete filter", async () => {
const formData = new FormData();
const response = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", filter.id),
config.http.base_url,
),
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
},
),
);
expect(response.status).toBe(200);
// Try to GET the filter again
const getResponse = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", filter.id),
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(getResponse.status).toBe(404);
});
});

View file

@ -0,0 +1,178 @@
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { and, eq, inArray, type InferSelectModel } from "drizzle-orm";
import { z } from "zod";
import { db } from "~drizzle/db";
import { FilterKeywords, Filters } from "~drizzle/schema";
export const meta = applyConfig({
allowedMethods: ["GET", "PUT", "DELETE"],
route: "/api/v2/filters/:id",
ratelimits: {
max: 60,
duration: 60,
},
auth: {
required: true,
},
});
export const schema = z.object({
title: z.string().min(1).max(100).optional(),
context: z
.array(z.enum(["home", "notifications", "public", "thread", "account"]))
.optional(),
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
keywords_attributes: z
.array(
z.object({
keyword: z.string().min(1).max(100).optional(),
id: z.string().regex(idValidator).optional(),
whole_word: z.boolean().optional(),
_destroy: z.boolean().optional(),
}),
)
.optional(),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const id = matchedRoute.params.id;
if (!id.match(idValidator)) return errorResponse("Invalid ID", 400);
if (!user) return errorResponse("Unauthorized", 401);
const userFilter = await db.query.Filters.findFirst({
where: (filter, { eq, and }) =>
and(eq(filter.userId, user.id), eq(filter.id, id)),
with: {
keywords: true,
},
});
if (!userFilter) return errorResponse("Filter not found", 404);
switch (req.method) {
case "GET": {
return jsonResponse({
id: userFilter.id,
title: userFilter.title,
context: userFilter.context,
expires_at: userFilter.expireAt
? new Date(userFilter.expireAt).toISOString()
: null,
filter_action: userFilter.filterAction,
keywords: userFilter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
});
}
case "PUT": {
const {
title,
context,
filter_action,
expires_in,
keywords_attributes,
} = extraData.parsedRequest;
await db
.update(Filters)
.set({
title,
context,
filterAction: filter_action,
expireAt: new Date(
Date.now() + (expires_in ?? 0),
).toISOString(),
})
.where(
and(eq(Filters.userId, user.id), eq(Filters.id, id)),
);
const toUpdate = keywords_attributes
?.filter((keyword) => keyword.id && !keyword._destroy)
.map((keyword) => ({
keyword: keyword.keyword,
wholeWord: keyword.whole_word ?? false,
id: keyword.id,
}));
const toDelete = keywords_attributes
?.filter((keyword) => keyword._destroy && keyword.id)
.map((keyword) => keyword.id ?? "");
if (toUpdate && toUpdate.length > 0) {
for (const keyword of toUpdate) {
await db
.update(FilterKeywords)
.set(keyword)
.where(
and(
eq(FilterKeywords.filterId, id),
eq(FilterKeywords.id, keyword.id ?? ""),
),
);
}
}
if (toDelete && toDelete.length > 0) {
await db
.delete(FilterKeywords)
.where(
and(
eq(FilterKeywords.filterId, id),
inArray(FilterKeywords.id, toDelete),
),
);
}
const updatedFilter = await db.query.Filters.findFirst({
where: (filter, { eq, and }) =>
and(eq(filter.userId, user.id), eq(filter.id, id)),
with: {
keywords: true,
},
});
if (!updatedFilter)
return errorResponse("Failed to update filter", 500);
return jsonResponse({
id: updatedFilter.id,
title: updatedFilter.title,
context: updatedFilter.context,
expires_at: updatedFilter.expireAt
? new Date(updatedFilter.expireAt).toISOString()
: null,
filter_action: updatedFilter.filterAction,
keywords: updatedFilter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
});
}
case "DELETE": {
await db
.delete(Filters)
.where(
and(eq(Filters.userId, user.id), eq(Filters.id, id)),
);
return jsonResponse({});
}
}
},
);

View file

@ -0,0 +1,72 @@
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { getTestUsers, sendTestRequest } from "~tests/utils";
import { meta } from "./index";
const { users, tokens, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
// /api/v2/filters
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url)),
);
expect(response.status).toBe(401);
});
test("should return user filters (none)", 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(200);
const json = await response.json();
expect(json).toBeArray();
expect(json).toBeEmpty();
});
test("should create a new filter", async () => {
const formData = new FormData();
formData.append("title", "Test Filter");
formData.append("context[]", "home");
formData.append("filter_action", "warn");
formData.append("expires_in", "86400");
formData.append("keywords_attributes[0][keyword]", "test");
formData.append("keywords_attributes[0][whole_word]", "true");
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
body: formData,
}),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toBeObject();
expect(json).toContainKeys(["id", "title"]);
expect(json.title).toBe("Test Filter");
expect(json.context).toEqual(["home"]);
expect(json.filter_action).toBe("warn");
expect(json.expires_at).toBeString();
expect(json.keywords).toBeArray();
expect(json.keywords).not.toBeEmpty();
expect(json.keywords[0]).toContainKeys(["keyword", "whole_word"]);
expect(json.keywords[0].keyword).toEqual("test");
});
});

View file

@ -0,0 +1,155 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import type { InferSelectModel } from "drizzle-orm";
import { z } from "zod";
import { db } from "~drizzle/db";
import { FilterKeywords, Filters } from "~drizzle/schema";
export const meta = applyConfig({
allowedMethods: ["GET", "POST"],
route: "/api/v2/filters",
ratelimits: {
max: 60,
duration: 60,
},
auth: {
required: true,
},
});
export const schema = z.object({
title: z.string().min(1).max(100).optional(),
context: z
.array(z.enum(["home", "notifications", "public", "thread", "account"]))
.optional(),
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
keywords_attributes: z
.array(
z.object({
keyword: z.string().min(1).max(100),
whole_word: z.boolean().optional(),
}),
)
.optional(),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
switch (req.method) {
case "GET": {
const userFilters = await db.query.Filters.findMany({
where: (filter, { eq }) => eq(filter.userId, user.id),
with: {
keywords: true,
},
});
return jsonResponse(
userFilters.map((filter) => ({
id: filter.id,
title: filter.title,
context: filter.context,
expires_at: filter.expireAt
? new Date(
Date.now() + filter.expireAt,
).toISOString()
: null,
filter_action: filter.filterAction,
keywords: filter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
})),
);
}
case "POST": {
const {
title,
context,
filter_action,
expires_in,
keywords_attributes,
} = extraData.parsedRequest;
if (!title || context?.length === 0) {
return errorResponse(
"Missing required fields (title and context)",
422,
);
}
const newFilter = (
await db
.insert(Filters)
.values({
title: title ?? "",
context: context ?? [],
filterAction: filter_action,
expireAt: new Date(
Date.now() + (expires_in ?? 0),
).toISOString(),
userId: user.id,
})
.returning()
)[0];
if (!newFilter)
return errorResponse("Failed to create filter", 500);
const insertedKeywords =
keywords_attributes && keywords_attributes.length > 0
? await db
.insert(FilterKeywords)
.values(
keywords_attributes?.map((keyword) => ({
filterId: newFilter.id,
keyword: keyword.keyword,
wholeWord: keyword.whole_word ?? false,
})) ?? [],
)
.returning()
: [];
return jsonResponse({
id: newFilter.id,
title: newFilter.title,
context: newFilter.context,
expires_at: expires_in
? new Date(Date.now() + expires_in).toISOString()
: null,
filter_action: newFilter.filterAction,
keywords: insertedKeywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
} as {
id: string;
title: string;
context: string[];
expires_at: string;
filter_action: "warn" | "hide";
keywords: {
id: string;
keyword: string;
whole_word: boolean;
}[];
statuses: [];
});
}
}
},
);