Switch all routes to use Zod for strict validation

This commit is contained in:
Jesse Wierzbinski 2024-04-14 00:36:25 -10:00
parent 53fa9ca545
commit 0b1c1ba128
No known key found for this signature in database
67 changed files with 2459 additions and 2600 deletions

BIN
bun.lockb

Binary file not shown.

4
cli.ts
View file

@ -5,7 +5,9 @@ import { Parser } from "@json2csv/plainjs";
import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch";
import chalk from "chalk";
import { CliBuilder, CliCommand } from "cli-parser";
import { CliParameterType } from "cli-parser/cli-builder.type";
import Table from "cli-table";
import { config } from "config-manager";
import { type SQL, eq, inArray, isNotNull, isNull, like } from "drizzle-orm";
import extract from "extract-zip";
import { MediaBackend } from "media-manager";
@ -20,8 +22,6 @@ import {
} from "~database/entities/User";
import { db } from "~drizzle/db";
import { emoji, openIdAccount, status, user } from "~drizzle/schema";
import { CliParameterType } from "cli-parser/cli-builder.type";
import { config } from "config-manager";
const args = process.argv;

View file

@ -25,7 +25,7 @@ import {
letter,
maybe,
oneOrMore,
} from "magic-regexp/further-magic";
} from "magic-regexp";
import { parse } from "marked";
import { db } from "~drizzle/db";
import {

View file

@ -1,9 +1,9 @@
import { config } from "config-manager";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { Client } from "pg";
import * as schema from "./schema";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { migrate } from "drizzle-orm/postgres-js/migrator";
export const client = new Client({
host: config.database.host,

View file

@ -98,6 +98,7 @@
"oauth4webapi": "^2.4.0",
"pg": "^8.11.5",
"request-parser": "workspace:*",
"sharp": "^0.33.3"
"sharp": "^0.33.3",
"zod": "^3.22.4"
}
}

View file

@ -58,11 +58,11 @@ describe("RequestParser", () => {
headers: { "Content-Type": "application/json" },
body: "invalid json",
});
const result = await new RequestParser(request).toObject<{
const result = new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({});
expect(result).rejects.toThrow();
});
describe("should parse form data correctly", () => {

View file

@ -108,8 +108,8 @@ export const processRoute = async (
}
}
// Check if Content-Type header is missing in POST, PUT and PATCH requests
if (["POST", "PUT", "PATCH"].includes(request.method)) {
// Check if Content-Type header is missing if there is a body
if (request.body) {
if (!request.headers.has("Content-Type")) {
return errorResponse(
`Content-Type header is missing but required on method ${request.method}`,

View file

@ -120,7 +120,7 @@ describe("Route Processor", () => {
expect(output.status).toBe(401);
});
it("should return a 400 when the Content-Type header is missing in POST, PUT and PATCH requests", async () => {
it("should return a 400 when the Content-Type header is missing but there is a body", async () => {
mock.module(
"./route",
() =>
@ -147,35 +147,12 @@ describe("Route Processor", () => {
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
body: "test",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(400);
const output2 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PUT",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output2.status).toBe(400);
const output3 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PATCH",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output3.status).toBe(400);
});
it("should return a 400 when the request could not be parsed", async () => {

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;

View file

@ -1,5 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
@ -20,15 +22,23 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
reblogs: z.coerce.boolean().optional(),
notify: z.coerce.boolean().optional(),
languages: z
.array(z.enum(ISO6391.getAllCodes() as [string, ...string[]]))
.optional(),
});
/**
* Follow a user
*/
export default apiRoute<{
reblogs?: boolean;
notify?: boolean;
languages?: string[];
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;
@ -57,4 +67,5 @@ export default apiRoute<{
}
return jsonResponse(relationshipToAPI(relationship));
});
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type UserWithRelations,
findFirstUser,
@ -21,26 +22,30 @@ export const meta = applyConfig({
},
});
const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!otherUser) return errorResponse("User not found", 404);
const { objects, link } = await fetchTimeline<UserWithRelations>(
@ -56,6 +61,7 @@ export default apiRoute<{
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
@ -67,4 +73,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type UserWithRelations,
findFirstUser,
@ -21,26 +22,30 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const otherUser = await findFirstUser({
where: (user, { eq }) => eq(user.id, id),
});
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!otherUser) return errorResponse("User not found", 404);
const { objects, link } = await fetchTimeline<UserWithRelations>(
@ -56,6 +61,7 @@ export default apiRoute<{
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
@ -67,4 +73,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { findFirstUser, userToAPI } from "~database/entities/User";
@ -20,13 +20,8 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
// Check if ID is valid UUIDv7
if (
!id.match(
/^[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
)
) {
return errorResponse("Invalid ID", 404);
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { relationshipToAPI } from "~database/entities/Relationship";
import {
findFirstUser,
@ -22,14 +23,25 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
notifications: z.coerce.boolean().optional(),
duration: z
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
});
/**
* Mute a user
*/
export default apiRoute<{
notifications: boolean;
duration: number;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;
@ -64,4 +76,5 @@ export default apiRoute<{
// TODO: Implement duration
return jsonResponse(relationshipToAPI(foundRelationship));
});
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -8,6 +8,7 @@ import {
} from "~database/entities/User";
import { db } from "~drizzle/db";
import { relationship } from "~drizzle/schema";
import { z } from "zod";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -22,13 +23,19 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
comment: z.string().min(0).max(5000).optional(),
});
/**
* Sets a user note
*/
export default apiRoute<{
comment: string;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;
@ -43,7 +50,10 @@ export default apiRoute<{
if (!otherUser) return errorResponse("User not found", 404);
// Check if already following
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
const foundRelationship = await getRelationshipToOtherUser(
self,
otherUser,
);
foundRelationship.note = comment ?? "";
@ -55,4 +65,5 @@ export default apiRoute<{
.where(eq(relationship.id, foundRelationship.id));
return jsonResponse(relationshipToAPI(foundRelationship));
});
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { and, eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type StatusWithRelations,
findManyStatuses,
@ -21,31 +22,36 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
only_media: z.coerce.boolean().optional(),
exclude_replies: z.coerce.boolean().optional(),
exclude_reblogs: z.coerce.boolean().optional(),
pinned: z.coerce.boolean().optional(),
tagged: z.string().optional(),
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: string;
only_media?: boolean;
exclude_replies?: boolean;
exclude_reblogs?: boolean;
// TODO: Add with_muted
pinned?: boolean;
tagged?: string;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
// TODO: Add pinned
const {
max_id,
min_id,
since_id,
limit = "20",
limit,
exclude_reblogs,
only_media = false,
only_media,
pinned,
} = extraData.parsedRequest;
@ -73,6 +79,7 @@ export default apiRoute<{
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
);
@ -105,15 +112,19 @@ export default apiRoute<{
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
);
return jsonResponse(
await Promise.all(objects.map((status) => statusToAPI(status, user))),
await Promise.all(
objects.map((status) => statusToAPI(status, user)),
),
200,
{
Link: link,
},
);
});
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -24,6 +24,9 @@ export const meta = applyConfig({
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { relationshipToAPI } from "~database/entities/Relationship";
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user: self } = extraData.auth;

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { findManyUsers, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db";
@ -16,23 +17,21 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
id: z.array(z.string().regex(idValidator)).min(1).max(10),
});
/**
* Find familiar followers (followers of a user that you also follow)
*/
export default apiRoute<{
id: string[];
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
const idFollowerRelationships = await db.query.relationship.findMany({
columns: {
ownerId: true,
@ -81,4 +80,5 @@ export default apiRoute<{
}
return jsonResponse(finalUsers.map((o) => userToAPI(o)));
});
},
);

View file

@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api";
import { jsonResponse, response } from "@response";
import { tempmailDomains } from "@tempmail";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { createNewLocalUser, findFirstUser } from "~database/entities/User";
export const meta = applyConfig({
@ -17,14 +18,18 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
username: string;
email: string;
password: string;
agreement: boolean;
locale: string;
reason: string;
}>(async (req, matchedRoute, extraData) => {
// No validation on the Zod side as we need to do custom validation
export const schema = z.object({
username: z.string(),
email: z.string(),
password: z.string(),
agreement: z.boolean(),
locale: z.string(),
reason: z.string(),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
// TODO: Add Authorization check
const body = extraData.parsedRequest;
@ -96,7 +101,9 @@ export default apiRoute<{
// Check if username doesnt match filters
if (
config.filters.username.some((filter) => body.username?.match(filter))
config.filters.username.some((filter) =>
body.username?.match(filter),
)
) {
errors.details.username.push({
error: "ERR_INVALID",
@ -152,7 +159,9 @@ export default apiRoute<{
if (
config.validation.email_blacklist.includes(body.email ?? "") ||
(config.validation.blacklist_tempmail &&
tempmailDomains.domains.includes((body.email ?? "").split("@")[1]))
tempmailDomains.domains.includes(
(body.email ?? "").split("@")[1],
))
)
errors.details.email.push({
error: "ERR_BLOCKED",
@ -206,4 +215,5 @@ export default apiRoute<{
});
return response(null, 200);
});
},
);

View file

@ -21,45 +21,6 @@ afterAll(async () => {
// /api/v1/accounts/lookup
describe(meta.route, () => {
test("should return 400 if acct is missing", 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(400);
});
test("should return 400 if acct is empty", async () => {
const response = await sendTestRequest(
new Request(new URL(`${meta.route}?acct=`, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(400);
});
test("should return 404 if acct is invalid", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?acct=invalid`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(404);
});
test("should return 200 with users", async () => {
const response = await sendTestRequest(
new Request(

View file

@ -1,5 +1,17 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import {
anyOf,
charIn,
createRegExp,
digit,
exactly,
letter,
maybe,
oneOrMore,
global,
} from "magic-regexp";
import { z } from "zod";
import {
findFirstUser,
resolveWebFinger,
@ -19,9 +31,12 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
acct: string;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
acct: z.string().min(1).max(512),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { acct } = extraData.parsedRequest;
if (!acct) {
@ -29,9 +44,21 @@ export default apiRoute<{
}
// Check if acct is matching format username@domain.com or @username@domain.com
const accountMatches = acct
?.trim()
.match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g);
const accountMatches = acct?.trim().match(
createRegExp(
maybe("@"),
oneOrMore(
anyOf(letter.lowercase, digit, charIn("-")),
).groupedAs("username"),
exactly("@"),
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
"domain",
),
[global],
),
);
if (accountMatches) {
// Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) {
@ -66,5 +93,9 @@ export default apiRoute<{
return jsonResponse(userToAPI(account));
}
return errorResponse(`Account with username ${username} not found`, 404);
});
return errorResponse(
`Account with username ${username} not found`,
404,
);
},
);

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import {
createNewRelationship,
relationshipToAPI,
@ -20,23 +21,21 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
id: z.array(z.string().regex(idValidator)).min(1).max(10),
});
/**
* Find relationships
*/
export default apiRoute<{
id: string[];
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422);
}
const relationships = await db.query.relationship.findMany({
where: (relationship, { inArray, and, eq }) =>
and(
@ -52,7 +51,9 @@ export default apiRoute<{
// Create the missing relationships
for (const id of missingIds) {
const relationship = await createNewRelationship(self, { id } as User);
const relationship = await createNewRelationship(self, {
id,
} as User);
relationships.push(relationship);
}
@ -63,4 +64,5 @@ export default apiRoute<{
);
return jsonResponse(relationships.map((r) => relationshipToAPI(r)));
});
},
);

View file

@ -8,7 +8,6 @@ import {
sendTestRequest,
} from "~tests/utils";
import type { APIAccount } from "~types/entities/account";
import type { APIStatus } from "~types/entities/status";
import { meta } from "./index";
await deleteOldTestUsers();
@ -21,66 +20,6 @@ afterAll(async () => {
// /api/v1/accounts/search
describe(meta.route, () => {
test("should return 400 if q is missing", 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(400);
});
test("should return 400 if q is empty", async () => {
const response = await sendTestRequest(
new Request(new URL(`${meta.route}?q=`, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?q=${users[0].username}&limit=0`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?q=${users[0].username}&limit=100`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 200 with users", async () => {
const response = await sendTestRequest(
new Request(

View file

@ -1,6 +1,18 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { sql } from "drizzle-orm";
import {
createRegExp,
maybe,
oneOrMore,
anyOf,
letter,
digit,
charIn,
exactly,
global,
} from "magic-regexp";
import { z } from "zod";
import {
type UserWithRelations,
findManyUsers,
@ -22,17 +34,38 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
q?: string;
limit?: number;
offset?: number;
resolve?: boolean;
following?: boolean;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
q: z
.string()
.min(1)
.max(512)
.regex(
createRegExp(
maybe("@"),
oneOrMore(
anyOf(letter.lowercase, digit, charIn("-")),
).groupedAs("username"),
maybe(
exactly("@"),
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
"domain",
),
),
[global],
),
),
limit: z.coerce.number().int().min(1).max(80).default(40),
offset: z.coerce.number().int().optional(),
resolve: z.coerce.boolean().optional(),
following: z.coerce.boolean().optional(),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts
const {
following = false,
limit = 40,
limit,
offset,
resolve,
q,
@ -42,15 +75,8 @@ export default apiRoute<{
if (!self && following) return errorResponse("Unauthorized", 401);
if (limit < 1 || limit > 80) {
return errorResponse("Limit must be between 1 and 80", 400);
}
if (!q) {
return errorResponse("Query is required", 400);
}
const [username, host] = q?.split("@") || [];
// Remove any leading @
const [username, host] = q.replace(/^@/, "").split("@");
const accounts: UserWithRelations[] = [];
@ -71,10 +97,12 @@ export default apiRoute<{
? sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${account.id} AND "Relationship"."following" = true)`
: undefined,
),
offset: Number(offset),
offset,
limit,
})),
);
}
return jsonResponse(accounts.map((acct) => userToAPI(acct)));
});
},
);

View file

@ -7,11 +7,13 @@ import ISO6391 from "iso-639-1";
import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { z } from "zod";
import { getUrl } from "~database/entities/Attachment";
import { parseEmojis } from "~database/entities/Emoji";
import { findFirstUser, userToAPI } from "~database/entities/User";
import { db } from "~drizzle/db";
import { emojiToUser, user } from "~drizzle/schema";
import { config } from "config-manager";
import type { APISource } from "~types/entities/source";
export const meta = applyConfig({
@ -27,18 +29,29 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
display_name: string;
note: string;
avatar: File;
header: File;
locked: string;
bot: string;
discoverable: string;
"source[privacy]": string;
"source[sensitive]": string;
"source[language]": string;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
display_name: z
.string()
.min(3)
.max(config.validation.max_displayname_size)
.optional(),
note: z.string().min(0).max(config.validation.max_bio_size).optional(),
avatar: z.instanceof(File).optional(),
header: z.instanceof(File).optional(),
locked: z.boolean().optional(),
bot: z.boolean().optional(),
discoverable: z.boolean().optional(),
"source[privacy]": z
.enum(["public", "unlisted", "private", "direct"])
.optional(),
"source[sensitive]": z.boolean().optional(),
"source[language]": z
.enum(ISO6391.getAllCodes() as [string, ...string[]])
.optional(),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -89,84 +102,42 @@ export default apiRoute<{
}
if (display_name) {
// Check if within allowed display name lengths
if (
sanitizedDisplayName.length < 3 ||
sanitizedDisplayName.length > config.validation.max_displayname_size
) {
return errorResponse(
`Display name must be between 3 and ${config.validation.max_displayname_size} characters`,
422,
);
}
// Check if display name doesnt match filters
if (
config.filters.displayname.some((filter) =>
sanitizedDisplayName.match(filter),
)
) {
return errorResponse("Display name contains blocked words", 422);
return errorResponse(
"Display name contains blocked words",
422,
);
}
// Remove emojis
self.emojis = [];
self.displayName = sanitizedDisplayName;
}
if (note && self.source) {
// Check if within allowed note length
if (sanitizedNote.length > config.validation.max_note_size) {
return errorResponse(
`Note must be less than ${config.validation.max_note_size} characters`,
422,
);
}
// Check if bio doesnt match filters
if (config.filters.bio.some((filter) => sanitizedNote.match(filter))) {
if (
config.filters.bio.some((filter) => sanitizedNote.match(filter))
) {
return errorResponse("Bio contains blocked words", 422);
}
(self.source as APISource).note = sanitizedNote;
// TODO: Convert note to HTML
self.note = await convertTextToHtml(sanitizedNote);
}
if (source_privacy && self.source) {
// Check if within allowed privacy values
if (
!["public", "unlisted", "private", "direct"].includes(
source_privacy,
)
) {
return errorResponse(
"Privacy must be one of public, unlisted, private, or direct",
422,
);
}
(self.source as APISource).privacy = source_privacy;
}
if (source_sensitive && self.source) {
// Check if within allowed sensitive values
if (source_sensitive !== "true" && source_sensitive !== "false") {
return errorResponse("Sensitive must be a boolean", 422);
}
(self.source as APISource).sensitive = source_sensitive === "true";
(self.source as APISource).sensitive = source_sensitive;
}
if (source_language && self.source) {
if (!ISO6391.validate(source_language)) {
return errorResponse(
"Language must be a valid ISO 639-1 code",
422,
);
}
(self.source as APISource).language = source_language;
}
@ -199,34 +170,18 @@ export default apiRoute<{
}
if (locked) {
// Check if locked is a boolean
if (locked !== "true" && locked !== "false") {
return errorResponse("Locked must be a boolean", 422);
}
self.isLocked = locked === "true";
self.isLocked = locked;
}
if (bot) {
// Check if bot is a boolean
if (bot !== "true" && bot !== "false") {
return errorResponse("Bot must be a boolean", 422);
}
self.isBot = bot === "true";
self.isBot = bot;
}
if (discoverable) {
// Check if discoverable is a boolean
if (discoverable !== "true" && discoverable !== "false") {
return errorResponse("Discoverable must be a boolean", 422);
}
self.isDiscoverable = discoverable === "true";
self.isDiscoverable = discoverable;
}
// Parse emojis
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote);
@ -256,14 +211,19 @@ export default apiRoute<{
for (const emoji of self.emojis) {
await db
.delete(emojiToUser)
.where(and(eq(emojiToUser.a, emoji.id), eq(emojiToUser.b, self.id)))
.where(
and(
eq(emojiToUser.emojiId, emoji.id),
eq(emojiToUser.userId, self.id),
),
)
.execute();
await db
.insert(emojiToUser)
.values({
a: emoji.id,
b: self.id,
emojiId: emoji.id,
userId: self.id,
})
.execute();
}
@ -275,4 +235,5 @@ export default apiRoute<{
if (!output) return errorResponse("Couldn't edit user", 500);
return jsonResponse(userToAPI(output));
});
},
);

View file

@ -3,6 +3,7 @@ import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { db } from "~drizzle/db";
import { application } from "~drizzle/schema";
import { z } from "zod";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -16,25 +17,21 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
client_name: z.string().min(1).max(100),
redirect_uris: z.string().min(0).max(2000).url(),
scopes: z.string().min(1).max(200),
website: z.string().min(0).max(2000).url().optional(),
});
/**
* Creates a new application to obtain OAuth 2 credentials
*/
export default apiRoute<{
client_name: string;
redirect_uris: string;
scopes: string;
website: string;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { client_name, redirect_uris, scopes, website } =
extraData.parsedRequest;
// Check if redirect URI is a valid URI, and also an absolute URI
if (redirect_uris) {
if (!URL.canParse(redirect_uris)) {
return errorResponse("Redirect URI must be a valid URI", 422);
}
}
const app = (
await db
.insert(application)
@ -58,4 +55,5 @@ export default apiRoute<{
redirect_uri: app.redirectUris,
vapid_link: app.vapidKey,
});
});
},
);

View file

@ -19,9 +19,12 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const { user, token } = extraData.auth;
if (!token) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401);
const application = await getFromToken(token);
if (!user) return errorResponse("Unauthorized", 401);
if (!application) return errorResponse("Unauthorized", 401);
return jsonResponse({

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type UserWithRelations,
findManyUsers,
@ -20,19 +21,23 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
const { max_id, since_id, min_id, limit = 40 } = extraData.parsedRequest;
const { max_id, since_id, min_id, limit } = extraData.parsedRequest;
const { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
const { objects: blocks, link } =
await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
@ -43,7 +48,7 @@ export default apiRoute<{
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${subject.id} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."blocking" = true)`,
),
limit: Number(limit),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
@ -57,4 +62,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type StatusWithRelations,
findManyStatuses,
@ -19,19 +20,18 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
@ -48,6 +48,7 @@ export default apiRoute<{
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
limit,
},
req,
);
@ -61,4 +62,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type UserWithRelations,
findManyUsers,
@ -19,19 +20,18 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).default(20),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
@ -46,7 +46,7 @@ export default apiRoute<{
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${subject.id} AND "Relationship"."requested" = true)`,
),
limit: Number(limit),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
@ -60,4 +60,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,12 +1,14 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse, response } from "@response";
import { eq } from "drizzle-orm";
import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { z } from "zod";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { db } from "~drizzle/db";
import { attachment } from "~drizzle/schema";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { config } from "config-manager";
export const meta = applyConfig({
allowedMethods: ["GET", "PUT"],
@ -21,14 +23,20 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
});
/**
* Get media information
*/
export default apiRoute<{
thumbnail?: File;
description?: string;
focus?: string;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) {
@ -36,6 +44,9 @@ export default apiRoute<{
}
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const foundAttachment = await db.query.attachment.findFirst({
where: (attachment, { eq }) => eq(attachment.id, id),
@ -78,7 +89,8 @@ export default apiRoute<{
thumbnailUrl = getUrl(path, config);
}
const descriptionText = description || foundAttachment.description;
const descriptionText =
description || foundAttachment.description;
if (
descriptionText !== foundAttachment.description ||
@ -103,4 +115,5 @@ export default apiRoute<{
}
return errorResponse("Method not allowed", 405);
});
},
);

View file

@ -3,11 +3,13 @@ import { errorResponse, jsonResponse } from "@response";
import { encode } from "blurhash";
import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import sharp from "sharp";
import { z } from "zod";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { db } from "~drizzle/db";
import { attachment } from "~drizzle/schema";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { config } from "config-manager";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -22,16 +24,21 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
file: z.instanceof(File),
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
});
/**
* Upload new media
*/
export default apiRoute<{
file: File;
thumbnail?: File;
description?: string;
// TODO: Add focus
focus?: string;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) {
@ -40,10 +47,6 @@ export default apiRoute<{
const { file, thumbnail, description } = extraData.parsedRequest;
if (!file) {
return errorResponse("No file provided", 400);
}
const config = await extraData.configManager.getConfig();
if (file.size > config.validation.max_media_size) {
@ -60,16 +63,6 @@ export default apiRoute<{
return errorResponse("Invalid file type", 415);
}
if (
description &&
description.length > config.validation.max_media_description_size
) {
return errorResponse(
`Description too long, max length is ${config.validation.max_media_description_size} characters`,
413,
);
}
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
@ -139,7 +132,9 @@ export default apiRoute<{
.values({
url,
thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
sha256: sha256
.update(await file.arrayBuffer())
.digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
@ -152,4 +147,5 @@ export default apiRoute<{
// TODO: Add job to process videos and other media
return jsonResponse(attachmentToAPI(newAttachment));
});
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type UserWithRelations,
findManyUsers,
@ -20,18 +21,22 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { max_id, since_id, limit = 40, min_id } = extraData.parsedRequest;
const { max_id, since_id, limit, min_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
const { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
const { objects: blocks, link } =
await fetchTimeline<UserWithRelations>(
findManyUsers,
{
// @ts-expect-error Yes I KNOW the types are wrong
@ -42,7 +47,7 @@ export default apiRoute<{
min_id ? gt(subject.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${subject.id} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."muting" = true)`,
),
limit: Number(limit),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (subject, { desc }) => desc(subject.id),
},
@ -50,4 +55,5 @@ export default apiRoute<{
);
return jsonResponse(blocks.map((u) => userToAPI(u)));
});
},
);

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
findManyNotifications,
notificationToAPI,
@ -19,15 +20,46 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
exclude_types?: string[];
types?: string[];
account_id?: string;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(15),
exclude_types: z
.enum([
"mention",
"status",
"follow",
"follow_request",
"reblog",
"poll",
"favourite",
"update",
"admin.sign_up",
"admin.report",
])
.array()
.optional(),
types: z
.enum([
"mention",
"status",
"follow",
"follow_request",
"reblog",
"poll",
"favourite",
"update",
"admin.sign_up",
"admin.report",
])
.array()
.optional(),
account_id: z.string().regex(idValidator).optional(),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
@ -35,37 +67,43 @@ export default apiRoute<{
const {
account_id,
exclude_types,
limit = 15,
limit,
max_id,
min_id,
since_id,
types,
} = extraData.parsedRequest;
if (limit > 80) return errorResponse("Limit too high", 400);
if (limit <= 0) return errorResponse("Limit too low", 400);
if (types && exclude_types) {
return errorResponse("Can't use both types and exclude_types", 400);
}
const { objects, link } = await fetchTimeline<NotificationWithRelations>(
const { objects, link } =
await fetchTimeline<NotificationWithRelations>(
findManyNotifications,
{
where: (
// @ts-expect-error Yes I KNOW the types are wrong
where: (notification, { lt, gte, gt, and, or, eq, inArray, sql }) =>
notification,
// @ts-expect-error Yes I KNOW the types are wrong
{ lt, gte, gt, and, or, eq, inArray, sql },
) =>
or(
and(
max_id ? lt(notification.id, max_id) : undefined,
since_id ? gte(notification.id, since_id) : undefined,
min_id ? gt(notification.id, min_id) : undefined,
max_id
? lt(notification.id, max_id)
: undefined,
since_id
? gte(notification.id, since_id)
: undefined,
min_id
? gt(notification.id, min_id)
: undefined,
),
eq(notification.notifiedId, user.id),
eq(notification.accountId, account_id),
),
with: {},
limit: Number(limit),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (notification, { desc }) => desc(notification.id),
},
@ -79,4 +117,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import type { Relationship } from "~database/entities/Relationship";
import {
@ -28,6 +28,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
// Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20.
// User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses.
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { createLike } from "~database/entities/Like";
import {
@ -26,6 +26,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;

View file

@ -28,7 +28,6 @@ beforeAll(async () => {
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens[1].accessToken}`,
},
},
@ -51,42 +50,6 @@ describe(meta.route, () => {
expect(response.status).toBe(401);
});
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=0`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=100`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 200 with users", async () => {
const response = await sendTestRequest(
new Request(

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
import {
type UserWithRelations,
@ -20,16 +21,22 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(40),
});
/**
* Fetch users who favourited the post
*/
export default apiRoute<{
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;
@ -41,11 +48,7 @@ export default apiRoute<{
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
const { max_id, min_id, since_id, limit = 40 } = extraData.parsedRequest;
// Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
@ -60,6 +63,7 @@ export default apiRoute<{
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
@ -71,4 +75,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,17 +1,19 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import { eq } from "drizzle-orm";
import { parse } from "marked";
import { z } from "zod";
import {
editStatus,
findFirstStatuses,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
import { db } from "~drizzle/db";
import { status } from "~drizzle/schema";
import { config } from "config-manager";
import ISO6391 from "iso-639-1";
export const meta = applyConfig({
allowedMethods: ["GET", "DELETE", "PUT"],
@ -26,22 +28,40 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
status: z.string().max(config.validation.max_note_size).optional(),
// TODO: Add regex to validate
content_type: z.string().optional(),
media_ids: z
.array(z.string().regex(idValidator))
.max(config.validation.max_media_attachments)
.optional(),
spoiler_text: z.string().max(255).optional(),
sensitive: z.boolean().optional(),
language: z.enum(ISO6391.getAllCodes() as [string, ...string[]]).optional(),
"poll[options]": z
.array(z.string().max(config.validation.max_poll_option_size))
.max(config.validation.max_poll_options)
.optional(),
"poll[expires_in]": z
.number()
.int()
.min(config.validation.min_poll_duration)
.max(config.validation.max_poll_duration)
.optional(),
"poll[multiple]": z.boolean().optional(),
"poll[hide_totals]": z.boolean().optional(),
});
/**
* Fetch a user
*/
export default apiRoute<{
status?: string;
spoiler_text?: string;
sensitive?: boolean;
language?: string;
content_type?: string;
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;
@ -103,59 +123,10 @@ export default apiRoute<{
);
}
// Validate media_ids
if (media_ids && !Array.isArray(media_ids)) {
return errorResponse("Media IDs must be an array", 422);
}
// Validate poll options
if (options && !Array.isArray(options)) {
return errorResponse("Poll options must be an array", 422);
}
if (options && options.length > 4) {
return errorResponse("Poll options must be less than 5", 422);
}
if (media_ids && media_ids.length > 0) {
if (media_ids && media_ids.length > 0 && options) {
// Disallow poll
if (options) {
return errorResponse("Cannot attach poll to media", 422);
}
if (media_ids.length > 4) {
return errorResponse("Media IDs must be less than 5", 422);
}
}
if (options && options.length > config.validation.max_poll_options) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_options}`,
422,
);
}
if (
options?.some(
(option) =>
option.length > config.validation.max_poll_option_size,
)
) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
422,
);
}
if (expires_in && expires_in < config.validation.min_poll_duration) {
return errorResponse(
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
422,
);
}
if (expires_in && expires_in > config.validation.max_poll_duration) {
return errorResponse(
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
"Cannot attach poll to post with media",
422,
);
}
@ -163,22 +134,19 @@ export default apiRoute<{
let sanitizedStatus: string;
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
sanitizedStatus = await sanitizeHtml(
await parse(statusText ?? ""),
);
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
sanitizedStatus = await sanitizeHtml(
await parse(statusText ?? ""),
);
} else {
sanitizedStatus = await sanitizeHtml(statusText ?? "");
}
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(
`Status must be less than ${config.validation.max_note_size} characters`,
400,
);
}
// Check if status body doesnt match filters
if (
config.filters.note_content.some((filter) =>
@ -217,4 +185,5 @@ export default apiRoute<{
}
return jsonResponse({});
});
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
import { db } from "~drizzle/db";
@ -21,6 +21,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;
@ -39,11 +42,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
// Check if post is already pinned
if (
await db.query.statusToUser.findFirst({
where: (statusToUser, { and, eq }) =>
await db.query.userPinnedNotes.findFirst({
where: (userPinnedNote, { and, eq }) =>
and(
eq(statusToUser.a, foundStatus.id),
eq(statusToUser.b, user.id),
eq(userPinnedNote.statusId, foundStatus.id),
eq(userPinnedNote.userId, user.id),
),
})
) {
@ -51,8 +54,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
}
await db.insert(statusToMentions).values({
a: foundStatus.id,
b: user.id,
statusId: foundStatus.id,
userId: user.id,
});
return jsonResponse(statusToAPI(foundStatus, user));

View file

@ -1,11 +1,11 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import {
findFirstStatuses,
isViewableByUser,
statusToAPI,
} from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
import { db } from "~drizzle/db";
import { notification, status } from "~drizzle/schema";
@ -21,19 +21,25 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
visibility: z.enum(["public", "unlisted", "private"]).default("public"),
});
/**
* Reblogs a post
*/
export default apiRoute<{
visibility: "public" | "unlisted" | "private";
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
const { visibility = "public" } = extraData.parsedRequest;
const { visibility } = extraData.parsedRequest;
const foundStatus = await findFirstStatuses({
where: (status, { eq }) => eq(status.id, id),
@ -45,7 +51,10 @@ export default apiRoute<{
const existingReblog = await db.query.status.findFirst({
where: (status, { and, eq }) =>
and(eq(status.authorId, user.id), eq(status.reblogId, status.id)),
and(
eq(status.authorId, user.id),
eq(status.reblogId, status.id),
),
});
if (existingReblog) {
@ -88,4 +97,5 @@ export default apiRoute<{
}
return jsonResponse(await statusToAPI(finalNewReblog, user));
});
},
);

View file

@ -29,7 +29,6 @@ beforeAll(async () => {
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens[1].accessToken}`,
},
},
@ -52,42 +51,6 @@ describe(meta.route, () => {
expect(response.status).toBe(401);
});
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=0`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route.replace(":id", timeline[0].id)}?limit=100`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 200 with users", async () => {
const response = await sendTestRequest(
new Request(

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
import {
type UserWithRelations,
@ -20,16 +21,22 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(40),
});
/**
* Fetch users who reblogged the post
*/
export default apiRoute<{
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;
@ -41,16 +48,7 @@ export default apiRoute<{
if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404);
const {
max_id = null,
min_id = null,
since_id = null,
limit = 40,
} = extraData.parsedRequest;
// Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
if (limit < 1) return errorResponse("Invalid limit", 400);
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
const { objects, link } = await fetchTimeline<UserWithRelations>(
findManyUsers,
@ -65,6 +63,7 @@ export default apiRoute<{
),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (liker, { desc }) => desc(liker.id),
limit,
},
req,
);
@ -76,4 +75,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse } from "@response";
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
@ -19,6 +19,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { deleteLike } from "~database/entities/Like";
import {
@ -25,6 +25,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { and, eq } from "drizzle-orm";
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
@ -22,6 +22,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;

View file

@ -1,4 +1,4 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import {
@ -27,6 +27,9 @@ export const meta = applyConfig({
*/
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
if (!id.match(idValidator)) {
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
}
const { user } = extraData.auth;

View file

@ -52,7 +52,7 @@ describe(meta.route, () => {
expect(response.status).toBe(422);
});
test("should return 400 is status is too long", async () => {
test("should return 422 is status is too long", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
method: "POST",
@ -67,7 +67,7 @@ describe(meta.route, () => {
}),
);
expect(response.status).toBe(400);
expect(response.status).toBe(422);
});
test("should return 422 is visibility is invalid", async () => {
@ -108,7 +108,7 @@ describe(meta.route, () => {
expect(response.status).toBe(422);
});
test("should return 404 is in_reply_to_id is invalid", async () => {
test("should return 422 is in_reply_to_id is invalid", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
method: "POST",
@ -124,10 +124,10 @@ describe(meta.route, () => {
}),
);
expect(response.status).toBe(404);
expect(response.status).toBe(422);
});
test("should return 404 is quote_id is invalid", async () => {
test("should return 422 is quote_id is invalid", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
method: "POST",
@ -143,7 +143,7 @@ describe(meta.route, () => {
}),
);
expect(response.status).toBe(404);
expect(response.status).toBe(422);
});
test("should return 422 is media_ids is invalid", async () => {

View file

@ -1,7 +1,8 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import { parse } from "marked";
import { z } from "zod";
import type { StatusWithRelations } from "~database/entities/Status";
import {
createNewStatus,
@ -11,6 +12,8 @@ import {
statusToAPI,
} from "~database/entities/Status";
import { db } from "~drizzle/db";
import { config } from "config-manager";
import ISO6391 from "iso-639-1";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -24,27 +27,45 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
status: z.string().max(config.validation.max_note_size).optional(),
// TODO: Add regex to validate
content_type: z.string().optional().default("text/plain"),
media_ids: z
.array(z.string().regex(idValidator))
.max(config.validation.max_media_attachments)
.optional(),
spoiler_text: z.string().max(255).optional(),
sensitive: z.boolean().optional(),
language: z.enum(ISO6391.getAllCodes() as [string, ...string[]]).optional(),
"poll[options]": z
.array(z.string().max(config.validation.max_poll_option_size))
.max(config.validation.max_poll_options)
.optional(),
"poll[expires_in]": z
.number()
.int()
.min(config.validation.min_poll_duration)
.max(config.validation.max_poll_duration)
.optional(),
"poll[multiple]": z.boolean().optional(),
"poll[hide_totals]": z.boolean().optional(),
in_reply_to_id: z.string().regex(idValidator).optional(),
quote_id: z.string().regex(idValidator).optional(),
visibility: z
.enum(["public", "unlisted", "private", "direct"])
.optional()
.default("public"),
scheduled_at: z.string().optional(),
local_only: z.boolean().optional(),
federate: z.boolean().optional().default(true),
});
/**
* Post new status
*/
export default apiRoute<{
status: string;
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
in_reply_to_id?: string;
quote_id?: string;
sensitive?: boolean;
spoiler_text?: string;
visibility?: "public" | "unlisted" | "private" | "direct";
language?: string;
scheduled_at?: string;
local_only?: boolean;
content_type?: string;
federate?: boolean;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
@ -55,18 +76,15 @@ export default apiRoute<{
status,
media_ids,
"poll[expires_in]": expires_in,
// "poll[hide_totals]": hide_totals,
// "poll[multiple]": multiple,
"poll[options]": options,
in_reply_to_id,
quote_id,
// language,
scheduled_at,
sensitive,
spoiler_text,
visibility,
content_type,
federate = true,
federate,
} = extraData.parsedRequest;
// Validate status
@ -77,79 +95,23 @@ export default apiRoute<{
);
}
// Validate media_ids
if (media_ids && !Array.isArray(media_ids)) {
return errorResponse("Media IDs must be an array", 422);
}
// Validate poll options
if (options && !Array.isArray(options)) {
return errorResponse("Poll options must be an array", 422);
}
if (options && options.length > 4) {
return errorResponse("Poll options must be less than 5", 422);
}
if (media_ids && media_ids.length > 0) {
if (media_ids && media_ids.length > 0 && options) {
// Disallow poll
if (options) {
return errorResponse("Cannot attach poll to media", 422);
}
if (media_ids.length > 4) {
return errorResponse("Media IDs must be less than 5", 422);
}
}
if (options && options.length > config.validation.max_poll_options) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_options}`,
422,
);
}
if (
options?.some(
(option) => option.length > config.validation.max_poll_option_size,
)
) {
return errorResponse(
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
422,
);
}
if (expires_in && expires_in < config.validation.min_poll_duration) {
return errorResponse(
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
422,
);
}
if (expires_in && expires_in > config.validation.max_poll_duration) {
return errorResponse(
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
422,
);
}
if (scheduled_at) {
if (
Number.isNaN(new Date(scheduled_at).getTime()) ||
new Date(scheduled_at).getTime() < Date.now()
) {
return errorResponse("Scheduled time must be in the future", 422);
return errorResponse(
"Scheduled time must be in the future",
422,
);
}
}
// Validate visibility
if (
visibility &&
!["public", "unlisted", "private", "direct"].includes(visibility)
) {
return errorResponse("Invalid visibility", 422);
}
let sanitizedStatus: string;
if (content_type === "text/markdown") {
@ -162,13 +124,6 @@ export default apiRoute<{
sanitizedStatus = await sanitizeHtml(status ?? "");
}
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(
`Status must be less than ${config.validation.max_note_size} characters`,
400,
);
}
// Get reply account and status if exists
let replyStatus: StatusWithRelations | null = null;
let quote: StatusWithRelations | null = null;
@ -194,7 +149,9 @@ export default apiRoute<{
}
// Check if status body doesnt match filters
if (config.filters.note_content.some((filter) => status?.match(filter))) {
if (
config.filters.note_content.some((filter) => status?.match(filter))
) {
return errorResponse("Status contains blocked words", 422);
}
@ -217,11 +174,11 @@ export default apiRoute<{
const newStatus = await createNewStatus(
user,
{
[content_type ?? "text/plain"]: {
[content_type]: {
content: sanitizedStatus ?? "",
},
},
visibility ?? "public",
visibility,
sensitive ?? false,
spoiler_text ?? "",
[],
@ -241,4 +198,5 @@ export default apiRoute<{
}
return jsonResponse(await statusToAPI(newStatus, user));
});
},
);

View file

@ -27,36 +27,6 @@ describe(meta.route, () => {
expect(response.status).toBe(401);
});
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=0`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=100`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should correctly parse limit", async () => {
const response = await sendTestRequest(
new Request(

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { z } from "zod";
import {
type StatusWithRelations,
findManyStatuses,
@ -20,33 +21,24 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(20),
});
/**
* Fetch home timeline statuses
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 80) {
return errorResponse("Limit must be between 1 and 40", 400);
}
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
if (!user) return errorResponse("Unauthorized", 401);
const followers = await db.query.relationship.findMany({
where: (relationship, { eq, and }) =>
and(
eq(relationship.subjectId, user.id),
eq(relationship.following, true),
),
});
const { objects, link } = await fetchTimeline<StatusWithRelations>(
findManyStatuses,
{
@ -72,7 +64,7 @@ export default apiRoute<{
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
),
),
limit: Number(limit),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
@ -88,4 +80,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -19,36 +19,6 @@ afterAll(async () => {
});
describe(meta.route, () => {
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=0`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=100`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should correctly parse limit", async () => {
const response = await sendTestRequest(
new Request(

View file

@ -1,6 +1,8 @@
import { apiRoute, applyConfig } from "@api";
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 {
type StatusWithRelations,
findManyStatuses,
@ -19,29 +21,21 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
local?: boolean;
only_media?: boolean;
remote?: boolean;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const {
local,
limit = 20,
max_id,
min_id,
// only_media,
remote,
since_id,
} = extraData.parsedRequest;
export const schema = z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).optional().default(20),
local: z.coerce.boolean().optional(),
remote: z.coerce.boolean().optional(),
only_media: z.coerce.boolean().optional(),
});
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { local, limit, max_id, min_id, only_media, remote, since_id } =
extraData.parsedRequest;
if (local && remote) {
return errorResponse("Cannot use both local and remote", 400);
@ -61,8 +55,11 @@ export default apiRoute<{
: local
? isNull(status.instanceId)
: undefined,
only_media
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
: undefined,
),
limit: Number(limit),
limit,
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),
},
@ -80,4 +77,5 @@ export default apiRoute<{
Link: link,
},
);
});
},
);

View file

@ -1,13 +1,15 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { encode } from "blurhash";
import { config } from "config-manager";
import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import sharp from "sharp";
import { z } from "zod";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { db } from "~drizzle/db";
import { attachment } from "~drizzle/schema";
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -22,28 +24,23 @@ export const meta = applyConfig({
},
});
export const schema = z.object({
file: z.instanceof(File),
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
});
/**
* Upload new media
*/
export default apiRoute<{
file: File;
thumbnail: File;
description: string;
// TODO: Implement focus storage
focus: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) {
return errorResponse("Unauthorized", 401);
}
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { file, thumbnail, description } = extraData.parsedRequest;
if (!file) {
return errorResponse("No file provided", 400);
}
const config = await extraData.configManager.getConfig();
if (file.size > config.validation.max_media_size) {
@ -60,16 +57,6 @@ export default apiRoute<{
return errorResponse("Invalid file type", 415);
}
if (
description &&
description.length > config.validation.max_media_description_size
) {
return errorResponse(
`Description too long, max length is ${config.validation.max_media_description_size} characters`,
413,
);
}
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
@ -141,7 +128,9 @@ export default apiRoute<{
.values({
url,
thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
sha256: sha256
.update(await file.arrayBuffer())
.digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
@ -165,4 +154,5 @@ export default apiRoute<{
},
202,
);
});
},
);

View file

@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api";
import { MeiliIndexType, meilisearch } from "@meilisearch";
import { errorResponse, jsonResponse } from "@response";
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
import { findManyStatuses, statusToAPI } from "~database/entities/Status";
import {
findFirstUser,
@ -25,17 +26,20 @@ export const meta = applyConfig({
},
});
export default apiRoute<{
q?: string;
type?: string;
resolve?: boolean;
following?: boolean;
account_id?: string;
max_id?: string;
min_id?: string;
limit?: number;
offset?: number;
}>(async (req, matchedRoute, extraData) => {
export const schema = z.object({
q: z.string().optional(),
type: z.string().optional(),
resolve: z.coerce.boolean().optional(),
following: z.coerce.boolean().optional(),
account_id: z.string().optional(),
max_id: z.string().optional(),
min_id: z.string().optional(),
limit: z.coerce.number().int().min(1).max(40).optional(),
offset: z.coerce.number().int().optional(),
});
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
const {
@ -63,10 +67,6 @@ export default apiRoute<{
);
}
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
let accountResults: { id: string }[] = [];
let statusResults: { id: string }[] = [];
@ -113,12 +113,13 @@ export default apiRoute<{
}
if (resolve) {
const newUser = await resolveWebFinger(username, domain).catch(
(e) => {
const newUser = await resolveWebFinger(
username,
domain,
).catch((e) => {
console.error(e);
return null;
},
);
});
if (newUser) {
return jsonResponse({
@ -197,4 +198,5 @@ export default apiRoute<{
),
hashtags: [],
});
});
},
);

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { db } from "~drizzle/db";
export const meta = applyConfig({
@ -14,30 +15,33 @@ export const meta = applyConfig({
route: "/oauth/token",
});
export const schema = z.object({
grant_type: z.string(),
code: z.string(),
redirect_uri: z.string().url(),
client_id: z.string(),
client_secret: z.string(),
scope: z.string(),
});
/**
* Allows getting token from OAuth code
*/
export default apiRoute<{
grant_type: string;
code: string;
redirect_uri: string;
client_id: string;
client_secret: string;
scope: string;
}>(async (req, matchedRoute, extraData) => {
const { grant_type, code, redirect_uri, client_id, client_secret, scope } =
extraData.parsedRequest;
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const {
grant_type,
code,
redirect_uri,
client_id,
client_secret,
scope,
} = extraData.parsedRequest;
if (grant_type !== "authorization_code")
return errorResponse(
"Invalid grant type (try 'authorization_code')",
400,
);
if (!code || !redirect_uri || !client_id || !client_secret || !scope)
return errorResponse(
"Missing required parameters code, redirect_uri, client_id, client_secret, scope",
400,
422,
);
// Get associated token
@ -53,7 +57,7 @@ export default apiRoute<{
if (!application)
return errorResponse(
"Invalid client credentials (missing applicaiton)",
"Invalid client credentials (missing application)",
401,
);
@ -63,7 +67,10 @@ export default apiRoute<{
});
if (!token)
return errorResponse("Invalid access token or client credentials", 401);
return errorResponse(
"Invalid access token or client credentials",
401,
);
return jsonResponse({
access_token: token.accessToken,
@ -71,4 +78,5 @@ export default apiRoute<{
scope: token.scope,
created_at: new Date(token.createdAt).getTime(),
});
});
},
);

View file

@ -1,15 +0,0 @@
import type { MatchedRoute } from "bun";
import type { Config } from "config-manager";
import type { AuthData } from "~database/entities/User";
export type RouteHandler<T> = (
req: Request,
matchedRoute: MatchedRoute,
extraData: {
auth: AuthData;
parsedRequest: Partial<T>;
configManager: {
getConfig: () => Promise<Config>;
};
},
) => Response | Promise<Response>;

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig } from "@api";
import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { z } from "zod";
import { findFirstUser } from "~database/entities/User";
export const meta = applyConfig({
@ -14,12 +15,13 @@ export const meta = applyConfig({
route: "/.well-known/webfinger",
});
export default apiRoute<{
resource: string;
}>(async (req, matchedRoute, extraData) => {
const { resource } = extraData.parsedRequest;
export const schema = z.object({
resource: z.string().min(1).max(512),
});
if (!resource) return errorResponse("No resource provided", 400);
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const { resource } = extraData.parsedRequest;
// Check if resource is in the correct format (acct:uuid/username@domain)
if (!resource.match(/^acct:[a-zA-Z0-9-]+@[a-zA-Z0-9.-:]+$/)) {
@ -39,15 +41,14 @@ export default apiRoute<{
return errorResponse("User is a remote user", 404);
}
const isUuid = requestedUser
.split("@")[0]
.match(
/[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/i,
);
const isUuid = requestedUser.split("@")[0].match(idValidator);
const user = await findFirstUser({
where: (user, { eq }) =>
eq(isUuid ? user.id : user.username, requestedUser.split("@")[0]),
eq(
isUuid ? user.id : user.username,
requestedUser.split("@")[0],
),
});
if (!user) {
@ -68,4 +69,5 @@ export default apiRoute<{
},
],
});
});
},
);

View file

@ -21,12 +21,6 @@ describe("API Tests", () => {
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url),
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
),
);

View file

@ -23,10 +23,8 @@ describe("API Tests", () => {
new Request(
wrapRelativeUrl("/api/v1/accounts/999999", base_url),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -80,10 +78,8 @@ describe("API Tests", () => {
base_url,
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -130,10 +126,8 @@ describe("API Tests", () => {
base_url,
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -585,7 +579,6 @@ describe("API Tests", () => {
method: "DELETE",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -612,7 +605,6 @@ describe("API Tests", () => {
method: "DELETE",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),

View file

@ -164,10 +164,8 @@ describe("API Tests", () => {
base_url,
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -218,7 +216,6 @@ describe("API Tests", () => {
method: "POST",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -249,7 +246,6 @@ describe("API Tests", () => {
method: "POST",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -276,10 +272,8 @@ describe("API Tests", () => {
base_url,
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -312,7 +306,6 @@ describe("API Tests", () => {
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -341,7 +334,6 @@ describe("API Tests", () => {
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -377,7 +369,6 @@ describe("API Tests", () => {
method: "POST",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -399,7 +390,6 @@ describe("API Tests", () => {
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),
@ -430,7 +420,6 @@ describe("API Tests", () => {
method: "POST",
headers: {
Authorization: `Bearer ${token.accessToken}`,
"Content-Type": "application/json",
},
},
),

View file

@ -1,8 +1,15 @@
import { config } from "config-manager";
import type { RouteHandler } from "~server/api/routes.type";
import type { APIRouteMeta } from "~types/api";
import {
anyOf,
caseInsensitive,
charIn,
createRegExp,
digit,
exactly,
} from "magic-regexp";
import type { APIRouteMetadata, RouteHandler } from "server-handler";
export const applyConfig = (routeMeta: APIRouteMeta) => {
export const applyConfig = (routeMeta: APIRouteMetadata) => {
const newMeta = routeMeta;
// Apply ratelimits from config
@ -16,6 +23,26 @@ export const applyConfig = (routeMeta: APIRouteMeta) => {
return newMeta;
};
export const apiRoute = <T>(routeFunction: RouteHandler<T>) => {
export const apiRoute = <
Metadata extends APIRouteMetadata,
ZodSchema extends Zod.AnyZodObject,
>(
routeFunction: RouteHandler<Metadata, ZodSchema>,
) => {
return routeFunction;
};
export const idValidator = createRegExp(
anyOf(digit, charIn("ABCDEF")).times(8),
exactly("-"),
anyOf(digit, charIn("ABCDEF")).times(4),
exactly("-"),
exactly("7"),
anyOf(digit, charIn("ABCDEF")).times(3),
exactly("-"),
anyOf("8", "9", "A", "B").times(1),
anyOf(digit, charIn("ABCDEF")).times(3),
exactly("-"),
anyOf(digit, charIn("ABCDEF")).times(12),
[caseInsensitive],
);