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/v1/trends/tags`
|
||||||
- [ ] `/api/v2/filters/:filter_id/keywords` (`GET`, `POST`)
|
- [ ] `/api/v2/filters/:filter_id/keywords` (`GET`, `POST`)
|
||||||
- [ ] `/api/v2/filters/:filter_id/statuses` (`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/keywords/:id` (`GET`, `PUT`, `DELETE`)
|
||||||
- [ ] `/api/v2/filters/statuses/:id` (`GET`, `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/instance`
|
||||||
- [x] `/api/v2/media`
|
- [x] `/api/v2/media`
|
||||||
- [x] `/api/v2/search`
|
- [x] `/api/v2/search`
|
||||||
|
|
@ -248,7 +248,6 @@ Working endpoints are:
|
||||||
### Main work to do
|
### Main work to do
|
||||||
|
|
||||||
- [ ] Announcements
|
- [ ] Announcements
|
||||||
- [ ] Filters
|
|
||||||
- [ ] Polls
|
- [ ] Polls
|
||||||
- [ ] Tags
|
- [ ] Tags
|
||||||
- [ ] Lists
|
- [ ] Lists
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,16 @@ export default {
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
schema: "./drizzle/schema.ts",
|
schema: "./drizzle/schema.ts",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
/* host: "localhost",
|
host: "localhost",
|
||||||
port: 40000,
|
port: 40000,
|
||||||
user: "lysand",
|
user: "lysand",
|
||||||
password: "lysand",
|
password: "lysand",
|
||||||
database: "lysand", */
|
database: "lysand",
|
||||||
host: config.database.host,
|
/* host: config.database.host,
|
||||||
port: Number(config.database.port),
|
port: Number(config.database.port),
|
||||||
user: config.database.username,
|
user: config.database.username,
|
||||||
password: config.database.password,
|
password: config.database.password,
|
||||||
database: config.database.database,
|
database: config.database.database, */
|
||||||
},
|
},
|
||||||
// Print all statements
|
// Print all statements
|
||||||
verbose: true,
|
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",
|
"version": "5",
|
||||||
"dialect": "pg",
|
"dialect": "pg",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1712805159664,
|
"when": 1712805159664,
|
||||||
"tag": "0000_illegal_living_lightning",
|
"tag": "0000_illegal_living_lightning",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713055774123,
|
"when": 1713055774123,
|
||||||
"tag": "0001_salty_night_thrasher",
|
"tag": "0001_salty_night_thrasher",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713056370431,
|
"when": 1713056370431,
|
||||||
"tag": "0002_stiff_ares",
|
"tag": "0002_stiff_ares",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713056528340,
|
"when": 1713056528340,
|
||||||
"tag": "0003_spicy_arachne",
|
"tag": "0003_spicy_arachne",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713056712218,
|
"when": 1713056712218,
|
||||||
"tag": "0004_burly_lockjaw",
|
"tag": "0004_burly_lockjaw",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713056917973,
|
"when": 1713056917973,
|
||||||
"tag": "0005_sleepy_puma",
|
"tag": "0005_sleepy_puma",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 6,
|
"idx": 6,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713057159867,
|
"when": 1713057159867,
|
||||||
"tag": "0006_messy_network",
|
"tag": "0006_messy_network",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713227918208,
|
"when": 1713227918208,
|
||||||
"tag": "0007_naive_sleeper",
|
"tag": "0007_naive_sleeper",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713246700119,
|
"when": 1713246700119,
|
||||||
"tag": "0008_flawless_brother_voodoo",
|
"tag": "0008_flawless_brother_voodoo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 9,
|
"idx": 9,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713327832438,
|
"when": 1713327832438,
|
||||||
"tag": "0009_easy_slyde",
|
"tag": "0009_easy_slyde",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 10,
|
"idx": 10,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713327880929,
|
"when": 1713327880929,
|
||||||
"tag": "0010_daffy_frightful_four",
|
"tag": "0010_daffy_frightful_four",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 11,
|
"idx": 11,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713333611707,
|
"when": 1713333611707,
|
||||||
"tag": "0011_special_the_fury",
|
"tag": "0011_special_the_fury",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 12,
|
"idx": 12,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713336108114,
|
"when": 1713336108114,
|
||||||
"tag": "0012_certain_thor_girl",
|
"tag": "0012_certain_thor_girl",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 13,
|
"idx": 13,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1713336611301,
|
"when": 1713336611301,
|
||||||
"tag": "0013_wandering_celestials",
|
"tag": "0013_wandering_celestials",
|
||||||
"breakpoints": true
|
"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", {
|
export const Markers = pgTable("Markers", {
|
||||||
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
|
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
|
||||||
noteId: uuid("noteId").references(() => Notes.id, {
|
noteId: uuid("noteId").references(() => Notes.id, {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
/**
|
import { parse } from "qs";
|
||||||
* RequestParser
|
|
||||||
* @file index.ts
|
|
||||||
* @module request-parser
|
|
||||||
* @description Parses Request object into a JavaScript object based on the content type
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RequestParser
|
* RequestParser
|
||||||
|
|
@ -98,19 +93,38 @@ export class RequestParser {
|
||||||
const formData = await this.request.formData();
|
const formData = await this.request.formData();
|
||||||
const result: Partial<T> = {};
|
const result: Partial<T> = {};
|
||||||
|
|
||||||
for (const [key, value] of formData.entries()) {
|
// Check if there are any files in the FormData
|
||||||
if (value instanceof Blob) {
|
if (
|
||||||
result[key as keyof T] = value as T[keyof T];
|
Array.from(formData.values()).some((value) => value instanceof Blob)
|
||||||
} else if (key.endsWith("[]")) {
|
) {
|
||||||
const arrayKey = key.slice(0, -2) as keyof T;
|
for (const [key, value] of formData.entries()) {
|
||||||
if (!result[arrayKey]) {
|
if (value instanceof Blob) {
|
||||||
result[arrayKey] = [] as T[keyof T];
|
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);
|
(result[arrayKey] as FormDataEntryValue[]).push(value);
|
||||||
} else {
|
} else {
|
||||||
result[key as keyof T] = value as T[keyof T];
|
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;
|
return result;
|
||||||
|
|
@ -159,29 +173,49 @@ export class RequestParser {
|
||||||
* @returns JavaScript object of type T
|
* @returns JavaScript object of type T
|
||||||
*/
|
*/
|
||||||
private parseQuery<T>(): Partial<T> {
|
private parseQuery<T>(): Partial<T> {
|
||||||
const result: Partial<T> = {};
|
const parsed = parse(
|
||||||
const url = new URL(this.request.url);
|
new URL(this.request.url).searchParams.toString(),
|
||||||
|
{
|
||||||
|
parseArrays: true,
|
||||||
|
interpretNumericEntities: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (const [key, value] of url.searchParams.entries()) {
|
return castBooleanObject(
|
||||||
if (decodeURIComponent(key).endsWith("[]")) {
|
parsed as PossiblyRecursiveObject,
|
||||||
const arrayKey = decodeURIComponent(key).slice(
|
) as Partial<T>;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const castBoolean = (value: string) => {
|
||||||
if (["true"].includes(value)) {
|
if (["true"].includes(value)) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "request-parser",
|
"name": "request-parser",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"dependencies": {}
|
"dependencies": { "qs": "^6.12.1" },
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/qs": "^6.9.15"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,28 @@ describe("RequestParser", () => {
|
||||||
expect(result.test).toEqual(["value1", "value2"]);
|
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 () => {
|
test("With both at once", async () => {
|
||||||
const request = new Request(
|
const request = new Request(
|
||||||
"http://localhost?param1=value1¶m2=value2&test[]=value1&test[]=value2",
|
"http://localhost?param1=value1¶m2=value2&test[]=value1&test[]=value2",
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,12 @@ export const processRoute = async (
|
||||||
return errorResponse("Method not allowed", 405);
|
return errorResponse("Method not allowed", 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth: AuthData | null = null;
|
const auth: AuthData = await getFromRequest(request);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
route.meta.auth.required ||
|
route.meta.auth.required ||
|
||||||
route.meta.auth.requiredOnMethods?.includes(request.method as HttpVerb)
|
route.meta.auth.requiredOnMethods?.includes(request.method as HttpVerb)
|
||||||
) {
|
) {
|
||||||
auth = await getFromRequest(request);
|
|
||||||
|
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"Unauthorized: access to this method requires an authenticated user",
|
"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()
|
.toObject()
|
||||||
.catch(async (err) => {
|
.catch(async (err) => {
|
||||||
await logger.logError(
|
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 { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { fetchTimeline } from "@timelines";
|
import { fetchTimeline } from "@timelines";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
findManyNotifications,
|
findManyNotifications,
|
||||||
|
|
@ -127,6 +128,24 @@ export default apiRoute<typeof meta, typeof schema>(
|
||||||
exclude_types
|
exclude_types
|
||||||
? not(inArray(notification.type, exclude_types))
|
? not(inArray(notification.type, exclude_types))
|
||||||
: undefined,
|
: 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,
|
limit,
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
|
|
||||||
|
|
@ -172,5 +172,72 @@ describe(meta.route, () => {
|
||||||
expect(status.id).toBe(timeline[index].id);
|
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 (... = ...)
|
// WHERE format (... = ...)
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`,
|
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,
|
limit,
|
||||||
req.url,
|
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
|
only_media
|
||||||
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
|
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
|
||||||
: undefined,
|
: 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,
|
limit,
|
||||||
req.url,
|
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