mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(api): ♻️ Move to Hono for HTTP
This commit is contained in:
parent
2237be3689
commit
826a260e90
|
|
@ -109,6 +109,15 @@ export const getFromRequest = async (req: Request): Promise<AuthData> => {
|
||||||
return { user, token, application };
|
return { user, token, application };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getFromHeader = async (value: string): Promise<AuthData> => {
|
||||||
|
const token = value.split(" ")[1];
|
||||||
|
|
||||||
|
const { user, application } =
|
||||||
|
await retrieveUserAndApplicationFromToken(token);
|
||||||
|
|
||||||
|
return { user, token, application };
|
||||||
|
};
|
||||||
|
|
||||||
export const followRequestUser = async (
|
export const followRequestUser = async (
|
||||||
follower: User,
|
follower: User,
|
||||||
followee: User,
|
followee: User,
|
||||||
|
|
|
||||||
43
index.ts
43
index.ts
|
|
@ -1,11 +1,13 @@
|
||||||
import { dualLogger } from "@loggers";
|
import { dualLogger } from "@loggers";
|
||||||
import { connectMeili } from "@meilisearch";
|
import { connectMeili } from "@meilisearch";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { count } from "drizzle-orm";
|
import { Hono } from "hono";
|
||||||
import { LogLevel, LogManager, type MultiLogManager } from "log-manager";
|
import { LogLevel, LogManager, type MultiLogManager } from "log-manager";
|
||||||
import { db, setupDatabase } from "~drizzle/db";
|
import { setupDatabase } from "~drizzle/db";
|
||||||
import { Notes } from "~drizzle/schema";
|
import { Note } from "~packages/database-interface/note";
|
||||||
import { createServer } from "~server";
|
import type { APIRouteExports } from "~packages/server-handler";
|
||||||
|
import { routes } from "~routes";
|
||||||
|
import { createServer } from "~server2";
|
||||||
|
|
||||||
const timeAtStart = performance.now();
|
const timeAtStart = performance.now();
|
||||||
|
|
||||||
|
|
@ -28,20 +30,7 @@ if (config.meilisearch.enabled) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if database is reachable
|
// Check if database is reachable
|
||||||
let postCount = 0;
|
const postCount = await Note.getCount();
|
||||||
try {
|
|
||||||
postCount = (
|
|
||||||
await db
|
|
||||||
.select({
|
|
||||||
count: count(),
|
|
||||||
})
|
|
||||||
.from(Notes)
|
|
||||||
)[0].count;
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
await dualServerLogger.logError(LogLevel.CRITICAL, "Database", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEntry) {
|
if (isEntry) {
|
||||||
// Check if JWT private key is set in config
|
// Check if JWT private key is set in config
|
||||||
|
|
@ -110,7 +99,21 @@ if (isEntry) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = createServer(config, dualServerLogger, true);
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Inject own filesystem router
|
||||||
|
for (const [route, path] of Object.entries(routes)) {
|
||||||
|
// use app.get(path, handler) to add routes
|
||||||
|
const route: APIRouteExports = await import(path);
|
||||||
|
|
||||||
|
if (!route.meta || !route.default) {
|
||||||
|
throw new Error(`Route ${path} does not have the correct exports.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
route.default(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
createServer(config, app);
|
||||||
|
|
||||||
await dualServerLogger.log(
|
await dualServerLogger.log(
|
||||||
LogLevel.INFO,
|
LogLevel.INFO,
|
||||||
|
|
@ -161,4 +164,4 @@ if (config.frontend.enabled) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { config, server };
|
export { app };
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch index.ts",
|
"dev": "bun run --hot index.ts",
|
||||||
"start": "NODE_ENV=production bun run dist/index.js --prod",
|
"start": "NODE_ENV=production bun run dist/index.js --prod",
|
||||||
"lint": "bunx @biomejs/biome check .",
|
"lint": "bunx @biomejs/biome check .",
|
||||||
"prod-build": "bun run build.ts",
|
"prod-build": "bun run build.ts",
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
"config-manager": "workspace:*",
|
"config-manager": "workspace:*",
|
||||||
"drizzle-orm": "^0.30.7",
|
"drizzle-orm": "^0.30.7",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
|
"hono": "^4.3.2",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ip-matching": "^2.1.2",
|
"ip-matching": "^2.1.2",
|
||||||
|
|
@ -99,6 +100,7 @@
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"oauth4webapi": "^2.4.0",
|
"oauth4webapi": "^2.4.0",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
|
"qs": "^6.12.1",
|
||||||
"request-parser": "workspace:*",
|
"request-parser": "workspace:*",
|
||||||
"sharp": "^0.33.3",
|
"sharp": "^0.33.3",
|
||||||
"string-comparison": "^1.3.0",
|
"string-comparison": "^1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import {
|
||||||
type InferInsertModel,
|
type InferInsertModel,
|
||||||
type SQL,
|
type SQL,
|
||||||
and,
|
and,
|
||||||
|
count,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
inArray,
|
inArray,
|
||||||
isNotNull,
|
isNotNull,
|
||||||
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { htmlToText } from "html-to-text";
|
import { htmlToText } from "html-to-text";
|
||||||
import type * as Lysand from "lysand-types";
|
import type * as Lysand from "lysand-types";
|
||||||
|
|
@ -161,6 +163,19 @@ export class Note {
|
||||||
return new User(this.status.author);
|
return new User(this.status.author);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getCount() {
|
||||||
|
return (
|
||||||
|
await db
|
||||||
|
.select({
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(Notes)
|
||||||
|
.where(
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`,
|
||||||
|
)
|
||||||
|
)[0].count;
|
||||||
|
}
|
||||||
|
|
||||||
async getReplyChildren() {
|
async getReplyChildren() {
|
||||||
return await Note.manyFromSql(eq(Notes.replyId, this.status.id));
|
return await Note.manyFromSql(eq(Notes.replyId, this.status.id));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,17 @@ import { idValidator } from "@api";
|
||||||
import { getBestContentType, urlToContentFormat } from "@content_types";
|
import { getBestContentType, urlToContentFormat } from "@content_types";
|
||||||
import { addUserToMeilisearch } from "@meilisearch";
|
import { addUserToMeilisearch } from "@meilisearch";
|
||||||
import { proxyUrl } from "@response";
|
import { proxyUrl } from "@response";
|
||||||
import { type SQL, and, desc, eq, inArray } from "drizzle-orm";
|
import {
|
||||||
|
type SQL,
|
||||||
|
and,
|
||||||
|
count,
|
||||||
|
countDistinct,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { htmlToText } from "html-to-text";
|
import { htmlToText } from "html-to-text";
|
||||||
import type * as Lysand from "lysand-types";
|
import type * as Lysand from "lysand-types";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +30,7 @@ import { db } from "~drizzle/db";
|
||||||
import {
|
import {
|
||||||
EmojiToUser,
|
EmojiToUser,
|
||||||
NoteToMentions,
|
NoteToMentions,
|
||||||
|
Notes,
|
||||||
UserToPinnedNotes,
|
UserToPinnedNotes,
|
||||||
Users,
|
Users,
|
||||||
} from "~drizzle/schema";
|
} from "~drizzle/schema";
|
||||||
|
|
@ -102,6 +113,37 @@ export class User {
|
||||||
return uri || new URL(`/users/${id}`, baseUrl).toString();
|
return uri || new URL(`/users/${id}`, baseUrl).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getCount() {
|
||||||
|
return (
|
||||||
|
await db
|
||||||
|
.select({
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(Users)
|
||||||
|
.where(isNull(Users.instanceId))
|
||||||
|
)[0].count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getActiveInPeriod(milliseconds: number) {
|
||||||
|
return (
|
||||||
|
await db
|
||||||
|
.select({
|
||||||
|
count: countDistinct(Users),
|
||||||
|
})
|
||||||
|
.from(Users)
|
||||||
|
.leftJoin(Notes, eq(Users.id, Notes.authorId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(Users.instanceId),
|
||||||
|
gte(
|
||||||
|
Notes.createdAt,
|
||||||
|
new Date(Date.now() - milliseconds).toISOString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)[0].count;
|
||||||
|
}
|
||||||
|
|
||||||
async pin(note: Note) {
|
async pin(note: Note) {
|
||||||
return (
|
return (
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -260,3 +260,23 @@ export const signedFetch = async (
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export all schemas as a single object
|
||||||
|
export default {
|
||||||
|
Note: schemas.Note,
|
||||||
|
User: schemas.User,
|
||||||
|
Reaction: schemas.Reaction,
|
||||||
|
Poll: schemas.Poll,
|
||||||
|
Vote: schemas.Vote,
|
||||||
|
VoteResult: schemas.VoteResult,
|
||||||
|
Report: schemas.Report,
|
||||||
|
ServerMetadata: schemas.ServerMetadata,
|
||||||
|
Like: schemas.Like,
|
||||||
|
Dislike: schemas.Dislike,
|
||||||
|
Follow: schemas.Follow,
|
||||||
|
FollowAccept: schemas.FollowAccept,
|
||||||
|
FollowReject: schemas.FollowReject,
|
||||||
|
Announce: schemas.Announce,
|
||||||
|
Undo: schemas.Undo,
|
||||||
|
Entity: schemas.Entity,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { dualLogger } from "@loggers";
|
||||||
import { errorResponse, jsonResponse, response } from "@response";
|
import { errorResponse, jsonResponse, response } from "@response";
|
||||||
import type { MatchedRoute } from "bun";
|
import type { MatchedRoute } from "bun";
|
||||||
import { type Config, config } from "config-manager";
|
import { type Config, config } from "config-manager";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import type { RouterRoute } from "hono/types";
|
||||||
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
|
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
|
||||||
import { RequestParser } from "request-parser";
|
import { RequestParser } from "request-parser";
|
||||||
import type { ZodType, z } from "zod";
|
import type { ZodType, z } from "zod";
|
||||||
|
|
@ -11,7 +13,7 @@ import { type AuthData, getFromRequest } from "~database/entities/User";
|
||||||
import type { User } from "~packages/database-interface/user";
|
import type { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
type MaybePromise<T> = T | Promise<T>;
|
type MaybePromise<T> = T | Promise<T>;
|
||||||
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
|
export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
|
||||||
|
|
||||||
export type RouteHandler<
|
export type RouteHandler<
|
||||||
RouteMeta extends APIRouteMetadata,
|
RouteMeta extends APIRouteMetadata,
|
||||||
|
|
@ -54,8 +56,11 @@ export interface APIRouteMetadata {
|
||||||
|
|
||||||
export interface APIRouteExports {
|
export interface APIRouteExports {
|
||||||
meta: APIRouteMetadata;
|
meta: APIRouteMetadata;
|
||||||
schema: z.AnyZodObject;
|
schemas?: {
|
||||||
default: RouteHandler<APIRouteMetadata, z.AnyZodObject>;
|
query?: z.AnyZodObject;
|
||||||
|
body?: z.AnyZodObject;
|
||||||
|
};
|
||||||
|
default: (app: Hono) => RouterRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processRoute = async (
|
export const processRoute = async (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const routeMatcher = new Bun.FileSystemRouter({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transform routes to be relative to the server/api directory
|
// Transform routes to be relative to the server/api directory
|
||||||
const routes = routeMatcher.routes;
|
let routes = routeMatcher.routes;
|
||||||
|
|
||||||
for (const [route, path] of Object.entries(routes)) {
|
for (const [route, path] of Object.entries(routes)) {
|
||||||
routes[route] = path.replace(join(process.cwd()), ".");
|
routes[route] = path.replace(join(process.cwd()), ".");
|
||||||
|
|
@ -17,6 +17,9 @@ for (const [route, path] of Object.entries(routes)) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent catch-all routes from being first by reversinbg the order
|
||||||
|
routes = Object.fromEntries(Object.entries(routes).reverse());
|
||||||
|
|
||||||
export { routes };
|
export { routes };
|
||||||
|
|
||||||
export const matchRoute = (request: Request) => {
|
export const matchRoute = (request: Request) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
import { jsonResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { config } from "~packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -13,17 +15,16 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default (app: Hono) =>
|
||||||
const config = await extraData.configManager.getConfig();
|
app.on(meta.allowedMethods, meta.route, async (context) => {
|
||||||
|
return jsonResponse({
|
||||||
return jsonResponse({
|
http: {
|
||||||
http: {
|
bind: config.http.bind,
|
||||||
bind: config.http.bind,
|
bind_port: config.http.bind_port,
|
||||||
bind_port: config.http.bind_port,
|
base_url: config.http.base_url,
|
||||||
base_url: config.http.base_url,
|
url: config.http.bind.includes("http")
|
||||||
url: config.http.bind.includes("http")
|
? `${config.http.bind}:${config.http.bind_port}`
|
||||||
? `${config.http.bind}:${config.http.bind_port}`
|
: `http://${config.http.bind}:${config.http.bind_port}`,
|
||||||
: `http://${config.http.bind}:${config.http.bind_port}`,
|
},
|
||||||
},
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, response } from "@response";
|
import { errorResponse, response } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
|
@ -20,35 +22,39 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
email: z.string().email().toLowerCase(),
|
form: z.object({
|
||||||
password: z.string().min(2).max(100),
|
email: z.string().email().toLowerCase(),
|
||||||
scope: z.string().optional(),
|
password: z.string().min(2).max(100),
|
||||||
redirect_uri: z.string().url().optional(),
|
}),
|
||||||
response_type: z.enum([
|
query: z.object({
|
||||||
"code",
|
scope: z.string().optional(),
|
||||||
"token",
|
redirect_uri: z.string().url().optional(),
|
||||||
"none",
|
response_type: z.enum([
|
||||||
"id_token",
|
"code",
|
||||||
"code id_token",
|
"token",
|
||||||
"code token",
|
"none",
|
||||||
"token id_token",
|
"id_token",
|
||||||
"code token id_token",
|
"code id_token",
|
||||||
]),
|
"code token",
|
||||||
client_id: z.string(),
|
"token id_token",
|
||||||
state: z.string().optional(),
|
"code token id_token",
|
||||||
code_challenge: z.string().optional(),
|
]),
|
||||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
client_id: z.string(),
|
||||||
prompt: z
|
state: z.string().optional(),
|
||||||
.enum(["none", "login", "consent", "select_account"])
|
code_challenge: z.string().optional(),
|
||||||
.optional()
|
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||||
.default("none"),
|
prompt: z
|
||||||
max_age: z
|
.enum(["none", "login", "consent", "select_account"])
|
||||||
.number()
|
.optional()
|
||||||
.int()
|
.default("none"),
|
||||||
.optional()
|
max_age: z
|
||||||
.default(60 * 60 * 24 * 7),
|
.number()
|
||||||
});
|
.int()
|
||||||
|
.optional()
|
||||||
|
.default(60 * 60 * 24 * 7),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
const returnError = (query: object, error: string, description: string) => {
|
const returnError = (query: object, error: string, description: string) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
@ -66,91 +72,92 @@ const returnError = (query: object, error: string, description: string) => {
|
||||||
Location: `/oauth/authorize?${searchParams.toString()}`,
|
Location: `/oauth/authorize?${searchParams.toString()}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* Login flow
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const { email, password } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
if (!email || !password)
|
export default (app: Hono) =>
|
||||||
return returnError(
|
app.on(
|
||||||
extraData.parsedRequest,
|
meta.allowedMethods,
|
||||||
"invalid_request",
|
meta.route,
|
||||||
"Missing email or password",
|
zValidator("form", schemas.form, handleZodError),
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
async (context) => {
|
||||||
|
const { email, password } = context.req.valid("form");
|
||||||
|
const { client_id } = context.req.valid("query");
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await User.fromSql(
|
||||||
|
eq(Users.email, email.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find user
|
if (
|
||||||
const user = await User.fromSql(eq(Users.email, email.toLowerCase()));
|
!user ||
|
||||||
|
!(await Bun.password.verify(
|
||||||
|
password,
|
||||||
|
user.getUser().password || "",
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return returnError(
|
||||||
|
context.req.query(),
|
||||||
|
"invalid_request",
|
||||||
|
"Invalid email or password",
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
// Try and import the key
|
||||||
!user ||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
!(await Bun.password.verify(
|
"pkcs8",
|
||||||
password,
|
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
||||||
user.getUser().password || "",
|
"Ed25519",
|
||||||
))
|
false,
|
||||||
)
|
["sign"],
|
||||||
return returnError(
|
|
||||||
extraData.parsedRequest,
|
|
||||||
"invalid_request",
|
|
||||||
"Invalid email or password",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { client_id } = extraData.parsedRequest;
|
// Generate JWT
|
||||||
|
const jwt = await new SignJWT({
|
||||||
|
sub: user.id,
|
||||||
|
iss: new URL(config.http.base_url).origin,
|
||||||
|
aud: client_id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
|
.sign(privateKey);
|
||||||
|
|
||||||
// Try and import the key
|
const application = await db.query.Applications.findFirst({
|
||||||
const privateKey = await crypto.subtle.importKey(
|
where: (app, { eq }) => eq(app.clientId, client_id),
|
||||||
"pkcs8",
|
});
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
|
||||||
"Ed25519",
|
|
||||||
false,
|
|
||||||
["sign"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate JWT
|
if (!application) {
|
||||||
const jwt = await new SignJWT({
|
return errorResponse("Invalid application", 400);
|
||||||
sub: user.id,
|
}
|
||||||
iss: new URL(config.http.base_url).origin,
|
|
||||||
aud: client_id,
|
|
||||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
|
||||||
iat: Math.floor(Date.now() / 1000),
|
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
|
||||||
.sign(privateKey);
|
|
||||||
|
|
||||||
const application = await db.query.Applications.findFirst({
|
const searchParams = new URLSearchParams({
|
||||||
where: (app, { eq }) => eq(app.clientId, client_id),
|
application: application.name,
|
||||||
});
|
client_secret: application.secret,
|
||||||
|
});
|
||||||
|
|
||||||
if (!application) {
|
if (application.website)
|
||||||
return errorResponse("Invalid application", 400);
|
searchParams.append("website", application.website);
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams({
|
// Add all data that is not undefined except email and password
|
||||||
application: application.name,
|
for (const [key, value] of Object.entries(context.req.query())) {
|
||||||
client_secret: application.secret,
|
if (
|
||||||
});
|
key !== "email" &&
|
||||||
|
key !== "password" &&
|
||||||
|
value !== undefined
|
||||||
|
)
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
if (application.website)
|
// Redirect to OAuth authorize with JWT
|
||||||
searchParams.append("website", application.website);
|
return response(null, 302, {
|
||||||
|
Location: new URL(
|
||||||
// Add all data that is not undefined except email and password
|
`/oauth/consent?${searchParams.toString()}`,
|
||||||
for (const [key, value] of Object.entries(extraData.parsedRequest)) {
|
config.http.base_url,
|
||||||
if (key !== "email" && key !== "password" && value !== undefined)
|
).toString(),
|
||||||
searchParams.append(key, String(value));
|
// Set cookie with JWT
|
||||||
}
|
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
|
||||||
|
60 * 60
|
||||||
// Redirect to OAuth authorize with JWT
|
}`,
|
||||||
return response(null, 302, {
|
});
|
||||||
Location: new URL(
|
},
|
||||||
`/oauth/consent?${searchParams.toString()}`,
|
);
|
||||||
config.http.base_url,
|
|
||||||
).toString(),
|
|
||||||
// Set cookie with JWT
|
|
||||||
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
|
|
||||||
60 * 60
|
|
||||||
}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { response } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TokenType } from "~database/entities/Token";
|
import { TokenType } from "~database/entities/Token";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
|
@ -20,66 +23,68 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
user: z.object({
|
form: z.object({
|
||||||
email: z.string().email().toLowerCase(),
|
user: z.object({
|
||||||
password: z.string().max(100).min(3),
|
email: z.string().email().toLowerCase(),
|
||||||
|
password: z.string().min(2).max(100),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mastodon-FE login route
|
* Mastodon-FE login route
|
||||||
*/
|
*/
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const {
|
meta.allowedMethods,
|
||||||
user: { email, password },
|
meta.route,
|
||||||
} = extraData.parsedRequest;
|
zValidator("form", schemas.form, handleZodError),
|
||||||
|
async (context) => {
|
||||||
|
const {
|
||||||
|
user: { email, password },
|
||||||
|
} = context.req.valid("form");
|
||||||
|
|
||||||
const redirectToLogin = (error: string) =>
|
const redirectToLogin = (error: string) =>
|
||||||
Response.redirect(
|
response(null, 302, {
|
||||||
`/auth/sign_in?${new URLSearchParams({
|
Location: `/auth/sign_in?${new URLSearchParams({
|
||||||
...matchedRoute.query,
|
...context.req.query,
|
||||||
error: encodeURIComponent(error),
|
error: encodeURIComponent(error),
|
||||||
}).toString()}`,
|
}).toString()}`,
|
||||||
302,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const user = await User.fromSql(eq(Users.email, email));
|
const user = await User.fromSql(eq(Users.email, email));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!user ||
|
!user ||
|
||||||
!(await Bun.password.verify(
|
!(await Bun.password.verify(
|
||||||
password,
|
password,
|
||||||
user.getUser().password || "",
|
user.getUser().password || "",
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
return redirectToLogin("Invalid email or password");
|
return redirectToLogin("Invalid email or password");
|
||||||
|
|
||||||
const code = randomBytes(32).toString("hex");
|
const code = randomBytes(32).toString("hex");
|
||||||
const accessToken = randomBytes(64).toString("base64url");
|
const accessToken = randomBytes(64).toString("base64url");
|
||||||
|
|
||||||
await db.insert(Tokens).values({
|
await db.insert(Tokens).values({
|
||||||
accessToken,
|
accessToken,
|
||||||
code: code,
|
code: code,
|
||||||
scope: "read write follow push",
|
scope: "read write follow push",
|
||||||
tokenType: TokenType.BEARER,
|
tokenType: TokenType.BEARER,
|
||||||
applicationId: null,
|
applicationId: null,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// One week from now
|
// One week from now
|
||||||
const maxAge = String(60 * 60 * 24 * 7);
|
const maxAge = String(60 * 60 * 24 * 7);
|
||||||
|
|
||||||
// Redirect to home
|
// Redirect to home
|
||||||
return new Response(null, {
|
return response(null, 303, {
|
||||||
headers: {
|
|
||||||
Location: "/",
|
Location: "/",
|
||||||
"Set-Cookie": `_session_id=${accessToken}; Domain=${
|
"Set-Cookie": `_session_id=${accessToken}; Domain=${
|
||||||
new URL(config.http.base_url).hostname
|
new URL(config.http.base_url).hostname
|
||||||
}; SameSite=Lax; Path=/; HttpOnly; Max-Age=${maxAge}`,
|
}; SameSite=Lax; Path=/; HttpOnly; Max-Age=${maxAge}`,
|
||||||
},
|
});
|
||||||
status: 303,
|
},
|
||||||
});
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { config } from "~packages/config-manager";
|
import { config } from "~packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -16,15 +17,15 @@ export const meta = applyConfig({
|
||||||
/**
|
/**
|
||||||
* Mastodon-FE logout route
|
* Mastodon-FE logout route
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default (app: Hono) =>
|
||||||
// Redirect to home
|
app.on(meta.allowedMethods, meta.route, async () => {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
headers: {
|
headers: {
|
||||||
Location: "/",
|
Location: "/",
|
||||||
"Set-Cookie": `_session_id=; Domain=${
|
"Set-Cookie": `_session_id=; Domain=${
|
||||||
new URL(config.http.base_url).hostname
|
new URL(config.http.base_url).hostname
|
||||||
}; SameSite=Lax; Path=/; HttpOnly; Max-Age=0; Expires=${new Date().toUTCString()}`,
|
}; SameSite=Lax; Path=/; HttpOnly; Max-Age=0; Expires=${new Date().toUTCString()}`,
|
||||||
},
|
},
|
||||||
status: 303,
|
status: 303,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Applications, Tokens } from "~drizzle/schema";
|
import { Applications, Tokens } from "~drizzle/schema";
|
||||||
|
|
||||||
|
|
@ -15,33 +18,54 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
query: z.object({
|
||||||
|
redirect_uri: z.string().url(),
|
||||||
|
client_id: z.string(),
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth Code flow
|
* OAuth Code flow
|
||||||
*/
|
*/
|
||||||
export default apiRoute(async (req, matchedRoute) => {
|
export default (app: Hono) =>
|
||||||
const redirect_uri = decodeURIComponent(matchedRoute.query.redirect_uri);
|
app.on(
|
||||||
const client_id = matchedRoute.query.client_id;
|
meta.allowedMethods,
|
||||||
const code = matchedRoute.query.code;
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
async (context) => {
|
||||||
|
const { redirect_uri, client_id, code } =
|
||||||
|
context.req.valid("query");
|
||||||
|
|
||||||
const redirectToLogin = (error: string) =>
|
const redirectToLogin = (error: string) =>
|
||||||
Response.redirect(
|
Response.redirect(
|
||||||
`/oauth/authorize?${new URLSearchParams({
|
`/oauth/authorize?${new URLSearchParams({
|
||||||
...matchedRoute.query,
|
...context.req.query,
|
||||||
error: encodeURIComponent(error),
|
error: encodeURIComponent(error),
|
||||||
}).toString()}`,
|
}).toString()}`,
|
||||||
302,
|
302,
|
||||||
);
|
);
|
||||||
|
|
||||||
const foundToken = await db
|
const foundToken = await db
|
||||||
.select()
|
.select()
|
||||||
.from(Tokens)
|
.from(Tokens)
|
||||||
.leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
|
.leftJoin(
|
||||||
.where(and(eq(Tokens.code, code), eq(Applications.clientId, client_id)))
|
Applications,
|
||||||
.limit(1);
|
eq(Tokens.applicationId, Applications.id),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Tokens.code, code),
|
||||||
|
eq(Applications.clientId, client_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (!foundToken || foundToken.length <= 0)
|
if (!foundToken || foundToken.length <= 0)
|
||||||
return redirectToLogin("Invalid code");
|
return redirectToLogin("Invalid code");
|
||||||
|
|
||||||
// Redirect back to application
|
// Redirect back to application
|
||||||
return Response.redirect(`${redirect_uri}?code=${code}`, 302);
|
return Response.redirect(`${redirect_uri}?code=${code}`, 302);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
66
server/api/api/v1/accounts/:id/block.ts
Normal file
66
server/api/api/v1/accounts/:id/block.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/block",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:blocks"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundRelationship.blocking) {
|
||||||
|
foundRelationship.blocking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
blocking: true,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -27,6 +27,10 @@ describe(meta.route, () => {
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -47,7 +51,9 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -65,7 +71,9 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -86,7 +94,9 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
79
server/api/api/v1/accounts/:id/follow.ts
Normal file
79
server/api/api/v1/accounts/:id/follow.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import ISO6391 from "iso-639-1";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import {
|
||||||
|
followRequestUser,
|
||||||
|
getRelationshipToOtherUser,
|
||||||
|
} from "~database/entities/User";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/follow",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:follows"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
json: z
|
||||||
|
.object({
|
||||||
|
reblogs: z.coerce.boolean().optional(),
|
||||||
|
notify: z.coerce.boolean().optional(),
|
||||||
|
languages: z
|
||||||
|
.array(z.enum(ISO6391.getAllCodes() as [string, ...string[]]))
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({ reblogs: true, notify: false, languages: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
zValidator("json", schemas.json, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
const { reblogs, notify, languages } = context.req.valid("json");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
let relationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relationship.following) {
|
||||||
|
relationship = await followRequestUser(
|
||||||
|
user,
|
||||||
|
otherUser,
|
||||||
|
relationship.id,
|
||||||
|
reblogs,
|
||||||
|
notify,
|
||||||
|
languages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(relationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -28,7 +28,9 @@ beforeAll(async () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
74
server/api/api/v1/accounts/:id/followers.ts
Normal file
74
server/api/api/v1/accounts/:id/followers.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Users } from "~drizzle/schema";
|
||||||
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 60,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/followers",
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
oauthPermissions: ["read:accounts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
query: 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),
|
||||||
|
}),
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
const { max_id, since_id, min_id, limit } =
|
||||||
|
context.req.valid("query");
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
// TODO: Add follower/following privacy settings
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const { objects, link } = await Timeline.getUserTimeline(
|
||||||
|
and(
|
||||||
|
max_id ? lt(Users.id, max_id) : undefined,
|
||||||
|
since_id ? gte(Users.id, since_id) : undefined,
|
||||||
|
min_id ? gt(Users.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
context.req.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
await Promise.all(objects.map((object) => object.toAPI())),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -28,7 +28,9 @@ beforeAll(async () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
73
server/api/api/v1/accounts/:id/following.ts
Normal file
73
server/api/api/v1/accounts/:id/following.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Users } from "~drizzle/schema";
|
||||||
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 60,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/following",
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
oauthPermissions: ["read:accounts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
query: 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),
|
||||||
|
}),
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
const { max_id, since_id, min_id } = context.req.valid("query");
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
// TODO: Add follower/following privacy settings
|
||||||
|
|
||||||
|
const { objects, link } = await Timeline.getUserTimeline(
|
||||||
|
and(
|
||||||
|
max_id ? lt(Users.id, max_id) : undefined,
|
||||||
|
since_id ? gte(Users.id, since_id) : undefined,
|
||||||
|
min_id ? gt(Users.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
||||||
|
),
|
||||||
|
context.req.valid("query").limit,
|
||||||
|
context.req.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
await Promise.all(objects.map((object) => object.toAPI())),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -20,17 +20,21 @@ afterAll(async () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
for (const status of timeline) {
|
for (const status of timeline) {
|
||||||
await fetch(
|
await sendTestRequest(
|
||||||
new URL(
|
new Request(
|
||||||
`/api/v1/statuses/${status.id}/favourite`,
|
new URL(
|
||||||
config.http.base_url,
|
`/api/v1/statuses/${status.id}/favourite`,
|
||||||
),
|
config.http.base_url,
|
||||||
{
|
),
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -46,7 +50,7 @@ describe(meta.route, () => {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(422);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return user", async () => {
|
test("should return user", async () => {
|
||||||
43
server/api/api/v1/accounts/:id/index.ts
Normal file
43
server/api/api/v1/accounts/:id/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id",
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
oauthPermissions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
const foundUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!foundUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
return jsonResponse(foundUser.toAPI(user?.id === foundUser.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -27,6 +27,10 @@ describe(meta.route, () => {
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -47,7 +51,9 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -65,7 +71,9 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -86,7 +94,9 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
83
server/api/api/v1/accounts/:id/mute.ts
Normal file
83
server/api/api/v1/accounts/:id/mute.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/mute",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:mutes"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
json: z.object({
|
||||||
|
notifications: z.boolean().optional(),
|
||||||
|
duration: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(60)
|
||||||
|
.max(60 * 60 * 24 * 365 * 5)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
zValidator("json", schemas.json, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
const { notifications, duration } = context.req.valid("json");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundRelationship.muting) {
|
||||||
|
foundRelationship.muting = true;
|
||||||
|
}
|
||||||
|
if (notifications ?? true) {
|
||||||
|
foundRelationship.mutingNotifications = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
muting: true,
|
||||||
|
mutingNotifications: notifications ?? true,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
|
||||||
|
// TODO: Implement duration
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
69
server/api/api/v1/accounts/:id/note.ts
Normal file
69
server/api/api/v1/accounts/:id/note.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/note",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:accounts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
json: z.object({
|
||||||
|
comment: z.string().min(0).max(5000).trim().optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
zValidator("json", schemas.json, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
const { comment } = context.req.valid("json");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
foundRelationship.note = comment ?? "";
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
note: foundRelationship.note,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
66
server/api/api/v1/accounts/:id/pin.ts
Normal file
66
server/api/api/v1/accounts/:id/pin.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/pin",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:accounts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundRelationship.endorsed) {
|
||||||
|
foundRelationship.endorsed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
endorsed: true,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
80
server/api/api/v1/accounts/:id/remove_from_followers.ts
Normal file
80
server/api/api/v1/accounts/:id/remove_from_followers.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/remove_from_followers",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:follows"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
self,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundRelationship.followedBy) {
|
||||||
|
foundRelationship.followedBy = false;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
followedBy: false,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
|
||||||
|
if (otherUser.isLocal()) {
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
following: false,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Relationships.ownerId, otherUser.id),
|
||||||
|
eq(Relationships.subjectId, self.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import {
|
import {
|
||||||
deleteOldTestUsers,
|
deleteOldTestUsers,
|
||||||
getTestStatuses,
|
getTestStatuses,
|
||||||
getTestUsers,
|
getTestUsers,
|
||||||
sendTestRequest,
|
sendTestRequest,
|
||||||
} from "~tests/utils";
|
} from "~tests/utils";
|
||||||
import type { Account as APIAccount } from "~types/mastodon/account";
|
|
||||||
import type { Status as APIStatus } from "~types/mastodon/status";
|
import type { Status as APIStatus } from "~types/mastodon/status";
|
||||||
import { meta } from "./statuses";
|
import { meta } from "./statuses";
|
||||||
|
|
||||||
|
|
@ -21,6 +19,12 @@ afterAll(async () => {
|
||||||
await deleteUsers();
|
await deleteUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getFormData = (object: Record<string, string | number | boolean>) =>
|
||||||
|
Object.keys(object).reduce((formData, key) => {
|
||||||
|
formData.append(key, String(object[key]));
|
||||||
|
return formData;
|
||||||
|
}, new FormData());
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
@ -100,9 +104,8 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: getFormData({
|
||||||
status: "Reply",
|
status: "Reply",
|
||||||
in_reply_to_id: timeline[0].id,
|
in_reply_to_id: timeline[0].id,
|
||||||
federate: false,
|
federate: false,
|
||||||
108
server/api/api/v1/accounts/:id/statuses.ts
Normal file
108
server/api/api/v1/accounts/:id/statuses.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Notes } from "~drizzle/schema";
|
||||||
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/statuses",
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
oauthPermissions: ["read:statuses"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
query: 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.number().int().min(1).max(40).optional().default(20),
|
||||||
|
only_media: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
exclude_replies: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
exclude_reblogs: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
pinned: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
tagged: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const {
|
||||||
|
max_id,
|
||||||
|
min_id,
|
||||||
|
since_id,
|
||||||
|
limit,
|
||||||
|
exclude_reblogs,
|
||||||
|
only_media,
|
||||||
|
exclude_replies,
|
||||||
|
pinned,
|
||||||
|
} = context.req.valid("query");
|
||||||
|
|
||||||
|
const { objects, link } = await Timeline.getNoteTimeline(
|
||||||
|
and(
|
||||||
|
max_id ? lt(Notes.id, max_id) : undefined,
|
||||||
|
since_id ? gte(Notes.id, since_id) : undefined,
|
||||||
|
min_id ? gt(Notes.id, min_id) : undefined,
|
||||||
|
eq(Notes.authorId, id),
|
||||||
|
only_media
|
||||||
|
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
|
||||||
|
: undefined,
|
||||||
|
pinned
|
||||||
|
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
|
||||||
|
: undefined,
|
||||||
|
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
|
||||||
|
exclude_replies ? isNull(Notes.replyId) : undefined,
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
context.req.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
await Promise.all(objects.map((note) => note.toAPI(otherUser))),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
66
server/api/api/v1/accounts/:id/unblock.ts
Normal file
66
server/api/api/v1/accounts/:id/unblock.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/unblock",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:blocks"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundRelationship.blocking) {
|
||||||
|
foundRelationship.blocking = false;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
blocking: false,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
67
server/api/api/v1/accounts/:id/unfollow.ts
Normal file
67
server/api/api/v1/accounts/:id/unfollow.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/unfollow",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:follows"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
self,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundRelationship.following) {
|
||||||
|
foundRelationship.following = false;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
following: false,
|
||||||
|
requested: false,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
68
server/api/api/v1/accounts/:id/unmute.ts
Normal file
68
server/api/api/v1/accounts/:id/unmute.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/unmute",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:mutes"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const user = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!user) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
self,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundRelationship.muting) {
|
||||||
|
foundRelationship.muting = false;
|
||||||
|
foundRelationship.mutingNotifications = false;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
muting: false,
|
||||||
|
mutingNotifications: false,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
66
server/api/api/v1/accounts/:id/unpin.ts
Normal file
66
server/api/api/v1/accounts/:id/unpin.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { relationshipToAPI } from "~database/entities/Relationship";
|
||||||
|
import { getRelationshipToOtherUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 30,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/accounts/:id/unpin",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:accounts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const otherUser = await User.fromId(id);
|
||||||
|
|
||||||
|
if (!otherUser) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
self,
|
||||||
|
otherUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundRelationship.endorsed) {
|
||||||
|
foundRelationship.endorsed = false;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
endorsed: false,
|
||||||
|
})
|
||||||
|
.where(eq(Relationships.id, foundRelationship.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/block",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:blocks"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blocks a user
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
|
|
||||||
|
|
||||||
if (!foundRelationship.blocking) {
|
|
||||||
foundRelationship.blocking = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
blocking: true,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import ISO6391 from "iso-639-1";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import {
|
|
||||||
followRequestUser,
|
|
||||||
getRelationshipToOtherUser,
|
|
||||||
} from "~database/entities/User";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/follow",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:follows"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schema = z.object({
|
|
||||||
reblogs: z.coerce.boolean().optional(),
|
|
||||||
notify: z.coerce.boolean().optional(),
|
|
||||||
languages: z
|
|
||||||
.array(z.enum(ISO6391.getAllCodes() as [string, ...string[]]))
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follow a user
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { languages, notify, reblogs } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
let relationship = await getRelationshipToOtherUser(self, otherUser);
|
|
||||||
|
|
||||||
if (!relationship.following) {
|
|
||||||
relationship = await followRequestUser(
|
|
||||||
self,
|
|
||||||
otherUser,
|
|
||||||
relationship.id,
|
|
||||||
reblogs,
|
|
||||||
notify,
|
|
||||||
languages,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(relationship));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Users } from "~drizzle/schema";
|
|
||||||
import { Timeline } from "~packages/database-interface/timeline";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["GET"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 60,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/followers",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["read:accounts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all statuses for a user
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getUserTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(objects.map((object) => object.toAPI())),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Users } from "~drizzle/schema";
|
|
||||||
import { Timeline } from "~packages/database-interface/timeline";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["GET"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 60,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/following",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["read:accounts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schema = z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all statuses for a user
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { max_id, min_id, since_id, limit } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getUserTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(objects.map((object) => object.toAPI())),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["GET"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a user
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
const foundUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!foundUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
return jsonResponse(foundUser.toAPI(user?.id === foundUser.id));
|
|
||||||
});
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/mute",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:mutes"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schema = z.object({
|
|
||||||
notifications: z.coerce.boolean().optional(),
|
|
||||||
duration: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(60)
|
|
||||||
.max(60 * 60 * 24 * 365 * 5)
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mute a user
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { notifications, duration } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const user = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!user) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, user);
|
|
||||||
|
|
||||||
if (!foundRelationship.muting) {
|
|
||||||
foundRelationship.muting = true;
|
|
||||||
}
|
|
||||||
if (notifications ?? true) {
|
|
||||||
foundRelationship.mutingNotifications = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
muting: true,
|
|
||||||
mutingNotifications: notifications ?? true,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
|
|
||||||
// TODO: Implement duration
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/note",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schema = z.object({
|
|
||||||
comment: z.string().min(0).max(5000).trim().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets a user note
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { comment } = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(
|
|
||||||
self,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
foundRelationship.note = comment ?? "";
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
note: foundRelationship.note,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/pin",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pin a user
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
|
|
||||||
|
|
||||||
if (!foundRelationship.endorsed) {
|
|
||||||
foundRelationship.endorsed = true;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
endorsed: true,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/remove_from_followers",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:follows"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes an account from your followers list
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
|
|
||||||
|
|
||||||
if (foundRelationship.followedBy) {
|
|
||||||
foundRelationship.followedBy = false;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
followedBy: false,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
|
|
||||||
if (otherUser.isLocal()) {
|
|
||||||
// Also remove from followers list
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
following: false,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Relationships.ownerId, otherUser.id),
|
|
||||||
eq(Relationships.subjectId, self.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Notes } from "~drizzle/schema";
|
|
||||||
import { Timeline } from "~packages/database-interface/timeline";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["GET"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/statuses",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["read:statuses"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schema = z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
|
||||||
only_media: z.coerce.boolean().optional(),
|
|
||||||
exclude_replies: z.coerce.boolean().optional(),
|
|
||||||
exclude_reblogs: z.coerce.boolean().optional(),
|
|
||||||
pinned: z.coerce.boolean().optional(),
|
|
||||||
tagged: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all statuses for a user
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
since_id,
|
|
||||||
limit,
|
|
||||||
exclude_reblogs,
|
|
||||||
only_media,
|
|
||||||
exclude_replies,
|
|
||||||
pinned,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const user = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!user) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getNoteTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Notes.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Notes.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Notes.id, min_id) : undefined,
|
|
||||||
eq(Notes.authorId, id),
|
|
||||||
only_media
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
|
|
||||||
: undefined,
|
|
||||||
pinned
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${user.id})`
|
|
||||||
: undefined,
|
|
||||||
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
|
|
||||||
exclude_replies ? isNull(Notes.replyId) : undefined,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
await Promise.all(objects.map((note) => note.toAPI(user))),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unblock",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:blocks"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
|
|
||||||
|
|
||||||
if (foundRelationship.blocking) {
|
|
||||||
foundRelationship.blocking = false;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
blocking: false,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unfollow",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:follows"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unfollows a user
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
|
|
||||||
|
|
||||||
if (foundRelationship.following) {
|
|
||||||
foundRelationship.following = false;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
following: false,
|
|
||||||
requested: false,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unmute",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:mutes"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmute a user
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const user = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!user) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, user);
|
|
||||||
|
|
||||||
if (foundRelationship.muting) {
|
|
||||||
foundRelationship.muting = false;
|
|
||||||
foundRelationship.mutingNotifications = false;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
muting: false,
|
|
||||||
mutingNotifications: false,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { relationshipToAPI } from "~database/entities/Relationship";
|
|
||||||
import { getRelationshipToOtherUser } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unpin",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpin a user
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) return errorResponse("User not found", 404);
|
|
||||||
|
|
||||||
// Check if already following
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(self, otherUser);
|
|
||||||
|
|
||||||
if (foundRelationship.endorsed) {
|
|
||||||
foundRelationship.endorsed = false;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
endorsed: false,
|
|
||||||
})
|
|
||||||
.where(eq(Relationships.id, foundRelationship.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { inArray } from "drizzle-orm";
|
import { inArray } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Users } from "~drizzle/schema";
|
import { Users } from "~drizzle/schema";
|
||||||
|
|
@ -19,63 +21,69 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
id: z.array(z.string().regex(idValidator)).min(1).max(10),
|
query: z.object({
|
||||||
});
|
"id[]": z.array(z.string().uuid()).min(1).max(10),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
export default (app: Hono) =>
|
||||||
* Find familiar followers (followers of a user that you also follow)
|
app.on(
|
||||||
*/
|
meta.allowedMethods,
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
meta.route,
|
||||||
async (req, matchedRoute, extraData) => {
|
zValidator("query", schemas.query, handleZodError),
|
||||||
const { user: self } = extraData.auth;
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
const { "id[]": ids } = context.req.valid("query");
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { id: ids } = extraData.parsedRequest;
|
const idFollowerRelationships =
|
||||||
|
await db.query.Relationships.findMany({
|
||||||
|
columns: {
|
||||||
|
ownerId: true,
|
||||||
|
},
|
||||||
|
where: (relationship, { inArray, and, eq }) =>
|
||||||
|
and(
|
||||||
|
inArray(relationship.subjectId, ids),
|
||||||
|
eq(relationship.following, true),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const idFollowerRelationships = await db.query.Relationships.findMany({
|
if (idFollowerRelationships.length === 0) {
|
||||||
columns: {
|
return jsonResponse([]);
|
||||||
ownerId: true,
|
}
|
||||||
},
|
|
||||||
where: (relationship, { inArray, and, eq }) =>
|
// Find users that you follow in idFollowerRelationships
|
||||||
and(
|
const relevantRelationships = await db.query.Relationships.findMany(
|
||||||
inArray(relationship.subjectId, ids),
|
{
|
||||||
eq(relationship.following, true),
|
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) {
|
||||||
|
return jsonResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUsers = await User.manyFromSql(
|
||||||
|
inArray(
|
||||||
|
Users.id,
|
||||||
|
relevantRelationships.map((r) => r.subjectId),
|
||||||
),
|
),
|
||||||
});
|
);
|
||||||
|
|
||||||
if (idFollowerRelationships.length === 0) {
|
return jsonResponse(finalUsers.map((o) => o.toAPI()));
|
||||||
return jsonResponse([]);
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
// Find users that you follow in idFollowerRelationships
|
|
||||||
const relevantRelationships = await db.query.Relationships.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) {
|
|
||||||
return jsonResponse([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalUsers = await User.manyFromSql(
|
|
||||||
inArray(
|
|
||||||
Users.id,
|
|
||||||
relevantRelationships.map((r) => r.subjectId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonResponse(finalUsers.map((o) => o.toAPI()));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { jsonResponse, response } from "@response";
|
import { jsonResponse, response } from "@response";
|
||||||
import { tempmailDomains } from "@tempmail";
|
import { tempmailDomains } from "@tempmail";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import ISO6391 from "iso-639-1";
|
import ISO6391 from "iso-639-1";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Users } from "~drizzle/schema";
|
import { Users } from "~drizzle/schema";
|
||||||
|
import { config } from "~packages/config-manager";
|
||||||
import { User } from "~packages/database-interface/user";
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -20,210 +23,217 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// No validation on the Zod side as we need to do custom validation
|
export const schemas = {
|
||||||
export const schema = z.object({
|
form: z.object({
|
||||||
username: z.string().toLowerCase(),
|
username: z.string(),
|
||||||
email: z.string().toLowerCase(),
|
email: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
agreement: z.boolean(),
|
agreement: z
|
||||||
locale: z.string(),
|
.string()
|
||||||
reason: z.string(),
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())),
|
||||||
});
|
locale: z.string(),
|
||||||
|
reason: z.string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
// TODO: Add Authorization check
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("form", schemas.form, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { username, email, password, agreement, locale, reason } =
|
||||||
|
context.req.valid("form");
|
||||||
|
|
||||||
const body = extraData.parsedRequest;
|
if (!config.signups.registration) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: "Registration is disabled",
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
const errors: {
|
||||||
|
details: Record<
|
||||||
if (!config.signups.registration) {
|
string,
|
||||||
return jsonResponse(
|
{
|
||||||
{
|
error:
|
||||||
error: "Registration is disabled",
|
| "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 (!parsedRequest[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 (!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({
|
username?.match(filter),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_INVALID",
|
||||||
|
description: "contains blocked words",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is too long
|
||||||
|
if ((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 ((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(username ?? ""))
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_RESERVED",
|
||||||
|
description: "is reserved",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if username is taken
|
||||||
|
if (await User.fromSql(eq(Users.username, username))) {
|
||||||
|
errors.details.username.push({
|
||||||
|
error: "ERR_TAKEN",
|
||||||
|
description: "is already taken",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is valid
|
||||||
|
if (
|
||||||
|
!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(email) ||
|
||||||
|
(config.validation.blacklist_tempmail &&
|
||||||
|
tempmailDomains.domains.includes(
|
||||||
|
(email ?? "").split("@")[1],
|
||||||
|
))
|
||||||
|
)
|
||||||
|
errors.details.email.push({
|
||||||
|
error: "ERR_BLOCKED",
|
||||||
|
description: "is from a blocked email provider",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if email is taken
|
||||||
|
if (await User.fromSql(eq(Users.email, email)))
|
||||||
|
errors.details.email.push({
|
||||||
|
error: "ERR_TAKEN",
|
||||||
|
description: "is already taken",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if agreement is accepted
|
||||||
|
if (!agreement)
|
||||||
|
errors.details.agreement.push({
|
||||||
|
error: "ERR_ACCEPTED",
|
||||||
|
description: "must be accepted",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!locale)
|
||||||
|
errors.details.locale.push({
|
||||||
error: "ERR_BLANK",
|
error: "ERR_BLANK",
|
||||||
description: `can't be blank`,
|
description: `can't be blank`,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is valid
|
if (!ISO6391.validate(locale ?? ""))
|
||||||
if (!body.username?.match(/^[a-z0-9_]+$/))
|
errors.details.locale.push({
|
||||||
errors.details.username.push({
|
error: "ERR_INVALID",
|
||||||
error: "ERR_INVALID",
|
description: "must be a valid ISO 639-1 code",
|
||||||
description:
|
});
|
||||||
"must only contain lowercase letters, numbers, and underscores",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if username doesnt match filters
|
// If any errors are present, return them
|
||||||
if (
|
if (
|
||||||
config.filters.username.some((filter) =>
|
Object.values(errors.details).some((value) => value.length > 0)
|
||||||
body.username?.match(filter),
|
) {
|
||||||
)
|
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
|
||||||
) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description: "contains blocked words",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is too long
|
const errorsText = Object.entries(errors.details)
|
||||||
if ((body.username?.length ?? 0) > config.validation.max_username_size)
|
.filter(([_, errors]) => errors.length > 0)
|
||||||
errors.details.username.push({
|
.map(
|
||||||
error: "ERR_TOO_LONG",
|
([name, errors]) =>
|
||||||
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
|
`${name} ${errors
|
||||||
});
|
.map((error) => error.description)
|
||||||
|
.join(", ")}`,
|
||||||
// Check if username is too short
|
)
|
||||||
if ((body.username?.length ?? 0) < 3)
|
.join(", ");
|
||||||
errors.details.username.push({
|
return jsonResponse(
|
||||||
error: "ERR_TOO_SHORT",
|
{
|
||||||
description: "is too short (minimum is 3 characters)",
|
error: `Validation failed: ${errorsText}`,
|
||||||
});
|
details: Object.fromEntries(
|
||||||
|
Object.entries(errors.details).filter(
|
||||||
// Check if username is reserved
|
([_, errors]) => errors.length > 0,
|
||||||
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 User.fromSql(eq(Users.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 email is taken
|
|
||||||
if (await User.fromSql(eq(Users.email, body.email)))
|
|
||||||
errors.details.email.push({
|
|
||||||
error: "ERR_TAKEN",
|
|
||||||
description: "is already taken",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
.filter(([_, errors]) => errors.length > 0)
|
|
||||||
.map(
|
|
||||||
([name, errors]) =>
|
|
||||||
`${name} ${errors
|
|
||||||
.map((error) => error.description)
|
|
||||||
.join(", ")}`,
|
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
error: `Validation failed: ${errorsText}`,
|
|
||||||
details: Object.fromEntries(
|
|
||||||
Object.entries(errors.details).filter(
|
|
||||||
([_, errors]) => errors.length > 0,
|
|
||||||
),
|
),
|
||||||
),
|
},
|
||||||
},
|
422,
|
||||||
422,
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await User.fromDataLocal({
|
await User.fromDataLocal({
|
||||||
username: body.username ?? "",
|
username: username ?? "",
|
||||||
password: body.password ?? "",
|
password: password ?? "",
|
||||||
email: body.email ?? "",
|
email: email ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(null, 200);
|
return response(null, 200);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { dualLogger } from "@loggers";
|
import { dualLogger } from "@loggers";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
anyOf,
|
anyOf,
|
||||||
charIn,
|
charIn,
|
||||||
|
|
@ -32,73 +34,81 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
acct: z.string().min(1).max(512),
|
query: z.object({
|
||||||
});
|
acct: z.string().min(1).max(512),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { acct } = extraData.parsedRequest;
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { acct } = context.req.valid("query");
|
||||||
|
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
return errorResponse("Invalid acct parameter", 400);
|
return errorResponse("Invalid acct parameter", 400);
|
||||||
}
|
|
||||||
|
|
||||||
// Check if acct is matching format username@domain.com or @username@domain.com
|
|
||||||
const accountMatches = acct?.trim().match(
|
|
||||||
createRegExp(
|
|
||||||
maybe("@"),
|
|
||||||
oneOrMore(
|
|
||||||
anyOf(letter.lowercase, digit, charIn("-")),
|
|
||||||
).groupedAs("username"),
|
|
||||||
exactly("@"),
|
|
||||||
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
|
|
||||||
"domain",
|
|
||||||
),
|
|
||||||
|
|
||||||
[global],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accountMatches) {
|
|
||||||
// Remove leading @ if it exists
|
|
||||||
if (accountMatches[0].startsWith("@")) {
|
|
||||||
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(
|
||||||
|
maybe("@"),
|
||||||
|
oneOrMore(
|
||||||
|
anyOf(letter.lowercase, digit, charIn("-")),
|
||||||
|
).groupedAs("username"),
|
||||||
|
exactly("@"),
|
||||||
|
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
|
||||||
|
"domain",
|
||||||
|
),
|
||||||
|
|
||||||
|
[global],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accountMatches) {
|
||||||
|
// Remove leading @ if it exists
|
||||||
|
if (accountMatches[0].startsWith("@")) {
|
||||||
|
accountMatches[0] = accountMatches[0].slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [username, domain] = accountMatches[0].split("@");
|
||||||
|
const foundAccount = await resolveWebFinger(
|
||||||
|
username,
|
||||||
|
domain,
|
||||||
|
).catch((e) => {
|
||||||
dualLogger.logError(
|
dualLogger.logError(
|
||||||
LogLevel.ERROR,
|
LogLevel.ERROR,
|
||||||
"WebFinger.Resolve",
|
"WebFinger.Resolve",
|
||||||
e as Error,
|
e as Error,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (foundAccount) {
|
if (foundAccount) {
|
||||||
return jsonResponse(foundAccount.toAPI());
|
return jsonResponse(foundAccount.toAPI());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 User.fromSql(eq(Users.username, username));
|
||||||
if (username.startsWith("@")) {
|
|
||||||
username = username.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await User.fromSql(eq(Users.username, username));
|
if (account) {
|
||||||
|
return jsonResponse(account.toAPI());
|
||||||
|
}
|
||||||
|
|
||||||
if (account) {
|
return errorResponse(
|
||||||
return jsonResponse(account.toAPI());
|
`Account with username ${username} not found`,
|
||||||
}
|
404,
|
||||||
|
);
|
||||||
return errorResponse(
|
},
|
||||||
`Account with username ${username} not found`,
|
);
|
||||||
404,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
createNewRelationship,
|
createNewRelationship,
|
||||||
relationshipToAPI,
|
relationshipToAPI,
|
||||||
} from "~database/entities/Relationship";
|
} from "~database/entities/Relationship";
|
||||||
import type { UserType } from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -21,48 +23,48 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
id: z.array(z.string().regex(idValidator)).min(1).max(10),
|
query: z.object({
|
||||||
});
|
"id[]": z.array(z.string().uuid()).min(1).max(10),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
export default (app: Hono) =>
|
||||||
* Find relationships
|
app.on(
|
||||||
*/
|
meta.allowedMethods,
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
meta.route,
|
||||||
async (req, matchedRoute, extraData) => {
|
zValidator("query", schemas.query, handleZodError),
|
||||||
const { user: self } = extraData.auth;
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
const { "id[]": ids } = context.req.valid("query");
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { id: ids } = extraData.parsedRequest;
|
const relationships = await db.query.Relationships.findMany({
|
||||||
|
where: (relationship, { inArray, and, eq }) =>
|
||||||
|
and(
|
||||||
|
inArray(relationship.subjectId, ids),
|
||||||
|
eq(relationship.ownerId, self.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const relationships = await db.query.Relationships.findMany({
|
const missingIds = ids.filter(
|
||||||
where: (relationship, { inArray, and, eq }) =>
|
(id) => !relationships.some((r) => r.subjectId === id),
|
||||||
and(
|
);
|
||||||
inArray(relationship.subjectId, ids),
|
|
||||||
eq(relationship.ownerId, self.id),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find IDs that dont have a relationship
|
for (const id of missingIds) {
|
||||||
const missingIds = ids.filter(
|
const user = await User.fromId(id);
|
||||||
(id) => !relationships.some((r) => r.subjectId === id),
|
if (!user) continue;
|
||||||
);
|
const relationship = await createNewRelationship(self, user);
|
||||||
|
|
||||||
// Create the missing relationships
|
relationships.push(relationship);
|
||||||
for (const id of missingIds) {
|
}
|
||||||
const relationship = await createNewRelationship(self, {
|
|
||||||
id,
|
|
||||||
} as UserType);
|
|
||||||
|
|
||||||
relationships.push(relationship);
|
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)));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq, like, not, or, sql } from "drizzle-orm";
|
import { eq, like, not, or, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
anyOf,
|
anyOf,
|
||||||
charIn,
|
charIn,
|
||||||
|
|
@ -31,87 +33,90 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
q: z
|
query: z.object({
|
||||||
.string()
|
q: z
|
||||||
.min(1)
|
.string()
|
||||||
.max(512)
|
.min(1)
|
||||||
.regex(
|
.max(512)
|
||||||
createRegExp(
|
.regex(
|
||||||
maybe("@"),
|
createRegExp(
|
||||||
oneOrMore(
|
maybe("@"),
|
||||||
anyOf(letter.lowercase, digit, charIn("-")),
|
oneOrMore(
|
||||||
).groupedAs("username"),
|
anyOf(letter.lowercase, digit, charIn("-")),
|
||||||
maybe(
|
).groupedAs("username"),
|
||||||
exactly("@"),
|
maybe(
|
||||||
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
|
exactly("@"),
|
||||||
"domain",
|
oneOrMore(
|
||||||
|
anyOf(letter, digit, charIn("_-.:")),
|
||||||
|
).groupedAs("domain"),
|
||||||
),
|
),
|
||||||
|
[global],
|
||||||
),
|
),
|
||||||
[global],
|
|
||||||
),
|
),
|
||||||
),
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
offset: z.coerce.number().int().optional(),
|
||||||
offset: z.coerce.number().int().optional(),
|
resolve: z
|
||||||
resolve: z.coerce.boolean().optional(),
|
.string()
|
||||||
following: z.coerce.boolean().optional(),
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
});
|
.optional(),
|
||||||
|
following: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
// TODO: Add checks for disabled or not email verified accounts
|
meta.allowedMethods,
|
||||||
const {
|
meta.route,
|
||||||
following = false,
|
zValidator("query", schemas.query, handleZodError),
|
||||||
limit,
|
auth(meta.auth),
|
||||||
offset,
|
async (context) => {
|
||||||
resolve,
|
const { q, limit, offset, resolve, following } =
|
||||||
q,
|
context.req.valid("query");
|
||||||
} = extraData.parsedRequest;
|
const { user: self } = context.req.valid("header");
|
||||||
|
|
||||||
const { user: self } = extraData.auth;
|
if (!self && following) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
if (!self && following) return errorResponse("Unauthorized", 401);
|
const [username, host] = q.replace(/^@/, "").split("@");
|
||||||
|
|
||||||
// Remove any leading @
|
const accounts: User[] = [];
|
||||||
const [username, host] = q.replace(/^@/, "").split("@");
|
|
||||||
|
|
||||||
const accounts: User[] = [];
|
if (resolve && username && host) {
|
||||||
|
const resolvedUser = await resolveWebFinger(username, host);
|
||||||
|
|
||||||
if (resolve && username && host) {
|
if (resolvedUser) {
|
||||||
const resolvedUser = await resolveWebFinger(username, host);
|
accounts.push(resolvedUser);
|
||||||
|
}
|
||||||
if (resolvedUser) {
|
} else {
|
||||||
accounts.push(resolvedUser);
|
accounts.push(
|
||||||
|
...(await User.manyFromSql(
|
||||||
|
or(
|
||||||
|
like(Users.displayName, `%${q}%`),
|
||||||
|
like(Users.username, `%${q}%`),
|
||||||
|
following && self
|
||||||
|
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)`
|
||||||
|
: undefined,
|
||||||
|
self ? not(eq(Users.id, self.id)) : undefined,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
accounts.push(
|
|
||||||
...(await User.manyFromSql(
|
|
||||||
or(
|
|
||||||
like(Users.displayName, `%${q}%`),
|
|
||||||
like(Users.username, `%${q}%`),
|
|
||||||
following && self
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)`
|
|
||||||
: undefined,
|
|
||||||
self ? not(eq(Users.id, self.id)) : undefined,
|
|
||||||
),
|
|
||||||
undefined,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort accounts by closest match
|
const indexOfCorrectSort = stringComparison.jaccardIndex
|
||||||
// Returns array of numbers (indexes of accounts array)
|
.sortMatch(
|
||||||
const indexOfCorrectSort = stringComparison.jaccardIndex
|
q,
|
||||||
.sortMatch(
|
accounts.map((acct) => acct.getAcct()),
|
||||||
q,
|
)
|
||||||
accounts.map((acct) => acct.getAcct()),
|
.map((sort) => sort.index);
|
||||||
)
|
|
||||||
.map((sort) => sort.index);
|
|
||||||
|
|
||||||
const result = indexOfCorrectSort.map((index) => accounts[index]);
|
const result = indexOfCorrectSort.map((index) => accounts[index]);
|
||||||
|
|
||||||
return jsonResponse(result.map((acct) => acct.toAPI()));
|
return jsonResponse(result.map((acct) => acct.toAPI()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { sanitizeHtml, sanitizedHtmlStrip } from "@sanitization";
|
import { sanitizeHtml, sanitizedHtmlStrip } from "@sanitization";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import ISO6391 from "iso-639-1";
|
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";
|
||||||
|
|
@ -28,274 +30,295 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
display_name: z
|
form: z.object({
|
||||||
.string()
|
display_name: z
|
||||||
.min(3)
|
.string()
|
||||||
.trim()
|
.min(3)
|
||||||
.max(config.validation.max_displayname_size)
|
.trim()
|
||||||
.optional(),
|
.max(config.validation.max_displayname_size)
|
||||||
note: z
|
.optional(),
|
||||||
.string()
|
note: z
|
||||||
.min(0)
|
.string()
|
||||||
.max(config.validation.max_bio_size)
|
.min(0)
|
||||||
.trim()
|
.max(config.validation.max_bio_size)
|
||||||
.optional(),
|
.trim()
|
||||||
avatar: z.instanceof(File).optional(),
|
.optional(),
|
||||||
header: z.instanceof(File).optional(),
|
avatar: z.instanceof(File).optional(),
|
||||||
locked: z.boolean().optional(),
|
header: z.instanceof(File).optional(),
|
||||||
bot: z.boolean().optional(),
|
locked: z
|
||||||
discoverable: z.boolean().optional(),
|
.string()
|
||||||
source: z
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
.object({
|
.optional(),
|
||||||
privacy: z
|
bot: z
|
||||||
.enum(["public", "unlisted", "private", "direct"])
|
.string()
|
||||||
.optional(),
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
sensitive: z.boolean().optional(),
|
.optional(),
|
||||||
language: z
|
discoverable: z
|
||||||
.enum(ISO6391.getAllCodes() as [string, ...string[]])
|
.string()
|
||||||
.optional(),
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
})
|
.optional(),
|
||||||
.optional(),
|
source: z
|
||||||
fields_attributes: z
|
.object({
|
||||||
.array(
|
privacy: z
|
||||||
z.object({
|
.enum(["public", "unlisted", "private", "direct"])
|
||||||
name: z
|
.optional(),
|
||||||
|
sensitive: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.transform((v) =>
|
||||||
.max(config.validation.max_field_name_size),
|
["true", "1", "on"].includes(v.toLowerCase()),
|
||||||
value: z
|
)
|
||||||
.string()
|
.optional(),
|
||||||
.trim()
|
language: z
|
||||||
.max(config.validation.max_field_value_size),
|
.enum(ISO6391.getAllCodes() as [string, ...string[]])
|
||||||
}),
|
.optional(),
|
||||||
)
|
|
||||||
.max(config.validation.max_field_count)
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
|
||||||
const self = user.getUser();
|
|
||||||
|
|
||||||
const {
|
|
||||||
display_name,
|
|
||||||
note,
|
|
||||||
avatar,
|
|
||||||
header,
|
|
||||||
locked,
|
|
||||||
bot,
|
|
||||||
discoverable,
|
|
||||||
source,
|
|
||||||
fields_attributes,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const sanitizedNote = await sanitizeHtml(note ?? "");
|
|
||||||
|
|
||||||
const sanitizedDisplayName = await sanitizedHtmlStrip(
|
|
||||||
display_name ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
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 (display_name) {
|
|
||||||
// Check if display name doesnt match filters
|
|
||||||
if (
|
|
||||||
config.filters.displayname.some((filter) =>
|
|
||||||
sanitizedDisplayName.match(filter),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return errorResponse(
|
|
||||||
"Display name contains blocked words",
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.displayName = sanitizedDisplayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note && self.source) {
|
|
||||||
// Check if bio doesnt match filters
|
|
||||||
if (
|
|
||||||
config.filters.bio.some((filter) => sanitizedNote.match(filter))
|
|
||||||
) {
|
|
||||||
return errorResponse("Bio contains blocked words", 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.source.note = sanitizedNote;
|
|
||||||
self.note = await contentToHtml({
|
|
||||||
"text/markdown": {
|
|
||||||
content: sanitizedNote,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source?.privacy) {
|
|
||||||
self.source.privacy = source.privacy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source?.sensitive) {
|
|
||||||
self.source.sensitive = source.sensitive;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source?.language) {
|
|
||||||
self.source.language = source.language;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { path } = await mediaManager.addFile(avatar);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
self.header = getUrl(path, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locked) {
|
|
||||||
self.isLocked = locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bot) {
|
|
||||||
self.isBot = bot;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discoverable) {
|
|
||||||
self.isDiscoverable = discoverable;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldEmojis: EmojiWithInstance[] = [];
|
|
||||||
|
|
||||||
if (fields_attributes) {
|
|
||||||
self.fields = [];
|
|
||||||
self.source.fields = [];
|
|
||||||
for (const field of fields_attributes) {
|
|
||||||
// Can be Markdown or plaintext, also has emojis
|
|
||||||
const parsedName = await contentToHtml({
|
|
||||||
"text/markdown": {
|
|
||||||
content: field.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedValue = await contentToHtml({
|
|
||||||
"text/markdown": {
|
|
||||||
content: field.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse emojis
|
|
||||||
const nameEmojis = await parseEmojis(parsedName);
|
|
||||||
const valueEmojis = await parseEmojis(parsedValue);
|
|
||||||
|
|
||||||
fieldEmojis.push(...nameEmojis, ...valueEmojis);
|
|
||||||
|
|
||||||
// Replace fields
|
|
||||||
self.fields.push({
|
|
||||||
key: {
|
|
||||||
"text/html": {
|
|
||||||
content: parsedName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
"text/html": {
|
|
||||||
content: parsedValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
self.source.fields.push({
|
|
||||||
name: field.name,
|
|
||||||
value: field.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse emojis
|
|
||||||
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
|
|
||||||
const noteEmojis = await parseEmojis(sanitizedNote);
|
|
||||||
|
|
||||||
self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis];
|
|
||||||
|
|
||||||
// Deduplicate emojis
|
|
||||||
self.emojis = self.emojis.filter(
|
|
||||||
(emoji, index, self) =>
|
|
||||||
self.findIndex((e) => e.id === emoji.id) === index,
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Users)
|
|
||||||
.set({
|
|
||||||
displayName: self.displayName,
|
|
||||||
note: self.note,
|
|
||||||
avatar: self.avatar,
|
|
||||||
header: self.header,
|
|
||||||
fields: self.fields,
|
|
||||||
isLocked: self.isLocked,
|
|
||||||
isBot: self.isBot,
|
|
||||||
isDiscoverable: self.isDiscoverable,
|
|
||||||
source: self.source || undefined,
|
|
||||||
})
|
})
|
||||||
.where(eq(Users.id, self.id));
|
.optional(),
|
||||||
|
fields_attributes: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(config.validation.max_field_name_size),
|
||||||
|
value: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(config.validation.max_field_value_size),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(config.validation.max_field_count)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// Connect emojis, if any
|
export default (app: Hono) =>
|
||||||
for (const emoji of self.emojis) {
|
app.on(
|
||||||
await db
|
meta.allowedMethods,
|
||||||
.delete(EmojiToUser)
|
meta.route,
|
||||||
.where(
|
zValidator("form", schemas.form, handleZodError),
|
||||||
and(
|
auth(meta.auth),
|
||||||
eq(EmojiToUser.emojiId, emoji.id),
|
async (context) => {
|
||||||
eq(EmojiToUser.userId, self.id),
|
const { user } = context.req.valid("header");
|
||||||
),
|
const {
|
||||||
)
|
display_name,
|
||||||
.execute();
|
note,
|
||||||
|
avatar,
|
||||||
|
header,
|
||||||
|
locked,
|
||||||
|
bot,
|
||||||
|
discoverable,
|
||||||
|
source,
|
||||||
|
fields_attributes,
|
||||||
|
} = context.req.valid("form");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const self = user.getUser();
|
||||||
|
|
||||||
|
const sanitizedNote = await sanitizeHtml(note ?? "");
|
||||||
|
|
||||||
|
const sanitizedDisplayName = await sanitizedHtmlStrip(
|
||||||
|
display_name ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (display_name) {
|
||||||
|
// Check if display name doesnt match filters
|
||||||
|
if (
|
||||||
|
config.filters.displayname.some((filter) =>
|
||||||
|
sanitizedDisplayName.match(filter),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return errorResponse(
|
||||||
|
"Display name contains blocked words",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.displayName = sanitizedDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note && self.source) {
|
||||||
|
// Check if bio doesnt match filters
|
||||||
|
if (
|
||||||
|
config.filters.bio.some((filter) =>
|
||||||
|
sanitizedNote.match(filter),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return errorResponse("Bio contains blocked words", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.source.note = sanitizedNote;
|
||||||
|
self.note = await contentToHtml({
|
||||||
|
"text/markdown": {
|
||||||
|
content: sanitizedNote,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source?.privacy) {
|
||||||
|
self.source.privacy = source.privacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source?.sensitive) {
|
||||||
|
self.source.sensitive = source.sensitive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source?.language) {
|
||||||
|
self.source.language = source.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = await mediaManager.addFile(avatar);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
self.header = getUrl(path, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
self.isLocked = locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bot) {
|
||||||
|
self.isBot = bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discoverable) {
|
||||||
|
self.isDiscoverable = discoverable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldEmojis: EmojiWithInstance[] = [];
|
||||||
|
|
||||||
|
if (fields_attributes) {
|
||||||
|
self.fields = [];
|
||||||
|
self.source.fields = [];
|
||||||
|
for (const field of fields_attributes) {
|
||||||
|
// Can be Markdown or plaintext, also has emojis
|
||||||
|
const parsedName = await contentToHtml({
|
||||||
|
"text/markdown": {
|
||||||
|
content: field.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedValue = await contentToHtml({
|
||||||
|
"text/markdown": {
|
||||||
|
content: field.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse emojis
|
||||||
|
const nameEmojis = await parseEmojis(parsedName);
|
||||||
|
const valueEmojis = await parseEmojis(parsedValue);
|
||||||
|
|
||||||
|
fieldEmojis.push(...nameEmojis, ...valueEmojis);
|
||||||
|
|
||||||
|
// Replace fields
|
||||||
|
self.fields.push({
|
||||||
|
key: {
|
||||||
|
"text/html": {
|
||||||
|
content: parsedName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
"text/html": {
|
||||||
|
content: parsedValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
self.source.fields.push({
|
||||||
|
name: field.name,
|
||||||
|
value: field.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse emojis
|
||||||
|
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
|
||||||
|
const noteEmojis = await parseEmojis(sanitizedNote);
|
||||||
|
|
||||||
|
self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis];
|
||||||
|
|
||||||
|
// Deduplicate emojis
|
||||||
|
self.emojis = self.emojis.filter(
|
||||||
|
(emoji, index, self) =>
|
||||||
|
self.findIndex((e) => e.id === emoji.id) === index,
|
||||||
|
);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(EmojiToUser)
|
.update(Users)
|
||||||
.values({
|
.set({
|
||||||
emojiId: emoji.id,
|
displayName: self.displayName,
|
||||||
userId: self.id,
|
note: self.note,
|
||||||
|
avatar: self.avatar,
|
||||||
|
header: self.header,
|
||||||
|
fields: self.fields,
|
||||||
|
isLocked: self.isLocked,
|
||||||
|
isBot: self.isBot,
|
||||||
|
isDiscoverable: self.isDiscoverable,
|
||||||
|
source: self.source || undefined,
|
||||||
})
|
})
|
||||||
.execute();
|
.where(eq(Users.id, self.id));
|
||||||
}
|
|
||||||
|
|
||||||
const output = await User.fromId(self.id);
|
// Connect emojis, if any
|
||||||
if (!output) return errorResponse("Couldn't edit user", 500);
|
for (const emoji of self.emojis) {
|
||||||
|
await db
|
||||||
|
.delete(EmojiToUser)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(EmojiToUser.emojiId, emoji.id),
|
||||||
|
eq(EmojiToUser.userId, self.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
return jsonResponse(output.toAPI());
|
await db
|
||||||
},
|
.insert(EmojiToUser)
|
||||||
);
|
.values({
|
||||||
|
emojiId: emoji.id,
|
||||||
|
userId: self.id,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await User.fromId(self.id);
|
||||||
|
if (!output) return errorResponse("Couldn't edit user", 500);
|
||||||
|
|
||||||
|
return jsonResponse(output.toAPI());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -14,12 +15,17 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute((req, matchedRoute, extraData) => {
|
export default (app: Hono) =>
|
||||||
// TODO: Add checks for disabled or not email verified accounts
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
// TODO: Add checks for disabled/unverified accounts
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
return jsonResponse(user.toAPI(true));
|
||||||
|
},
|
||||||
return jsonResponse(user.toAPI(true));
|
);
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { jsonResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Applications } from "~drizzle/schema";
|
import { Applications } from "~drizzle/schema";
|
||||||
|
|
@ -17,43 +19,46 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
client_name: z.string().trim().min(1).max(100),
|
form: z.object({
|
||||||
redirect_uris: z.string().min(0).max(2000).url(),
|
client_name: z.string().trim().min(1).max(100),
|
||||||
scopes: z.string().min(1).max(200),
|
redirect_uris: z.string().min(0).max(2000).url(),
|
||||||
website: z.string().min(0).max(2000).url().optional(),
|
scopes: z.string().min(1).max(200),
|
||||||
});
|
website: z.string().min(0).max(2000).url().optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
export default (app: Hono) =>
|
||||||
* Creates a new application to obtain OAuth 2 credentials
|
app.on(
|
||||||
*/
|
meta.allowedMethods,
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
meta.route,
|
||||||
async (req, matchedRoute, extraData) => {
|
zValidator("form", schemas.form, handleZodError),
|
||||||
const { client_name, redirect_uris, scopes, website } =
|
async (context) => {
|
||||||
extraData.parsedRequest;
|
const { client_name, redirect_uris, scopes, website } =
|
||||||
|
context.req.valid("form");
|
||||||
|
|
||||||
const app = (
|
const app = (
|
||||||
await db
|
await db
|
||||||
.insert(Applications)
|
.insert(Applications)
|
||||||
.values({
|
.values({
|
||||||
name: client_name || "",
|
name: client_name || "",
|
||||||
redirectUri: decodeURIComponent(redirect_uris) || "",
|
redirectUri: decodeURIComponent(redirect_uris) || "",
|
||||||
scopes: scopes || "read",
|
scopes: scopes || "read",
|
||||||
website: website || null,
|
website: website || null,
|
||||||
clientId: randomBytes(32).toString("base64url"),
|
clientId: randomBytes(32).toString("base64url"),
|
||||||
secret: randomBytes(64).toString("base64url"),
|
secret: randomBytes(64).toString("base64url"),
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
id: app.id,
|
id: app.id,
|
||||||
name: app.name,
|
name: app.name,
|
||||||
website: app.website,
|
website: app.website,
|
||||||
client_id: app.clientId,
|
client_id: app.clientId,
|
||||||
client_secret: app.secret,
|
client_secret: app.secret,
|
||||||
redirect_uri: app.redirectUri,
|
redirect_uri: app.redirectUri,
|
||||||
vapid_link: app.vapidKey,
|
vapid_link: app.vapidKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { getFromToken } from "~database/entities/Application";
|
import { getFromToken } from "~database/entities/Application";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -14,24 +15,27 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export default (app: Hono) =>
|
||||||
* Returns OAuth2 credentials
|
app.on(
|
||||||
*/
|
meta.allowedMethods,
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
meta.route,
|
||||||
const { user, token } = extraData.auth;
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user, token } = context.req.valid("header");
|
||||||
|
|
||||||
if (!token) return errorResponse("Unauthorized", 401);
|
if (!token) return errorResponse("Unauthorized", 401);
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const application = await getFromToken(token);
|
const application = await getFromToken(token);
|
||||||
|
|
||||||
if (!application) return errorResponse("Unauthorized", 401);
|
if (!application) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
name: application.name,
|
name: application.name,
|
||||||
website: application.website,
|
website: application.website,
|
||||||
vapid_key: application.vapidKey,
|
vapid_key: application.vapidKey,
|
||||||
redirect_uris: application.redirectUri,
|
redirect_uris: application.redirectUri,
|
||||||
scopes: application.scopes,
|
scopes: application.scopes,
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Users } from "~drizzle/schema";
|
import { Users } from "~drizzle/schema";
|
||||||
import { Timeline } from "~packages/database-interface/timeline";
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
|
@ -18,38 +20,46 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
query: z.object({
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
});
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { user } = extraData.auth;
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { max_id, since_id, min_id, limit } =
|
||||||
|
context.req.valid("query");
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
const { max_id, since_id, min_id, limit } = extraData.parsedRequest;
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { objects: blocks, link } = await Timeline.getUserTimeline(
|
const { objects: blocks, link } = await Timeline.getUserTimeline(
|
||||||
and(
|
and(
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
max_id ? lt(Users.id, max_id) : undefined,
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
since_id ? gte(Users.id, since_id) : undefined,
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
min_id ? gt(Users.id, min_id) : undefined,
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
|
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
|
||||||
),
|
),
|
||||||
limit,
|
limit,
|
||||||
req.url,
|
context.req.url,
|
||||||
);
|
);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
blocks.map((u) => u.toAPI()),
|
blocks.map((u) => u.toAPI()),
|
||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
Link: link,
|
Link: link,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
import { jsonResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { emojiToAPI } from "~database/entities/Emoji";
|
import { emojiToAPI } from "~database/entities/Emoji";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
|
|
||||||
|
|
@ -15,15 +16,16 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute(async () => {
|
export default (app: Hono) =>
|
||||||
const emojis = await db.query.Emojis.findMany({
|
app.on(meta.allowedMethods, meta.route, async () => {
|
||||||
where: (emoji, { isNull }) => isNull(emoji.instanceId),
|
const emojis = await db.query.Emojis.findMany({
|
||||||
with: {
|
where: (emoji, { isNull }) => isNull(emoji.instanceId),
|
||||||
instance: true,
|
with: {
|
||||||
},
|
instance: true,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))),
|
await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Notes } from "~drizzle/schema";
|
import { Notes } from "~drizzle/schema";
|
||||||
import { Timeline } from "~packages/database-interface/timeline";
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
|
@ -17,38 +19,49 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
query: z.object({
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
});
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { user } = extraData.auth;
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { max_id, since_id, min_id, limit } =
|
||||||
|
context.req.valid("query");
|
||||||
|
|
||||||
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getNoteTimeline(
|
const { objects: favourites, link } =
|
||||||
and(
|
await Timeline.getNoteTimeline(
|
||||||
max_id ? lt(Notes.id, max_id) : undefined,
|
and(
|
||||||
since_id ? gte(Notes.id, since_id) : undefined,
|
max_id ? lt(Notes.id, max_id) : undefined,
|
||||||
min_id ? gt(Notes.id, min_id) : undefined,
|
since_id ? gte(Notes.id, since_id) : undefined,
|
||||||
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
|
min_id ? gt(Notes.id, min_id) : undefined,
|
||||||
),
|
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
|
||||||
limit,
|
),
|
||||||
req.url,
|
limit,
|
||||||
);
|
context.req.url,
|
||||||
|
);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
await Promise.all(objects.map(async (note) => note.toAPI(user))),
|
await Promise.all(
|
||||||
200,
|
favourites.map(async (note) => note.toAPI(user)),
|
||||||
{
|
),
|
||||||
Link: link,
|
200,
|
||||||
},
|
{
|
||||||
);
|
Link: link,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
100
server/api/api/v1/follow_requests/:account_id/authorize.ts
Normal file
100
server/api/api/v1/follow_requests/:account_id/authorize.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
checkForBidirectionalRelationships,
|
||||||
|
relationshipToAPI,
|
||||||
|
} from "~database/entities/Relationship";
|
||||||
|
import {
|
||||||
|
getRelationshipToOtherUser,
|
||||||
|
sendFollowAccept,
|
||||||
|
} from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
route: "/api/v1/follow_requests/:account_id/authorize",
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
account_id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { account_id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const account = await User.fromId(account_id);
|
||||||
|
|
||||||
|
if (!account) return errorResponse("Account not found", 404);
|
||||||
|
|
||||||
|
// Check if there is a relationship on both sides
|
||||||
|
await checkForBidirectionalRelationships(user, account);
|
||||||
|
|
||||||
|
// Authorize follow request
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
requested: false,
|
||||||
|
following: true,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Relationships.subjectId, user.id),
|
||||||
|
eq(Relationships.ownerId, account.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update followedBy for other user
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
followedBy: true,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Relationships.subjectId, account.id),
|
||||||
|
eq(Relationships.ownerId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundRelationship)
|
||||||
|
return errorResponse("Relationship not found", 404);
|
||||||
|
|
||||||
|
// Check if accepting remote follow
|
||||||
|
if (account.isRemote()) {
|
||||||
|
// Federate follow accept
|
||||||
|
await sendFollowAccept(account, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
100
server/api/api/v1/follow_requests/:account_id/reject.ts
Normal file
100
server/api/api/v1/follow_requests/:account_id/reject.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
checkForBidirectionalRelationships,
|
||||||
|
relationshipToAPI,
|
||||||
|
} from "~database/entities/Relationship";
|
||||||
|
import {
|
||||||
|
getRelationshipToOtherUser,
|
||||||
|
sendFollowReject,
|
||||||
|
} from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Relationships } from "~drizzle/schema";
|
||||||
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
route: "/api/v1/follow_requests/:account_id/reject",
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
account_id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { account_id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const account = await User.fromId(account_id);
|
||||||
|
|
||||||
|
if (!account) return errorResponse("Account not found", 404);
|
||||||
|
|
||||||
|
// Check if there is a relationship on both sides
|
||||||
|
await checkForBidirectionalRelationships(user, account);
|
||||||
|
|
||||||
|
// Reject follow request
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
requested: false,
|
||||||
|
following: false,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Relationships.subjectId, user.id),
|
||||||
|
eq(Relationships.ownerId, account.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update followedBy for other user
|
||||||
|
await db
|
||||||
|
.update(Relationships)
|
||||||
|
.set({
|
||||||
|
followedBy: false,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Relationships.subjectId, account.id),
|
||||||
|
eq(Relationships.ownerId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const foundRelationship = await getRelationshipToOtherUser(
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundRelationship)
|
||||||
|
return errorResponse("Relationship not found", 404);
|
||||||
|
|
||||||
|
// Check if rejecting remote follow
|
||||||
|
if (account.isRemote()) {
|
||||||
|
// Federate follow reject
|
||||||
|
await sendFollowReject(account, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(relationshipToAPI(foundRelationship));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
checkForBidirectionalRelationships,
|
|
||||||
relationshipToAPI,
|
|
||||||
} from "~database/entities/Relationship";
|
|
||||||
import {
|
|
||||||
getRelationshipToOtherUser,
|
|
||||||
sendFollowAccept,
|
|
||||||
} from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
route: "/api/v1/follow_requests/:account_id/authorize",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { account_id } = matchedRoute.params;
|
|
||||||
|
|
||||||
const account = await User.fromId(account_id);
|
|
||||||
|
|
||||||
if (!account) return errorResponse("Account not found", 404);
|
|
||||||
|
|
||||||
// Check if there is a relationship on both sides
|
|
||||||
await checkForBidirectionalRelationships(user, account);
|
|
||||||
|
|
||||||
// Authorize follow request
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
requested: false,
|
|
||||||
following: true,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Relationships.subjectId, user.id),
|
|
||||||
eq(Relationships.ownerId, account.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update followedBy for other user
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
followedBy: true,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Relationships.subjectId, account.id),
|
|
||||||
eq(Relationships.ownerId, user.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(user, account);
|
|
||||||
|
|
||||||
if (!foundRelationship) return errorResponse("Relationship not found", 404);
|
|
||||||
|
|
||||||
// Check if accepting remote follow
|
|
||||||
if (account.isRemote()) {
|
|
||||||
// Federate follow accept
|
|
||||||
await sendFollowAccept(account, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
checkForBidirectionalRelationships,
|
|
||||||
relationshipToAPI,
|
|
||||||
} from "~database/entities/Relationship";
|
|
||||||
import {
|
|
||||||
getRelationshipToOtherUser,
|
|
||||||
sendFollowReject,
|
|
||||||
} from "~database/entities/User";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Relationships } from "~drizzle/schema";
|
|
||||||
import { User } from "~packages/database-interface/user";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
route: "/api/v1/follow_requests/:account_id/reject",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { account_id } = matchedRoute.params;
|
|
||||||
|
|
||||||
const account = await User.fromId(account_id);
|
|
||||||
|
|
||||||
if (!account) return errorResponse("Account not found", 404);
|
|
||||||
|
|
||||||
// Check if there is a relationship on both sides
|
|
||||||
await checkForBidirectionalRelationships(user, account);
|
|
||||||
|
|
||||||
// Reject follow request
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
requested: false,
|
|
||||||
following: false,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Relationships.subjectId, user.id),
|
|
||||||
eq(Relationships.ownerId, account.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update followedBy for other user
|
|
||||||
await db
|
|
||||||
.update(Relationships)
|
|
||||||
.set({
|
|
||||||
followedBy: false,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Relationships.subjectId, account.id),
|
|
||||||
eq(Relationships.ownerId, user.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const foundRelationship = await getRelationshipToOtherUser(user, account);
|
|
||||||
|
|
||||||
if (!foundRelationship) return errorResponse("Relationship not found", 404);
|
|
||||||
|
|
||||||
// Check if rejecting remote follow
|
|
||||||
if (account.isRemote()) {
|
|
||||||
// Federate follow reject
|
|
||||||
await sendFollowReject(account, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(foundRelationship));
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Users } from "~drizzle/schema";
|
import { Users } from "~drizzle/schema";
|
||||||
import { Timeline } from "~packages/database-interface/timeline";
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
|
@ -17,38 +19,47 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
query: z.object({
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(20),
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
});
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { user } = extraData.auth;
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { max_id, since_id, min_id, limit } =
|
||||||
|
context.req.valid("query");
|
||||||
|
|
||||||
const { limit, max_id, min_id, since_id } = extraData.parsedRequest;
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getUserTimeline(
|
const { objects: followRequests, link } =
|
||||||
and(
|
await Timeline.getUserTimeline(
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
and(
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
max_id ? lt(Users.id, max_id) : undefined,
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
since_id ? gte(Users.id, since_id) : undefined,
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
|
min_id ? gt(Users.id, min_id) : undefined,
|
||||||
),
|
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
|
||||||
limit,
|
),
|
||||||
req.url,
|
limit,
|
||||||
);
|
context.req.url,
|
||||||
|
);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
objects.map((user) => user.toAPI()),
|
followRequests.map((u) => u.toAPI()),
|
||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
Link: link,
|
Link: link,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { dualLogger } from "@loggers";
|
import { dualLogger } from "@loggers";
|
||||||
import { jsonResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { getMarkdownRenderer } from "~database/entities/Status";
|
import { getMarkdownRenderer } from "~database/entities/Status";
|
||||||
|
import { config } from "~packages/config-manager";
|
||||||
import { LogLevel } from "~packages/log-manager";
|
import { LogLevel } from "~packages/log-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -16,32 +18,31 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default (app: Hono) =>
|
||||||
const config = await extraData.configManager.getConfig();
|
app.on(meta.allowedMethods, meta.route, auth(meta.auth), async () => {
|
||||||
|
let extended_description = (await getMarkdownRenderer()).render(
|
||||||
|
"This is a [Lysand](https://lysand.org) server with the default extended description.",
|
||||||
|
);
|
||||||
|
let lastModified = new Date(2024, 0, 0);
|
||||||
|
|
||||||
let extended_description = (await getMarkdownRenderer()).render(
|
const extended_description_file = Bun.file(
|
||||||
"This is a [Lysand](https://lysand.org) server with the default extended description.",
|
config.instance.extended_description_path,
|
||||||
);
|
);
|
||||||
let lastModified = new Date(2024, 0, 0);
|
|
||||||
|
|
||||||
const extended_description_file = Bun.file(
|
if (await extended_description_file.exists()) {
|
||||||
config.instance.extended_description_path,
|
extended_description =
|
||||||
);
|
(await getMarkdownRenderer()).render(
|
||||||
|
(await extended_description_file.text().catch(async (e) => {
|
||||||
|
await dualLogger.logError(LogLevel.ERROR, "Routes", e);
|
||||||
|
return "";
|
||||||
|
})) ||
|
||||||
|
"This is a [Lysand](https://lysand.org) server with the default extended description.",
|
||||||
|
) || "";
|
||||||
|
lastModified = new Date(extended_description_file.lastModified);
|
||||||
|
}
|
||||||
|
|
||||||
if (await extended_description_file.exists()) {
|
return jsonResponse({
|
||||||
extended_description =
|
updated_at: lastModified.toISOString(),
|
||||||
(await getMarkdownRenderer()).render(
|
content: extended_description,
|
||||||
(await extended_description_file.text().catch(async (e) => {
|
});
|
||||||
await dualLogger.logError(LogLevel.ERROR, "Routes", e);
|
|
||||||
return "";
|
|
||||||
})) ||
|
|
||||||
"This is a [Lysand](https://lysand.org) server with the default extended description.",
|
|
||||||
) || "";
|
|
||||||
lastModified = new Date(extended_description_file.lastModified);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({
|
|
||||||
updated_at: lastModified.toISOString(),
|
|
||||||
content: extended_description,
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { jsonResponse, proxyUrl } from "@response";
|
import { jsonResponse, proxyUrl } from "@response";
|
||||||
import { and, count, countDistinct, eq, gte, isNull, sql } from "drizzle-orm";
|
import { and, count, eq, isNull } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Instances, Notes, Users } from "~drizzle/schema";
|
import { Instances, Users } from "~drizzle/schema";
|
||||||
import manifest from "~package.json";
|
import manifest from "~package.json";
|
||||||
|
import { config } from "~packages/config-manager";
|
||||||
|
import { Note } from "~packages/database-interface/note";
|
||||||
import { User } from "~packages/database-interface/user";
|
import { User } from "~packages/database-interface/user";
|
||||||
import type { Instance as APIInstance } from "~types/mastodon/instance";
|
import type { Instance as APIInstance } from "~types/mastodon/instance";
|
||||||
|
|
||||||
|
|
@ -19,177 +22,145 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default (app: Hono) =>
|
||||||
const config = await extraData.configManager.getConfig();
|
app.on(meta.allowedMethods, meta.route, auth(meta.auth), async () => {
|
||||||
|
// Get software version from package.json
|
||||||
|
const version = manifest.version;
|
||||||
|
|
||||||
// Get software version from package.json
|
const statusCount = await Note.getCount();
|
||||||
const version = manifest.version;
|
|
||||||
|
|
||||||
const statusCount = (
|
const userCount = await User.getCount();
|
||||||
await db
|
|
||||||
.select({
|
|
||||||
count: count(),
|
|
||||||
})
|
|
||||||
.from(Notes)
|
|
||||||
.where(
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`,
|
|
||||||
)
|
|
||||||
)[0].count;
|
|
||||||
|
|
||||||
const userCount = (
|
const contactAccount = await User.fromSql(
|
||||||
await db
|
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
||||||
.select({
|
);
|
||||||
count: count(),
|
|
||||||
})
|
|
||||||
.from(Users)
|
|
||||||
.where(isNull(Users.instanceId))
|
|
||||||
)[0].count;
|
|
||||||
|
|
||||||
const contactAccount = await User.fromSql(
|
const monthlyActiveUsers = await User.getActiveInPeriod(
|
||||||
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
30 * 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const monthlyActiveUsers = (
|
const knownDomainsCount = (
|
||||||
await db
|
await db
|
||||||
.select({
|
.select({
|
||||||
count: countDistinct(Users),
|
count: count(),
|
||||||
})
|
})
|
||||||
.from(Users)
|
.from(Instances)
|
||||||
.leftJoin(Notes, eq(Users.id, Notes.authorId))
|
)[0].count;
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNull(Users.instanceId),
|
|
||||||
gte(
|
|
||||||
Notes.createdAt,
|
|
||||||
new Date(
|
|
||||||
Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
||||||
).toISOString(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)[0].count;
|
|
||||||
|
|
||||||
const knownDomainsCount = (
|
// TODO: fill in more values
|
||||||
await db
|
return jsonResponse({
|
||||||
.select({
|
approval_required: false,
|
||||||
count: count(),
|
configuration: {
|
||||||
})
|
polls: {
|
||||||
.from(Instances)
|
max_characters_per_option:
|
||||||
)[0].count;
|
config.validation.max_poll_option_size,
|
||||||
|
max_expiration: config.validation.max_poll_duration,
|
||||||
// TODO: fill in more values
|
max_options: config.validation.max_poll_options,
|
||||||
return jsonResponse({
|
min_expiration: config.validation.min_poll_duration,
|
||||||
approval_required: false,
|
|
||||||
configuration: {
|
|
||||||
polls: {
|
|
||||||
max_characters_per_option:
|
|
||||||
config.validation.max_poll_option_size,
|
|
||||||
max_expiration: config.validation.max_poll_duration,
|
|
||||||
max_options: config.validation.max_poll_options,
|
|
||||||
min_expiration: 60,
|
|
||||||
},
|
|
||||||
statuses: {
|
|
||||||
characters_reserved_per_url: 0,
|
|
||||||
max_characters: config.validation.max_note_size,
|
|
||||||
max_media_attachments: config.validation.max_media_attachments,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
description: "A test instance",
|
|
||||||
email: "",
|
|
||||||
invites_enabled: false,
|
|
||||||
registrations: config.signups.registration,
|
|
||||||
languages: ["en"],
|
|
||||||
rules: config.signups.rules.map((r, index) => ({
|
|
||||||
id: String(index),
|
|
||||||
text: r,
|
|
||||||
})),
|
|
||||||
stats: {
|
|
||||||
domain_count: knownDomainsCount,
|
|
||||||
status_count: statusCount,
|
|
||||||
user_count: userCount,
|
|
||||||
},
|
|
||||||
thumbnail: proxyUrl(config.instance.logo),
|
|
||||||
banner: proxyUrl(config.instance.banner) ?? "",
|
|
||||||
title: config.instance.name,
|
|
||||||
uri: config.http.base_url,
|
|
||||||
urls: {
|
|
||||||
streaming_api: "",
|
|
||||||
},
|
|
||||||
version: "4.3.0-alpha.3+glitch",
|
|
||||||
lysand_version: version,
|
|
||||||
pleroma: {
|
|
||||||
metadata: {
|
|
||||||
account_activation_required: false,
|
|
||||||
features: [
|
|
||||||
"pleroma_api",
|
|
||||||
"akkoma_api",
|
|
||||||
"mastodon_api",
|
|
||||||
// "mastodon_api_streaming",
|
|
||||||
// "polls",
|
|
||||||
// "v2_suggestions",
|
|
||||||
// "pleroma_explicit_addressing",
|
|
||||||
// "shareable_emoji_packs",
|
|
||||||
// "multifetch",
|
|
||||||
// "pleroma:api/v1/notifications:include_types_filter",
|
|
||||||
"quote_posting",
|
|
||||||
"editing",
|
|
||||||
// "bubble_timeline",
|
|
||||||
// "relay",
|
|
||||||
// "pleroma_emoji_reactions",
|
|
||||||
// "exposable_reactions",
|
|
||||||
// "profile_directory",
|
|
||||||
"custom_emoji_reactions",
|
|
||||||
// "pleroma:get:main/ostatus",
|
|
||||||
],
|
|
||||||
federation: {
|
|
||||||
enabled: true,
|
|
||||||
exclusions: false,
|
|
||||||
mrf_policies: [],
|
|
||||||
mrf_simple: {
|
|
||||||
accept: [],
|
|
||||||
avatar_removal: [],
|
|
||||||
background_removal: [],
|
|
||||||
banner_removal: [],
|
|
||||||
federated_timeline_removal: [],
|
|
||||||
followers_only: [],
|
|
||||||
media_nsfw: [],
|
|
||||||
media_removal: [],
|
|
||||||
reject: [],
|
|
||||||
reject_deletes: [],
|
|
||||||
report_removal: [],
|
|
||||||
},
|
|
||||||
mrf_simple_info: {
|
|
||||||
media_nsfw: {},
|
|
||||||
reject: {},
|
|
||||||
},
|
|
||||||
quarantined_instances: [],
|
|
||||||
quarantined_instances_info: {
|
|
||||||
quarantined_instances: {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
fields_limits: {
|
statuses: {
|
||||||
max_fields: config.validation.max_field_count,
|
characters_reserved_per_url: 0,
|
||||||
max_remote_fields: 9999,
|
max_characters: config.validation.max_note_size,
|
||||||
name_length: config.validation.max_field_name_size,
|
max_media_attachments:
|
||||||
value_length: config.validation.max_field_value_size,
|
config.validation.max_media_attachments,
|
||||||
},
|
},
|
||||||
post_formats: [
|
|
||||||
"text/plain",
|
|
||||||
"text/html",
|
|
||||||
"text/markdown",
|
|
||||||
"text/x.misskeymarkdown",
|
|
||||||
],
|
|
||||||
privileged_staff: false,
|
|
||||||
},
|
},
|
||||||
|
description: config.instance.description,
|
||||||
|
email: "",
|
||||||
|
invites_enabled: false,
|
||||||
|
registrations: config.signups.registration,
|
||||||
|
languages: ["en"],
|
||||||
|
rules: config.signups.rules.map((r, index) => ({
|
||||||
|
id: String(index),
|
||||||
|
text: r,
|
||||||
|
})),
|
||||||
stats: {
|
stats: {
|
||||||
mau: monthlyActiveUsers,
|
domain_count: knownDomainsCount,
|
||||||
|
status_count: statusCount,
|
||||||
|
user_count: userCount,
|
||||||
},
|
},
|
||||||
vapid_public_key: "",
|
thumbnail: proxyUrl(config.instance.logo),
|
||||||
},
|
banner: proxyUrl(config.instance.banner) ?? "",
|
||||||
contact_account: contactAccount?.toAPI() || undefined,
|
title: config.instance.name,
|
||||||
} satisfies APIInstance & {
|
uri: config.http.base_url,
|
||||||
banner: string;
|
urls: {
|
||||||
lysand_version: string;
|
streaming_api: "",
|
||||||
pleroma: object;
|
},
|
||||||
|
version: "4.3.0-alpha.3+glitch",
|
||||||
|
lysand_version: version,
|
||||||
|
pleroma: {
|
||||||
|
metadata: {
|
||||||
|
account_activation_required: false,
|
||||||
|
features: [
|
||||||
|
"pleroma_api",
|
||||||
|
"akkoma_api",
|
||||||
|
"mastodon_api",
|
||||||
|
// "mastodon_api_streaming",
|
||||||
|
// "polls",
|
||||||
|
// "v2_suggestions",
|
||||||
|
// "pleroma_explicit_addressing",
|
||||||
|
// "shareable_emoji_packs",
|
||||||
|
// "multifetch",
|
||||||
|
// "pleroma:api/v1/notifications:include_types_filter",
|
||||||
|
"quote_posting",
|
||||||
|
"editing",
|
||||||
|
// "bubble_timeline",
|
||||||
|
// "relay",
|
||||||
|
// "pleroma_emoji_reactions",
|
||||||
|
// "exposable_reactions",
|
||||||
|
// "profile_directory",
|
||||||
|
"custom_emoji_reactions",
|
||||||
|
// "pleroma:get:main/ostatus",
|
||||||
|
],
|
||||||
|
federation: {
|
||||||
|
enabled: true,
|
||||||
|
exclusions: false,
|
||||||
|
mrf_policies: [],
|
||||||
|
mrf_simple: {
|
||||||
|
accept: [],
|
||||||
|
avatar_removal: [],
|
||||||
|
background_removal: [],
|
||||||
|
banner_removal: [],
|
||||||
|
federated_timeline_removal: [],
|
||||||
|
followers_only: [],
|
||||||
|
media_nsfw: [],
|
||||||
|
media_removal: [],
|
||||||
|
reject: [],
|
||||||
|
reject_deletes: [],
|
||||||
|
report_removal: [],
|
||||||
|
},
|
||||||
|
mrf_simple_info: {
|
||||||
|
media_nsfw: {},
|
||||||
|
reject: {},
|
||||||
|
},
|
||||||
|
quarantined_instances: [],
|
||||||
|
quarantined_instances_info: {
|
||||||
|
quarantined_instances: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields_limits: {
|
||||||
|
max_fields: config.validation.max_field_count,
|
||||||
|
max_remote_fields: 9999,
|
||||||
|
name_length: config.validation.max_field_name_size,
|
||||||
|
value_length: config.validation.max_field_value_size,
|
||||||
|
},
|
||||||
|
post_formats: [
|
||||||
|
"text/plain",
|
||||||
|
"text/html",
|
||||||
|
"text/markdown",
|
||||||
|
"text/x.misskeymarkdown",
|
||||||
|
],
|
||||||
|
privileged_staff: false,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
mau: monthlyActiveUsers,
|
||||||
|
},
|
||||||
|
vapid_public_key: "",
|
||||||
|
},
|
||||||
|
contact_account: contactAccount?.toAPI() || undefined,
|
||||||
|
} satisfies APIInstance & {
|
||||||
|
banner: string;
|
||||||
|
lysand_version: string;
|
||||||
|
pleroma: object;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { jsonResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { config } from "~packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -13,14 +15,18 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default (app: Hono) =>
|
||||||
const config = await extraData.configManager.getConfig();
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
return jsonResponse(
|
meta.route,
|
||||||
config.signups.rules.map((rule, index) => ({
|
auth(meta.auth),
|
||||||
id: String(index),
|
async (context) => {
|
||||||
text: rule,
|
return jsonResponse(
|
||||||
hint: "",
|
config.signups.rules.map((rule, index) => ({
|
||||||
})),
|
id: String(index),
|
||||||
|
text: rule,
|
||||||
|
hint: "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -53,16 +53,20 @@ describe(meta.route, () => {
|
||||||
|
|
||||||
test("should create markers", async () => {
|
test("should create markers", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(new URL(meta.route, config.http.base_url), {
|
new Request(
|
||||||
method: "POST",
|
new URL(
|
||||||
headers: {
|
`${meta.route}?${new URLSearchParams({
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
"home[last_read_id]": timeline[0].id,
|
||||||
"Content-Type": "application/json",
|
})}`,
|
||||||
|
config.http.base_url,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
),
|
||||||
"home[last_read_id]": timeline[0].id,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import {
|
||||||
|
applyConfig,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
idValidator,
|
||||||
|
qs,
|
||||||
|
qsQuery,
|
||||||
|
} from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, count, eq } from "drizzle-orm";
|
import { and, count, eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { validator } from "hono/validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Markers } from "~drizzle/schema";
|
import { Markers } from "~drizzle/schema";
|
||||||
|
|
@ -19,175 +29,188 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
timeline: z
|
query: z.object({
|
||||||
.array(z.enum(["home", "notifications"]))
|
"timeline[]": z
|
||||||
.max(2)
|
.array(z.enum(["home", "notifications"]))
|
||||||
.optional(),
|
.max(2)
|
||||||
"home[last_read_id]": z.string().regex(idValidator).optional(),
|
.optional(),
|
||||||
"notifications[last_read_id]": z.string().regex(idValidator).optional(),
|
"home[last_read_id]": z.string().regex(idValidator).optional(),
|
||||||
});
|
"notifications[last_read_id]": z.string().regex(idValidator).optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { user } = extraData.auth;
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { "timeline[]": timeline } = context.req.valid("query");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
switch (req.method) {
|
switch (context.req.method) {
|
||||||
case "GET": {
|
case "GET": {
|
||||||
const { timeline } = extraData.parsedRequest;
|
if (!timeline) {
|
||||||
|
return jsonResponse({});
|
||||||
|
}
|
||||||
|
|
||||||
if (!timeline) {
|
const markers: APIMarker = {
|
||||||
return jsonResponse({});
|
home: undefined,
|
||||||
|
notifications: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timeline.includes("home")) {
|
||||||
|
const found = await db.query.Markers.findFirst({
|
||||||
|
where: (marker, { and, eq }) =>
|
||||||
|
and(
|
||||||
|
eq(marker.userId, user.id),
|
||||||
|
eq(marker.timeline, "home"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCount = await db
|
||||||
|
.select({
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(Markers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Markers.userId, user.id),
|
||||||
|
eq(Markers.timeline, "home"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (found?.noteId) {
|
||||||
|
markers.home = {
|
||||||
|
last_read_id: found.noteId,
|
||||||
|
version: totalCount[0].count,
|
||||||
|
updated_at: new Date(
|
||||||
|
found.createdAt,
|
||||||
|
).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeline.includes("notifications")) {
|
||||||
|
const found = await db.query.Markers.findFirst({
|
||||||
|
where: (marker, { and, eq }) =>
|
||||||
|
and(
|
||||||
|
eq(marker.userId, user.id),
|
||||||
|
eq(marker.timeline, "notifications"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCount = await db
|
||||||
|
.select({
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(Markers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Markers.userId, user.id),
|
||||||
|
eq(Markers.timeline, "notifications"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (found?.notificationId) {
|
||||||
|
markers.notifications = {
|
||||||
|
last_read_id: found.notificationId,
|
||||||
|
version: totalCount[0].count,
|
||||||
|
updated_at: new Date(
|
||||||
|
found.createdAt,
|
||||||
|
).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(markers);
|
||||||
}
|
}
|
||||||
|
|
||||||
const markers: APIMarker = {
|
case "POST": {
|
||||||
home: undefined,
|
const {
|
||||||
notifications: undefined,
|
"home[last_read_id]": home_id,
|
||||||
};
|
"notifications[last_read_id]": notifications_id,
|
||||||
|
} = context.req.valid("query");
|
||||||
|
|
||||||
if (timeline.includes("home")) {
|
const markers: APIMarker = {
|
||||||
const found = await db.query.Markers.findFirst({
|
home: undefined,
|
||||||
where: (marker, { and, eq }) =>
|
notifications: undefined,
|
||||||
and(
|
};
|
||||||
eq(marker.userId, user.id),
|
|
||||||
eq(marker.timeline, "home"),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalCount = await db
|
if (home_id) {
|
||||||
.select({
|
const insertedMarker = (
|
||||||
count: count(),
|
await db
|
||||||
})
|
.insert(Markers)
|
||||||
.from(Markers)
|
.values({
|
||||||
.where(
|
userId: user.id,
|
||||||
and(
|
timeline: "home",
|
||||||
eq(Markers.userId, user.id),
|
noteId: home_id,
|
||||||
eq(Markers.timeline, "home"),
|
})
|
||||||
),
|
.returning()
|
||||||
);
|
)[0];
|
||||||
|
|
||||||
|
const totalCount = await db
|
||||||
|
.select({
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(Markers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Markers.userId, user.id),
|
||||||
|
eq(Markers.timeline, "home"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (found?.noteId) {
|
|
||||||
markers.home = {
|
markers.home = {
|
||||||
last_read_id: found.noteId,
|
last_read_id: home_id,
|
||||||
version: totalCount[0].count,
|
version: totalCount[0].count,
|
||||||
updated_at: new Date(found.createdAt).toISOString(),
|
updated_at: new Date(
|
||||||
|
insertedMarker.createdAt,
|
||||||
|
).toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (timeline.includes("notifications")) {
|
if (notifications_id) {
|
||||||
const found = await db.query.Markers.findFirst({
|
const insertedMarker = (
|
||||||
where: (marker, { and, eq }) =>
|
await db
|
||||||
and(
|
.insert(Markers)
|
||||||
eq(marker.userId, user.id),
|
.values({
|
||||||
eq(marker.timeline, "notifications"),
|
userId: user.id,
|
||||||
),
|
timeline: "notifications",
|
||||||
});
|
notificationId: notifications_id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
const totalCount = await db
|
const totalCount = await db
|
||||||
.select({
|
.select({
|
||||||
count: count(),
|
count: count(),
|
||||||
})
|
})
|
||||||
.from(Markers)
|
.from(Markers)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(Markers.userId, user.id),
|
eq(Markers.userId, user.id),
|
||||||
eq(Markers.timeline, "notifications"),
|
eq(Markers.timeline, "notifications"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (found?.notificationId) {
|
|
||||||
markers.notifications = {
|
markers.notifications = {
|
||||||
last_read_id: found.notificationId,
|
last_read_id: notifications_id,
|
||||||
version: totalCount[0].count,
|
version: totalCount[0].count,
|
||||||
updated_at: new Date(found.createdAt).toISOString(),
|
updated_at: new Date(
|
||||||
|
insertedMarker.createdAt,
|
||||||
|
).toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(markers);
|
return jsonResponse(markers);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "POST": {
|
},
|
||||||
const {
|
);
|
||||||
"home[last_read_id]": home_id,
|
|
||||||
"notifications[last_read_id]": notifications_id,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
const markers: APIMarker = {
|
|
||||||
home: undefined,
|
|
||||||
notifications: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (home_id) {
|
|
||||||
const insertedMarker = (
|
|
||||||
await db
|
|
||||||
.insert(Markers)
|
|
||||||
.values({
|
|
||||||
userId: user.id,
|
|
||||||
timeline: "home",
|
|
||||||
noteId: home_id,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
const totalCount = await db
|
|
||||||
.select({
|
|
||||||
count: count(),
|
|
||||||
})
|
|
||||||
.from(Markers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Markers.userId, user.id),
|
|
||||||
eq(Markers.timeline, "home"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
markers.home = {
|
|
||||||
last_read_id: home_id,
|
|
||||||
version: totalCount[0].count,
|
|
||||||
updated_at: new Date(
|
|
||||||
insertedMarker.createdAt,
|
|
||||||
).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notifications_id) {
|
|
||||||
const insertedMarker = (
|
|
||||||
await db
|
|
||||||
.insert(Markers)
|
|
||||||
.values({
|
|
||||||
userId: user.id,
|
|
||||||
timeline: "notifications",
|
|
||||||
notificationId: notifications_id,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
const totalCount = await db
|
|
||||||
.select({
|
|
||||||
count: count(),
|
|
||||||
})
|
|
||||||
.from(Markers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Markers.userId, user.id),
|
|
||||||
eq(Markers.timeline, "notifications"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
markers.notifications = {
|
|
||||||
last_read_id: notifications_id,
|
|
||||||
version: totalCount[0].count,
|
|
||||||
updated_at: new Date(
|
|
||||||
insertedMarker.createdAt,
|
|
||||||
).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(markers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
123
server/api/api/v1/media/:id/index.ts
Normal file
123
server/api/api/v1/media/:id/index.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse, response } from "@response";
|
||||||
|
import { config } from "config-manager";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import type { MediaBackend } from "media-manager";
|
||||||
|
import { MediaBackendType } from "media-manager";
|
||||||
|
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Attachments } from "~drizzle/schema";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET", "PUT"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 10,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/media/:id",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:media"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
form: z.object({
|
||||||
|
thumbnail: z.instanceof(File).optional(),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.max(config.validation.max_media_description_size)
|
||||||
|
.optional(),
|
||||||
|
focus: z.string().optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
zValidator("form", schemas.form, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
if (!id.match(idValidator)) {
|
||||||
|
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundAttachment = await db.query.Attachments.findFirst({
|
||||||
|
where: (attachment, { eq }) => eq(attachment.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!foundAttachment) {
|
||||||
|
return errorResponse("Media not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (context.req.method) {
|
||||||
|
case "GET": {
|
||||||
|
if (foundAttachment.url) {
|
||||||
|
return jsonResponse(attachmentToAPI(foundAttachment));
|
||||||
|
}
|
||||||
|
return response(null, 206);
|
||||||
|
}
|
||||||
|
case "PUT": {
|
||||||
|
const { description, thumbnail } =
|
||||||
|
context.req.valid("form");
|
||||||
|
|
||||||
|
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(Attachments)
|
||||||
|
.set({
|
||||||
|
description: descriptionText,
|
||||||
|
thumbnailUrl,
|
||||||
|
})
|
||||||
|
.where(eq(Attachments.id, id))
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
return jsonResponse(attachmentToAPI(newAttachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(attachmentToAPI(foundAttachment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorResponse("Method not allowed", 405);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse, response } from "@response";
|
|
||||||
import { config } from "config-manager";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { MediaBackend } from "media-manager";
|
|
||||||
import { MediaBackendType } from "media-manager";
|
|
||||||
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Attachments } from "~drizzle/schema";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["GET", "PUT"],
|
|
||||||
ratelimits: {
|
|
||||||
max: 10,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/media/:id",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:media"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schema = z.object({
|
|
||||||
thumbnail: z.instanceof(File).optional(),
|
|
||||||
description: z
|
|
||||||
.string()
|
|
||||||
.max(config.validation.max_media_description_size)
|
|
||||||
.optional(),
|
|
||||||
focus: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get media information
|
|
||||||
*/
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
|
||||||
async (req, matchedRoute, extraData) => {
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return errorResponse("Unauthorized", 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundAttachment = await db.query.Attachments.findFirst({
|
|
||||||
where: (attachment, { eq }) => eq(attachment.id, id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!foundAttachment) {
|
|
||||||
return errorResponse("Media not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(Attachments)
|
|
||||||
.set({
|
|
||||||
description: descriptionText,
|
|
||||||
thumbnailUrl,
|
|
||||||
})
|
|
||||||
.where(eq(Attachments.id, id))
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
return jsonResponse(attachmentToAPI(newAttachment));
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(attachmentToAPI(foundAttachment));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResponse("Method not allowed", 405);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { encode } from "blurhash";
|
import { encode } from "blurhash";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
|
import type { Hono } from "hono";
|
||||||
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";
|
||||||
|
|
@ -24,128 +26,125 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
file: z.instanceof(File),
|
form: z.object({
|
||||||
thumbnail: z.instanceof(File).optional(),
|
file: z.instanceof(File),
|
||||||
description: z
|
thumbnail: z.instanceof(File).optional(),
|
||||||
.string()
|
description: z
|
||||||
.max(config.validation.max_media_description_size)
|
.string()
|
||||||
.optional(),
|
.max(config.validation.max_media_description_size)
|
||||||
focus: z.string().optional(),
|
.optional(),
|
||||||
});
|
focus: z.string().optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
export default (app: Hono) =>
|
||||||
* Upload new media
|
app.on(
|
||||||
*/
|
meta.allowedMethods,
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
meta.route,
|
||||||
async (req, matchedRoute, extraData) => {
|
zValidator("form", schemas.form, handleZodError),
|
||||||
const { user } = extraData.auth;
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { file, thumbnail, description, focus } =
|
||||||
|
context.req.valid("form");
|
||||||
|
|
||||||
if (!user) {
|
if (file.size > config.validation.max_media_size) {
|
||||||
return errorResponse("Unauthorized", 401);
|
return errorResponse(
|
||||||
}
|
`File too large, max size is ${config.validation.max_media_size} bytes`,
|
||||||
|
413,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { file, thumbnail, description } = extraData.parsedRequest;
|
if (
|
||||||
|
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`,
|
|
||||||
413,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
const metadata = isImage
|
||||||
config.validation.enforce_mime_types &&
|
? await sharp(await file.arrayBuffer()).metadata()
|
||||||
!config.validation.allowed_mime_types.includes(file.type)
|
: null;
|
||||||
) {
|
|
||||||
return errorResponse("Invalid file type", 415);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha256 = new Bun.SHA256();
|
const blurhash = await new Promise<string | null>((resolve) => {
|
||||||
|
(async () =>
|
||||||
|
sharp(await file.arrayBuffer())
|
||||||
|
.raw()
|
||||||
|
.ensureAlpha()
|
||||||
|
.toBuffer((err, buffer) => {
|
||||||
|
if (err) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isImage = file.type.startsWith("image/");
|
try {
|
||||||
|
resolve(
|
||||||
|
encode(
|
||||||
|
new Uint8ClampedArray(buffer),
|
||||||
|
metadata?.width ?? 0,
|
||||||
|
metadata?.height ?? 0,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
) as string,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}))();
|
||||||
|
});
|
||||||
|
|
||||||
const metadata = isImage
|
let url = "";
|
||||||
? await sharp(await file.arrayBuffer()).metadata()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const blurhash = await new Promise<string | null>((resolve) => {
|
let mediaManager: MediaBackend;
|
||||||
(async () =>
|
|
||||||
sharp(await file.arrayBuffer())
|
|
||||||
.raw()
|
|
||||||
.ensureAlpha()
|
|
||||||
.toBuffer((err, buffer) => {
|
|
||||||
if (err) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
switch (config.media.backend as MediaBackendType) {
|
||||||
resolve(
|
case MediaBackendType.LOCAL:
|
||||||
encode(
|
mediaManager = new LocalMediaBackend(config);
|
||||||
new Uint8ClampedArray(buffer),
|
break;
|
||||||
metadata?.width ?? 0,
|
case MediaBackendType.S3:
|
||||||
metadata?.height ?? 0,
|
mediaManager = new S3MediaBackend(config);
|
||||||
4,
|
break;
|
||||||
4,
|
default:
|
||||||
) as string,
|
// TODO: Replace with logger
|
||||||
);
|
throw new Error("Invalid media backend");
|
||||||
} catch {
|
}
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}))();
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = "";
|
const { path } = await mediaManager.addFile(file);
|
||||||
|
|
||||||
let mediaManager: MediaBackend;
|
url = getUrl(path, config);
|
||||||
|
|
||||||
switch (config.media.backend as MediaBackendType) {
|
let thumbnailUrl = "";
|
||||||
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);
|
if (thumbnail) {
|
||||||
|
const { path } = await mediaManager.addFile(thumbnail);
|
||||||
|
|
||||||
url = getUrl(path, config);
|
thumbnailUrl = getUrl(path, config);
|
||||||
|
}
|
||||||
|
|
||||||
let thumbnailUrl = "";
|
const newAttachment = (
|
||||||
|
await db
|
||||||
|
.insert(Attachments)
|
||||||
|
.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 (thumbnail) {
|
return jsonResponse(attachmentToAPI(newAttachment));
|
||||||
const { path } = await mediaManager.addFile(thumbnail);
|
},
|
||||||
|
);
|
||||||
thumbnailUrl = getUrl(path, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAttachment = (
|
|
||||||
await db
|
|
||||||
.insert(Attachments)
|
|
||||||
.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));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ beforeAll(async () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -81,7 +83,9 @@ describe(meta.route, () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Users } from "~drizzle/schema";
|
import { Users } from "~drizzle/schema";
|
||||||
import { Timeline } from "~packages/database-interface/timeline";
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
|
@ -18,31 +20,45 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
query: z.object({
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
});
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { user } = extraData.auth;
|
meta.allowedMethods,
|
||||||
const { max_id, since_id, limit, min_id } = extraData.parsedRequest;
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { max_id, since_id, limit, min_id } =
|
||||||
|
context.req.valid("query");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { objects: mutes, link } = await Timeline.getUserTimeline(
|
const { objects: mutes, link } = await Timeline.getUserTimeline(
|
||||||
and(
|
and(
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
max_id ? lt(Users.id, max_id) : undefined,
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
since_id ? gte(Users.id, since_id) : undefined,
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
min_id ? gt(Users.id, min_id) : undefined,
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
|
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
|
||||||
),
|
),
|
||||||
limit,
|
limit,
|
||||||
req.url,
|
context.req.url,
|
||||||
);
|
);
|
||||||
|
|
||||||
return jsonResponse(mutes.map((u) => u.toAPI()));
|
return jsonResponse(
|
||||||
},
|
mutes.map((u) => u.toAPI()),
|
||||||
);
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,29 @@ let notifications: APINotification[] = [];
|
||||||
|
|
||||||
// Create some test notifications: follow, favourite, reblog, mention
|
// Create some test notifications: follow, favourite, reblog, mention
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fetch(
|
await sendTestRequest(
|
||||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
new Request(
|
||||||
{
|
new URL(
|
||||||
method: "POST",
|
`/api/v1/accounts/${users[0].id}/follow`,
|
||||||
headers: {
|
config.http.base_url,
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
notifications = await fetch(
|
notifications = await sendTestRequest(
|
||||||
new URL("/api/v1/notifications", config.http.base_url),
|
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
).then((r) => r.json());
|
).then((r) => r.json());
|
||||||
|
|
||||||
expect(notifications.length).toBe(1);
|
expect(notifications.length).toBe(1);
|
||||||
|
|
@ -45,9 +51,15 @@ afterAll(async () => {
|
||||||
describe(meta.route, () => {
|
describe(meta.route, () => {
|
||||||
test("should return 401 if not authenticated", async () => {
|
test("should return 401 if not authenticated", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(new URL(meta.route, config.http.base_url), {
|
new Request(
|
||||||
method: "POST",
|
new URL(
|
||||||
}),
|
meta.route.replace(":id", notifications[0].id),
|
||||||
|
config.http.base_url,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
50
server/api/api/v1/notifications/:id/dismiss.ts
Normal file
50
server/api/api/v1/notifications/:id/dismiss.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Notifications } from "~drizzle/schema";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
route: "/api/v1/notifications/:id/dismiss",
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["write:notifications"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Notifications)
|
||||||
|
.set({
|
||||||
|
dismissed: true,
|
||||||
|
})
|
||||||
|
.where(eq(Notifications.id, id));
|
||||||
|
|
||||||
|
return jsonResponse({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -15,23 +15,29 @@ let notifications: APINotification[] = [];
|
||||||
|
|
||||||
// Create some test notifications: follow, favourite, reblog, mention
|
// Create some test notifications: follow, favourite, reblog, mention
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fetch(
|
await sendTestRequest(
|
||||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
new Request(
|
||||||
{
|
new URL(
|
||||||
method: "POST",
|
`/api/v1/accounts/${users[0].id}/follow`,
|
||||||
headers: {
|
config.http.base_url,
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
notifications = await fetch(
|
notifications = await sendTestRequest(
|
||||||
new URL("/api/v1/notifications", config.http.base_url),
|
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
).then((r) => r.json());
|
).then((r) => r.json());
|
||||||
|
|
||||||
expect(notifications.length).toBe(1);
|
expect(notifications.length).toBe(1);
|
||||||
|
|
@ -45,13 +51,21 @@ afterAll(async () => {
|
||||||
describe(meta.route, () => {
|
describe(meta.route, () => {
|
||||||
test("should return 401 if not authenticated", async () => {
|
test("should return 401 if not authenticated", 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.replace(
|
||||||
|
":id",
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
),
|
||||||
|
config.http.base_url,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 404 if ID is invalid", async () => {
|
test("should return 422 if ID is invalid", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
new URL(
|
new URL(
|
||||||
|
|
@ -65,7 +79,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(422);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 404 if notification not found", async () => {
|
test("should return 404 if notification not found", async () => {
|
||||||
51
server/api/api/v1/notifications/:id/index.ts
Normal file
51
server/api/api/v1/notifications/:id/index.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { findManyNotifications } from "~database/entities/Notification";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET"],
|
||||||
|
route: "/api/v1/notifications/:id",
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
oauthPermissions: ["read:notifications"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const notification = (
|
||||||
|
await findManyNotifications({
|
||||||
|
where: (notification, { eq }) => eq(notification.id, id),
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (!notification)
|
||||||
|
return errorResponse("Notification not found", 404);
|
||||||
|
|
||||||
|
return jsonResponse(notification);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
import { Notifications } from "~drizzle/schema";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["POST"],
|
|
||||||
route: "/api/v1/notifications/:id/dismiss",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:notifications"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(Notifications)
|
|
||||||
.set({
|
|
||||||
dismissed: true,
|
|
||||||
})
|
|
||||||
.where(eq(Notifications.id, id));
|
|
||||||
|
|
||||||
return jsonResponse({});
|
|
||||||
});
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { findManyNotifications } from "~database/entities/Notification";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
allowedMethods: ["GET"],
|
|
||||||
route: "/api/v1/notifications/:id",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["read:notifications"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
if (!id.match(idValidator)) {
|
|
||||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = extraData.auth;
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const notification = (
|
|
||||||
await findManyNotifications({
|
|
||||||
where: (notification, { eq }) => eq(notification.id, id),
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
if (!notification) return errorResponse("Notification not found", 404);
|
|
||||||
|
|
||||||
return jsonResponse(notification);
|
|
||||||
});
|
|
||||||
|
|
@ -15,23 +15,29 @@ let notifications: APINotification[] = [];
|
||||||
|
|
||||||
// Create some test notifications: follow, favourite, reblog, mention
|
// Create some test notifications: follow, favourite, reblog, mention
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fetch(
|
await sendTestRequest(
|
||||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
new Request(
|
||||||
{
|
new URL(
|
||||||
method: "POST",
|
`/api/v1/accounts/${users[0].id}/follow`,
|
||||||
headers: {
|
config.http.base_url,
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
notifications = await fetch(
|
notifications = await sendTestRequest(
|
||||||
new URL("/api/v1/notifications", config.http.base_url),
|
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
).then((r) => r.json());
|
).then((r) => r.json());
|
||||||
|
|
||||||
expect(notifications.length).toBe(1);
|
expect(notifications.length).toBe(1);
|
||||||
|
|
@ -65,13 +71,15 @@ describe(meta.route, () => {
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
const newNotifications = await fetch(
|
const newNotifications = await sendTestRequest(
|
||||||
new URL("/api/v1/notifications", config.http.base_url),
|
new Request(
|
||||||
{
|
new URL("/api/v1/notifications", config.http.base_url),
|
||||||
headers: {
|
{
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
).then((r) => r.json());
|
).then((r) => r.json());
|
||||||
|
|
||||||
expect(newNotifications.length).toBe(0);
|
expect(newNotifications.length).toBe(0);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Notifications } from "~drizzle/schema";
|
import { Notifications } from "~drizzle/schema";
|
||||||
|
|
||||||
|
|
@ -17,16 +18,22 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default (app: Hono) =>
|
||||||
const { user } = extraData.auth;
|
app.on(
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(Notifications)
|
.update(Notifications)
|
||||||
.set({
|
.set({
|
||||||
dismissed: true,
|
dismissed: true,
|
||||||
})
|
})
|
||||||
.where(eq(Notifications.notifiedId, user.id));
|
.where(eq(Notifications.notifiedId, user.id));
|
||||||
|
|
||||||
return jsonResponse({});
|
return jsonResponse({});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,38 +17,48 @@ let notifications: APINotification[] = [];
|
||||||
|
|
||||||
// Create some test notifications
|
// Create some test notifications
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fetch(
|
await sendTestRequest(
|
||||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
new Request(
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const i of [0, 1, 2, 3]) {
|
|
||||||
await fetch(
|
|
||||||
new URL(
|
new URL(
|
||||||
`/api/v1/statuses/${statuses[i].id}/favourite`,
|
`/api/v1/accounts/${users[0].id}/follow`,
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const i of [0, 1, 2, 3]) {
|
||||||
|
await sendTestRequest(
|
||||||
|
new Request(
|
||||||
|
new URL(
|
||||||
|
`/api/v1/statuses/${statuses[i].id}/favourite`,
|
||||||
|
config.http.base_url,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications = await fetch(
|
notifications = await sendTestRequest(
|
||||||
new URL("/api/v1/notifications", config.http.base_url),
|
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
).then((r) => r.json());
|
).then((r) => r.json());
|
||||||
|
|
||||||
expect(notifications.length).toBe(5);
|
expect(notifications.length).toBe(5);
|
||||||
|
|
@ -62,9 +72,17 @@ afterAll(async () => {
|
||||||
describe(meta.route, () => {
|
describe(meta.route, () => {
|
||||||
test("should return 401 if not authenticated", async () => {
|
test("should return 401 if not authenticated", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(new URL(meta.route, config.http.base_url), {
|
new Request(
|
||||||
method: "DELETE",
|
new URL(
|
||||||
}),
|
`${meta.route}?${new URLSearchParams(
|
||||||
|
notifications.slice(1).map((n) => ["ids[]", n.id]),
|
||||||
|
).toString()}`,
|
||||||
|
config.http.base_url,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Notifications } from "~drizzle/schema";
|
import { Notifications } from "~drizzle/schema";
|
||||||
|
|
@ -18,24 +20,37 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
ids: z.array(z.string().regex(idValidator)),
|
query: z.object({
|
||||||
});
|
"ids[]": z.array(z.string().uuid()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { user } = extraData.auth;
|
meta.allowedMethods,
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
const { ids } = extraData.parsedRequest;
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
await db
|
const { "ids[]": ids } = context.req.valid("query");
|
||||||
.update(Notifications)
|
|
||||||
.set({
|
|
||||||
dismissed: true,
|
|
||||||
})
|
|
||||||
.where(inArray(Notifications.id, ids));
|
|
||||||
|
|
||||||
return jsonResponse({});
|
await db
|
||||||
},
|
.update(Notifications)
|
||||||
);
|
.set({
|
||||||
|
dismissed: true,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(Notifications.id, ids),
|
||||||
|
eq(Notifications.notifiedId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,58 +11,87 @@ import { meta } from "./index";
|
||||||
|
|
||||||
await deleteOldTestUsers();
|
await deleteOldTestUsers();
|
||||||
|
|
||||||
|
const getFormData = (object: Record<string, string | number | boolean>) =>
|
||||||
|
Object.keys(object).reduce((formData, key) => {
|
||||||
|
formData.append(key, String(object[key]));
|
||||||
|
return formData;
|
||||||
|
}, new FormData());
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||||
const timeline = (await getTestStatuses(40, users[0])).toReversed();
|
const timeline = (await getTestStatuses(40, users[0])).toReversed();
|
||||||
// Create some test notifications: follow, favourite, reblog, mention
|
// Create some test notifications: follow, favourite, reblog, mention
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fetch(
|
const res1 = await sendTestRequest(
|
||||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
new Request(
|
||||||
{
|
new URL(
|
||||||
method: "POST",
|
`/api/v1/accounts/${users[0].id}/follow`,
|
||||||
headers: {
|
config.http.base_url,
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await fetch(
|
|
||||||
new URL(
|
|
||||||
`/api/v1/statuses/${timeline[0].id}/favourite`,
|
|
||||||
config.http.base_url,
|
|
||||||
),
|
),
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await fetch(
|
expect(res1.status).toBe(200);
|
||||||
new URL(
|
|
||||||
`/api/v1/statuses/${timeline[0].id}/reblog`,
|
const res2 = await sendTestRequest(
|
||||||
config.http.base_url,
|
new Request(
|
||||||
|
new URL(
|
||||||
|
`/api/v1/statuses/${timeline[0].id}/favourite`,
|
||||||
|
config.http.base_url,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
{
|
);
|
||||||
|
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
|
||||||
|
const res3 = await sendTestRequest(
|
||||||
|
new Request(
|
||||||
|
new URL(
|
||||||
|
`/api/v1/statuses/${timeline[0].id}/reblog`,
|
||||||
|
config.http.base_url,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
},
|
||||||
|
body: getFormData({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res3.status).toBe(200);
|
||||||
|
|
||||||
|
const res4 = await sendTestRequest(
|
||||||
|
new Request(new URL("/api/v1/statuses", config.http.base_url), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
body: getFormData({
|
||||||
);
|
status: `@${users[0].getUser().username} test mention`,
|
||||||
|
visibility: "direct",
|
||||||
await fetch(new URL("/api/v1/statuses", config.http.base_url), {
|
federate: false,
|
||||||
method: "POST",
|
}),
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
status: `@${users[0].getUser().username} test mention`,
|
|
||||||
visibility: "direct",
|
|
||||||
federate: false,
|
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
|
expect(res4.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
@ -109,24 +138,21 @@ describe(meta.route, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not return notifications with filtered keywords", async () => {
|
test("should not return notifications with filtered keywords", async () => {
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("title", "Test Filter");
|
|
||||||
formData.append("context[]", "notifications");
|
|
||||||
formData.append("filter_action", "hide");
|
|
||||||
formData.append(
|
|
||||||
"keywords_attributes[0][keyword]",
|
|
||||||
timeline[0].content.slice(4, 20),
|
|
||||||
);
|
|
||||||
formData.append("keywords_attributes[0][whole_word]", "false");
|
|
||||||
|
|
||||||
const filterResponse = await sendTestRequest(
|
const filterResponse = await sendTestRequest(
|
||||||
new Request(new URL("/api/v2/filters", config.http.base_url), {
|
new Request(new URL("/api/v2/filters", config.http.base_url), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
body: formData,
|
body: new URLSearchParams({
|
||||||
|
title: "Test Filter",
|
||||||
|
"context[]": "notifications",
|
||||||
|
filter_action: "hide",
|
||||||
|
"keywords_attributes[0][keyword]":
|
||||||
|
timeline[0].content.slice(4, 20),
|
||||||
|
"keywords_attributes[0][whole_word]": "false",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { apiRoute, applyConfig, idValidator } from "@api";
|
import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { fetchTimeline } from "@timelines";
|
import { fetchTimeline } from "@timelines";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
findManyNotifications,
|
findManyNotifications,
|
||||||
|
|
@ -22,144 +24,164 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schemas = {
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
query: z.object({
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(80).optional().default(15),
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
exclude_types: z
|
limit: z.coerce.number().int().min(1).max(80).default(15),
|
||||||
.enum([
|
exclude_types: z
|
||||||
"mention",
|
.enum([
|
||||||
"status",
|
"mention",
|
||||||
"follow",
|
"status",
|
||||||
"follow_request",
|
"follow",
|
||||||
"reblog",
|
"follow_request",
|
||||||
"poll",
|
"reblog",
|
||||||
"favourite",
|
"poll",
|
||||||
"update",
|
"favourite",
|
||||||
"admin.sign_up",
|
"update",
|
||||||
"admin.report",
|
"admin.sign_up",
|
||||||
"chat",
|
"admin.report",
|
||||||
"pleroma:chat_mention",
|
"chat",
|
||||||
"pleroma:emoji_reaction",
|
"pleroma:chat_mention",
|
||||||
"pleroma:event_reminder",
|
"pleroma:emoji_reaction",
|
||||||
"pleroma:participation_request",
|
"pleroma:event_reminder",
|
||||||
"pleroma:participation_accepted",
|
"pleroma:participation_request",
|
||||||
"move",
|
"pleroma:participation_accepted",
|
||||||
"group_reblog",
|
"move",
|
||||||
"group_favourite",
|
"group_reblog",
|
||||||
"user_approved",
|
"group_favourite",
|
||||||
])
|
"user_approved",
|
||||||
.array()
|
])
|
||||||
.optional(),
|
.array()
|
||||||
types: z
|
.optional(),
|
||||||
.enum([
|
types: z
|
||||||
"mention",
|
.enum([
|
||||||
"status",
|
"mention",
|
||||||
"follow",
|
"status",
|
||||||
"follow_request",
|
"follow",
|
||||||
"reblog",
|
"follow_request",
|
||||||
"poll",
|
"reblog",
|
||||||
"favourite",
|
"poll",
|
||||||
"update",
|
"favourite",
|
||||||
"admin.sign_up",
|
"update",
|
||||||
"admin.report",
|
"admin.sign_up",
|
||||||
"chat",
|
"admin.report",
|
||||||
"pleroma:chat_mention",
|
"chat",
|
||||||
"pleroma:emoji_reaction",
|
"pleroma:chat_mention",
|
||||||
"pleroma:event_reminder",
|
"pleroma:emoji_reaction",
|
||||||
"pleroma:participation_request",
|
"pleroma:event_reminder",
|
||||||
"pleroma:participation_accepted",
|
"pleroma:participation_request",
|
||||||
"move",
|
"pleroma:participation_accepted",
|
||||||
"group_reblog",
|
"move",
|
||||||
"group_favourite",
|
"group_reblog",
|
||||||
"user_approved",
|
"group_favourite",
|
||||||
])
|
"user_approved",
|
||||||
.array()
|
])
|
||||||
.optional(),
|
.array()
|
||||||
account_id: z.string().regex(idValidator).optional(),
|
.optional(),
|
||||||
});
|
account_id: z.string().regex(idValidator).optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export default apiRoute<typeof meta, typeof schema>(
|
export default (app: Hono) =>
|
||||||
async (req, matchedRoute, extraData) => {
|
app.on(
|
||||||
const { user } = extraData.auth;
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
const {
|
||||||
|
account_id,
|
||||||
|
exclude_types,
|
||||||
|
limit,
|
||||||
|
max_id,
|
||||||
|
min_id,
|
||||||
|
since_id,
|
||||||
|
types,
|
||||||
|
} = context.req.valid("query");
|
||||||
|
|
||||||
const {
|
if (types && exclude_types) {
|
||||||
account_id,
|
return errorResponse(
|
||||||
exclude_types,
|
"Can't use both types and exclude_types",
|
||||||
limit,
|
400,
|
||||||
max_id,
|
);
|
||||||
min_id,
|
}
|
||||||
since_id,
|
|
||||||
types,
|
|
||||||
} = extraData.parsedRequest;
|
|
||||||
|
|
||||||
if (types && exclude_types) {
|
const { objects, link } =
|
||||||
return errorResponse("Can't use both types and exclude_types", 400);
|
await fetchTimeline<NotificationWithRelations>(
|
||||||
}
|
findManyNotifications,
|
||||||
|
{
|
||||||
const { objects, link } =
|
where: (
|
||||||
await fetchTimeline<NotificationWithRelations>(
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
findManyNotifications,
|
notification,
|
||||||
{
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
where: (
|
{ lt, gte, gt, and, eq, not, inArray },
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
) =>
|
||||||
notification,
|
and(
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
max_id
|
||||||
{ lt, gte, gt, and, eq, not, inArray },
|
? lt(notification.id, max_id)
|
||||||
) =>
|
: undefined,
|
||||||
and(
|
since_id
|
||||||
max_id ? lt(notification.id, max_id) : undefined,
|
? gte(notification.id, since_id)
|
||||||
since_id
|
: undefined,
|
||||||
? gte(notification.id, since_id)
|
min_id
|
||||||
: undefined,
|
? gt(notification.id, min_id)
|
||||||
min_id ? gt(notification.id, min_id) : undefined,
|
: undefined,
|
||||||
eq(notification.notifiedId, user.id),
|
eq(notification.notifiedId, user.id),
|
||||||
eq(notification.dismissed, false),
|
eq(notification.dismissed, false),
|
||||||
account_id
|
account_id
|
||||||
? eq(notification.accountId, account_id)
|
? eq(notification.accountId, account_id)
|
||||||
: undefined,
|
: undefined,
|
||||||
not(eq(notification.accountId, user.id)),
|
not(eq(notification.accountId, user.id)),
|
||||||
types
|
types
|
||||||
? inArray(notification.type, types)
|
? inArray(notification.type, types)
|
||||||
: undefined,
|
: undefined,
|
||||||
exclude_types
|
exclude_types
|
||||||
? not(inArray(notification.type, exclude_types))
|
? not(
|
||||||
: undefined,
|
inArray(
|
||||||
// Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
|
notification.type,
|
||||||
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
|
exclude_types,
|
||||||
// Filters table has a userId and a context which is an array
|
),
|
||||||
sql`NOT EXISTS (
|
)
|
||||||
SELECT 1
|
: undefined,
|
||||||
FROM "Filters"
|
// Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
|
||||||
WHERE "Filters"."userId" = ${user.id}
|
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
|
||||||
AND "Filters"."filter_action" = 'hide'
|
// Filters table has a userId and a context which is an array
|
||||||
AND EXISTS (
|
sql`NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
|
FROM "Filters"
|
||||||
WHERE "FilterKeywords"."filterId" = "Filters"."id"
|
WHERE "Filters"."userId" = ${user.id}
|
||||||
AND "n_inner"."noteId" = "Notes"."id"
|
AND "Filters"."filter_action" = 'hide'
|
||||||
AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%'
|
AND EXISTS (
|
||||||
AND "n_inner"."id" = "Notifications"."id"
|
SELECT 1
|
||||||
)
|
FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
|
||||||
AND "Filters"."context" @> ARRAY['notifications']
|
WHERE "FilterKeywords"."filterId" = "Filters"."id"
|
||||||
)`,
|
AND "n_inner"."noteId" = "Notes"."id"
|
||||||
),
|
AND "Notes"."content" LIKE
|
||||||
limit,
|
'%' || "FilterKeywords"."keyword" || '%'
|
||||||
// @ts-expect-error Yes I KNOW the types are wrong
|
AND "n_inner"."id" = "Notifications"."id"
|
||||||
orderBy: (notification, { desc }) => desc(notification.id),
|
)
|
||||||
},
|
AND "Filters"."context" @> ARRAY['notifications']
|
||||||
req,
|
)`,
|
||||||
);
|
),
|
||||||
|
limit,
|
||||||
|
// @ts-expect-error Yes I KNOW the types are wrong
|
||||||
|
orderBy: (notification, { desc }) =>
|
||||||
|
desc(notification.id),
|
||||||
|
},
|
||||||
|
context.req.raw,
|
||||||
|
);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
await Promise.all(objects.map((n) => notificationToAPI(n))),
|
await Promise.all(objects.map((n) => notificationToAPI(n))),
|
||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
Link: link,
|
Link: link,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Users } from "~drizzle/schema";
|
import { Users } from "~drizzle/schema";
|
||||||
import { User } from "~packages/database-interface/user";
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
@ -17,18 +18,24 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export default (app: Hono) =>
|
||||||
* Deletes a user avatar
|
app.on(
|
||||||
*/
|
meta.allowedMethods,
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
meta.route,
|
||||||
const { user: self } = extraData.auth;
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
await db.update(Users).set({ avatar: "" }).where(eq(Users.id, self.id));
|
await db
|
||||||
|
.update(Users)
|
||||||
|
.set({ avatar: "" })
|
||||||
|
.where(eq(Users.id, self.id));
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
...(await User.fromId(self.id))?.toAPI(),
|
...(await User.fromId(self.id))?.toAPI(),
|
||||||
avatar: "",
|
avatar: "",
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { applyConfig, auth } from "@api";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Users } from "~drizzle/schema";
|
import { Users } from "~drizzle/schema";
|
||||||
import { User } from "~packages/database-interface/user";
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
@ -17,19 +18,24 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export default (app: Hono) =>
|
||||||
* Deletes a user header
|
app.on(
|
||||||
*/
|
meta.allowedMethods,
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
meta.route,
|
||||||
const { user: self } = extraData.auth;
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { user: self } = context.req.valid("header");
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
// Delete user header
|
await db
|
||||||
await db.update(Users).set({ header: "" }).where(eq(Users.id, self.id));
|
.update(Users)
|
||||||
|
.set({ header: "" })
|
||||||
|
.where(eq(Users.id, self.id));
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
...(await User.fromId(self.id))?.toAPI(),
|
...(await User.fromId(self.id))?.toAPI(),
|
||||||
header: "",
|
header: "",
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
54
server/api/api/v1/statuses/:id/context.ts
Normal file
54
server/api/api/v1/statuses/:id/context.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Note } from "~packages/database-interface/note";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 8,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/statuses/:id/context",
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
const foundStatus = await Note.fromId(id);
|
||||||
|
|
||||||
|
if (!foundStatus) return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
const ancestors = await foundStatus.getAncestors(user ?? null);
|
||||||
|
|
||||||
|
const descendants = await foundStatus.getDescendants(user ?? null);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
ancestors: await Promise.all(
|
||||||
|
ancestors.map((status) => status.toAPI(user)),
|
||||||
|
),
|
||||||
|
descendants: await Promise.all(
|
||||||
|
descendants.map((status) => status.toAPI(user)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
65
server/api/api/v1/statuses/:id/favourite.ts
Normal file
65
server/api/api/v1/statuses/:id/favourite.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createLike } from "~database/entities/Like";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Note } from "~packages/database-interface/note";
|
||||||
|
import type { Status as APIStatus } from "~types/mastodon/status";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/statuses/:id/favourite",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const note = await Note.fromId(id);
|
||||||
|
|
||||||
|
if (!note?.isViewableByUser(user))
|
||||||
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
const existingLike = await db.query.Likes.findFirst({
|
||||||
|
where: (like, { and, eq }) =>
|
||||||
|
and(
|
||||||
|
eq(like.likedId, note.getStatus().id),
|
||||||
|
eq(like.likerId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingLike) {
|
||||||
|
await createLike(user, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
...(await note.toAPI(user)),
|
||||||
|
favourited: true,
|
||||||
|
favourites_count: note.getStatus().likeCount + 1,
|
||||||
|
} as APIStatus);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -20,17 +20,19 @@ afterAll(async () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
for (const status of timeline) {
|
for (const status of timeline) {
|
||||||
const res = await fetch(
|
const res = await sendTestRequest(
|
||||||
new URL(
|
new Request(
|
||||||
`/api/v1/statuses/${status.id}/favourite`,
|
new URL(
|
||||||
config.http.base_url,
|
`/api/v1/statuses/${status.id}/favourite`,
|
||||||
),
|
config.http.base_url,
|
||||||
{
|
),
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
75
server/api/api/v1/statuses/:id/favourited_by.ts
Normal file
75
server/api/api/v1/statuses/:id/favourited_by.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Users } from "~drizzle/schema";
|
||||||
|
import { Note } from "~packages/database-interface/note";
|
||||||
|
import { Timeline } from "~packages/database-interface/timeline";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/statuses/:id/favourited_by",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
query: z.object({
|
||||||
|
max_id: z.string().regex(idValidator).optional(),
|
||||||
|
since_id: z.string().regex(idValidator).optional(),
|
||||||
|
min_id: z.string().regex(idValidator).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||||
|
}),
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("query", schemas.query, handleZodError),
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { max_id, since_id, min_id, limit } =
|
||||||
|
context.req.valid("query");
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const status = await Note.fromId(id);
|
||||||
|
|
||||||
|
if (!status?.isViewableByUser(user))
|
||||||
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
const { objects, link } = await Timeline.getUserTimeline(
|
||||||
|
and(
|
||||||
|
max_id ? lt(Users.id, max_id) : undefined,
|
||||||
|
since_id ? gte(Users.id, since_id) : undefined,
|
||||||
|
min_id ? gt(Users.id, min_id) : undefined,
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`,
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
context.req.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
objects.map((user) => user.toAPI()),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Link: link,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
160
server/api/api/v1/statuses/:id/index.ts
Normal file
160
server/api/api/v1/statuses/:id/index.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { config } from "config-manager";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import ISO6391 from "iso-639-1";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Note } from "~packages/database-interface/note";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["GET", "DELETE", "PUT"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/statuses/:id",
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
requiredOnMethods: ["DELETE", "PUT"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().regex(idValidator),
|
||||||
|
}),
|
||||||
|
form: z.object({
|
||||||
|
status: z.string().max(config.validation.max_note_size).optional(),
|
||||||
|
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
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.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
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
"poll[hide_totals]": z
|
||||||
|
.string()
|
||||||
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
zValidator("form", schemas.form, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
const foundStatus = await Note.fromId(id);
|
||||||
|
|
||||||
|
if (!foundStatus?.isViewableByUser(user))
|
||||||
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
if (context.req.method === "GET") {
|
||||||
|
return jsonResponse(await foundStatus.toAPI(user));
|
||||||
|
}
|
||||||
|
if (context.req.method === "DELETE") {
|
||||||
|
if (foundStatus.getAuthor().id !== user?.id) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Delete and redraft
|
||||||
|
|
||||||
|
await foundStatus.delete();
|
||||||
|
|
||||||
|
return jsonResponse(await foundStatus.toAPI(user), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Polls
|
||||||
|
const {
|
||||||
|
status: statusText,
|
||||||
|
content_type,
|
||||||
|
"poll[options]": options,
|
||||||
|
media_ids,
|
||||||
|
spoiler_text,
|
||||||
|
sensitive,
|
||||||
|
} = context.req.valid("form");
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return errorResponse(
|
||||||
|
"Cannot attach poll to post with media",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.filters.note_content.some((filter) =>
|
||||||
|
statusText?.match(filter),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return errorResponse("Status contains blocked words", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media_ids && media_ids.length > 0) {
|
||||||
|
const foundAttachments = await db.query.Attachments.findMany({
|
||||||
|
where: (attachment, { inArray }) =>
|
||||||
|
inArray(attachment.id, media_ids),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundAttachments.length !== (media_ids ?? []).length) {
|
||||||
|
return errorResponse("Invalid media IDs", 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNote = await foundStatus.updateFromData(
|
||||||
|
statusText
|
||||||
|
? {
|
||||||
|
[content_type]: {
|
||||||
|
content: statusText,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
undefined,
|
||||||
|
sensitive,
|
||||||
|
spoiler_text,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
media_ids,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newNote) {
|
||||||
|
return errorResponse("Failed to update status", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(await newNote.toAPI(user));
|
||||||
|
},
|
||||||
|
);
|
||||||
65
server/api/api/v1/statuses/:id/pin.ts
Normal file
65
server/api/api/v1/statuses/:id/pin.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { applyConfig, auth, handleZodError, idValidator } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Note } from "~packages/database-interface/note";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/statuses/:id/pin",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().regex(idValidator),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const foundStatus = await Note.fromId(id);
|
||||||
|
|
||||||
|
if (!foundStatus) return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
if (foundStatus.getAuthor().id !== user.id)
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
if (
|
||||||
|
await db.query.UserToPinnedNotes.findFirst({
|
||||||
|
where: (userPinnedNote, { and, eq }) =>
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
userPinnedNote.noteId,
|
||||||
|
foundStatus.getStatus().id,
|
||||||
|
),
|
||||||
|
eq(userPinnedNote.userId, user.id),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return errorResponse("Already pinned", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.pin(foundStatus);
|
||||||
|
|
||||||
|
return jsonResponse(await foundStatus.toAPI(user));
|
||||||
|
},
|
||||||
|
);
|
||||||
92
server/api/api/v1/statuses/:id/reblog.ts
Normal file
92
server/api/api/v1/statuses/:id/reblog.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { applyConfig, auth, handleZodError } from "@api";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Notes, Notifications } from "~drizzle/schema";
|
||||||
|
import { Note } from "~packages/database-interface/note";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 100,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/v1/statuses/:id/reblog",
|
||||||
|
auth: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
param: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
form: z.object({
|
||||||
|
visibility: z.enum(["public", "unlisted", "private"]).default("public"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Hono) =>
|
||||||
|
app.on(
|
||||||
|
meta.allowedMethods,
|
||||||
|
meta.route,
|
||||||
|
zValidator("param", schemas.param, handleZodError),
|
||||||
|
zValidator("form", schemas.form, handleZodError),
|
||||||
|
auth(meta.auth),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { visibility } = context.req.valid("form");
|
||||||
|
const { user } = context.req.valid("header");
|
||||||
|
|
||||||
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const foundStatus = await Note.fromId(id);
|
||||||
|
|
||||||
|
if (!foundStatus?.isViewableByUser(user))
|
||||||
|
return errorResponse("Record not found", 404);
|
||||||
|
|
||||||
|
const existingReblog = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.authorId, user.id),
|
||||||
|
eq(Notes.reblogId, foundStatus.getStatus().id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingReblog) {
|
||||||
|
return errorResponse("Already reblogged", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newReblog = await Note.insert({
|
||||||
|
authorId: user.id,
|
||||||
|
reblogId: foundStatus.getStatus().id,
|
||||||
|
visibility,
|
||||||
|
sensitive: false,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
applicationId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!newReblog) {
|
||||||
|
return errorResponse("Failed to reblog", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalNewReblog = await Note.fromId(newReblog.id);
|
||||||
|
|
||||||
|
if (!finalNewReblog) {
|
||||||
|
return errorResponse("Failed to reblog", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundStatus.getAuthor().isLocal() && user.isLocal()) {
|
||||||
|
await db.insert(Notifications).values({
|
||||||
|
accountId: user.id,
|
||||||
|
notifiedId: foundStatus.getAuthor().id,
|
||||||
|
type: "reblog",
|
||||||
|
noteId: newReblog.reblogId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(await finalNewReblog.toAPI(user));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -20,17 +20,19 @@ afterAll(async () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
for (const status of timeline) {
|
for (const status of timeline) {
|
||||||
await fetch(
|
await sendTestRequest(
|
||||||
new URL(
|
new Request(
|
||||||
`/api/v1/statuses/${status.id}/reblog`,
|
new URL(
|
||||||
config.http.base_url,
|
`/api/v1/statuses/${status.id}/reblog`,
|
||||||
),
|
config.http.base_url,
|
||||||
{
|
),
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue