mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Implement filters API v2 (with some routes missing)
This commit is contained in:
parent
ce082f8e6a
commit
a37e8e92c5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
28
drizzle/0014_wonderful_sandman.sql
Normal file
28
drizzle/0014_wonderful_sandman.sql
Normal 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 $$;
|
||||
1965
drizzle/meta/0014_snapshot.json
Normal file
1965
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,104 +1,111 @@
|
|||
{
|
||||
"version": "5",
|
||||
"dialect": "pg",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1712805159664,
|
||||
"tag": "0000_illegal_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1713055774123,
|
||||
"tag": "0001_salty_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1713056370431,
|
||||
"tag": "0002_stiff_ares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1713056528340,
|
||||
"tag": "0003_spicy_arachne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1713056712218,
|
||||
"tag": "0004_burly_lockjaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1713056917973,
|
||||
"tag": "0005_sleepy_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1713057159867,
|
||||
"tag": "0006_messy_network",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1713227918208,
|
||||
"tag": "0007_naive_sleeper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1713246700119,
|
||||
"tag": "0008_flawless_brother_voodoo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1713327832438,
|
||||
"tag": "0009_easy_slyde",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1713327880929,
|
||||
"tag": "0010_daffy_frightful_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1713333611707,
|
||||
"tag": "0011_special_the_fury",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "5",
|
||||
"when": 1713336108114,
|
||||
"tag": "0012_certain_thor_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "5",
|
||||
"when": 1713336611301,
|
||||
"tag": "0013_wandering_celestials",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
"version": "5",
|
||||
"dialect": "pg",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1712805159664,
|
||||
"tag": "0000_illegal_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1713055774123,
|
||||
"tag": "0001_salty_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1713056370431,
|
||||
"tag": "0002_stiff_ares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1713056528340,
|
||||
"tag": "0003_spicy_arachne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1713056712218,
|
||||
"tag": "0004_burly_lockjaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1713056917973,
|
||||
"tag": "0005_sleepy_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1713057159867,
|
||||
"tag": "0006_messy_network",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1713227918208,
|
||||
"tag": "0007_naive_sleeper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1713246700119,
|
||||
"tag": "0008_flawless_brother_voodoo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1713327832438,
|
||||
"tag": "0009_easy_slyde",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1713327880929,
|
||||
"tag": "0010_daffy_frightful_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1713333611707,
|
||||
"tag": "0011_special_the_fury",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "5",
|
||||
"when": 1713336108114,
|
||||
"tag": "0012_certain_thor_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "5",
|
||||
"when": 1713336611301,
|
||||
"tag": "0013_wandering_celestials",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "5",
|
||||
"when": 1713389937821,
|
||||
"tag": "0014_wonderful_sandman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,19 +93,38 @@ export class RequestParser {
|
|||
const formData = await this.request.formData();
|
||||
const result: Partial<T> = {};
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value instanceof Blob) {
|
||||
result[key as keyof T] = value as T[keyof T];
|
||||
} else if (key.endsWith("[]")) {
|
||||
const arrayKey = key.slice(0, -2) as keyof T;
|
||||
if (!result[arrayKey]) {
|
||||
result[arrayKey] = [] as T[keyof 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];
|
||||
} else if (key.endsWith("[]")) {
|
||||
const arrayKey = key.slice(0, -2) as keyof T;
|
||||
if (!result[arrayKey]) {
|
||||
result[arrayKey] = [] as T[keyof T];
|
||||
}
|
||||
|
||||
(result[arrayKey] as FormDataEntryValue[]).push(value);
|
||||
} else {
|
||||
result[key as keyof T] = value as T[keyof T];
|
||||
(result[arrayKey] as FormDataEntryValue[]).push(value);
|
||||
} else {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"name": "request-parser",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {}
|
||||
"name": "request-parser",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": { "qs": "^6.12.1" },
|
||||
"devDependencies": {
|
||||
"@types/qs": "^6.9.15"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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¶m2=value2&test[]=value1&test[]=value2",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
203
server/api/api/v2/filters/[id]/index.test.ts
Normal file
203
server/api/api/v2/filters/[id]/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
server/api/api/v2/filters/[id]/index.ts
Normal file
178
server/api/api/v2/filters/[id]/index.ts
Normal 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({});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
72
server/api/api/v2/filters/index.test.ts
Normal file
72
server/api/api/v2/filters/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
155
server/api/api/v2/filters/index.ts
Normal file
155
server/api/api/v2/filters/index.ts
Normal 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: [];
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
Loading…
Reference in a new issue