feat: add logging to next api routes
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,9 @@ export default withRateLimit(
|
|||||||
try {
|
try {
|
||||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Storage endpoint misconfigured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||||
@@ -66,10 +69,7 @@ export default withRateLimit(
|
|||||||
const buffer = await upstream.arrayBuffer();
|
const buffer = await upstream.arrayBuffer();
|
||||||
return res.send(Buffer.from(buffer));
|
return res.send(Buffer.from(buffer));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error downloading attachment:", error);
|
return res.status(500).json({ message: "Failed to download attachment" });
|
||||||
return res
|
|
||||||
.status(500)
|
|
||||||
.json({ message: "Failed to download attachment" });
|
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -18,8 +19,7 @@ export default withRateLimit(
|
|||||||
|
|
||||||
return res.status(200).json(data);
|
return res.status(200).json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching OSS friends:", error);
|
|
||||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -28,8 +29,7 @@ export default withRateLimit(
|
|||||||
|
|
||||||
return res.status(200).json({ url: session.url });
|
return res.status(200).json({ url: session.url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error:", error);
|
|
||||||
return res.status(500).json({ error: "Error creating portal session" });
|
return res.status(500).json({ error: "Error creating portal session" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { addYears } from "date-fns";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { integrations } from "@kan/db/schema";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { addYears } from "date-fns";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import { integrations } from "@kan/db/schema";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -20,22 +21,26 @@ export default withRateLimit(
|
|||||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||||
|
|
||||||
if (!apiKey)
|
if (!apiKey)
|
||||||
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Trello API key not set in Environment Variables" });
|
||||||
|
|
||||||
const token = req.body.token;
|
const token = req.body.token;
|
||||||
|
|
||||||
if (!token)
|
if (!token) return res.status(400).json({ message: "No token found" });
|
||||||
return res.status(400).json({ message: "No token found" });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { db } = await createNextApiContext(req);
|
const { db } = await createNextApiContext(req);
|
||||||
|
|
||||||
await db.insert(integrations).values({
|
await db
|
||||||
|
.insert(integrations)
|
||||||
|
.values({
|
||||||
provider: "trello",
|
provider: "trello",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
accessToken: token,
|
accessToken: token,
|
||||||
expiresAt: addYears(new Date(), 1),
|
expiresAt: addYears(new Date(), 1),
|
||||||
}).onConflictDoUpdate({
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
set: {
|
set: {
|
||||||
accessToken: token,
|
accessToken: token,
|
||||||
expiresAt: addYears(new Date(), 1),
|
expiresAt: addYears(new Date(), 1),
|
||||||
@@ -43,10 +48,11 @@ export default withRateLimit(
|
|||||||
target: [integrations.userId, integrations.provider],
|
target: [integrations.userId, integrations.provider],
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json({ message: "Trello authentication successful" });
|
return res
|
||||||
|
.status(200)
|
||||||
|
.json({ message: "Trello authentication successful" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Trello authentication error:", err);
|
|
||||||
return res.status(400).json({ message: "Trello authentication failed" });
|
return res.status(400).json({ message: "Trello authentication failed" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
@@ -3,9 +3,11 @@ import { Novu } from "@novu/api";
|
|||||||
import { jwtVerify } from "jose";
|
import { jwtVerify } from "jose";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -22,6 +24,7 @@ const textEncoder = new TextEncoder();
|
|||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
|
withApiLogging(
|
||||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -91,7 +94,6 @@ export default withRateLimit(
|
|||||||
payload.subscriberId,
|
payload.subscriberId,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update Novu preferences", error);
|
|
||||||
return res.status(502).json({
|
return res.status(502).json({
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
@@ -102,4 +104,5 @@ export default withRateLimit(
|
|||||||
|
|
||||||
return res.status(200).json({ success: true });
|
return res.status(200).json({ success: true });
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { Upload } from "@aws-sdk/lib-storage";
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { assertPermission } from "@kan/api/utils/permissions";
|
import { assertPermission } from "@kan/api/utils/permissions";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
@@ -22,7 +23,7 @@ export const config = {
|
|||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -36,7 +37,9 @@ export default withRateLimit(
|
|||||||
|
|
||||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Attachments bucket not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardPublicId = req.query.cardPublicId;
|
const cardPublicId = req.query.cardPublicId;
|
||||||
@@ -55,7 +58,9 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing or invalid content length" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentLength > MAX_SIZE_BYTES) {
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
@@ -129,8 +134,7 @@ export default withRateLimit(
|
|||||||
|
|
||||||
return res.status(200).json({ attachment });
|
return res.status(200).json({ attachment });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Attachment upload failed", error);
|
|
||||||
return res.status(500).json({ error: "Internal server error" });
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import { createS3Client } from "@kan/shared/utils";
|
import { createS3Client } from "@kan/shared/utils";
|
||||||
|
|
||||||
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
const MAX_SIZE_BYTES = parseInt(
|
||||||
|
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
|
||||||
|
10,
|
||||||
|
); // Default 2MB
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
@@ -19,7 +23,7 @@ export const config = {
|
|||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -51,7 +55,9 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing or invalid content length" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentLength > MAX_SIZE_BYTES) {
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
@@ -93,9 +99,7 @@ export default withRateLimit(
|
|||||||
user: updatedUser,
|
user: updatedUser,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Avatar upload failed", error);
|
|
||||||
return res.status(500).json({ error: "Internal server error" });
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
"types": "./dist/utils/rateLimit.d.ts",
|
"types": "./dist/utils/rateLimit.d.ts",
|
||||||
"default": "./src/utils/rateLimit.ts"
|
"default": "./src/utils/rateLimit.ts"
|
||||||
},
|
},
|
||||||
|
"./utils/apiLogging": {
|
||||||
|
"types": "./dist/utils/apiLogging.d.ts",
|
||||||
|
"default": "./src/utils/apiLogging.ts"
|
||||||
|
},
|
||||||
"./utils/permissions": {
|
"./utils/permissions": {
|
||||||
"types": "./dist/utils/permissions.d.ts",
|
"types": "./dist/utils/permissions.d.ts",
|
||||||
"default": "./src/utils/permissions.ts"
|
"default": "./src/utils/permissions.ts"
|
||||||
|
|||||||
64
packages/api/src/utils/apiLogging.ts
Normal file
64
packages/api/src/utils/apiLogging.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { createLogger } from "@kan/logger";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "../trpc";
|
||||||
|
|
||||||
|
const log = createLogger("api");
|
||||||
|
|
||||||
|
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
|
||||||
|
|
||||||
|
export function withApiLogging(
|
||||||
|
handler: (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) => Promise<unknown> | unknown,
|
||||||
|
) {
|
||||||
|
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const route = req.url?.split("?")[0] ?? "unknown";
|
||||||
|
const input = {
|
||||||
|
...(req.query && Object.keys(req.query).length > 0 && { query: req.query }),
|
||||||
|
...(req.body && typeof req.body === "object" && Object.keys(req.body).length > 0 && { body: req.body }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let statusCode = 200;
|
||||||
|
const originalStatus = res.status.bind(res);
|
||||||
|
res.status = (code: number) => {
|
||||||
|
statusCode = code;
|
||||||
|
return originalStatus(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
let userId: string | undefined;
|
||||||
|
let email: string | undefined;
|
||||||
|
try {
|
||||||
|
const ctx = await createNextApiContext(req);
|
||||||
|
userId = ctx.user?.id;
|
||||||
|
email = ctx.user?.email ?? undefined;
|
||||||
|
} catch {
|
||||||
|
// unauthenticated or auth unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const meta = {
|
||||||
|
requestId,
|
||||||
|
procedure: route,
|
||||||
|
transport: "rest",
|
||||||
|
duration,
|
||||||
|
userId,
|
||||||
|
...(isCloud && email && { email }),
|
||||||
|
...(Object.keys(input).length > 0 && { input }),
|
||||||
|
status: statusCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (statusCode < 400) {
|
||||||
|
log.info(meta, "API OK");
|
||||||
|
} else {
|
||||||
|
log.error(meta, "API error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user