mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
Switch all routes to use Zod for strict validation
This commit is contained in:
parent
53fa9ca545
commit
0b1c1ba128
4
cli.ts
4
cli.ts
|
|
@ -5,7 +5,9 @@ import { Parser } from "@json2csv/plainjs";
|
||||||
import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch";
|
import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { CliBuilder, CliCommand } from "cli-parser";
|
import { CliBuilder, CliCommand } from "cli-parser";
|
||||||
|
import { CliParameterType } from "cli-parser/cli-builder.type";
|
||||||
import Table from "cli-table";
|
import Table from "cli-table";
|
||||||
|
import { config } from "config-manager";
|
||||||
import { type SQL, eq, inArray, isNotNull, isNull, like } from "drizzle-orm";
|
import { type SQL, eq, inArray, isNotNull, isNull, like } from "drizzle-orm";
|
||||||
import extract from "extract-zip";
|
import extract from "extract-zip";
|
||||||
import { MediaBackend } from "media-manager";
|
import { MediaBackend } from "media-manager";
|
||||||
|
|
@ -20,8 +22,6 @@ import {
|
||||||
} from "~database/entities/User";
|
} from "~database/entities/User";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { emoji, openIdAccount, status, user } from "~drizzle/schema";
|
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;
|
const args = process.argv;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
letter,
|
letter,
|
||||||
maybe,
|
maybe,
|
||||||
oneOrMore,
|
oneOrMore,
|
||||||
} from "magic-regexp/further-magic";
|
} from "magic-regexp";
|
||||||
import { parse } from "marked";
|
import { parse } from "marked";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
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 { Client } from "pg";
|
||||||
import * as schema from "./schema";
|
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({
|
export const client = new Client({
|
||||||
host: config.database.host,
|
host: config.database.host,
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
"oauth4webapi": "^2.4.0",
|
"oauth4webapi": "^2.4.0",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"request-parser": "workspace:*",
|
"request-parser": "workspace:*",
|
||||||
"sharp": "^0.33.3"
|
"sharp": "^0.33.3",
|
||||||
|
"zod": "^3.22.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,11 @@ describe("RequestParser", () => {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: "invalid json",
|
body: "invalid json",
|
||||||
});
|
});
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = new RequestParser(request).toObject<{
|
||||||
param1: string;
|
param1: string;
|
||||||
param2: string;
|
param2: string;
|
||||||
}>();
|
}>();
|
||||||
expect(result).toEqual({});
|
expect(result).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should parse form data correctly", () => {
|
describe("should parse form data correctly", () => {
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,8 @@ export const processRoute = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Content-Type header is missing in POST, PUT and PATCH requests
|
// Check if Content-Type header is missing if there is a body
|
||||||
if (["POST", "PUT", "PATCH"].includes(request.method)) {
|
if (request.body) {
|
||||||
if (!request.headers.has("Content-Type")) {
|
if (!request.headers.has("Content-Type")) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
`Content-Type header is missing but required on method ${request.method}`,
|
`Content-Type header is missing but required on method ${request.method}`,
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ describe("Route Processor", () => {
|
||||||
expect(output.status).toBe(401);
|
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(
|
mock.module(
|
||||||
"./route",
|
"./route",
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -147,35 +147,12 @@ describe("Route Processor", () => {
|
||||||
} as MatchedRoute,
|
} as MatchedRoute,
|
||||||
new Request("https://test.com/route", {
|
new Request("https://test.com/route", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: "test",
|
||||||
}),
|
}),
|
||||||
new LogManager(Bun.file("/dev/null")),
|
new LogManager(Bun.file("/dev/null")),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(output.status).toBe(400);
|
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 () => {
|
it("should return a 400 when the request could not be parsed", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -27,6 +27,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import ISO6391 from "iso-639-1";
|
||||||
|
import { z } from "zod";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
import {
|
import {
|
||||||
findFirstUser,
|
findFirstUser,
|
||||||
|
|
@ -20,41 +22,50 @@ 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
|
* Follow a user
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
reblogs?: boolean;
|
async (req, matchedRoute, extraData) => {
|
||||||
notify?: boolean;
|
const id = matchedRoute.params.id;
|
||||||
languages?: string[];
|
if (!id.match(idValidator)) {
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
const id = matchedRoute.params.id;
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { languages, notify, reblogs } = extraData.parsedRequest;
|
const { languages, notify, reblogs } = extraData.parsedRequest;
|
||||||
|
|
||||||
const otherUser = await findFirstUser({
|
const otherUser = await findFirstUser({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
where: (user, { eq }) => eq(user.id, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
// Check if already following
|
// Check if already following
|
||||||
let relationship = await getRelationshipToOtherUser(self, otherUser);
|
let relationship = await getRelationshipToOtherUser(self, otherUser);
|
||||||
|
|
||||||
if (!relationship.following) {
|
if (!relationship.following) {
|
||||||
relationship = await followRequestUser(
|
relationship = await followRequestUser(
|
||||||
self,
|
self,
|
||||||
otherUser,
|
otherUser,
|
||||||
relationship.id,
|
relationship.id,
|
||||||
reblogs,
|
reblogs,
|
||||||
notify,
|
notify,
|
||||||
languages,
|
languages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(relationship));
|
return jsonResponse(relationshipToAPI(relationship));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
findFirstUser,
|
findFirstUser,
|
||||||
|
|
@ -21,50 +22,56 @@ 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
|
* Fetch all statuses for a user
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
max_id?: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
since_id?: string;
|
const id = matchedRoute.params.id;
|
||||||
min_id?: string;
|
if (!id.match(idValidator)) {
|
||||||
limit?: number;
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
}
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
// TODO: Add pinned
|
// 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({
|
const otherUser = await findFirstUser({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
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);
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
||||||
|
findManyUsers,
|
||||||
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
where: (follower, { and, lt, gt, gte, eq, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(follower.id, max_id) : undefined,
|
||||||
|
since_id ? gte(follower.id, since_id) : undefined,
|
||||||
|
min_id ? gt(follower.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${otherUser.id} AND "Relationship"."objectId" = ${follower.id} AND "Relationship"."following" = true)`,
|
||||||
|
),
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (liker, { desc }) => desc(liker.id),
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
return jsonResponse(
|
||||||
findManyUsers,
|
await Promise.all(objects.map((object) => userToAPI(object))),
|
||||||
{
|
200,
|
||||||
// @ts-ignore
|
{
|
||||||
where: (follower, { and, lt, gt, gte, eq, sql }) =>
|
Link: link,
|
||||||
and(
|
},
|
||||||
max_id ? lt(follower.id, max_id) : undefined,
|
);
|
||||||
since_id ? gte(follower.id, since_id) : undefined,
|
},
|
||||||
min_id ? gt(follower.id, min_id) : undefined,
|
);
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${otherUser.id} AND "Relationship"."objectId" = ${follower.id} AND "Relationship"."following" = true)`,
|
|
||||||
),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (liker, { desc }) => desc(liker.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(objects.map((object) => userToAPI(object))),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
findFirstUser,
|
findFirstUser,
|
||||||
|
|
@ -21,50 +22,56 @@ 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
|
* Fetch all statuses for a user
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
max_id?: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
since_id?: string;
|
const id = matchedRoute.params.id;
|
||||||
min_id?: string;
|
if (!id.match(idValidator)) {
|
||||||
limit?: number;
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
}
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
// TODO: Add pinned
|
// 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({
|
const otherUser = await findFirstUser({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
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);
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
||||||
|
findManyUsers,
|
||||||
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
where: (following, { and, lt, gt, gte, eq, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(following.id, max_id) : undefined,
|
||||||
|
since_id ? gte(following.id, since_id) : undefined,
|
||||||
|
min_id ? gt(following.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${following.id} AND "Relationship"."objectId" = ${otherUser.id} AND "Relationship"."following" = true)`,
|
||||||
|
),
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (liker, { desc }) => desc(liker.id),
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
return jsonResponse(
|
||||||
findManyUsers,
|
await Promise.all(objects.map((object) => userToAPI(object))),
|
||||||
{
|
200,
|
||||||
// @ts-ignore
|
{
|
||||||
where: (following, { and, lt, gt, gte, eq, sql }) =>
|
Link: link,
|
||||||
and(
|
},
|
||||||
max_id ? lt(following.id, max_id) : undefined,
|
);
|
||||||
since_id ? gte(following.id, since_id) : undefined,
|
},
|
||||||
min_id ? gt(following.id, min_id) : undefined,
|
);
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${following.id} AND "Relationship"."objectId" = ${otherUser.id} AND "Relationship"."following" = true)`,
|
|
||||||
),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (liker, { desc }) => desc(liker.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(objects.map((object) => userToAPI(object))),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { findFirstUser, userToAPI } from "~database/entities/User";
|
import { findFirstUser, userToAPI } from "~database/entities/User";
|
||||||
|
|
||||||
|
|
@ -20,13 +20,8 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
// Check if ID is valid UUIDv7
|
if (!id.match(idValidator)) {
|
||||||
if (
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
!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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
import {
|
import {
|
||||||
findFirstUser,
|
findFirstUser,
|
||||||
|
|
@ -22,46 +23,58 @@ 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
|
* Mute a user
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
notifications: boolean;
|
async (req, matchedRoute, extraData) => {
|
||||||
duration: number;
|
const id = matchedRoute.params.id;
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
if (!id.match(idValidator)) {
|
||||||
const id = matchedRoute.params.id;
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { notifications, duration } = extraData.parsedRequest;
|
const { notifications, duration } = extraData.parsedRequest;
|
||||||
|
|
||||||
const user = await findFirstUser({
|
const user = await findFirstUser({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
where: (user, { eq }) => eq(user.id, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return errorResponse("User not found", 404);
|
if (!user) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
// Check if already following
|
// Check if already following
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, user);
|
const foundRelationship = await getRelationshipToOtherUser(self, user);
|
||||||
|
|
||||||
if (!foundRelationship.muting) {
|
if (!foundRelationship.muting) {
|
||||||
foundRelationship.muting = true;
|
foundRelationship.muting = true;
|
||||||
}
|
}
|
||||||
if (notifications ?? true) {
|
if (notifications ?? true) {
|
||||||
foundRelationship.mutingNotifications = true;
|
foundRelationship.mutingNotifications = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(relationship)
|
.update(relationship)
|
||||||
.set({
|
.set({
|
||||||
muting: true,
|
muting: true,
|
||||||
mutingNotifications: notifications ?? true,
|
mutingNotifications: notifications ?? true,
|
||||||
})
|
})
|
||||||
.where(eq(relationship.id, foundRelationship.id));
|
.where(eq(relationship.id, foundRelationship.id));
|
||||||
|
|
||||||
// TODO: Implement duration
|
// TODO: Implement duration
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "~database/entities/User";
|
} from "~database/entities/User";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { relationship } from "~drizzle/schema";
|
import { relationship } from "~drizzle/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -22,37 +23,47 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
comment: z.string().min(0).max(5000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a user note
|
* Sets a user note
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
comment: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
const id = matchedRoute.params.id;
|
||||||
const id = matchedRoute.params.id;
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { comment } = extraData.parsedRequest;
|
const { comment } = extraData.parsedRequest;
|
||||||
|
|
||||||
const otherUser = await findFirstUser({
|
const otherUser = await findFirstUser({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
where: (user, { eq }) => eq(user.id, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
// Check if already following
|
// Check if already following
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
self,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
foundRelationship.note = comment ?? "";
|
foundRelationship.note = comment ?? "";
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(relationship)
|
.update(relationship)
|
||||||
.set({
|
.set({
|
||||||
note: foundRelationship.note,
|
note: foundRelationship.note,
|
||||||
})
|
})
|
||||||
.where(eq(relationship.id, foundRelationship.id));
|
.where(eq(relationship.id, foundRelationship.id));
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -27,6 +27,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -27,6 +27,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type StatusWithRelations,
|
type StatusWithRelations,
|
||||||
findManyStatuses,
|
findManyStatuses,
|
||||||
|
|
@ -21,41 +22,79 @@ 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
|
* Fetch all statuses for a user
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
max_id?: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
since_id?: string;
|
const id = matchedRoute.params.id;
|
||||||
min_id?: string;
|
if (!id.match(idValidator)) {
|
||||||
limit?: string;
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
only_media?: boolean;
|
}
|
||||||
exclude_replies?: boolean;
|
|
||||||
exclude_reblogs?: boolean;
|
|
||||||
// TODO: Add with_muted
|
|
||||||
pinned?: boolean;
|
|
||||||
tagged?: string;
|
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
// TODO: Add pinned
|
// TODO: Add pinned
|
||||||
const {
|
const {
|
||||||
max_id,
|
max_id,
|
||||||
min_id,
|
min_id,
|
||||||
since_id,
|
since_id,
|
||||||
limit = "20",
|
limit,
|
||||||
exclude_reblogs,
|
exclude_reblogs,
|
||||||
only_media = false,
|
only_media,
|
||||||
pinned,
|
pinned,
|
||||||
} = extraData.parsedRequest;
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
const user = await findFirstUser({
|
const user = await findFirstUser({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
where: (user, { eq }) => eq(user.id, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return errorResponse("User not found", 404);
|
if (!user) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
if (pinned) {
|
||||||
|
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
||||||
|
findManyStatuses,
|
||||||
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
where: (status, { and, lt, gt, gte, eq, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(status.id, max_id) : undefined,
|
||||||
|
since_id ? gte(status.id, since_id) : undefined,
|
||||||
|
min_id ? gt(status.id, min_id) : undefined,
|
||||||
|
eq(status.authorId, id),
|
||||||
|
sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`,
|
||||||
|
only_media
|
||||||
|
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
// @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)),
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (pinned) {
|
|
||||||
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
||||||
findManyStatuses,
|
findManyStatuses,
|
||||||
{
|
{
|
||||||
|
|
@ -66,13 +105,14 @@ export default apiRoute<{
|
||||||
since_id ? gte(status.id, since_id) : undefined,
|
since_id ? gte(status.id, since_id) : undefined,
|
||||||
min_id ? gt(status.id, min_id) : undefined,
|
min_id ? gt(status.id, min_id) : undefined,
|
||||||
eq(status.authorId, id),
|
eq(status.authorId, id),
|
||||||
sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`,
|
|
||||||
only_media
|
only_media
|
||||||
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
|
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
exclude_reblogs ? eq(status.reblogId, null) : undefined,
|
||||||
),
|
),
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
orderBy: (status, { desc }) => desc(status.id),
|
orderBy: (status, { desc }) => desc(status.id),
|
||||||
|
limit,
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
);
|
);
|
||||||
|
|
@ -86,34 +126,5 @@ export default apiRoute<{
|
||||||
Link: link,
|
Link: link,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
|
||||||
findManyStatuses,
|
|
||||||
{
|
|
||||||
// @ts-ignore
|
|
||||||
where: (status, { and, lt, gt, gte, eq, sql }) =>
|
|
||||||
and(
|
|
||||||
max_id ? lt(status.id, max_id) : undefined,
|
|
||||||
since_id ? gte(status.id, since_id) : undefined,
|
|
||||||
min_id ? gt(status.id, min_id) : undefined,
|
|
||||||
eq(status.authorId, id),
|
|
||||||
only_media
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
|
|
||||||
: undefined,
|
|
||||||
exclude_reblogs ? eq(status.reblogId, null) : undefined,
|
|
||||||
),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (status, { desc }) => desc(status.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(objects.map((status) => statusToAPI(status, user))),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -24,6 +24,9 @@ export const meta = applyConfig({
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -27,6 +27,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -27,6 +27,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
|
@ -27,6 +27,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { z } from "zod";
|
||||||
import { findManyUsers, userToAPI } from "~database/entities/User";
|
import { findManyUsers, userToAPI } from "~database/entities/User";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
|
||||||
|
|
@ -16,69 +17,68 @@ 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)
|
* Find familiar followers (followers of a user that you also follow)
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
id: string[];
|
async (req, matchedRoute, extraData) => {
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
const { user: self } = extraData.auth;
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { id: ids } = extraData.parsedRequest;
|
const { id: ids } = extraData.parsedRequest;
|
||||||
|
|
||||||
// Minimum id count 1, maximum 10
|
const idFollowerRelationships = await db.query.relationship.findMany({
|
||||||
if (!ids || ids.length < 1 || ids.length > 10) {
|
columns: {
|
||||||
return errorResponse("Number of ids must be between 1 and 10", 422);
|
ownerId: true,
|
||||||
}
|
},
|
||||||
|
where: (relationship, { inArray, and, eq }) =>
|
||||||
const idFollowerRelationships = await db.query.relationship.findMany({
|
and(
|
||||||
columns: {
|
inArray(relationship.subjectId, ids),
|
||||||
ownerId: true,
|
eq(relationship.following, true),
|
||||||
},
|
|
||||||
where: (relationship, { inArray, and, eq }) =>
|
|
||||||
and(
|
|
||||||
inArray(relationship.subjectId, ids),
|
|
||||||
eq(relationship.following, true),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (idFollowerRelationships.length === 0) {
|
|
||||||
return jsonResponse([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find users that you follow in idFollowerRelationships
|
|
||||||
const relevantRelationships = await db.query.relationship.findMany({
|
|
||||||
columns: {
|
|
||||||
subjectId: true,
|
|
||||||
},
|
|
||||||
where: (relationship, { inArray, and, eq }) =>
|
|
||||||
and(
|
|
||||||
eq(relationship.ownerId, self.id),
|
|
||||||
inArray(
|
|
||||||
relationship.subjectId,
|
|
||||||
idFollowerRelationships.map((f) => f.ownerId),
|
|
||||||
),
|
),
|
||||||
eq(relationship.following, true),
|
});
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (relevantRelationships.length === 0) {
|
if (idFollowerRelationships.length === 0) {
|
||||||
return jsonResponse([]);
|
return jsonResponse([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalUsers = await findManyUsers({
|
// Find users that you follow in idFollowerRelationships
|
||||||
where: (user, { inArray }) =>
|
const relevantRelationships = await db.query.relationship.findMany({
|
||||||
inArray(
|
columns: {
|
||||||
user.id,
|
subjectId: true,
|
||||||
relevantRelationships.map((r) => r.subjectId),
|
},
|
||||||
),
|
where: (relationship, { inArray, and, eq }) =>
|
||||||
});
|
and(
|
||||||
|
eq(relationship.ownerId, self.id),
|
||||||
|
inArray(
|
||||||
|
relationship.subjectId,
|
||||||
|
idFollowerRelationships.map((f) => f.ownerId),
|
||||||
|
),
|
||||||
|
eq(relationship.following, true),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
if (finalUsers.length === 0) {
|
if (relevantRelationships.length === 0) {
|
||||||
return jsonResponse([]);
|
return jsonResponse([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse(finalUsers.map((o) => userToAPI(o)));
|
const finalUsers = await findManyUsers({
|
||||||
});
|
where: (user, { inArray }) =>
|
||||||
|
inArray(
|
||||||
|
user.id,
|
||||||
|
relevantRelationships.map((r) => r.subjectId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (finalUsers.length === 0) {
|
||||||
|
return jsonResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(finalUsers.map((o) => userToAPI(o)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api";
|
||||||
import { jsonResponse, response } from "@response";
|
import { jsonResponse, response } from "@response";
|
||||||
import { tempmailDomains } from "@tempmail";
|
import { tempmailDomains } from "@tempmail";
|
||||||
import ISO6391 from "iso-639-1";
|
import ISO6391 from "iso-639-1";
|
||||||
|
import { z } from "zod";
|
||||||
import { createNewLocalUser, findFirstUser } from "~database/entities/User";
|
import { createNewLocalUser, findFirstUser } from "~database/entities/User";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -17,193 +18,202 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
// No validation on the Zod side as we need to do custom validation
|
||||||
username: string;
|
export const schema = z.object({
|
||||||
email: string;
|
username: z.string(),
|
||||||
password: string;
|
email: z.string(),
|
||||||
agreement: boolean;
|
password: z.string(),
|
||||||
locale: string;
|
agreement: z.boolean(),
|
||||||
reason: string;
|
locale: z.string(),
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
reason: z.string(),
|
||||||
// TODO: Add Authorization check
|
});
|
||||||
|
|
||||||
const body = extraData.parsedRequest;
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
|
async (req, matchedRoute, extraData) => {
|
||||||
|
// TODO: Add Authorization check
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
const body = extraData.parsedRequest;
|
||||||
|
|
||||||
if (!config.signups.registration) {
|
const config = await extraData.configManager.getConfig();
|
||||||
return jsonResponse(
|
|
||||||
{
|
if (!config.signups.registration) {
|
||||||
error: "Registration is disabled",
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: "Registration is disabled",
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: {
|
||||||
|
details: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
| "ERR_BLANK"
|
||||||
|
| "ERR_INVALID"
|
||||||
|
| "ERR_TOO_LONG"
|
||||||
|
| "ERR_TOO_SHORT"
|
||||||
|
| "ERR_BLOCKED"
|
||||||
|
| "ERR_TAKEN"
|
||||||
|
| "ERR_RESERVED"
|
||||||
|
| "ERR_ACCEPTED"
|
||||||
|
| "ERR_INCLUSION";
|
||||||
|
description: string;
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
} = {
|
||||||
|
details: {
|
||||||
|
password: [],
|
||||||
|
username: [],
|
||||||
|
email: [],
|
||||||
|
agreement: [],
|
||||||
|
locale: [],
|
||||||
|
reason: [],
|
||||||
},
|
},
|
||||||
422,
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errors: {
|
// Check if fields are blank
|
||||||
details: Record<
|
for (const value of [
|
||||||
string,
|
"username",
|
||||||
{
|
"email",
|
||||||
error:
|
"password",
|
||||||
| "ERR_BLANK"
|
"agreement",
|
||||||
| "ERR_INVALID"
|
"locale",
|
||||||
| "ERR_TOO_LONG"
|
"reason",
|
||||||
| "ERR_TOO_SHORT"
|
]) {
|
||||||
| "ERR_BLOCKED"
|
// @ts-expect-error We don't care about typing here
|
||||||
| "ERR_TAKEN"
|
if (!body[value]) {
|
||||||
| "ERR_RESERVED"
|
errors.details[value].push({
|
||||||
| "ERR_ACCEPTED"
|
error: "ERR_BLANK",
|
||||||
| "ERR_INCLUSION";
|
description: `can't be blank`,
|
||||||
description: string;
|
});
|
||||||
}[]
|
}
|
||||||
>;
|
}
|
||||||
} = {
|
|
||||||
details: {
|
|
||||||
password: [],
|
|
||||||
username: [],
|
|
||||||
email: [],
|
|
||||||
agreement: [],
|
|
||||||
locale: [],
|
|
||||||
reason: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if fields are blank
|
// Check if username is valid
|
||||||
for (const value of [
|
if (!body.username?.match(/^[a-z0-9_]+$/))
|
||||||
"username",
|
errors.details.username.push({
|
||||||
"email",
|
error: "ERR_INVALID",
|
||||||
"password",
|
description:
|
||||||
"agreement",
|
"must only contain lowercase letters, numbers, and underscores",
|
||||||
"locale",
|
});
|
||||||
"reason",
|
|
||||||
]) {
|
// Check if username doesnt match filters
|
||||||
// @ts-expect-error We don't care about typing here
|
if (
|
||||||
if (!body[value]) {
|
config.filters.username.some((filter) =>
|
||||||
errors.details[value].push({
|
body.username?.match(filter),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_INVALID",
|
||||||
|
description: "contains blocked words",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is too long
|
||||||
|
if ((body.username?.length ?? 0) > config.validation.max_username_size)
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_TOO_LONG",
|
||||||
|
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if username is too short
|
||||||
|
if ((body.username?.length ?? 0) < 3)
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_TOO_SHORT",
|
||||||
|
description: "is too short (minimum is 3 characters)",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if username is reserved
|
||||||
|
if (config.validation.username_blacklist.includes(body.username ?? ""))
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_RESERVED",
|
||||||
|
description: "is reserved",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if username is taken
|
||||||
|
if (
|
||||||
|
await findFirstUser({
|
||||||
|
where: (user, { eq }) => eq(user.username, body.username ?? ""),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_TAKEN",
|
||||||
|
description: "is already taken",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is valid
|
||||||
|
if (
|
||||||
|
!body.email?.match(
|
||||||
|
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
errors.details.email.push({
|
||||||
|
error: "ERR_INVALID",
|
||||||
|
description: "must be a valid email address",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if email is blocked
|
||||||
|
if (
|
||||||
|
config.validation.email_blacklist.includes(body.email ?? "") ||
|
||||||
|
(config.validation.blacklist_tempmail &&
|
||||||
|
tempmailDomains.domains.includes(
|
||||||
|
(body.email ?? "").split("@")[1],
|
||||||
|
))
|
||||||
|
)
|
||||||
|
errors.details.email.push({
|
||||||
|
error: "ERR_BLOCKED",
|
||||||
|
description: "is from a blocked email provider",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if agreement is accepted
|
||||||
|
if (!body.agreement)
|
||||||
|
errors.details.agreement.push({
|
||||||
|
error: "ERR_ACCEPTED",
|
||||||
|
description: "must be accepted",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!body.locale)
|
||||||
|
errors.details.locale.push({
|
||||||
error: "ERR_BLANK",
|
error: "ERR_BLANK",
|
||||||
description: `can't be blank`,
|
description: `can't be blank`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!ISO6391.validate(body.locale ?? ""))
|
||||||
|
errors.details.locale.push({
|
||||||
|
error: "ERR_INVALID",
|
||||||
|
description: "must be a valid ISO 639-1 code",
|
||||||
|
});
|
||||||
|
|
||||||
|
// If any errors are present, return them
|
||||||
|
if (Object.values(errors.details).some((value) => value.length > 0)) {
|
||||||
|
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
|
||||||
|
|
||||||
|
const errorsText = Object.entries(errors.details)
|
||||||
|
.map(
|
||||||
|
([name, errors]) =>
|
||||||
|
`${name} ${errors
|
||||||
|
.map((error) => error.description)
|
||||||
|
.join(", ")}`,
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: `Validation failed: ${errorsText}`,
|
||||||
|
details: errors.details,
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is valid
|
await createNewLocalUser({
|
||||||
if (!body.username?.match(/^[a-z0-9_]+$/))
|
username: body.username ?? "",
|
||||||
errors.details.username.push({
|
password: body.password ?? "",
|
||||||
error: "ERR_INVALID",
|
email: body.email ?? "",
|
||||||
description:
|
|
||||||
"must only contain lowercase letters, numbers, and underscores",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if username doesnt match filters
|
return response(null, 200);
|
||||||
if (
|
},
|
||||||
config.filters.username.some((filter) => body.username?.match(filter))
|
);
|
||||||
) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description: "contains blocked words",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is too long
|
|
||||||
if ((body.username?.length ?? 0) > config.validation.max_username_size)
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_TOO_LONG",
|
|
||||||
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if username is too short
|
|
||||||
if ((body.username?.length ?? 0) < 3)
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_TOO_SHORT",
|
|
||||||
description: "is too short (minimum is 3 characters)",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if username is reserved
|
|
||||||
if (config.validation.username_blacklist.includes(body.username ?? ""))
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_RESERVED",
|
|
||||||
description: "is reserved",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if username is taken
|
|
||||||
if (
|
|
||||||
await findFirstUser({
|
|
||||||
where: (user, { eq }) => eq(user.username, body.username ?? ""),
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_TAKEN",
|
|
||||||
description: "is already taken",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email is valid
|
|
||||||
if (
|
|
||||||
!body.email?.match(
|
|
||||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
errors.details.email.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description: "must be a valid email address",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if email is blocked
|
|
||||||
if (
|
|
||||||
config.validation.email_blacklist.includes(body.email ?? "") ||
|
|
||||||
(config.validation.blacklist_tempmail &&
|
|
||||||
tempmailDomains.domains.includes((body.email ?? "").split("@")[1]))
|
|
||||||
)
|
|
||||||
errors.details.email.push({
|
|
||||||
error: "ERR_BLOCKED",
|
|
||||||
description: "is from a blocked email provider",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if agreement is accepted
|
|
||||||
if (!body.agreement)
|
|
||||||
errors.details.agreement.push({
|
|
||||||
error: "ERR_ACCEPTED",
|
|
||||||
description: "must be accepted",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!body.locale)
|
|
||||||
errors.details.locale.push({
|
|
||||||
error: "ERR_BLANK",
|
|
||||||
description: `can't be blank`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ISO6391.validate(body.locale ?? ""))
|
|
||||||
errors.details.locale.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description: "must be a valid ISO 639-1 code",
|
|
||||||
});
|
|
||||||
|
|
||||||
// If any errors are present, return them
|
|
||||||
if (Object.values(errors.details).some((value) => value.length > 0)) {
|
|
||||||
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
|
|
||||||
|
|
||||||
const errorsText = Object.entries(errors.details)
|
|
||||||
.map(
|
|
||||||
([name, errors]) =>
|
|
||||||
`${name} ${errors
|
|
||||||
.map((error) => error.description)
|
|
||||||
.join(", ")}`,
|
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
error: `Validation failed: ${errorsText}`,
|
|
||||||
details: errors.details,
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await createNewLocalUser({
|
|
||||||
username: body.username ?? "",
|
|
||||||
password: body.password ?? "",
|
|
||||||
email: body.email ?? "",
|
|
||||||
});
|
|
||||||
|
|
||||||
return response(null, 200);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -21,45 +21,6 @@ afterAll(async () => {
|
||||||
|
|
||||||
// /api/v1/accounts/lookup
|
// /api/v1/accounts/lookup
|
||||||
describe(meta.route, () => {
|
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 () => {
|
test("should return 200 with users", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import {
|
||||||
|
anyOf,
|
||||||
|
charIn,
|
||||||
|
createRegExp,
|
||||||
|
digit,
|
||||||
|
exactly,
|
||||||
|
letter,
|
||||||
|
maybe,
|
||||||
|
oneOrMore,
|
||||||
|
global,
|
||||||
|
} from "magic-regexp";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
findFirstUser,
|
findFirstUser,
|
||||||
resolveWebFinger,
|
resolveWebFinger,
|
||||||
|
|
@ -19,52 +31,71 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
acct: string;
|
acct: z.string().min(1).max(512),
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
});
|
||||||
const { acct } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
if (!acct) {
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
return errorResponse("Invalid acct parameter", 400);
|
async (req, matchedRoute, extraData) => {
|
||||||
}
|
const { acct } = extraData.parsedRequest;
|
||||||
|
|
||||||
// Check if acct is matching format username@domain.com or @username@domain.com
|
if (!acct) {
|
||||||
const accountMatches = acct
|
return errorResponse("Invalid acct parameter", 400);
|
||||||
?.trim()
|
|
||||||
.match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g);
|
|
||||||
if (accountMatches) {
|
|
||||||
// Remove leading @ if it exists
|
|
||||||
if (accountMatches[0].startsWith("@")) {
|
|
||||||
accountMatches[0] = accountMatches[0].slice(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [username, domain] = accountMatches[0].split("@");
|
// Check if acct is matching format username@domain.com or @username@domain.com
|
||||||
const foundAccount = await resolveWebFinger(username, domain).catch(
|
const accountMatches = acct?.trim().match(
|
||||||
(e) => {
|
createRegExp(
|
||||||
console.error(e);
|
maybe("@"),
|
||||||
return null;
|
oneOrMore(
|
||||||
},
|
anyOf(letter.lowercase, digit, charIn("-")),
|
||||||
|
).groupedAs("username"),
|
||||||
|
exactly("@"),
|
||||||
|
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
|
||||||
|
"domain",
|
||||||
|
),
|
||||||
|
|
||||||
|
[global],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (foundAccount) {
|
if (accountMatches) {
|
||||||
return jsonResponse(userToAPI(foundAccount));
|
// Remove leading @ if it exists
|
||||||
|
if (accountMatches[0].startsWith("@")) {
|
||||||
|
accountMatches[0] = accountMatches[0].slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [username, domain] = accountMatches[0].split("@");
|
||||||
|
const foundAccount = await resolveWebFinger(username, domain).catch(
|
||||||
|
(e) => {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundAccount) {
|
||||||
|
return jsonResponse(userToAPI(foundAccount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorResponse("Account not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse("Account not found", 404);
|
let username = acct;
|
||||||
}
|
if (username.startsWith("@")) {
|
||||||
|
username = username.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
let username = acct;
|
const account = await findFirstUser({
|
||||||
if (username.startsWith("@")) {
|
where: (user, { eq }) => eq(user.username, username),
|
||||||
username = username.slice(1);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const account = await findFirstUser({
|
if (account) {
|
||||||
where: (user, { eq }) => eq(user.username, username),
|
return jsonResponse(userToAPI(account));
|
||||||
});
|
}
|
||||||
|
|
||||||
if (account) {
|
return errorResponse(
|
||||||
return jsonResponse(userToAPI(account));
|
`Account with username ${username} not found`,
|
||||||
}
|
404,
|
||||||
|
);
|
||||||
return errorResponse(`Account with username ${username} not found`, 404);
|
},
|
||||||
});
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
createNewRelationship,
|
createNewRelationship,
|
||||||
relationshipToAPI,
|
relationshipToAPI,
|
||||||
|
|
@ -20,47 +21,48 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
id: z.array(z.string().regex(idValidator)).min(1).max(10),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find relationships
|
* Find relationships
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
id: string[];
|
async (req, matchedRoute, extraData) => {
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
const { user: self } = extraData.auth;
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { id: ids } = extraData.parsedRequest;
|
const { id: ids } = extraData.parsedRequest;
|
||||||
|
|
||||||
// Minimum id count 1, maximum 10
|
const relationships = await db.query.relationship.findMany({
|
||||||
if (!ids || ids.length < 1 || ids.length > 10) {
|
where: (relationship, { inArray, and, eq }) =>
|
||||||
return errorResponse("Number of ids must be between 1 and 10", 422);
|
and(
|
||||||
}
|
inArray(relationship.subjectId, ids),
|
||||||
|
eq(relationship.ownerId, self.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const relationships = await db.query.relationship.findMany({
|
// Find IDs that dont have a relationship
|
||||||
where: (relationship, { inArray, and, eq }) =>
|
const missingIds = ids.filter(
|
||||||
and(
|
(id) => !relationships.some((r) => r.subjectId === id),
|
||||||
inArray(relationship.subjectId, ids),
|
);
|
||||||
eq(relationship.ownerId, self.id),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find IDs that dont have a relationship
|
// Create the missing relationships
|
||||||
const missingIds = ids.filter(
|
for (const id of missingIds) {
|
||||||
(id) => !relationships.some((r) => r.subjectId === id),
|
const relationship = await createNewRelationship(self, {
|
||||||
);
|
id,
|
||||||
|
} as User);
|
||||||
|
|
||||||
// Create the missing relationships
|
relationships.push(relationship);
|
||||||
for (const id of missingIds) {
|
}
|
||||||
const relationship = await createNewRelationship(self, { id } as User);
|
|
||||||
|
|
||||||
relationships.push(relationship);
|
// Order in the same order as ids
|
||||||
}
|
relationships.sort(
|
||||||
|
(a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId),
|
||||||
|
);
|
||||||
|
|
||||||
// Order in the same order as ids
|
return jsonResponse(relationships.map((r) => relationshipToAPI(r)));
|
||||||
relationships.sort(
|
},
|
||||||
(a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(relationships.map((r) => relationshipToAPI(r)));
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
sendTestRequest,
|
sendTestRequest,
|
||||||
} from "~tests/utils";
|
} from "~tests/utils";
|
||||||
import type { APIAccount } from "~types/entities/account";
|
import type { APIAccount } from "~types/entities/account";
|
||||||
import type { APIStatus } from "~types/entities/status";
|
|
||||||
import { meta } from "./index";
|
import { meta } from "./index";
|
||||||
|
|
||||||
await deleteOldTestUsers();
|
await deleteOldTestUsers();
|
||||||
|
|
@ -21,66 +20,6 @@ afterAll(async () => {
|
||||||
|
|
||||||
// /api/v1/accounts/search
|
// /api/v1/accounts/search
|
||||||
describe(meta.route, () => {
|
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 () => {
|
test("should return 200 with users", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
createRegExp,
|
||||||
|
maybe,
|
||||||
|
oneOrMore,
|
||||||
|
anyOf,
|
||||||
|
letter,
|
||||||
|
digit,
|
||||||
|
charIn,
|
||||||
|
exactly,
|
||||||
|
global,
|
||||||
|
} from "magic-regexp";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
findManyUsers,
|
findManyUsers,
|
||||||
|
|
@ -22,59 +34,75 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
q?: string;
|
q: z
|
||||||
limit?: number;
|
.string()
|
||||||
offset?: number;
|
.min(1)
|
||||||
resolve?: boolean;
|
.max(512)
|
||||||
following?: boolean;
|
.regex(
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
createRegExp(
|
||||||
// TODO: Add checks for disabled or not email verified accounts
|
maybe("@"),
|
||||||
const {
|
oneOrMore(
|
||||||
following = false,
|
anyOf(letter.lowercase, digit, charIn("-")),
|
||||||
limit = 40,
|
).groupedAs("username"),
|
||||||
offset,
|
maybe(
|
||||||
resolve,
|
exactly("@"),
|
||||||
q,
|
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
|
||||||
} = extraData.parsedRequest;
|
"domain",
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
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("@") || [];
|
|
||||||
|
|
||||||
const accounts: UserWithRelations[] = [];
|
|
||||||
|
|
||||||
if (resolve && username && host) {
|
|
||||||
const resolvedUser = await resolveWebFinger(username, host);
|
|
||||||
|
|
||||||
if (resolvedUser) {
|
|
||||||
accounts.push(resolvedUser);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
accounts.push(
|
|
||||||
...(await findManyUsers({
|
|
||||||
where: (account, { or, like }) =>
|
|
||||||
or(
|
|
||||||
like(account.displayName, `%${q}%`),
|
|
||||||
like(account.username, `%${q}%`),
|
|
||||||
following
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${account.id} AND "Relationship"."following" = true)`
|
|
||||||
: undefined,
|
|
||||||
),
|
),
|
||||||
offset: Number(offset),
|
),
|
||||||
})),
|
[global],
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
return jsonResponse(accounts.map((acct) => userToAPI(acct)));
|
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,
|
||||||
|
offset,
|
||||||
|
resolve,
|
||||||
|
q,
|
||||||
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
if (!self && following) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
// Remove any leading @
|
||||||
|
const [username, host] = q.replace(/^@/, "").split("@");
|
||||||
|
|
||||||
|
const accounts: UserWithRelations[] = [];
|
||||||
|
|
||||||
|
if (resolve && username && host) {
|
||||||
|
const resolvedUser = await resolveWebFinger(username, host);
|
||||||
|
|
||||||
|
if (resolvedUser) {
|
||||||
|
accounts.push(resolvedUser);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accounts.push(
|
||||||
|
...(await findManyUsers({
|
||||||
|
where: (account, { or, like }) =>
|
||||||
|
or(
|
||||||
|
like(account.displayName, `%${q}%`),
|
||||||
|
like(account.username, `%${q}%`),
|
||||||
|
following
|
||||||
|
? sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${account.id} AND "Relationship"."following" = true)`
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(accounts.map((acct) => userToAPI(acct)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import ISO6391 from "iso-639-1";
|
||||||
import { MediaBackendType } from "media-manager";
|
import { MediaBackendType } from "media-manager";
|
||||||
import type { MediaBackend } from "media-manager";
|
import type { MediaBackend } from "media-manager";
|
||||||
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
||||||
|
import { z } from "zod";
|
||||||
import { getUrl } from "~database/entities/Attachment";
|
import { getUrl } from "~database/entities/Attachment";
|
||||||
import { parseEmojis } from "~database/entities/Emoji";
|
import { parseEmojis } from "~database/entities/Emoji";
|
||||||
import { findFirstUser, userToAPI } from "~database/entities/User";
|
import { findFirstUser, userToAPI } from "~database/entities/User";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { emojiToUser, user } from "~drizzle/schema";
|
import { emojiToUser, user } from "~drizzle/schema";
|
||||||
|
import { config } from "config-manager";
|
||||||
import type { APISource } from "~types/entities/source";
|
import type { APISource } from "~types/entities/source";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -27,45 +29,56 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
display_name: string;
|
display_name: z
|
||||||
note: string;
|
.string()
|
||||||
avatar: File;
|
.min(3)
|
||||||
header: File;
|
.max(config.validation.max_displayname_size)
|
||||||
locked: string;
|
.optional(),
|
||||||
bot: string;
|
note: z.string().min(0).max(config.validation.max_bio_size).optional(),
|
||||||
discoverable: string;
|
avatar: z.instanceof(File).optional(),
|
||||||
"source[privacy]": string;
|
header: z.instanceof(File).optional(),
|
||||||
"source[sensitive]": string;
|
locked: z.boolean().optional(),
|
||||||
"source[language]": string;
|
bot: z.boolean().optional(),
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
discoverable: z.boolean().optional(),
|
||||||
const { user: self } = extraData.auth;
|
"source[privacy]": z
|
||||||
|
.enum(["public", "unlisted", "private", "direct"])
|
||||||
|
.optional(),
|
||||||
|
"source[sensitive]": z.boolean().optional(),
|
||||||
|
"source[language]": z
|
||||||
|
.enum(ISO6391.getAllCodes() as [string, ...string[]])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
|
async (req, matchedRoute, extraData) => {
|
||||||
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const {
|
const config = await extraData.configManager.getConfig();
|
||||||
display_name,
|
|
||||||
note,
|
|
||||||
avatar,
|
|
||||||
header,
|
|
||||||
locked,
|
|
||||||
bot,
|
|
||||||
discoverable,
|
|
||||||
"source[privacy]": source_privacy,
|
|
||||||
"source[sensitive]": source_sensitive,
|
|
||||||
"source[language]": source_language,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const sanitizedNote = await sanitizeHtml(note ?? "");
|
const {
|
||||||
|
display_name,
|
||||||
|
note,
|
||||||
|
avatar,
|
||||||
|
header,
|
||||||
|
locked,
|
||||||
|
bot,
|
||||||
|
discoverable,
|
||||||
|
"source[privacy]": source_privacy,
|
||||||
|
"source[sensitive]": source_sensitive,
|
||||||
|
"source[language]": source_language,
|
||||||
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
const sanitizedDisplayName = display_name ?? ""; /* sanitize(display_name ?? "", {
|
const sanitizedNote = await sanitizeHtml(note ?? "");
|
||||||
|
|
||||||
|
const sanitizedDisplayName = display_name ?? ""; /* sanitize(display_name ?? "", {
|
||||||
ALLOWED_TAGS: [],
|
ALLOWED_TAGS: [],
|
||||||
ALLOWED_ATTR: [],
|
ALLOWED_ATTR: [],
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
/* if (!user.source) {
|
/* if (!user.source) {
|
||||||
user.source = {
|
user.source = {
|
||||||
privacy: "public",
|
privacy: "public",
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
|
|
@ -74,205 +87,153 @@ export default apiRoute<{
|
||||||
};
|
};
|
||||||
} */
|
} */
|
||||||
|
|
||||||
let mediaManager: MediaBackend;
|
let mediaManager: MediaBackend;
|
||||||
|
|
||||||
switch (config.media.backend as MediaBackendType) {
|
switch (config.media.backend as MediaBackendType) {
|
||||||
case MediaBackendType.LOCAL:
|
case MediaBackendType.LOCAL:
|
||||||
mediaManager = new LocalMediaBackend(config);
|
mediaManager = new LocalMediaBackend(config);
|
||||||
break;
|
break;
|
||||||
case MediaBackendType.S3:
|
case MediaBackendType.S3:
|
||||||
mediaManager = new S3MediaBackend(config);
|
mediaManager = new S3MediaBackend(config);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// TODO: Replace with logger
|
// TODO: Replace with logger
|
||||||
throw new Error("Invalid media backend");
|
throw new Error("Invalid media backend");
|
||||||
}
|
|
||||||
|
|
||||||
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 (display_name) {
|
||||||
if (
|
// Check if display name doesnt match filters
|
||||||
config.filters.displayname.some((filter) =>
|
if (
|
||||||
sanitizedDisplayName.match(filter),
|
config.filters.displayname.some((filter) =>
|
||||||
)
|
sanitizedDisplayName.match(filter),
|
||||||
) {
|
)
|
||||||
return errorResponse("Display name contains blocked words", 422);
|
) {
|
||||||
|
return errorResponse(
|
||||||
|
"Display name contains blocked words",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.displayName = sanitizedDisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove emojis
|
if (note && self.source) {
|
||||||
self.emojis = [];
|
// Check if bio doesnt match filters
|
||||||
|
if (
|
||||||
|
config.filters.bio.some((filter) => sanitizedNote.match(filter))
|
||||||
|
) {
|
||||||
|
return errorResponse("Bio contains blocked words", 422);
|
||||||
|
}
|
||||||
|
|
||||||
self.displayName = sanitizedDisplayName;
|
(self.source as APISource).note = sanitizedNote;
|
||||||
}
|
self.note = await convertTextToHtml(sanitizedNote);
|
||||||
|
|
||||||
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 (source_privacy && self.source) {
|
||||||
if (config.filters.bio.some((filter) => sanitizedNote.match(filter))) {
|
(self.source as APISource).privacy = source_privacy;
|
||||||
return errorResponse("Bio contains blocked words", 422);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(self.source as APISource).note = sanitizedNote;
|
if (source_sensitive && self.source) {
|
||||||
// TODO: Convert note to HTML
|
(self.source as APISource).sensitive = source_sensitive;
|
||||||
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_language && self.source) {
|
||||||
}
|
(self.source as APISource).language = source_language;
|
||||||
|
|
||||||
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";
|
if (avatar) {
|
||||||
}
|
// Check if within allowed avatar length (avatar is an image)
|
||||||
|
if (avatar.size > config.validation.max_avatar_size) {
|
||||||
|
return errorResponse(
|
||||||
|
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (source_language && self.source) {
|
const { path } = await mediaManager.addFile(avatar);
|
||||||
if (!ISO6391.validate(source_language)) {
|
|
||||||
return errorResponse(
|
self.avatar = getUrl(path, config);
|
||||||
"Language must be a valid ISO 639-1 code",
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(self.source as APISource).language = source_language;
|
if (header) {
|
||||||
}
|
// Check if within allowed header length (header is an image)
|
||||||
|
if (header.size > config.validation.max_header_size) {
|
||||||
|
return errorResponse(
|
||||||
|
`Header must be less than ${config.validation.max_avatar_size} bytes`,
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (avatar) {
|
const { path } = await mediaManager.addFile(header);
|
||||||
// Check if within allowed avatar length (avatar is an image)
|
|
||||||
if (avatar.size > config.validation.max_avatar_size) {
|
self.header = getUrl(path, config);
|
||||||
return errorResponse(
|
|
||||||
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path } = await mediaManager.addFile(avatar);
|
if (locked) {
|
||||||
|
self.isLocked = locked;
|
||||||
self.avatar = getUrl(path, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (header) {
|
|
||||||
// Check if within allowed header length (header is an image)
|
|
||||||
if (header.size > config.validation.max_header_size) {
|
|
||||||
return errorResponse(
|
|
||||||
`Header must be less than ${config.validation.max_avatar_size} bytes`,
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path } = await mediaManager.addFile(header);
|
if (bot) {
|
||||||
|
self.isBot = bot;
|
||||||
self.header = getUrl(path, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
if (discoverable) {
|
||||||
}
|
self.isDiscoverable = discoverable;
|
||||||
|
|
||||||
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";
|
// Parse emojis
|
||||||
}
|
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
|
||||||
|
const noteEmojis = await parseEmojis(sanitizedNote);
|
||||||
|
|
||||||
if (discoverable) {
|
self.emojis = [...displaynameEmojis, ...noteEmojis];
|
||||||
// Check if discoverable is a boolean
|
|
||||||
if (discoverable !== "true" && discoverable !== "false") {
|
|
||||||
return errorResponse("Discoverable must be a boolean", 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.isDiscoverable = discoverable === "true";
|
// Deduplicate emojis
|
||||||
}
|
self.emojis = self.emojis.filter(
|
||||||
|
(emoji, index, self) =>
|
||||||
// Parse emojis
|
self.findIndex((e) => e.id === emoji.id) === index,
|
||||||
|
);
|
||||||
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
|
|
||||||
const noteEmojis = await parseEmojis(sanitizedNote);
|
|
||||||
|
|
||||||
self.emojis = [...displaynameEmojis, ...noteEmojis];
|
|
||||||
|
|
||||||
// Deduplicate emojis
|
|
||||||
self.emojis = self.emojis.filter(
|
|
||||||
(emoji, index, self) =>
|
|
||||||
self.findIndex((e) => e.id === emoji.id) === index,
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(user)
|
|
||||||
.set({
|
|
||||||
displayName: self.displayName,
|
|
||||||
note: self.note,
|
|
||||||
avatar: self.avatar,
|
|
||||||
header: self.header,
|
|
||||||
isLocked: self.isLocked,
|
|
||||||
isBot: self.isBot,
|
|
||||||
isDiscoverable: self.isDiscoverable,
|
|
||||||
source: self.source || undefined,
|
|
||||||
})
|
|
||||||
.where(eq(user.id, self.id));
|
|
||||||
|
|
||||||
// Connect emojis, if any
|
|
||||||
for (const emoji of self.emojis) {
|
|
||||||
await db
|
|
||||||
.delete(emojiToUser)
|
|
||||||
.where(and(eq(emojiToUser.a, emoji.id), eq(emojiToUser.b, self.id)))
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(emojiToUser)
|
.update(user)
|
||||||
.values({
|
.set({
|
||||||
a: emoji.id,
|
displayName: self.displayName,
|
||||||
b: self.id,
|
note: self.note,
|
||||||
|
avatar: self.avatar,
|
||||||
|
header: self.header,
|
||||||
|
isLocked: self.isLocked,
|
||||||
|
isBot: self.isBot,
|
||||||
|
isDiscoverable: self.isDiscoverable,
|
||||||
|
source: self.source || undefined,
|
||||||
})
|
})
|
||||||
.execute();
|
.where(eq(user.id, self.id));
|
||||||
}
|
|
||||||
|
|
||||||
const output = await findFirstUser({
|
// Connect emojis, if any
|
||||||
where: (user, { eq }) => eq(user.id, self.id),
|
for (const emoji of self.emojis) {
|
||||||
});
|
await db
|
||||||
|
.delete(emojiToUser)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(emojiToUser.emojiId, emoji.id),
|
||||||
|
eq(emojiToUser.userId, self.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
if (!output) return errorResponse("Couldn't edit user", 500);
|
await db
|
||||||
|
.insert(emojiToUser)
|
||||||
|
.values({
|
||||||
|
emojiId: emoji.id,
|
||||||
|
userId: self.id,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse(userToAPI(output));
|
const output = await findFirstUser({
|
||||||
});
|
where: (user, { eq }) => eq(user.id, self.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!output) return errorResponse("Couldn't edit user", 500);
|
||||||
|
|
||||||
|
return jsonResponse(userToAPI(output));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { apiRoute, applyConfig } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { application } from "~drizzle/schema";
|
import { application } from "~drizzle/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -16,46 +17,43 @@ 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
|
* Creates a new application to obtain OAuth 2 credentials
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
client_name: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
redirect_uris: string;
|
const { client_name, redirect_uris, scopes, website } =
|
||||||
scopes: string;
|
extraData.parsedRequest;
|
||||||
website: string;
|
|
||||||
}>(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
|
const app = (
|
||||||
if (redirect_uris) {
|
await db
|
||||||
if (!URL.canParse(redirect_uris)) {
|
.insert(application)
|
||||||
return errorResponse("Redirect URI must be a valid URI", 422);
|
.values({
|
||||||
}
|
name: client_name || "",
|
||||||
}
|
redirectUris: redirect_uris || "",
|
||||||
|
scopes: scopes || "read",
|
||||||
|
website: website || null,
|
||||||
|
clientId: randomBytes(32).toString("base64url"),
|
||||||
|
secret: randomBytes(64).toString("base64url"),
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
const app = (
|
return jsonResponse({
|
||||||
await db
|
id: app.id,
|
||||||
.insert(application)
|
name: app.name,
|
||||||
.values({
|
website: app.website,
|
||||||
name: client_name || "",
|
client_id: app.clientId,
|
||||||
redirectUris: redirect_uris || "",
|
client_secret: app.secret,
|
||||||
scopes: scopes || "read",
|
redirect_uri: app.redirectUris,
|
||||||
website: website || null,
|
vapid_link: app.vapidKey,
|
||||||
clientId: randomBytes(32).toString("base64url"),
|
});
|
||||||
secret: randomBytes(64).toString("base64url"),
|
},
|
||||||
})
|
);
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
return jsonResponse({
|
|
||||||
id: app.id,
|
|
||||||
name: app.name,
|
|
||||||
website: app.website,
|
|
||||||
client_id: app.clientId,
|
|
||||||
client_secret: app.secret,
|
|
||||||
redirect_uri: app.redirectUris,
|
|
||||||
vapid_link: app.vapidKey,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,12 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const { user, token } = extraData.auth;
|
const { user, token } = extraData.auth;
|
||||||
|
|
||||||
|
if (!token) return errorResponse("Unauthorized", 401);
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const application = await getFromToken(token);
|
const application = await getFromToken(token);
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
if (!application) return errorResponse("Unauthorized", 401);
|
if (!application) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
findManyUsers,
|
findManyUsers,
|
||||||
|
|
@ -20,41 +21,46 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
max_id?: string;
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
since_id?: string;
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
min_id?: string;
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
limit?: number;
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
}>(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 { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
|
|
||||||
findManyUsers,
|
|
||||||
{
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
where: (subject, { lt, gte, gt, and, sql }) =>
|
|
||||||
and(
|
|
||||||
max_id ? lt(subject.id, max_id) : undefined,
|
|
||||||
since_id ? gte(subject.id, since_id) : undefined,
|
|
||||||
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),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (subject, { desc }) => desc(subject.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
blocks.map((u) => userToAPI(u)),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 } = extraData.parsedRequest;
|
||||||
|
|
||||||
|
const { objects: blocks, link } =
|
||||||
|
await fetchTimeline<UserWithRelations>(
|
||||||
|
findManyUsers,
|
||||||
|
{
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
where: (subject, { lt, gte, gt, and, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(subject.id, max_id) : undefined,
|
||||||
|
since_id ? gte(subject.id, since_id) : undefined,
|
||||||
|
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,
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (subject, { desc }) => desc(subject.id),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
blocks.map((u) => userToAPI(u)),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type StatusWithRelations,
|
type StatusWithRelations,
|
||||||
findManyStatuses,
|
findManyStatuses,
|
||||||
|
|
@ -19,46 +20,47 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
max_id?: string;
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
since_id?: string;
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
min_id?: string;
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
limit?: number;
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
}>(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
|
||||||
findManyStatuses,
|
|
||||||
{
|
|
||||||
// @ts-ignore
|
|
||||||
where: (status, { and, lt, gt, gte, eq, sql }) =>
|
|
||||||
and(
|
|
||||||
max_id ? lt(status.id, max_id) : undefined,
|
|
||||||
since_id ? gte(status.id, since_id) : undefined,
|
|
||||||
min_id ? gt(status.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`,
|
|
||||||
),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (status, { desc }) => desc(status.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(
|
|
||||||
objects.map(async (status) => statusToAPI(status, user)),
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
|
async (req, matchedRoute, extraData) => {
|
||||||
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
||||||
|
findManyStatuses,
|
||||||
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
where: (status, { and, lt, gt, gte, eq, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(status.id, max_id) : undefined,
|
||||||
|
since_id ? gte(status.id, since_id) : undefined,
|
||||||
|
min_id ? gt(status.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`,
|
||||||
|
),
|
||||||
|
// @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(async (status) => statusToAPI(status, user)),
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
findManyUsers,
|
findManyUsers,
|
||||||
|
|
@ -19,45 +20,45 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
max_id?: string;
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
since_id?: string;
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
min_id?: string;
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
limit?: number;
|
limit: z.coerce.number().int().min(1).max(80).default(20),
|
||||||
}>(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
|
||||||
findManyUsers,
|
|
||||||
{
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
where: (subject, { lt, gte, gt, and, sql }) =>
|
|
||||||
and(
|
|
||||||
max_id ? lt(subject.id, max_id) : undefined,
|
|
||||||
since_id ? gte(subject.id, since_id) : undefined,
|
|
||||||
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),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (subject, { desc }) => desc(subject.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
objects.map((user) => userToAPI(user)),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
|
async (req, matchedRoute, extraData) => {
|
||||||
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
||||||
|
findManyUsers,
|
||||||
|
{
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
where: (subject, { lt, gte, gt, and, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(subject.id, max_id) : undefined,
|
||||||
|
since_id ? gte(subject.id, since_id) : undefined,
|
||||||
|
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,
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (subject, { desc }) => desc(subject.id),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
objects.map((user) => userToAPI(user)),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse, response } from "@response";
|
import { errorResponse, jsonResponse, response } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { MediaBackend } from "media-manager";
|
import type { MediaBackend } from "media-manager";
|
||||||
import { MediaBackendType } 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 { attachmentToAPI, getUrl } from "~database/entities/Attachment";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { attachment } from "~drizzle/schema";
|
import { attachment } from "~drizzle/schema";
|
||||||
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
import { config } from "config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET", "PUT"],
|
allowedMethods: ["GET", "PUT"],
|
||||||
|
|
@ -21,86 +23,97 @@ 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
|
* Get media information
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
thumbnail?: File;
|
async (req, matchedRoute, extraData) => {
|
||||||
description?: string;
|
const { user } = extraData.auth;
|
||||||
focus?: string;
|
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse("Unauthorized", 401);
|
return errorResponse("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = matchedRoute.params.id;
|
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({
|
const foundAttachment = await db.query.attachment.findFirst({
|
||||||
where: (attachment, { eq }) => eq(attachment.id, id),
|
where: (attachment, { eq }) => eq(attachment.id, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!foundAttachment) {
|
if (!foundAttachment) {
|
||||||
return errorResponse("Media not found", 404);
|
return errorResponse("Media not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
const config = await extraData.configManager.getConfig();
|
||||||
|
|
||||||
|
switch (req.method) {
|
||||||
|
case "GET": {
|
||||||
|
if (foundAttachment.url) {
|
||||||
|
return jsonResponse(attachmentToAPI(foundAttachment));
|
||||||
|
}
|
||||||
|
return response(null, 206);
|
||||||
|
}
|
||||||
|
case "PUT": {
|
||||||
|
const { description, thumbnail } = extraData.parsedRequest;
|
||||||
|
|
||||||
|
let thumbnailUrl = foundAttachment.thumbnailUrl;
|
||||||
|
|
||||||
|
let mediaManager: MediaBackend;
|
||||||
|
|
||||||
|
switch (config.media.backend as MediaBackendType) {
|
||||||
|
case MediaBackendType.LOCAL:
|
||||||
|
mediaManager = new LocalMediaBackend(config);
|
||||||
|
break;
|
||||||
|
case MediaBackendType.S3:
|
||||||
|
mediaManager = new S3MediaBackend(config);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// TODO: Replace with logger
|
||||||
|
throw new Error("Invalid media backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumbnail) {
|
||||||
|
const { path } = await mediaManager.addFile(thumbnail);
|
||||||
|
thumbnailUrl = getUrl(path, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptionText =
|
||||||
|
description || foundAttachment.description;
|
||||||
|
|
||||||
|
if (
|
||||||
|
descriptionText !== foundAttachment.description ||
|
||||||
|
thumbnailUrl !== foundAttachment.thumbnailUrl
|
||||||
|
) {
|
||||||
|
const newAttachment = (
|
||||||
|
await db
|
||||||
|
.update(attachment)
|
||||||
|
.set({
|
||||||
|
description: descriptionText,
|
||||||
|
thumbnailUrl,
|
||||||
|
})
|
||||||
|
.where(eq(attachment.id, id))
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
return jsonResponse(attachmentToAPI(newAttachment));
|
||||||
|
}
|
||||||
|
|
||||||
switch (req.method) {
|
|
||||||
case "GET": {
|
|
||||||
if (foundAttachment.url) {
|
|
||||||
return jsonResponse(attachmentToAPI(foundAttachment));
|
return jsonResponse(attachmentToAPI(foundAttachment));
|
||||||
}
|
}
|
||||||
return response(null, 206);
|
|
||||||
}
|
}
|
||||||
case "PUT": {
|
|
||||||
const { description, thumbnail } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
let thumbnailUrl = foundAttachment.thumbnailUrl;
|
return errorResponse("Method not allowed", 405);
|
||||||
|
},
|
||||||
let mediaManager: MediaBackend;
|
);
|
||||||
|
|
||||||
switch (config.media.backend as MediaBackendType) {
|
|
||||||
case MediaBackendType.LOCAL:
|
|
||||||
mediaManager = new LocalMediaBackend(config);
|
|
||||||
break;
|
|
||||||
case MediaBackendType.S3:
|
|
||||||
mediaManager = new S3MediaBackend(config);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// TODO: Replace with logger
|
|
||||||
throw new Error("Invalid media backend");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thumbnail) {
|
|
||||||
const { path } = await mediaManager.addFile(thumbnail);
|
|
||||||
thumbnailUrl = getUrl(path, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
const descriptionText = description || foundAttachment.description;
|
|
||||||
|
|
||||||
if (
|
|
||||||
descriptionText !== foundAttachment.description ||
|
|
||||||
thumbnailUrl !== foundAttachment.thumbnailUrl
|
|
||||||
) {
|
|
||||||
const newAttachment = (
|
|
||||||
await db
|
|
||||||
.update(attachment)
|
|
||||||
.set({
|
|
||||||
description: descriptionText,
|
|
||||||
thumbnailUrl,
|
|
||||||
})
|
|
||||||
.where(eq(attachment.id, id))
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
return jsonResponse(attachmentToAPI(newAttachment));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(attachmentToAPI(foundAttachment));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResponse("Method not allowed", 405);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ import { errorResponse, jsonResponse } from "@response";
|
||||||
import { encode } from "blurhash";
|
import { encode } from "blurhash";
|
||||||
import { MediaBackendType } from "media-manager";
|
import { MediaBackendType } from "media-manager";
|
||||||
import type { MediaBackend } from "media-manager";
|
import type { MediaBackend } from "media-manager";
|
||||||
|
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { z } from "zod";
|
||||||
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
|
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { attachment } from "~drizzle/schema";
|
import { attachment } from "~drizzle/schema";
|
||||||
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
import { config } from "config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -22,134 +24,128 @@ 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
|
* Upload new media
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
file: File;
|
async (req, matchedRoute, extraData) => {
|
||||||
thumbnail?: File;
|
const { user } = extraData.auth;
|
||||||
description?: string;
|
|
||||||
// TODO: Add focus
|
|
||||||
focus?: string;
|
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse("Unauthorized", 401);
|
return errorResponse("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { file, thumbnail, description } = extraData.parsedRequest;
|
const { file, thumbnail, description } = extraData.parsedRequest;
|
||||||
|
|
||||||
if (!file) {
|
const config = await extraData.configManager.getConfig();
|
||||||
return errorResponse("No file provided", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
if (file.size > config.validation.max_media_size) {
|
||||||
|
return errorResponse(
|
||||||
|
`File too large, max size is ${config.validation.max_media_size} bytes`,
|
||||||
|
413,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > config.validation.max_media_size) {
|
if (
|
||||||
return errorResponse(
|
config.validation.enforce_mime_types &&
|
||||||
`File too large, max size is ${config.validation.max_media_size} bytes`,
|
!config.validation.allowed_mime_types.includes(file.type)
|
||||||
413,
|
) {
|
||||||
);
|
return errorResponse("Invalid file type", 415);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const sha256 = new Bun.SHA256();
|
||||||
config.validation.enforce_mime_types &&
|
|
||||||
!config.validation.allowed_mime_types.includes(file.type)
|
|
||||||
) {
|
|
||||||
return errorResponse("Invalid file type", 415);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
const isImage = file.type.startsWith("image/");
|
||||||
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 metadata = isImage
|
||||||
|
? await sharp(await file.arrayBuffer()).metadata()
|
||||||
|
: null;
|
||||||
|
|
||||||
const isImage = file.type.startsWith("image/");
|
const blurhash = await new Promise<string | null>((resolve) => {
|
||||||
|
(async () =>
|
||||||
|
sharp(await file.arrayBuffer())
|
||||||
|
.raw()
|
||||||
|
.ensureAlpha()
|
||||||
|
.toBuffer((err, buffer) => {
|
||||||
|
if (err) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const metadata = isImage
|
try {
|
||||||
? await sharp(await file.arrayBuffer()).metadata()
|
resolve(
|
||||||
: null;
|
encode(
|
||||||
|
new Uint8ClampedArray(buffer),
|
||||||
|
metadata?.width ?? 0,
|
||||||
|
metadata?.height ?? 0,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
) as string,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}))();
|
||||||
|
});
|
||||||
|
|
||||||
const blurhash = await new Promise<string | null>((resolve) => {
|
let url = "";
|
||||||
(async () =>
|
|
||||||
sharp(await file.arrayBuffer())
|
|
||||||
.raw()
|
|
||||||
.ensureAlpha()
|
|
||||||
.toBuffer((err, buffer) => {
|
|
||||||
if (err) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
let mediaManager: MediaBackend;
|
||||||
resolve(
|
|
||||||
encode(
|
|
||||||
new Uint8ClampedArray(buffer),
|
|
||||||
metadata?.width ?? 0,
|
|
||||||
metadata?.height ?? 0,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}))();
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = "";
|
switch (config.media.backend as MediaBackendType) {
|
||||||
|
case MediaBackendType.LOCAL:
|
||||||
|
mediaManager = new LocalMediaBackend(config);
|
||||||
|
break;
|
||||||
|
case MediaBackendType.S3:
|
||||||
|
mediaManager = new S3MediaBackend(config);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// TODO: Replace with logger
|
||||||
|
throw new Error("Invalid media backend");
|
||||||
|
}
|
||||||
|
|
||||||
let mediaManager: MediaBackend;
|
const { path } = await mediaManager.addFile(file);
|
||||||
|
|
||||||
switch (config.media.backend as MediaBackendType) {
|
url = getUrl(path, config);
|
||||||
case MediaBackendType.LOCAL:
|
|
||||||
mediaManager = new LocalMediaBackend(config);
|
|
||||||
break;
|
|
||||||
case MediaBackendType.S3:
|
|
||||||
mediaManager = new S3MediaBackend(config);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// TODO: Replace with logger
|
|
||||||
throw new Error("Invalid media backend");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { path } = await mediaManager.addFile(file);
|
let thumbnailUrl = "";
|
||||||
|
|
||||||
url = getUrl(path, config);
|
if (thumbnail) {
|
||||||
|
const { path } = await mediaManager.addFile(thumbnail);
|
||||||
|
|
||||||
let thumbnailUrl = "";
|
thumbnailUrl = getUrl(path, config);
|
||||||
|
}
|
||||||
|
|
||||||
if (thumbnail) {
|
const newAttachment = (
|
||||||
const { path } = await mediaManager.addFile(thumbnail);
|
await db
|
||||||
|
.insert(attachment)
|
||||||
|
.values({
|
||||||
|
url,
|
||||||
|
thumbnailUrl,
|
||||||
|
sha256: sha256
|
||||||
|
.update(await file.arrayBuffer())
|
||||||
|
.digest("hex"),
|
||||||
|
mimeType: file.type,
|
||||||
|
description: description ?? "",
|
||||||
|
size: file.size,
|
||||||
|
blurhash: blurhash ?? undefined,
|
||||||
|
width: metadata?.width ?? undefined,
|
||||||
|
height: metadata?.height ?? undefined,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
// TODO: Add job to process videos and other media
|
||||||
|
|
||||||
thumbnailUrl = getUrl(path, config);
|
return jsonResponse(attachmentToAPI(newAttachment));
|
||||||
}
|
},
|
||||||
|
);
|
||||||
const newAttachment = (
|
|
||||||
await db
|
|
||||||
.insert(attachment)
|
|
||||||
.values({
|
|
||||||
url,
|
|
||||||
thumbnailUrl,
|
|
||||||
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
|
|
||||||
mimeType: file.type,
|
|
||||||
description: description ?? "",
|
|
||||||
size: file.size,
|
|
||||||
blurhash: blurhash ?? undefined,
|
|
||||||
width: metadata?.width ?? undefined,
|
|
||||||
height: metadata?.height ?? undefined,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
// TODO: Add job to process videos and other media
|
|
||||||
|
|
||||||
return jsonResponse(attachmentToAPI(newAttachment));
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
findManyUsers,
|
findManyUsers,
|
||||||
|
|
@ -20,34 +21,39 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
max_id?: string;
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
since_id?: string;
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
min_id?: string;
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
limit?: number;
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
const { max_id, since_id, limit = 40, min_id } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { objects: blocks, link } = await fetchTimeline<UserWithRelations>(
|
|
||||||
findManyUsers,
|
|
||||||
{
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
where: (subject, { lt, gte, gt, and, sql }) =>
|
|
||||||
and(
|
|
||||||
max_id ? lt(subject.id, max_id) : undefined,
|
|
||||||
since_id ? gte(subject.id, since_id) : undefined,
|
|
||||||
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),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (subject, { desc }) => desc(subject.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(blocks.map((u) => userToAPI(u)));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
|
async (req, matchedRoute, extraData) => {
|
||||||
|
const { user } = extraData.auth;
|
||||||
|
const { max_id, since_id, limit, min_id } = extraData.parsedRequest;
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { objects: blocks, link } =
|
||||||
|
await fetchTimeline<UserWithRelations>(
|
||||||
|
findManyUsers,
|
||||||
|
{
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
where: (subject, { lt, gte, gt, and, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(subject.id, max_id) : undefined,
|
||||||
|
since_id ? gte(subject.id, since_id) : undefined,
|
||||||
|
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,
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (subject, { desc }) => desc(subject.id),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(blocks.map((u) => userToAPI(u)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
findManyNotifications,
|
findManyNotifications,
|
||||||
notificationToAPI,
|
notificationToAPI,
|
||||||
|
|
@ -19,64 +20,102 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
max_id?: string;
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
since_id?: string;
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
min_id?: string;
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
limit?: number;
|
limit: z.coerce.number().int().min(1).max(80).optional().default(15),
|
||||||
exclude_types?: string[];
|
exclude_types: z
|
||||||
types?: string[];
|
.enum([
|
||||||
account_id?: string;
|
"mention",
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
"status",
|
||||||
const { user } = extraData.auth;
|
"follow",
|
||||||
|
"follow_request",
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
"reblog",
|
||||||
|
"poll",
|
||||||
const {
|
"favourite",
|
||||||
account_id,
|
"update",
|
||||||
exclude_types,
|
"admin.sign_up",
|
||||||
limit = 15,
|
"admin.report",
|
||||||
max_id,
|
])
|
||||||
min_id,
|
.array()
|
||||||
since_id,
|
.optional(),
|
||||||
types,
|
types: z
|
||||||
} = extraData.parsedRequest;
|
.enum([
|
||||||
|
"mention",
|
||||||
if (limit > 80) return errorResponse("Limit too high", 400);
|
"status",
|
||||||
|
"follow",
|
||||||
if (limit <= 0) return errorResponse("Limit too low", 400);
|
"follow_request",
|
||||||
|
"reblog",
|
||||||
if (types && exclude_types) {
|
"poll",
|
||||||
return errorResponse("Can't use both types and exclude_types", 400);
|
"favourite",
|
||||||
}
|
"update",
|
||||||
|
"admin.sign_up",
|
||||||
const { objects, link } = await fetchTimeline<NotificationWithRelations>(
|
"admin.report",
|
||||||
findManyNotifications,
|
])
|
||||||
{
|
.array()
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
.optional(),
|
||||||
where: (notification, { lt, gte, gt, and, or, eq, inArray, sql }) =>
|
account_id: z.string().regex(idValidator).optional(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
eq(notification.notifiedId, user.id),
|
|
||||||
eq(notification.accountId, account_id),
|
|
||||||
),
|
|
||||||
with: {},
|
|
||||||
limit: Number(limit),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (notification, { desc }) => desc(notification.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(objects.map((n) => notificationToAPI(n))),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
|
async (req, matchedRoute, extraData) => {
|
||||||
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const {
|
||||||
|
account_id,
|
||||||
|
exclude_types,
|
||||||
|
limit,
|
||||||
|
max_id,
|
||||||
|
min_id,
|
||||||
|
since_id,
|
||||||
|
types,
|
||||||
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
|
if (types && exclude_types) {
|
||||||
|
return errorResponse("Can't use both types and exclude_types", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { objects, link } =
|
||||||
|
await fetchTimeline<NotificationWithRelations>(
|
||||||
|
findManyNotifications,
|
||||||
|
{
|
||||||
|
where: (
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
eq(notification.notifiedId, user.id),
|
||||||
|
eq(notification.accountId, account_id),
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (notification, { desc }) => desc(notification.id),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
await Promise.all(objects.map((n) => notificationToAPI(n))),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import type { Relationship } from "~database/entities/Relationship";
|
import type { Relationship } from "~database/entities/Relationship";
|
||||||
import {
|
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.
|
// 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.
|
// User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses.
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { createLike } from "~database/entities/Like";
|
import { createLike } from "~database/entities/Like";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,6 +26,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ beforeAll(async () => {
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -51,42 +50,6 @@ describe(meta.route, () => {
|
||||||
expect(response.status).toBe(401);
|
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 () => {
|
test("should return 200 with users", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
|
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
|
|
@ -20,55 +21,59 @@ 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
|
* Fetch users who favourited the post
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
max_id?: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
min_id?: string;
|
const id = matchedRoute.params.id;
|
||||||
since_id?: string;
|
if (!id.match(idValidator)) {
|
||||||
limit?: number;
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
}
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
const status = await findFirstStatuses({
|
const status = await findFirstStatuses({
|
||||||
where: (status, { eq }) => eq(status.id, id),
|
where: (status, { eq }) => eq(status.id, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if user is authorized to view this status (if it's private)
|
// Check if user is authorized to view this status (if it's private)
|
||||||
if (!status || !isViewableByUser(status, user))
|
if (!status || !isViewableByUser(status, user))
|
||||||
return errorResponse("Record not found", 404);
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
const { max_id, min_id, since_id, limit = 40 } = extraData.parsedRequest;
|
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
|
||||||
|
|
||||||
// Check for limit limits
|
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
||||||
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
|
findManyUsers,
|
||||||
if (limit < 1) return errorResponse("Invalid limit", 400);
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
where: (liker, { and, lt, gt, gte, eq, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(liker.id, max_id) : undefined,
|
||||||
|
since_id ? gte(liker.id, since_id) : undefined,
|
||||||
|
min_id ? gt(liker.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${liker.id})`,
|
||||||
|
),
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (liker, { desc }) => desc(liker.id),
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
return jsonResponse(
|
||||||
findManyUsers,
|
objects.map((user) => userToAPI(user)),
|
||||||
{
|
200,
|
||||||
// @ts-ignore
|
{
|
||||||
where: (liker, { and, lt, gt, gte, eq, sql }) =>
|
Link: link,
|
||||||
and(
|
},
|
||||||
max_id ? lt(liker.id, max_id) : undefined,
|
);
|
||||||
since_id ? gte(liker.id, since_id) : undefined,
|
},
|
||||||
min_id ? gt(liker.id, min_id) : undefined,
|
);
|
||||||
sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${liker.id})`,
|
|
||||||
),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (liker, { desc }) => desc(liker.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
objects.map((user) => userToAPI(user)),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { sanitizeHtml } from "@sanitization";
|
import { sanitizeHtml } from "@sanitization";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { parse } from "marked";
|
import { parse } from "marked";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
editStatus,
|
editStatus,
|
||||||
findFirstStatuses,
|
findFirstStatuses,
|
||||||
isViewableByUser,
|
isViewableByUser,
|
||||||
statusToAPI,
|
statusToAPI,
|
||||||
} from "~database/entities/Status";
|
} from "~database/entities/Status";
|
||||||
import { statusAndUserRelations } from "~database/entities/relations";
|
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { status } from "~drizzle/schema";
|
import { status } from "~drizzle/schema";
|
||||||
|
import { config } from "config-manager";
|
||||||
|
import ISO6391 from "iso-639-1";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET", "DELETE", "PUT"],
|
allowedMethods: ["GET", "DELETE", "PUT"],
|
||||||
|
|
@ -26,195 +28,162 @@ 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
|
* Fetch a user
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
status?: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
spoiler_text?: string;
|
const id = matchedRoute.params.id;
|
||||||
sensitive?: boolean;
|
if (!id.match(idValidator)) {
|
||||||
language?: string;
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
content_type?: string;
|
|
||||||
media_ids?: string[];
|
|
||||||
"poll[options]"?: string[];
|
|
||||||
"poll[expires_in]"?: number;
|
|
||||||
"poll[multiple]"?: boolean;
|
|
||||||
"poll[hide_totals]"?: boolean;
|
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
const foundStatus = await findFirstStatuses({
|
|
||||||
where: (status, { eq }) => eq(status.id, id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
|
||||||
|
|
||||||
// Check if user is authorized to view this status (if it's private)
|
|
||||||
if (!foundStatus || !isViewableByUser(foundStatus, user))
|
|
||||||
return errorResponse("Record not found", 404);
|
|
||||||
|
|
||||||
if (req.method === "GET") {
|
|
||||||
return jsonResponse(await statusToAPI(foundStatus));
|
|
||||||
}
|
|
||||||
if (req.method === "DELETE") {
|
|
||||||
if (foundStatus.authorId !== user?.id) {
|
|
||||||
return errorResponse("Unauthorized", 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement delete and redraft functionality
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
// Delete status and all associated objects
|
const foundStatus = await findFirstStatuses({
|
||||||
await db.delete(status).where(eq(status.id, id));
|
where: (status, { eq }) => eq(status.id, id),
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
...(await statusToAPI(foundStatus, user)),
|
|
||||||
// TODO: Add
|
|
||||||
// text: Add source text
|
|
||||||
// poll: Add source poll
|
|
||||||
// media_attachments
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (req.method === "PUT") {
|
|
||||||
if (foundStatus.authorId !== user?.id) {
|
|
||||||
return errorResponse("Unauthorized", 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
status: statusText,
|
|
||||||
content_type,
|
|
||||||
"poll[expires_in]": expires_in,
|
|
||||||
"poll[options]": options,
|
|
||||||
media_ids,
|
|
||||||
spoiler_text,
|
|
||||||
sensitive,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
// TODO: Add Poll support
|
|
||||||
// Validate status
|
|
||||||
if (!statusText && !(media_ids && media_ids.length > 0)) {
|
|
||||||
return errorResponse(
|
|
||||||
"Status is required unless media is attached",
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitizedStatus: string;
|
|
||||||
|
|
||||||
if (content_type === "text/markdown") {
|
|
||||||
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 ?? ""));
|
|
||||||
} 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) =>
|
|
||||||
statusText?.match(filter),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return errorResponse("Status contains blocked words", 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if media attachments are all valid
|
|
||||||
if (media_ids && media_ids.length > 0) {
|
|
||||||
const foundAttachments = await db.query.attachment.findMany({
|
|
||||||
where: (attachment, { inArray }) =>
|
|
||||||
inArray(attachment.id, media_ids),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (foundAttachments.length !== (media_ids ?? []).length) {
|
|
||||||
return errorResponse("Invalid media IDs", 422);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
const newStatus = await editStatus(foundStatus, {
|
|
||||||
content: sanitizedStatus,
|
|
||||||
content_type,
|
|
||||||
media_attachments: media_ids,
|
|
||||||
spoiler_text: spoiler_text ?? "",
|
|
||||||
sensitive: sensitive ?? false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!newStatus) {
|
const config = await extraData.configManager.getConfig();
|
||||||
return errorResponse("Failed to update status", 500);
|
|
||||||
|
// Check if user is authorized to view this status (if it's private)
|
||||||
|
if (!foundStatus || !isViewableByUser(foundStatus, user))
|
||||||
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
return jsonResponse(await statusToAPI(foundStatus));
|
||||||
|
}
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
if (foundStatus.authorId !== user?.id) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement delete and redraft functionality
|
||||||
|
|
||||||
|
// Delete status and all associated objects
|
||||||
|
await db.delete(status).where(eq(status.id, id));
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
...(await statusToAPI(foundStatus, user)),
|
||||||
|
// TODO: Add
|
||||||
|
// text: Add source text
|
||||||
|
// poll: Add source poll
|
||||||
|
// media_attachments
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
if (foundStatus.authorId !== user?.id) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
status: statusText,
|
||||||
|
content_type,
|
||||||
|
"poll[expires_in]": expires_in,
|
||||||
|
"poll[options]": options,
|
||||||
|
media_ids,
|
||||||
|
spoiler_text,
|
||||||
|
sensitive,
|
||||||
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
|
// TODO: Add Poll support
|
||||||
|
// Validate status
|
||||||
|
if (!statusText && !(media_ids && media_ids.length > 0)) {
|
||||||
|
return errorResponse(
|
||||||
|
"Status is required unless media is attached",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media_ids && media_ids.length > 0 && options) {
|
||||||
|
// Disallow poll
|
||||||
|
return errorResponse(
|
||||||
|
"Cannot attach poll to post with media",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitizedStatus: string;
|
||||||
|
|
||||||
|
if (content_type === "text/markdown") {
|
||||||
|
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 ?? ""),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sanitizedStatus = await sanitizeHtml(statusText ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if status body doesnt match filters
|
||||||
|
if (
|
||||||
|
config.filters.note_content.some((filter) =>
|
||||||
|
statusText?.match(filter),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return errorResponse("Status contains blocked words", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if media attachments are all valid
|
||||||
|
if (media_ids && media_ids.length > 0) {
|
||||||
|
const foundAttachments = await db.query.attachment.findMany({
|
||||||
|
where: (attachment, { inArray }) =>
|
||||||
|
inArray(attachment.id, media_ids),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundAttachments.length !== (media_ids ?? []).length) {
|
||||||
|
return errorResponse("Invalid media IDs", 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
const newStatus = await editStatus(foundStatus, {
|
||||||
|
content: sanitizedStatus,
|
||||||
|
content_type,
|
||||||
|
media_attachments: media_ids,
|
||||||
|
spoiler_text: spoiler_text ?? "",
|
||||||
|
sensitive: sensitive ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!newStatus) {
|
||||||
|
return errorResponse("Failed to update status", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(await statusToAPI(newStatus, user));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse(await statusToAPI(newStatus, user));
|
return jsonResponse({});
|
||||||
}
|
},
|
||||||
|
);
|
||||||
return jsonResponse({});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
|
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
|
@ -21,6 +21,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
|
@ -39,11 +42,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
|
|
||||||
// Check if post is already pinned
|
// Check if post is already pinned
|
||||||
if (
|
if (
|
||||||
await db.query.statusToUser.findFirst({
|
await db.query.userPinnedNotes.findFirst({
|
||||||
where: (statusToUser, { and, eq }) =>
|
where: (userPinnedNote, { and, eq }) =>
|
||||||
and(
|
and(
|
||||||
eq(statusToUser.a, foundStatus.id),
|
eq(userPinnedNote.statusId, foundStatus.id),
|
||||||
eq(statusToUser.b, user.id),
|
eq(userPinnedNote.userId, user.id),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
|
|
@ -51,8 +54,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(statusToMentions).values({
|
await db.insert(statusToMentions).values({
|
||||||
a: foundStatus.id,
|
statusId: foundStatus.id,
|
||||||
b: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(statusToAPI(foundStatus, user));
|
return jsonResponse(statusToAPI(foundStatus, user));
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
findFirstStatuses,
|
findFirstStatuses,
|
||||||
isViewableByUser,
|
isViewableByUser,
|
||||||
statusToAPI,
|
statusToAPI,
|
||||||
} from "~database/entities/Status";
|
} from "~database/entities/Status";
|
||||||
import { statusAndUserRelations } from "~database/entities/relations";
|
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { notification, status } from "~drizzle/schema";
|
import { notification, status } from "~drizzle/schema";
|
||||||
|
|
||||||
|
|
@ -21,71 +21,81 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
visibility: z.enum(["public", "unlisted", "private"]).default("public"),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reblogs a post
|
* Reblogs a post
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
visibility: "public" | "unlisted" | "private";
|
async (req, matchedRoute, extraData) => {
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
const id = matchedRoute.params.id;
|
||||||
const id = matchedRoute.params.id;
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { visibility = "public" } = extraData.parsedRequest;
|
const { visibility } = extraData.parsedRequest;
|
||||||
|
|
||||||
const foundStatus = await findFirstStatuses({
|
const foundStatus = await findFirstStatuses({
|
||||||
where: (status, { eq }) => eq(status.id, id),
|
where: (status, { eq }) => eq(status.id, id),
|
||||||
});
|
|
||||||
|
|
||||||
// Check if user is authorized to view this status (if it's private)
|
|
||||||
if (!foundStatus || !isViewableByUser(foundStatus, user))
|
|
||||||
return errorResponse("Record not found", 404);
|
|
||||||
|
|
||||||
const existingReblog = await db.query.status.findFirst({
|
|
||||||
where: (status, { and, eq }) =>
|
|
||||||
and(eq(status.authorId, user.id), eq(status.reblogId, status.id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingReblog) {
|
|
||||||
return errorResponse("Already reblogged", 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newReblog = (
|
|
||||||
await db
|
|
||||||
.insert(status)
|
|
||||||
.values({
|
|
||||||
authorId: user.id,
|
|
||||||
reblogId: foundStatus.id,
|
|
||||||
visibility,
|
|
||||||
sensitive: false,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
if (!newReblog) {
|
|
||||||
return errorResponse("Failed to reblog", 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalNewReblog = await findFirstStatuses({
|
|
||||||
where: (status, { eq }) => eq(status.id, newReblog.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!finalNewReblog) {
|
|
||||||
return errorResponse("Failed to reblog", 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create notification for reblog if reblogged user is on the same instance
|
|
||||||
if (foundStatus.author.instanceId === user.instanceId) {
|
|
||||||
await db.insert(notification).values({
|
|
||||||
accountId: user.id,
|
|
||||||
notifiedId: foundStatus.authorId,
|
|
||||||
type: "reblog",
|
|
||||||
statusId: foundStatus.reblogId,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(await statusToAPI(finalNewReblog, user));
|
// Check if user is authorized to view this status (if it's private)
|
||||||
});
|
if (!foundStatus || !isViewableByUser(foundStatus, user))
|
||||||
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
const existingReblog = await db.query.status.findFirst({
|
||||||
|
where: (status, { and, eq }) =>
|
||||||
|
and(
|
||||||
|
eq(status.authorId, user.id),
|
||||||
|
eq(status.reblogId, status.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingReblog) {
|
||||||
|
return errorResponse("Already reblogged", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newReblog = (
|
||||||
|
await db
|
||||||
|
.insert(status)
|
||||||
|
.values({
|
||||||
|
authorId: user.id,
|
||||||
|
reblogId: foundStatus.id,
|
||||||
|
visibility,
|
||||||
|
sensitive: false,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (!newReblog) {
|
||||||
|
return errorResponse("Failed to reblog", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalNewReblog = await findFirstStatuses({
|
||||||
|
where: (status, { eq }) => eq(status.id, newReblog.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finalNewReblog) {
|
||||||
|
return errorResponse("Failed to reblog", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification for reblog if reblogged user is on the same instance
|
||||||
|
if (foundStatus.author.instanceId === user.instanceId) {
|
||||||
|
await db.insert(notification).values({
|
||||||
|
accountId: user.id,
|
||||||
|
notifiedId: foundStatus.authorId,
|
||||||
|
type: "reblog",
|
||||||
|
statusId: foundStatus.reblogId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(await statusToAPI(finalNewReblog, user));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ beforeAll(async () => {
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -52,42 +51,6 @@ describe(meta.route, () => {
|
||||||
expect(response.status).toBe(401);
|
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 () => {
|
test("should return 200 with users", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
|
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
|
||||||
import {
|
import {
|
||||||
type UserWithRelations,
|
type UserWithRelations,
|
||||||
|
|
@ -20,60 +21,59 @@ 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
|
* Fetch users who reblogged the post
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
max_id?: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
min_id?: string;
|
const id = matchedRoute.params.id;
|
||||||
since_id?: string;
|
if (!id.match(idValidator)) {
|
||||||
limit?: number;
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
}
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
const status = await findFirstStatuses({
|
const status = await findFirstStatuses({
|
||||||
where: (status, { eq }) => eq(status.id, id),
|
where: (status, { eq }) => eq(status.id, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if user is authorized to view this status (if it's private)
|
// Check if user is authorized to view this status (if it's private)
|
||||||
if (!status || !isViewableByUser(status, user))
|
if (!status || !isViewableByUser(status, user))
|
||||||
return errorResponse("Record not found", 404);
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
const {
|
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
|
||||||
max_id = null,
|
|
||||||
min_id = null,
|
|
||||||
since_id = null,
|
|
||||||
limit = 40,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
// Check for limit limits
|
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
||||||
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
|
findManyUsers,
|
||||||
if (limit < 1) return errorResponse("Invalid limit", 400);
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
where: (reblogger, { and, lt, gt, gte, eq, sql }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(reblogger.id, max_id) : undefined,
|
||||||
|
since_id ? gte(reblogger.id, since_id) : undefined,
|
||||||
|
min_id ? gt(reblogger.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${status.id} AND "Status"."authorId" = ${reblogger.id})`,
|
||||||
|
),
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (liker, { desc }) => desc(liker.id),
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<UserWithRelations>(
|
return jsonResponse(
|
||||||
findManyUsers,
|
objects.map((user) => userToAPI(user)),
|
||||||
{
|
200,
|
||||||
// @ts-ignore
|
{
|
||||||
where: (reblogger, { and, lt, gt, gte, eq, sql }) =>
|
Link: link,
|
||||||
and(
|
},
|
||||||
max_id ? lt(reblogger.id, max_id) : undefined,
|
);
|
||||||
since_id ? gte(reblogger.id, since_id) : undefined,
|
},
|
||||||
min_id ? gt(reblogger.id, min_id) : undefined,
|
);
|
||||||
sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${status.id} AND "Status"."authorId" = ${reblogger.id})`,
|
|
||||||
),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (liker, { desc }) => desc(liker.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
objects.map((user) => userToAPI(user)),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse } from "@response";
|
import { errorResponse } from "@response";
|
||||||
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
|
import { findFirstStatuses, isViewableByUser } from "~database/entities/Status";
|
||||||
|
|
||||||
|
|
@ -19,6 +19,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { deleteLike } from "~database/entities/Like";
|
import { deleteLike } from "~database/entities/Like";
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,6 +25,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
|
import { findFirstStatuses, statusToAPI } from "~database/entities/Status";
|
||||||
|
|
@ -22,6 +22,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,6 +27,9 @@ export const meta = applyConfig({
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const id = matchedRoute.params.id;
|
const id = matchedRoute.params.id;
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ describe(meta.route, () => {
|
||||||
expect(response.status).toBe(422);
|
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(
|
const response = await sendTestRequest(
|
||||||
new Request(new URL(meta.route, config.http.base_url), {
|
new Request(new URL(meta.route, config.http.base_url), {
|
||||||
method: "POST",
|
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 () => {
|
test("should return 422 is visibility is invalid", async () => {
|
||||||
|
|
@ -108,7 +108,7 @@ describe(meta.route, () => {
|
||||||
expect(response.status).toBe(422);
|
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(
|
const response = await sendTestRequest(
|
||||||
new Request(new URL(meta.route, config.http.base_url), {
|
new Request(new URL(meta.route, config.http.base_url), {
|
||||||
method: "POST",
|
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(
|
const response = await sendTestRequest(
|
||||||
new Request(new URL(meta.route, config.http.base_url), {
|
new Request(new URL(meta.route, config.http.base_url), {
|
||||||
method: "POST",
|
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 () => {
|
test("should return 422 is media_ids is invalid", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { sanitizeHtml } from "@sanitization";
|
import { sanitizeHtml } from "@sanitization";
|
||||||
import { parse } from "marked";
|
import { parse } from "marked";
|
||||||
|
import { z } from "zod";
|
||||||
import type { StatusWithRelations } from "~database/entities/Status";
|
import type { StatusWithRelations } from "~database/entities/Status";
|
||||||
import {
|
import {
|
||||||
createNewStatus,
|
createNewStatus,
|
||||||
|
|
@ -11,6 +12,8 @@ import {
|
||||||
statusToAPI,
|
statusToAPI,
|
||||||
} from "~database/entities/Status";
|
} from "~database/entities/Status";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
import { config } from "config-manager";
|
||||||
|
import ISO6391 from "iso-639-1";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -24,221 +27,176 @@ 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
|
* Post new status
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
status: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
media_ids?: string[];
|
const { user } = extraData.auth;
|
||||||
"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) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
const config = await extraData.configManager.getConfig();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
media_ids,
|
media_ids,
|
||||||
"poll[expires_in]": expires_in,
|
"poll[expires_in]": expires_in,
|
||||||
// "poll[hide_totals]": hide_totals,
|
"poll[options]": options,
|
||||||
// "poll[multiple]": multiple,
|
in_reply_to_id,
|
||||||
"poll[options]": options,
|
quote_id,
|
||||||
in_reply_to_id,
|
scheduled_at,
|
||||||
quote_id,
|
sensitive,
|
||||||
// language,
|
spoiler_text,
|
||||||
scheduled_at,
|
visibility,
|
||||||
sensitive,
|
content_type,
|
||||||
spoiler_text,
|
federate,
|
||||||
visibility,
|
} = extraData.parsedRequest;
|
||||||
content_type,
|
|
||||||
federate = true,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
// Validate status
|
// Validate status
|
||||||
if (!status && !(media_ids && media_ids.length > 0)) {
|
if (!status && !(media_ids && media_ids.length > 0)) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"Status is required unless media is attached",
|
"Status is required unless media is attached",
|
||||||
422,
|
422,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate media_ids
|
if (media_ids && media_ids.length > 0 && options) {
|
||||||
if (media_ids && !Array.isArray(media_ids)) {
|
// Disallow poll
|
||||||
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) {
|
|
||||||
// Disallow poll
|
|
||||||
if (options) {
|
|
||||||
return errorResponse("Cannot attach poll to media", 422);
|
return errorResponse("Cannot attach poll to media", 422);
|
||||||
}
|
}
|
||||||
if (media_ids.length > 4) {
|
|
||||||
return errorResponse("Media IDs must be less than 5", 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (options && options.length > config.validation.max_poll_options) {
|
let sanitizedStatus: string;
|
||||||
return errorResponse(
|
|
||||||
`Poll options must be less than ${config.validation.max_poll_options}`,
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (content_type === "text/markdown") {
|
||||||
options?.some(
|
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
|
||||||
(option) => option.length > config.validation.max_poll_option_size,
|
} else if (content_type === "text/x.misskeymarkdown") {
|
||||||
)
|
// Parse as MFM
|
||||||
) {
|
// TODO: Parse as MFM
|
||||||
return errorResponse(
|
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
|
||||||
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
|
} else {
|
||||||
422,
|
sanitizedStatus = await sanitizeHtml(status ?? "");
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (expires_in && expires_in < config.validation.min_poll_duration) {
|
// Get reply account and status if exists
|
||||||
return errorResponse(
|
let replyStatus: StatusWithRelations | null = null;
|
||||||
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
|
let quote: StatusWithRelations | null = null;
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expires_in && expires_in > config.validation.max_poll_duration) {
|
if (in_reply_to_id) {
|
||||||
return errorResponse(
|
replyStatus = await findFirstStatuses({
|
||||||
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
|
where: (status, { eq }) => eq(status.id, in_reply_to_id),
|
||||||
422,
|
}).catch(() => null);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scheduled_at) {
|
if (!replyStatus) {
|
||||||
|
return errorResponse("Reply status not found", 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote_id) {
|
||||||
|
quote = await findFirstStatuses({
|
||||||
|
where: (status, { eq }) => eq(status.id, quote_id),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!quote) {
|
||||||
|
return errorResponse("Quote status not found", 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if status body doesnt match filters
|
||||||
if (
|
if (
|
||||||
Number.isNaN(new Date(scheduled_at).getTime()) ||
|
config.filters.note_content.some((filter) => status?.match(filter))
|
||||||
new Date(scheduled_at).getTime() < Date.now()
|
|
||||||
) {
|
) {
|
||||||
return errorResponse("Scheduled time must be in the future", 422);
|
return errorResponse("Status contains blocked words", 422);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Validate visibility
|
// Check if media attachments are all valid
|
||||||
if (
|
if (media_ids && media_ids.length > 0) {
|
||||||
visibility &&
|
const foundAttachments = await db.query.attachment
|
||||||
!["public", "unlisted", "private", "direct"].includes(visibility)
|
.findMany({
|
||||||
) {
|
where: (attachment, { inArray }) =>
|
||||||
return errorResponse("Invalid visibility", 422);
|
inArray(attachment.id, media_ids),
|
||||||
}
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
let sanitizedStatus: string;
|
if (foundAttachments.length !== (media_ids ?? []).length) {
|
||||||
|
return errorResponse("Invalid media IDs", 422);
|
||||||
if (content_type === "text/markdown") {
|
}
|
||||||
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
|
|
||||||
} else if (content_type === "text/x.misskeymarkdown") {
|
|
||||||
// Parse as MFM
|
|
||||||
// TODO: Parse as MFM
|
|
||||||
sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string);
|
|
||||||
} else {
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (in_reply_to_id) {
|
|
||||||
replyStatus = await findFirstStatuses({
|
|
||||||
where: (status, { eq }) => eq(status.id, in_reply_to_id),
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!replyStatus) {
|
|
||||||
return errorResponse("Reply status not found", 404);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (quote_id) {
|
const mentions = await parseTextMentions(sanitizedStatus);
|
||||||
quote = await findFirstStatuses({
|
|
||||||
where: (status, { eq }) => eq(status.id, quote_id),
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!quote) {
|
const newStatus = await createNewStatus(
|
||||||
return errorResponse("Quote status not found", 404);
|
user,
|
||||||
}
|
{
|
||||||
}
|
[content_type]: {
|
||||||
|
content: sanitizedStatus ?? "",
|
||||||
// Check if status body doesnt match filters
|
},
|
||||||
if (config.filters.note_content.some((filter) => status?.match(filter))) {
|
|
||||||
return errorResponse("Status contains blocked words", 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if media attachments are all valid
|
|
||||||
if (media_ids && media_ids.length > 0) {
|
|
||||||
const foundAttachments = await db.query.attachment
|
|
||||||
.findMany({
|
|
||||||
where: (attachment, { inArray }) =>
|
|
||||||
inArray(attachment.id, media_ids),
|
|
||||||
})
|
|
||||||
.catch(() => []);
|
|
||||||
|
|
||||||
if (foundAttachments.length !== (media_ids ?? []).length) {
|
|
||||||
return errorResponse("Invalid media IDs", 422);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentions = await parseTextMentions(sanitizedStatus);
|
|
||||||
|
|
||||||
const newStatus = await createNewStatus(
|
|
||||||
user,
|
|
||||||
{
|
|
||||||
[content_type ?? "text/plain"]: {
|
|
||||||
content: sanitizedStatus ?? "",
|
|
||||||
},
|
},
|
||||||
},
|
visibility,
|
||||||
visibility ?? "public",
|
sensitive ?? false,
|
||||||
sensitive ?? false,
|
spoiler_text ?? "",
|
||||||
spoiler_text ?? "",
|
[],
|
||||||
[],
|
undefined,
|
||||||
undefined,
|
mentions,
|
||||||
mentions,
|
media_ids,
|
||||||
media_ids,
|
replyStatus ?? undefined,
|
||||||
replyStatus ?? undefined,
|
quote ?? undefined,
|
||||||
quote ?? undefined,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (!newStatus) {
|
if (!newStatus) {
|
||||||
return errorResponse("Failed to create status", 500);
|
return errorResponse("Failed to create status", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (federate) {
|
if (federate) {
|
||||||
await federateStatus(newStatus);
|
await federateStatus(newStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse(await statusToAPI(newStatus, user));
|
return jsonResponse(await statusToAPI(newStatus, user));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,36 +27,6 @@ describe(meta.route, () => {
|
||||||
expect(response.status).toBe(401);
|
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 () => {
|
test("should correctly parse limit", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type StatusWithRelations,
|
type StatusWithRelations,
|
||||||
findManyStatuses,
|
findManyStatuses,
|
||||||
|
|
@ -20,72 +21,64 @@ 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
|
* Fetch home timeline statuses
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
max_id?: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
since_id?: string;
|
const { user } = extraData.auth;
|
||||||
min_id?: string;
|
|
||||||
limit?: number;
|
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
|
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
|
||||||
|
|
||||||
if (limit < 1 || limit > 80) {
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
return errorResponse("Limit must be between 1 and 40", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
||||||
|
findManyStatuses,
|
||||||
const followers = await db.query.relationship.findMany({
|
{
|
||||||
where: (relationship, { eq, and }) =>
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
and(
|
where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) =>
|
||||||
eq(relationship.subjectId, user.id),
|
|
||||||
eq(relationship.following, true),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
|
||||||
findManyStatuses,
|
|
||||||
{
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) =>
|
|
||||||
and(
|
|
||||||
and(
|
and(
|
||||||
max_id ? lt(status.id, max_id) : undefined,
|
and(
|
||||||
since_id ? gte(status.id, since_id) : undefined,
|
max_id ? lt(status.id, max_id) : undefined,
|
||||||
min_id ? gt(status.id, min_id) : undefined,
|
since_id ? gte(status.id, since_id) : undefined,
|
||||||
),
|
min_id ? gt(status.id, min_id) : undefined,
|
||||||
or(
|
),
|
||||||
eq(status.authorId, user.id),
|
or(
|
||||||
/* inArray(
|
eq(status.authorId, user.id),
|
||||||
|
/* inArray(
|
||||||
status.authorId,
|
status.authorId,
|
||||||
followers.map((f) => f.ownerId),
|
followers.map((f) => f.ownerId),
|
||||||
), */
|
), */
|
||||||
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
|
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
|
||||||
// WHERE format (... = ...)
|
// WHERE format (... = ...)
|
||||||
sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`,
|
sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`,
|
||||||
// All statuses from users that the user is following
|
// All statuses from users that the user is following
|
||||||
// WHERE format (... = ...)
|
// WHERE format (... = ...)
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
|
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
limit,
|
||||||
limit: Number(limit),
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
orderBy: (status, { desc }) => desc(status.id),
|
||||||
orderBy: (status, { desc }) => desc(status.id),
|
},
|
||||||
},
|
req,
|
||||||
req,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
objects.map(async (status) => statusToAPI(status, user)),
|
objects.map(async (status) => statusToAPI(status, user)),
|
||||||
),
|
),
|
||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
Link: link,
|
Link: link,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,36 +19,6 @@ afterAll(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(meta.route, () => {
|
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 () => {
|
test("should correctly parse limit", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig } 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 {
|
import {
|
||||||
type StatusWithRelations,
|
type StatusWithRelations,
|
||||||
findManyStatuses,
|
findManyStatuses,
|
||||||
|
|
@ -19,65 +21,61 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
local?: boolean;
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
only_media?: boolean;
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
remote?: boolean;
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
max_id?: string;
|
limit: z.coerce.number().int().min(1).max(80).optional().default(20),
|
||||||
since_id?: string;
|
local: z.coerce.boolean().optional(),
|
||||||
min_id?: string;
|
remote: z.coerce.boolean().optional(),
|
||||||
limit?: number;
|
only_media: z.coerce.boolean().optional(),
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
const {
|
|
||||||
local,
|
|
||||||
limit = 20,
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
// only_media,
|
|
||||||
remote,
|
|
||||||
since_id,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
if (limit < 1 || limit > 40) {
|
|
||||||
return errorResponse("Limit must be between 1 and 40", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local && remote) {
|
|
||||||
return errorResponse("Cannot use both local and remote", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
|
||||||
findManyStatuses,
|
|
||||||
{
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
where: (status, { lt, gte, gt, and, isNull, isNotNull }) =>
|
|
||||||
and(
|
|
||||||
max_id ? lt(status.id, max_id) : undefined,
|
|
||||||
since_id ? gte(status.id, since_id) : undefined,
|
|
||||||
min_id ? gt(status.id, min_id) : undefined,
|
|
||||||
remote
|
|
||||||
? isNotNull(status.instanceId)
|
|
||||||
: local
|
|
||||||
? isNull(status.instanceId)
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
limit: Number(limit),
|
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
|
||||||
orderBy: (status, { desc }) => desc(status.id),
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(
|
|
||||||
objects.map(async (status) =>
|
|
||||||
statusToAPI(status, user || undefined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { objects, link } = await fetchTimeline<StatusWithRelations>(
|
||||||
|
findManyStatuses,
|
||||||
|
{
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
where: (status, { lt, gte, gt, and, isNull, isNotNull }) =>
|
||||||
|
and(
|
||||||
|
max_id ? lt(status.id, max_id) : undefined,
|
||||||
|
since_id ? gte(status.id, since_id) : undefined,
|
||||||
|
min_id ? gt(status.id, min_id) : undefined,
|
||||||
|
remote
|
||||||
|
? isNotNull(status.instanceId)
|
||||||
|
: local
|
||||||
|
? isNull(status.instanceId)
|
||||||
|
: undefined,
|
||||||
|
only_media
|
||||||
|
? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})`
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (status, { desc }) => desc(status.id),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
await Promise.all(
|
||||||
|
objects.map(async (status) =>
|
||||||
|
statusToAPI(status, user || undefined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { encode } from "blurhash";
|
import { encode } from "blurhash";
|
||||||
|
import { config } from "config-manager";
|
||||||
import type { MediaBackend } from "media-manager";
|
import type { MediaBackend } from "media-manager";
|
||||||
import { MediaBackendType } from "media-manager";
|
import { MediaBackendType } from "media-manager";
|
||||||
|
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { z } from "zod";
|
||||||
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
|
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { attachment } from "~drizzle/schema";
|
import { attachment } from "~drizzle/schema";
|
||||||
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -22,147 +24,135 @@ 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
|
* Upload new media
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
file: File;
|
async (req, matchedRoute, extraData) => {
|
||||||
thumbnail: File;
|
const { file, thumbnail, description } = extraData.parsedRequest;
|
||||||
description: string;
|
|
||||||
// TODO: Implement focus storage
|
|
||||||
focus: string;
|
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) {
|
const config = await extraData.configManager.getConfig();
|
||||||
return errorResponse("Unauthorized", 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { file, thumbnail, description } = extraData.parsedRequest;
|
if (file.size > config.validation.max_media_size) {
|
||||||
|
return errorResponse(
|
||||||
|
`File too large, max size is ${config.validation.max_media_size} bytes`,
|
||||||
|
413,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!file) {
|
if (
|
||||||
return errorResponse("No file provided", 400);
|
config.validation.enforce_mime_types &&
|
||||||
}
|
!config.validation.allowed_mime_types.includes(file.type)
|
||||||
|
) {
|
||||||
|
return errorResponse("Invalid file type", 415);
|
||||||
|
}
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
const sha256 = new Bun.SHA256();
|
||||||
|
|
||||||
if (file.size > config.validation.max_media_size) {
|
const isImage = file.type.startsWith("image/");
|
||||||
return errorResponse(
|
|
||||||
`File too large, max size is ${config.validation.max_media_size} bytes`,
|
const metadata = isImage
|
||||||
413,
|
? await sharp(await file.arrayBuffer()).metadata()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const blurhash = await new Promise<string | null>((resolve) => {
|
||||||
|
(async () =>
|
||||||
|
sharp(await file.arrayBuffer())
|
||||||
|
.raw()
|
||||||
|
.ensureAlpha()
|
||||||
|
.toBuffer((err, buffer) => {
|
||||||
|
if (err) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolve(
|
||||||
|
encode(
|
||||||
|
new Uint8ClampedArray(buffer),
|
||||||
|
metadata?.width ?? 0,
|
||||||
|
metadata?.height ?? 0,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
) as string,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}))();
|
||||||
|
});
|
||||||
|
|
||||||
|
let url = "";
|
||||||
|
|
||||||
|
let mediaManager: MediaBackend;
|
||||||
|
|
||||||
|
switch (config.media.backend as MediaBackendType) {
|
||||||
|
case MediaBackendType.LOCAL:
|
||||||
|
mediaManager = new LocalMediaBackend(config);
|
||||||
|
break;
|
||||||
|
case MediaBackendType.S3:
|
||||||
|
mediaManager = new S3MediaBackend(config);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// TODO: Replace with logger
|
||||||
|
throw new Error("Invalid media backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
const { path } = await mediaManager.addFile(file);
|
||||||
|
|
||||||
|
url = getUrl(path, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let thumbnailUrl = "";
|
||||||
|
|
||||||
|
if (thumbnail) {
|
||||||
|
const { path } = await mediaManager.addFile(thumbnail);
|
||||||
|
|
||||||
|
thumbnailUrl = getUrl(path, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAttachment = (
|
||||||
|
await db
|
||||||
|
.insert(attachment)
|
||||||
|
.values({
|
||||||
|
url,
|
||||||
|
thumbnailUrl,
|
||||||
|
sha256: sha256
|
||||||
|
.update(await file.arrayBuffer())
|
||||||
|
.digest("hex"),
|
||||||
|
mimeType: file.type,
|
||||||
|
description: description ?? "",
|
||||||
|
size: file.size,
|
||||||
|
blurhash: blurhash ?? undefined,
|
||||||
|
width: metadata?.width ?? undefined,
|
||||||
|
height: metadata?.height ?? undefined,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
// TODO: Add job to process videos and other media
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
return jsonResponse(attachmentToAPI(newAttachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
...attachmentToAPI(newAttachment),
|
||||||
|
url: null,
|
||||||
|
},
|
||||||
|
202,
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
if (
|
|
||||||
config.validation.enforce_mime_types &&
|
|
||||||
!config.validation.allowed_mime_types.includes(file.type)
|
|
||||||
) {
|
|
||||||
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/");
|
|
||||||
|
|
||||||
const metadata = isImage
|
|
||||||
? await sharp(await file.arrayBuffer()).metadata()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const blurhash = await new Promise<string | null>((resolve) => {
|
|
||||||
(async () =>
|
|
||||||
sharp(await file.arrayBuffer())
|
|
||||||
.raw()
|
|
||||||
.ensureAlpha()
|
|
||||||
.toBuffer((err, buffer) => {
|
|
||||||
if (err) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolve(
|
|
||||||
encode(
|
|
||||||
new Uint8ClampedArray(buffer),
|
|
||||||
metadata?.width ?? 0,
|
|
||||||
metadata?.height ?? 0,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}))();
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = "";
|
|
||||||
|
|
||||||
let mediaManager: MediaBackend;
|
|
||||||
|
|
||||||
switch (config.media.backend as MediaBackendType) {
|
|
||||||
case MediaBackendType.LOCAL:
|
|
||||||
mediaManager = new LocalMediaBackend(config);
|
|
||||||
break;
|
|
||||||
case MediaBackendType.S3:
|
|
||||||
mediaManager = new S3MediaBackend(config);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// TODO: Replace with logger
|
|
||||||
throw new Error("Invalid media backend");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isImage) {
|
|
||||||
const { path } = await mediaManager.addFile(file);
|
|
||||||
|
|
||||||
url = getUrl(path, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
let thumbnailUrl = "";
|
|
||||||
|
|
||||||
if (thumbnail) {
|
|
||||||
const { path } = await mediaManager.addFile(thumbnail);
|
|
||||||
|
|
||||||
thumbnailUrl = getUrl(path, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAttachment = (
|
|
||||||
await db
|
|
||||||
.insert(attachment)
|
|
||||||
.values({
|
|
||||||
url,
|
|
||||||
thumbnailUrl,
|
|
||||||
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
|
|
||||||
mimeType: file.type,
|
|
||||||
description: description ?? "",
|
|
||||||
size: file.size,
|
|
||||||
blurhash: blurhash ?? undefined,
|
|
||||||
width: metadata?.width ?? undefined,
|
|
||||||
height: metadata?.height ?? undefined,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
// TODO: Add job to process videos and other media
|
|
||||||
|
|
||||||
if (isImage) {
|
|
||||||
return jsonResponse(attachmentToAPI(newAttachment));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
...attachmentToAPI(newAttachment),
|
|
||||||
url: null,
|
|
||||||
},
|
|
||||||
202,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@api";
|
||||||
import { MeiliIndexType, meilisearch } from "@meilisearch";
|
import { MeiliIndexType, meilisearch } from "@meilisearch";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
import { findManyStatuses, statusToAPI } from "~database/entities/Status";
|
import { findManyStatuses, statusToAPI } from "~database/entities/Status";
|
||||||
import {
|
import {
|
||||||
findFirstUser,
|
findFirstUser,
|
||||||
|
|
@ -25,176 +26,177 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
q?: string;
|
q: z.string().optional(),
|
||||||
type?: string;
|
type: z.string().optional(),
|
||||||
resolve?: boolean;
|
resolve: z.coerce.boolean().optional(),
|
||||||
following?: boolean;
|
following: z.coerce.boolean().optional(),
|
||||||
account_id?: string;
|
account_id: z.string().optional(),
|
||||||
max_id?: string;
|
max_id: z.string().optional(),
|
||||||
min_id?: string;
|
min_id: z.string().optional(),
|
||||||
limit?: number;
|
limit: z.coerce.number().int().min(1).max(40).optional(),
|
||||||
offset?: number;
|
offset: z.coerce.number().int().optional(),
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
});
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
const {
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
q,
|
async (req, matchedRoute, extraData) => {
|
||||||
type,
|
const { user: self } = extraData.auth;
|
||||||
resolve,
|
|
||||||
following,
|
|
||||||
account_id,
|
|
||||||
// max_id,
|
|
||||||
// min_id,
|
|
||||||
limit = 20,
|
|
||||||
offset,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
const {
|
||||||
|
q,
|
||||||
|
type,
|
||||||
|
resolve,
|
||||||
|
following,
|
||||||
|
account_id,
|
||||||
|
// max_id,
|
||||||
|
// min_id,
|
||||||
|
limit = 20,
|
||||||
|
offset,
|
||||||
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
if (!config.meilisearch.enabled) {
|
const config = await extraData.configManager.getConfig();
|
||||||
return errorResponse("Meilisearch is not enabled", 501);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self && (resolve || offset)) {
|
if (!config.meilisearch.enabled) {
|
||||||
return errorResponse(
|
return errorResponse("Meilisearch is not enabled", 501);
|
||||||
"Cannot use resolve or offset without being authenticated",
|
}
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (limit < 1 || limit > 40) {
|
if (!self && (resolve || offset)) {
|
||||||
return errorResponse("Limit must be between 1 and 40", 400);
|
return errorResponse(
|
||||||
}
|
"Cannot use resolve or offset without being authenticated",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let accountResults: { id: string }[] = [];
|
let accountResults: { id: string }[] = [];
|
||||||
let statusResults: { id: string }[] = [];
|
let statusResults: { id: string }[] = [];
|
||||||
|
|
||||||
if (!type || type === "accounts") {
|
if (!type || type === "accounts") {
|
||||||
// Check if q is matching format username@domain.com or @username@domain.com
|
// Check if q is matching format username@domain.com or @username@domain.com
|
||||||
const accountMatches = q
|
const accountMatches = q
|
||||||
?.trim()
|
?.trim()
|
||||||
.match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g);
|
.match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g);
|
||||||
if (accountMatches) {
|
if (accountMatches) {
|
||||||
// Remove leading @ if it exists
|
// Remove leading @ if it exists
|
||||||
if (accountMatches[0].startsWith("@")) {
|
if (accountMatches[0].startsWith("@")) {
|
||||||
accountMatches[0] = accountMatches[0].slice(1);
|
accountMatches[0] = accountMatches[0].slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [username, domain] = accountMatches[0].split("@");
|
const [username, domain] = accountMatches[0].split("@");
|
||||||
|
|
||||||
const accountId = (
|
const accountId = (
|
||||||
await db
|
await db
|
||||||
.select({
|
.select({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
})
|
})
|
||||||
.from(user)
|
.from(user)
|
||||||
.leftJoin(instance, eq(user.instanceId, instance.id))
|
.leftJoin(instance, eq(user.instanceId, instance.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(user.username, username),
|
eq(user.username, username),
|
||||||
eq(instance.baseUrl, domain),
|
eq(instance.baseUrl, domain),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)[0]?.id;
|
)[0]?.id;
|
||||||
|
|
||||||
const account = accountId
|
const account = accountId
|
||||||
? await findFirstUser({
|
? await findFirstUser({
|
||||||
where: (user, { eq }) => eq(user.id, accountId),
|
where: (user, { eq }) => eq(user.id, accountId),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
return jsonResponse({
|
|
||||||
accounts: [userToAPI(account)],
|
|
||||||
statuses: [],
|
|
||||||
hashtags: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolve) {
|
|
||||||
const newUser = await resolveWebFinger(username, domain).catch(
|
|
||||||
(e) => {
|
|
||||||
console.error(e);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newUser) {
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
accounts: [userToAPI(newUser)],
|
accounts: [userToAPI(account)],
|
||||||
statuses: [],
|
statuses: [],
|
||||||
hashtags: [],
|
hashtags: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolve) {
|
||||||
|
const newUser = await resolveWebFinger(
|
||||||
|
username,
|
||||||
|
domain,
|
||||||
|
).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newUser) {
|
||||||
|
return jsonResponse({
|
||||||
|
accounts: [userToAPI(newUser)],
|
||||||
|
statuses: [],
|
||||||
|
hashtags: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountResults = (
|
||||||
|
await meilisearch.index(MeiliIndexType.Accounts).search<{
|
||||||
|
id: string;
|
||||||
|
}>(q, {
|
||||||
|
limit: Number(limit) || 10,
|
||||||
|
offset: Number(offset) || 0,
|
||||||
|
sort: ["createdAt:desc"],
|
||||||
|
})
|
||||||
|
).hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
accountResults = (
|
if (!type || type === "statuses") {
|
||||||
await meilisearch.index(MeiliIndexType.Accounts).search<{
|
statusResults = (
|
||||||
id: string;
|
await meilisearch.index(MeiliIndexType.Statuses).search<{
|
||||||
}>(q, {
|
id: string;
|
||||||
limit: Number(limit) || 10,
|
}>(q, {
|
||||||
offset: Number(offset) || 0,
|
limit: Number(limit) || 10,
|
||||||
sort: ["createdAt:desc"],
|
offset: Number(offset) || 0,
|
||||||
})
|
sort: ["createdAt:desc"],
|
||||||
).hits;
|
})
|
||||||
}
|
).hits;
|
||||||
|
}
|
||||||
|
|
||||||
if (!type || type === "statuses") {
|
const accounts = await findManyUsers({
|
||||||
statusResults = (
|
where: (user, { and, eq, inArray }) =>
|
||||||
await meilisearch.index(MeiliIndexType.Statuses).search<{
|
and(
|
||||||
id: string;
|
inArray(
|
||||||
}>(q, {
|
user.id,
|
||||||
limit: Number(limit) || 10,
|
accountResults.map((hit) => hit.id),
|
||||||
offset: Number(offset) || 0,
|
),
|
||||||
sort: ["createdAt:desc"],
|
self
|
||||||
})
|
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
|
||||||
).hits;
|
self?.id
|
||||||
}
|
} AND Relationships.following = ${
|
||||||
|
following ? true : false
|
||||||
const accounts = await findManyUsers({
|
} AND Relationships.objectId = ${user.id})`
|
||||||
where: (user, { and, eq, inArray }) =>
|
: undefined,
|
||||||
and(
|
|
||||||
inArray(
|
|
||||||
user.id,
|
|
||||||
accountResults.map((hit) => hit.id),
|
|
||||||
),
|
),
|
||||||
self
|
orderBy: (user, { desc }) => desc(user.createdAt),
|
||||||
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
|
});
|
||||||
self?.id
|
|
||||||
} AND Relationships.following = ${
|
|
||||||
following ? true : false
|
|
||||||
} AND Relationships.objectId = ${user.id})`
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
orderBy: (user, { desc }) => desc(user.createdAt),
|
|
||||||
});
|
|
||||||
|
|
||||||
const statuses = await findManyStatuses({
|
const statuses = await findManyStatuses({
|
||||||
where: (status, { and, eq, inArray }) =>
|
where: (status, { and, eq, inArray }) =>
|
||||||
and(
|
and(
|
||||||
inArray(
|
inArray(
|
||||||
status.id,
|
status.id,
|
||||||
statusResults.map((hit) => hit.id),
|
statusResults.map((hit) => hit.id),
|
||||||
|
),
|
||||||
|
account_id ? eq(status.authorId, account_id) : undefined,
|
||||||
|
self
|
||||||
|
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
|
||||||
|
self?.id
|
||||||
|
} AND Relationships.following = ${
|
||||||
|
following ? true : false
|
||||||
|
} AND Relationships.objectId = ${status.authorId})`
|
||||||
|
: undefined,
|
||||||
),
|
),
|
||||||
account_id ? eq(status.authorId, account_id) : undefined,
|
orderBy: (status, { desc }) => desc(status.createdAt),
|
||||||
self
|
});
|
||||||
? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${
|
|
||||||
self?.id
|
|
||||||
} AND Relationships.following = ${
|
|
||||||
following ? true : false
|
|
||||||
} AND Relationships.objectId = ${status.authorId})`
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
orderBy: (status, { desc }) => desc(status.createdAt),
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
accounts: accounts.map((account) => userToAPI(account)),
|
accounts: accounts.map((account) => userToAPI(account)),
|
||||||
statuses: await Promise.all(
|
statuses: await Promise.all(
|
||||||
statuses.map((status) => statusToAPI(status)),
|
statuses.map((status) => statusToAPI(status)),
|
||||||
),
|
),
|
||||||
hashtags: [],
|
hashtags: [],
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -14,61 +15,68 @@ export const meta = applyConfig({
|
||||||
route: "/oauth/token",
|
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
|
* Allows getting token from OAuth code
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
grant_type: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
code: string;
|
const {
|
||||||
redirect_uri: string;
|
grant_type,
|
||||||
client_id: string;
|
code,
|
||||||
client_secret: string;
|
redirect_uri,
|
||||||
scope: string;
|
client_id,
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
client_secret,
|
||||||
const { grant_type, code, redirect_uri, client_id, client_secret, scope } =
|
scope,
|
||||||
extraData.parsedRequest;
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
if (grant_type !== "authorization_code")
|
if (grant_type !== "authorization_code")
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"Invalid grant type (try 'authorization_code')",
|
"Invalid grant type (try 'authorization_code')",
|
||||||
400,
|
422,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!code || !redirect_uri || !client_id || !client_secret || !scope)
|
// Get associated token
|
||||||
return errorResponse(
|
const application = await db.query.application.findFirst({
|
||||||
"Missing required parameters code, redirect_uri, client_id, client_secret, scope",
|
where: (application, { eq, and }) =>
|
||||||
400,
|
and(
|
||||||
);
|
eq(application.clientId, client_id),
|
||||||
|
eq(application.secret, client_secret),
|
||||||
|
eq(application.redirectUris, redirect_uri),
|
||||||
|
eq(application.scopes, scope?.replaceAll("+", " ")),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
// Get associated token
|
if (!application)
|
||||||
const application = await db.query.application.findFirst({
|
return errorResponse(
|
||||||
where: (application, { eq, and }) =>
|
"Invalid client credentials (missing application)",
|
||||||
and(
|
401,
|
||||||
eq(application.clientId, client_id),
|
);
|
||||||
eq(application.secret, client_secret),
|
|
||||||
eq(application.redirectUris, redirect_uri),
|
|
||||||
eq(application.scopes, scope?.replaceAll("+", " ")),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!application)
|
const token = await db.query.token.findFirst({
|
||||||
return errorResponse(
|
where: (token, { eq }) =>
|
||||||
"Invalid client credentials (missing applicaiton)",
|
eq(token.code, code) && eq(token.applicationId, application.id),
|
||||||
401,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const token = await db.query.token.findFirst({
|
if (!token)
|
||||||
where: (token, { eq }) =>
|
return errorResponse(
|
||||||
eq(token.code, code) && eq(token.applicationId, application.id),
|
"Invalid access token or client credentials",
|
||||||
});
|
401,
|
||||||
|
);
|
||||||
|
|
||||||
if (!token)
|
return jsonResponse({
|
||||||
return errorResponse("Invalid access token or client credentials", 401);
|
access_token: token.accessToken,
|
||||||
|
token_type: token.tokenType,
|
||||||
return jsonResponse({
|
scope: token.scope,
|
||||||
access_token: token.accessToken,
|
created_at: new Date(token.createdAt).getTime(),
|
||||||
token_type: token.tokenType,
|
});
|
||||||
scope: token.scope,
|
},
|
||||||
created_at: new Date(token.createdAt).getTime(),
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { z } from "zod";
|
||||||
import { findFirstUser } from "~database/entities/User";
|
import { findFirstUser } from "~database/entities/User";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -14,58 +15,59 @@ export const meta = applyConfig({
|
||||||
route: "/.well-known/webfinger",
|
route: "/.well-known/webfinger",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute<{
|
export const schema = z.object({
|
||||||
resource: string;
|
resource: z.string().min(1).max(512),
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
|
||||||
const { resource } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
if (!resource) return errorResponse("No resource provided", 400);
|
|
||||||
|
|
||||||
// Check if resource is in the correct format (acct:uuid/username@domain)
|
|
||||||
if (!resource.match(/^acct:[a-zA-Z0-9-]+@[a-zA-Z0-9.-:]+$/)) {
|
|
||||||
return errorResponse(
|
|
||||||
"Invalid resource (should be acct:(id or username)@domain)",
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedUser = resource.split("acct:")[1];
|
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
|
||||||
const host = new URL(config.http.base_url).host;
|
|
||||||
|
|
||||||
// Check if user is a local user
|
|
||||||
if (requestedUser.split("@")[1] !== host) {
|
|
||||||
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 user = await findFirstUser({
|
|
||||||
where: (user, { eq }) =>
|
|
||||||
eq(isUuid ? user.id : user.username, requestedUser.split("@")[0]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return errorResponse("User not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({
|
|
||||||
subject: `acct:${isUuid ? user.id : user.username}@${host}`,
|
|
||||||
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
rel: "self",
|
|
||||||
type: "application/json",
|
|
||||||
href: new URL(
|
|
||||||
`/users/${user.id}`,
|
|
||||||
config.http.base_url,
|
|
||||||
).toString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.-:]+$/)) {
|
||||||
|
return errorResponse(
|
||||||
|
"Invalid resource (should be acct:(id or username)@domain)",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedUser = resource.split("acct:")[1];
|
||||||
|
|
||||||
|
const config = await extraData.configManager.getConfig();
|
||||||
|
const host = new URL(config.http.base_url).host;
|
||||||
|
|
||||||
|
// Check if user is a local user
|
||||||
|
if (requestedUser.split("@")[1] !== host) {
|
||||||
|
return errorResponse("User is a remote user", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUuid = requestedUser.split("@")[0].match(idValidator);
|
||||||
|
|
||||||
|
const user = await findFirstUser({
|
||||||
|
where: (user, { eq }) =>
|
||||||
|
eq(
|
||||||
|
isUuid ? user.id : user.username,
|
||||||
|
requestedUser.split("@")[0],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
subject: `acct:${isUuid ? user.id : user.username}@${host}`,
|
||||||
|
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
rel: "self",
|
||||||
|
type: "application/json",
|
||||||
|
href: new URL(
|
||||||
|
`/users/${user.id}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).toString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,6 @@ describe("API Tests", () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url),
|
wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url),
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,8 @@ describe("API Tests", () => {
|
||||||
new Request(
|
new Request(
|
||||||
wrapRelativeUrl("/api/v1/accounts/999999", base_url),
|
wrapRelativeUrl("/api/v1/accounts/999999", base_url),
|
||||||
{
|
{
|
||||||
method: "GET",
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -80,10 +78,8 @@ describe("API Tests", () => {
|
||||||
base_url,
|
base_url,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "GET",
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -130,10 +126,8 @@ describe("API Tests", () => {
|
||||||
base_url,
|
base_url,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "GET",
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -585,7 +579,6 @@ describe("API Tests", () => {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -612,7 +605,6 @@ describe("API Tests", () => {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -164,10 +164,8 @@ describe("API Tests", () => {
|
||||||
base_url,
|
base_url,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "GET",
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -218,7 +216,6 @@ describe("API Tests", () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -249,7 +246,6 @@ describe("API Tests", () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -276,10 +272,8 @@ describe("API Tests", () => {
|
||||||
base_url,
|
base_url,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "GET",
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -312,7 +306,6 @@ describe("API Tests", () => {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -341,7 +334,6 @@ describe("API Tests", () => {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -377,7 +369,6 @@ describe("API Tests", () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -399,7 +390,6 @@ describe("API Tests", () => {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -430,7 +420,6 @@ describe("API Tests", () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.accessToken}`,
|
Authorization: `Bearer ${token.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
35
utils/api.ts
35
utils/api.ts
|
|
@ -1,8 +1,15 @@
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import type { RouteHandler } from "~server/api/routes.type";
|
import {
|
||||||
import type { APIRouteMeta } from "~types/api";
|
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;
|
const newMeta = routeMeta;
|
||||||
|
|
||||||
// Apply ratelimits from config
|
// Apply ratelimits from config
|
||||||
|
|
@ -16,6 +23,26 @@ export const applyConfig = (routeMeta: APIRouteMeta) => {
|
||||||
return newMeta;
|
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;
|
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],
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue