re-vibecode
This commit is contained in:
+102
-146
@@ -1,21 +1,43 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { login, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service";
|
||||
import { HttpError } from "./lib/errors";
|
||||
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http";
|
||||
import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, seedAdminAccount } from "./auth/service";
|
||||
import { cleanupDatabase } from "./lib/db";
|
||||
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
||||
import { config } from "./lib/config";
|
||||
import { assertAllowedOrigin, enforceLimiter, globalLimiter, loginLimiter, routeLimiter } from "./lib/security";
|
||||
import { composeSchema, forwardingSchema, loginSchema, messageIdSchema, profileSchema, strictEmailSchema, totpCodeSchema } from "./lib/validation";
|
||||
import { composeAndSendMessage, getMailboxFolderLabel, getMessageForMailbox, listMessagesForMailbox } from "./mail/service";
|
||||
import { getAvatarFile, getForwardingSettings, getProfileSettings, saveAvatar, updateDisplayName, updateForwarding } from "./settings/service";
|
||||
import {
|
||||
createUserSchema,
|
||||
loginSchema,
|
||||
parseRouteEmail,
|
||||
resetPasswordSchema,
|
||||
resetTokenSchema,
|
||||
updatePasswordSchema
|
||||
} from "./lib/validation";
|
||||
import {
|
||||
consumePasswordReset,
|
||||
createUser,
|
||||
deleteUser,
|
||||
inspectPasswordReset,
|
||||
listUsers,
|
||||
triggerPasswordReset,
|
||||
updateUserPassword
|
||||
} from "./users/service";
|
||||
|
||||
const app = new Elysia();
|
||||
|
||||
const route = (handler: (request: Request, params?: Record<string, string>) => Promise<Response> | Response) =>
|
||||
const route = (
|
||||
handler: (context: {
|
||||
request: Request;
|
||||
params: Record<string, string>;
|
||||
}) => Promise<Response> | Response
|
||||
) =>
|
||||
async ({ request, params }: { request: Request; params: Record<string, string> }) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
return optionsResponse(request);
|
||||
}
|
||||
|
||||
try {
|
||||
assertAllowedOrigin(request);
|
||||
enforceLimiter(globalLimiter, request, "global", config.globalRateLimit, config.rateLimitWindowMs);
|
||||
return await handler(request, params);
|
||||
cleanupDatabase();
|
||||
return await handler({ request, params });
|
||||
} catch (error) {
|
||||
return errorResponse(request, error);
|
||||
}
|
||||
@@ -23,179 +45,113 @@ const route = (handler: (request: Request, params?: Record<string, string>) => P
|
||||
|
||||
await seedAdminAccount();
|
||||
|
||||
app.onRequest(({ request }) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
return emptyResponse(request, 204);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/health",
|
||||
route(async (request) =>
|
||||
route(async ({ request }) =>
|
||||
jsonResponse(request, 200, {
|
||||
ok: true,
|
||||
mode: config.nodeEnv,
|
||||
folder: getMailboxFolderLabel()
|
||||
ok: true
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/auth/session",
|
||||
route(async (request) => {
|
||||
try {
|
||||
const session = await getSessionFromRequest(request);
|
||||
return jsonResponse(request, 200, { session: session.session });
|
||||
} catch {
|
||||
return jsonResponse(request, 200, { session: getAnonymousSession() });
|
||||
}
|
||||
})
|
||||
route(async ({ request }) =>
|
||||
jsonResponse(request, 200, {
|
||||
session: getSessionFromRequest(request)
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/auth/login",
|
||||
route(async (request) => {
|
||||
if (!config.allowInsecureLogin) {
|
||||
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
|
||||
}
|
||||
route(async ({ request }) => {
|
||||
const body = await parseJson(request, loginSchema);
|
||||
const result = await login(body.email, body.password, body.totpCode, request);
|
||||
const result = await loginAdmin(body.email, body.password);
|
||||
|
||||
return jsonResponse(
|
||||
request,
|
||||
200,
|
||||
{ session: result.session }
|
||||
{ session: result.session },
|
||||
{ "set-cookie": result.cookie }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/auth/logout",
|
||||
route(async (request) => {
|
||||
return jsonResponse(request, 200, { session: getAnonymousSession() });
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/auth/totp/setup",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
const setup = await createTotpSecret(session.email);
|
||||
return jsonResponse(request, 200, setup);
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/auth/totp/enable",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
const body = await parseJson(request, totpCodeSchema);
|
||||
await enableTotp(session.email, body.code);
|
||||
return emptyResponse(request, 204);
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/auth/totp/disable",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
const body = await parseJson(request, totpCodeSchema);
|
||||
await disableTotp(session.email, body.code);
|
||||
return emptyResponse(request, 204);
|
||||
})
|
||||
route(async ({ request }) =>
|
||||
jsonResponse(
|
||||
request,
|
||||
200,
|
||||
{ session: { authenticated: false, adminEmail: null } },
|
||||
{ "set-cookie": logoutAdmin(request) }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/settings/profile",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
return jsonResponse(request, 200, getProfileSettings(session.email));
|
||||
"/api/users",
|
||||
route(async ({ request }) => {
|
||||
requireAdminSession(request);
|
||||
return jsonResponse(request, 200, { items: await listUsers() });
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/users",
|
||||
route(async ({ request }) => {
|
||||
const session = requireAdminSession(request);
|
||||
const body = await parseJson(request, createUserSchema);
|
||||
const result = await createUser(body.email, session.adminEmail);
|
||||
return jsonResponse(request, 201, result);
|
||||
})
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/api/settings/profile",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
const body = await parseJson(request, profileSchema);
|
||||
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName));
|
||||
"/api/users/:email/password",
|
||||
route(async ({ request, params }) => {
|
||||
requireAdminSession(request);
|
||||
const email = parseRouteEmail(params.email);
|
||||
const body = await parseJson(request, updatePasswordSchema);
|
||||
await updateUserPassword(email, body.password);
|
||||
return emptyResponse(request, 204);
|
||||
})
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/api/users/:email",
|
||||
route(async ({ request, params }) => {
|
||||
requireAdminSession(request);
|
||||
const email = parseRouteEmail(params.email);
|
||||
await deleteUser(email);
|
||||
return emptyResponse(request, 204);
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/settings/avatar",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
const formData = await request.formData();
|
||||
const avatar = formData.get("avatar");
|
||||
if (!(avatar instanceof File)) {
|
||||
throw new HttpError(400, "Avatar upload is required.");
|
||||
}
|
||||
|
||||
const profile = await saveAvatar(session.email, avatar);
|
||||
return jsonResponse(request, 200, profile);
|
||||
})
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/settings/avatar/:email",
|
||||
route(async (request, params) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
const targetEmail = strictEmailSchema.parse(params?.email ?? "");
|
||||
if (targetEmail !== session.email && session.role !== "admin") {
|
||||
throw new HttpError(403, "You can only access your own avatar.");
|
||||
}
|
||||
|
||||
const file = getAvatarFile(targetEmail);
|
||||
return fileResponse(request, file, file.type || "application/octet-stream");
|
||||
})
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/settings/forwarding",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
return jsonResponse(request, 200, getForwardingSettings(session.email));
|
||||
})
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/settings/forwarding",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
const body = await parseJson(request, forwardingSchema);
|
||||
const rule = await updateForwarding(session.email, body.enabled, body.target);
|
||||
return jsonResponse(request, 200, rule);
|
||||
})
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/mail/messages",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
enforceLimiter(routeLimiter, request, "messages", config.routeRateLimit, config.rateLimitWindowMs, session.email);
|
||||
const items = await listMessagesForMailbox(session.email);
|
||||
return jsonResponse(request, 200, { items });
|
||||
})
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/mail/messages/:id",
|
||||
route(async (request, params) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
enforceLimiter(routeLimiter, request, "message-detail", config.routeRateLimit, config.rateLimitWindowMs, session.email);
|
||||
const { id } = messageIdSchema.parse({ id: params?.id ?? "" });
|
||||
const item = await getMessageForMailbox(session.email, id);
|
||||
return jsonResponse(request, 200, item);
|
||||
"/api/users/:email/password-reset",
|
||||
route(async ({ request, params }) => {
|
||||
const session = requireAdminSession(request);
|
||||
const email = parseRouteEmail(params.email);
|
||||
const result = await triggerPasswordReset(email, session.adminEmail);
|
||||
return jsonResponse(request, 200, result);
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/mail/send",
|
||||
route(async (request) => {
|
||||
const session = await getSessionFromRequest(request);
|
||||
enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email);
|
||||
const body = await parseJson(request, composeSchema);
|
||||
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody);
|
||||
"/api/password-resets/inspect",
|
||||
route(async ({ request }) => {
|
||||
const body = await parseJson(request, resetTokenSchema);
|
||||
return jsonResponse(request, 200, await inspectPasswordReset(body.token));
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/password-resets/consume",
|
||||
route(async ({ request }) => {
|
||||
const body = await parseJson(request, resetPasswordSchema);
|
||||
await consumePasswordReset(body.token, body.password);
|
||||
return emptyResponse(request, 204);
|
||||
})
|
||||
);
|
||||
@@ -205,4 +161,4 @@ app.listen({
|
||||
port: config.port
|
||||
});
|
||||
|
||||
console.log(`Secure webmail API listening on http://${config.host}:${config.port}`);
|
||||
console.log(`Mail admin API listening on http://${config.host}:${config.port}`);
|
||||
|
||||
Reference in New Issue
Block a user