refactor(api): ♻️ Move to Hono for HTTP

This commit is contained in:
Jesse Wierzbinski 2024-05-06 07:16:33 +00:00
parent 2237be3689
commit 826a260e90
No known key found for this signature in database
155 changed files with 7226 additions and 6077 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -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,

View file

@ -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 };

View file

@ -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",

View file

@ -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));
} }

View file

@ -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

View file

@ -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,
};

View file

@ -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 (

View file

@ -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) => {

View file

@ -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}`, },
}, });
}); });
});

View file

@ -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
}`,
});
},
);

View file

@ -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, },
}); );
},
);

View file

@ -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,
});
}); });
});

View file

@ -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);
}); },
);

View 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));
},
);

View file

@ -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({}),
}, },
), ),
); );

View 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));
},
);

View file

@ -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({}),
}, },
), ),
); );

View 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,
},
);
},
);

View file

@ -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({}),
}, },
), ),
); );

View 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,
},
);
},
);

View file

@ -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 () => {

View 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));
},
);

View file

@ -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({}),
}, },
), ),
); );

View 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));
},
);

View 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));
},
);

View 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));
},
);

View 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));
},
);

View file

@ -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,

View 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,
},
);
},
);

View 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));
},
);

View 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));
},
);

View 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));
},
);

View 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));
},
);

View file

@ -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));
});

View file

@ -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));
},
);

View file

@ -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,
},
);
},
);

View file

@ -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,
},
);
},
);

View file

@ -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));
});

View file

@ -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));
},
);

View file

@ -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));
},
);

View file

@ -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));
});

View file

@ -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));
});

View file

@ -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,
},
);
},
);

View file

@ -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));
});

View file

@ -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));
});

View file

@ -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));
});

View file

@ -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));
});

View file

@ -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()));
},
);

View file

@ -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);
}, },
); );

View file

@ -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,
);
},
);

View file

@ -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)));
},
);

View file

@ -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()));
}, },
); );

View file

@ -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());
},
);

View file

@ -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)); );
});

View file

@ -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,
}); });
}, },
); );

View file

@ -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,
}); });
}); },
);

View file

@ -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,
}, },
); );
}, },
); );

View file

@ -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))),
); );
}); });

View file

@ -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,
}, },
); );
},
);

View 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));
},
);

View 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));
},
);

View file

@ -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));
});

View file

@ -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));
});

View file

@ -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,
}, },
); );
}, },
); );

View file

@ -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,
}); });
});

View file

@ -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;
});
}); });
});

View file

@ -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: "",
})),
);
},
); );
});

View file

@ -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);

View file

@ -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);
}
}
},
);

View 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);
},
);

View file

@ -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);
},
);

View file

@ -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));
},
);

View file

@ -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({}),
}, },
), ),
); );

View file

@ -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,
},
);
},
);

View file

@ -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);

View 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({});
},
);

View file

@ -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 () => {

View 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);
},
);

View file

@ -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({});
});

View file

@ -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);
});

View file

@ -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);

View file

@ -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({});
}); },
);

View file

@ -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);

View file

@ -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({});
},
);

View file

@ -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",
}),
}), }),
); );

View file

@ -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,
}, },
); );
}, },
); );

View file

@ -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: "",
}); });
}); },
);

View file

@ -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: "",
}); });
}); },
);

View 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)),
),
});
},
);

View 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);
},
);

View file

@ -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}`,
},
}, },
}, ),
); );
} }
}); });

View 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,
},
);
},
);

View 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));
},
);

View 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));
},
);

View 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));
},
);

View file

@ -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