mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
fix(segurança): endurecer autenticação e rotas privadas
This commit is contained in:
@@ -5,6 +5,10 @@ import { getUserId } from "@/shared/lib/auth/server";
|
|||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
{ params }: { params: Promise<{ attachmentId: string }> },
|
{ params }: { params: Promise<{ attachmentId: string }> },
|
||||||
@@ -19,9 +23,20 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json(
|
||||||
|
{ error: "Not found" },
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await createPresignedGetUrl(row.fileKey);
|
const url = await createPresignedGetUrl(row.fileKey);
|
||||||
return NextResponse.json({ url });
|
return NextResponse.json(
|
||||||
|
{ url },
|
||||||
|
{
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -16,14 +16,17 @@ const createTokenSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
await connection();
|
||||||
|
|
||||||
|
// Verificar autenticação via sessão web
|
||||||
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar autenticação via sessão web
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar body
|
// Validar body
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, deviceId } = createTokenSchema.parse(body);
|
const { name, deviceId } = createTokenSchema.parse(body);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -10,16 +10,19 @@ interface RouteParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||||
|
await connection();
|
||||||
|
|
||||||
|
const { tokenId } = await params;
|
||||||
|
|
||||||
|
// Verificar autenticação via sessão web
|
||||||
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tokenId } = await params;
|
|
||||||
|
|
||||||
// Verificar autenticação via sessão web
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se token pertence ao usuário
|
// Verificar se token pertence ao usuário
|
||||||
const token = await db.query.apiTokens.findFirst({
|
const token = await db.query.apiTokens.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
await connection();
|
||||||
|
|
||||||
|
// Verificar autenticação via sessão web
|
||||||
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar autenticação via sessão web
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buscar tokens ativos do usuário
|
// Buscar tokens ativos do usuário
|
||||||
const activeTokens = await db
|
const activeTokens = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
34
src/app/api/insights/saved/route.ts
Normal file
34
src/app/api/insights/saved/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
fetchSavedInsights,
|
||||||
|
savedInsightsPeriodSchema,
|
||||||
|
} from "@/features/insights/queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const period = new URL(request.url).searchParams.get("period") ?? "";
|
||||||
|
const validatedPeriod = savedInsightsPeriodSchema.safeParse(period);
|
||||||
|
|
||||||
|
if (!validatedPeriod.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await getUserId();
|
||||||
|
const insights = await fetchSavedInsights(userId, validatedPeriod.data);
|
||||||
|
|
||||||
|
return NextResponse.json(insights, {
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ transactionId: string }> },
|
||||||
|
) {
|
||||||
|
const [userId, { transactionId }] = await Promise.all([getUserId(), params]);
|
||||||
|
const attachments = await fetchTransactionAttachments(userId, transactionId);
|
||||||
|
|
||||||
|
return NextResponse.json(attachments, {
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ seriesId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const [userId, { seriesId }] = await Promise.all([getUserId(), params]);
|
||||||
|
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
|
||||||
|
|
||||||
|
return NextResponse.json(anticipations, {
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar histórico de antecipações:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Erro ao carregar histórico de antecipações.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,46 @@ export async function createInstallmentAnticipationAction(
|
|||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = createAnticipationSchema.parse(input);
|
const data = createAnticipationSchema.parse(input);
|
||||||
|
|
||||||
|
if (data.payerId || data.categoryId) {
|
||||||
|
const [payer, category] = await Promise.all([
|
||||||
|
data.payerId
|
||||||
|
? db
|
||||||
|
.select({ id: payers.id })
|
||||||
|
.from(payers)
|
||||||
|
.where(
|
||||||
|
and(eq(payers.id, data.payerId), eq(payers.userId, user.id)),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
data.categoryId
|
||||||
|
? db
|
||||||
|
.select({ id: categories.id })
|
||||||
|
.from(categories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(categories.id, data.categoryId),
|
||||||
|
eq(categories.userId, user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (data.payerId && payer.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Pagador inválido para esta conta.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.categoryId && category.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Categoria inválida para esta conta.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Validar parcelas selecionadas
|
// 1. Validar parcelas selecionadas
|
||||||
const installments = await db.query.transactions.findMany({
|
const installments = await db.query.transactions.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
99
src/features/transactions/anticipation-queries.ts
Normal file
99
src/features/transactions/anticipation-queries.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
categories,
|
||||||
|
installmentAnticipations,
|
||||||
|
payers,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
|
|
||||||
|
export type InstallmentAnticipationListItem = {
|
||||||
|
id: string;
|
||||||
|
anticipationPeriod: string;
|
||||||
|
anticipationDate: string;
|
||||||
|
installmentCount: number;
|
||||||
|
totalAmount: string;
|
||||||
|
discount: string;
|
||||||
|
transactionId: string;
|
||||||
|
note: string | null;
|
||||||
|
transaction: {
|
||||||
|
isSettled: boolean | null;
|
||||||
|
} | null;
|
||||||
|
payer: {
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
category: {
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchInstallmentAnticipations(
|
||||||
|
userId: string,
|
||||||
|
seriesId: string,
|
||||||
|
): Promise<InstallmentAnticipationListItem[]> {
|
||||||
|
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
|
||||||
|
|
||||||
|
const anticipations = await db
|
||||||
|
.select({
|
||||||
|
id: installmentAnticipations.id,
|
||||||
|
anticipationPeriod: installmentAnticipations.anticipationPeriod,
|
||||||
|
anticipationDate: installmentAnticipations.anticipationDate,
|
||||||
|
installmentCount: installmentAnticipations.installmentCount,
|
||||||
|
totalAmount: installmentAnticipations.totalAmount,
|
||||||
|
discount: installmentAnticipations.discount,
|
||||||
|
transactionId: installmentAnticipations.transactionId,
|
||||||
|
note: installmentAnticipations.note,
|
||||||
|
transactionRecordId: transactions.id,
|
||||||
|
transactionIsSettled: transactions.isSettled,
|
||||||
|
payerName: payers.name,
|
||||||
|
categoryName: categories.name,
|
||||||
|
})
|
||||||
|
.from(installmentAnticipations)
|
||||||
|
.leftJoin(
|
||||||
|
transactions,
|
||||||
|
and(
|
||||||
|
eq(installmentAnticipations.transactionId, transactions.id),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
payers,
|
||||||
|
and(
|
||||||
|
eq(installmentAnticipations.payerId, payers.id),
|
||||||
|
eq(payers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
categories,
|
||||||
|
and(
|
||||||
|
eq(installmentAnticipations.categoryId, categories.id),
|
||||||
|
eq(categories.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(installmentAnticipations.seriesId, validatedSeriesId),
|
||||||
|
eq(installmentAnticipations.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(installmentAnticipations.createdAt));
|
||||||
|
|
||||||
|
return anticipations.map((anticipation) => ({
|
||||||
|
id: anticipation.id,
|
||||||
|
anticipationPeriod: anticipation.anticipationPeriod,
|
||||||
|
anticipationDate: anticipation.anticipationDate.toISOString(),
|
||||||
|
installmentCount: anticipation.installmentCount,
|
||||||
|
totalAmount: anticipation.totalAmount,
|
||||||
|
discount: anticipation.discount,
|
||||||
|
transactionId: anticipation.transactionId,
|
||||||
|
note: anticipation.note,
|
||||||
|
transaction: anticipation.transactionRecordId
|
||||||
|
? { isSettled: anticipation.transactionIsSettled }
|
||||||
|
: null,
|
||||||
|
payer: anticipation.payerName ? { name: anticipation.payerName } : null,
|
||||||
|
category: anticipation.categoryName
|
||||||
|
? { name: anticipation.categoryName }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
66
src/features/transactions/attachment-queries.ts
Normal file
66
src/features/transactions/attachment-queries.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
|
export type TransactionAttachmentListItem = {
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
createdAt: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchTransactionAttachments(
|
||||||
|
userId: string,
|
||||||
|
transactionId: string,
|
||||||
|
): Promise<TransactionAttachmentListItem[]> {
|
||||||
|
const [transaction] = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
attachmentId: transactionAttachments.attachmentId,
|
||||||
|
fileName: attachments.fileName,
|
||||||
|
fileSize: attachments.fileSize,
|
||||||
|
mimeType: attachments.mimeType,
|
||||||
|
fileKey: attachments.fileKey,
|
||||||
|
createdAt: attachments.createdAt,
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
transactions,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.transactionId, transactions.id),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
eq(attachments.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(eq(transactionAttachments.transactionId, transactionId));
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
rows.map(async (row) => ({
|
||||||
|
attachmentId: row.attachmentId,
|
||||||
|
fileName: row.fileName,
|
||||||
|
fileSize: row.fileSize,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
url: await createPresignedGetUrl(row.fileKey),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ export default async function proxy(request: NextRequest) {
|
|||||||
const hostname = request.headers.get("host")?.replace(/:\d+$/, "");
|
const hostname = request.headers.get("host")?.replace(/:\d+$/, "");
|
||||||
|
|
||||||
if (publicDomain && hostname === publicDomain) {
|
if (publicDomain && hostname === publicDomain) {
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
if (pathname !== "/") {
|
if (pathname !== "/") {
|
||||||
return NextResponse.redirect(new URL("/", request.url));
|
return NextResponse.redirect(new URL("/", request.url));
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,7 @@ export const config = {
|
|||||||
// Apply middleware to protected and auth routes
|
// Apply middleware to protected and auth routes
|
||||||
matcher: [
|
matcher: [
|
||||||
"/",
|
"/",
|
||||||
|
"/api/:path*",
|
||||||
"/settings/:path*",
|
"/settings/:path*",
|
||||||
"/notes/:path*",
|
"/notes/:path*",
|
||||||
"/calendar/:path*",
|
"/calendar/:path*",
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export const auth = betterAuth({
|
|||||||
autoSignIn: true,
|
autoSignIn: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
rateLimit: {
|
||||||
|
window: 60,
|
||||||
|
max: 100,
|
||||||
|
customRules: {
|
||||||
|
"/sign-in/email": { window: 60, max: 5 },
|
||||||
|
"/sign-up/email": { window: 60, max: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Database adapter (Drizzle + PostgreSQL)
|
// Database adapter (Drizzle + PostgreSQL)
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
|
|||||||
Reference in New Issue
Block a user