mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10afef9fec | ||
|
|
fd4d90a53e | ||
|
|
a24406271c | ||
|
|
a09942e3d8 | ||
|
|
96febd5904 |
1
.github/workflows/docker-publish.yml
vendored
1
.github/workflows/docker-publish.yml
vendored
@@ -13,6 +13,7 @@ on:
|
||||
|
||||
env:
|
||||
DOCKER_IMAGE_NAME: openmonetis
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -5,6 +5,9 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -7,6 +7,27 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.3.2] - 2026-04-04
|
||||
|
||||
### Segurança
|
||||
|
||||
- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano
|
||||
- Tokens: corrigido refresh que sobrescrevia hash e invalidava access token anterior; verify agora valida JWT por assinatura
|
||||
- xlsx: desabilitado parsing de fórmulas (`cellFormula: false`) para mitigar CVE-2024-44294
|
||||
- CSP: expandida Content-Security-Policy com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src`
|
||||
- Headers: adicionados `Referrer-Policy` e `X-Permitted-Cross-Domain-Policies`
|
||||
- API: rotas autenticadas agora retornam `401 JSON` em vez de redirect `302` para clientes não autenticados
|
||||
- Health: removido campo `version` da resposta do `/api/health`
|
||||
- robots.txt: simplificado para não expor mapa de rotas internas
|
||||
- Sitemap: corrigida URL com protocolo duplicado (`https://https://`)
|
||||
- Criado `security.txt` (RFC 9116)
|
||||
|
||||
## [2.3.1] - 2026-04-03
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container
|
||||
|
||||
## [2.3.0] - 2026-04-03
|
||||
|
||||
### Adicionado
|
||||
|
||||
35
Dockerfile
35
Dockerfile
@@ -56,10 +56,27 @@ WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
|
||||
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
|
||||
COPY --from=builder /app/package.json /tmp/pkg.json
|
||||
RUN mkdir -p /app/migrate && \
|
||||
node -e "\
|
||||
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
|
||||
require('fs').writeFileSync('/app/migrate/package.json',JSON.stringify({\
|
||||
name:'openmonetis-migrate',version:p.version,\
|
||||
dependencies:{\
|
||||
'drizzle-orm':p.dependencies['drizzle-orm'],\
|
||||
'pg':p.dependencies['pg']\
|
||||
},\
|
||||
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
|
||||
}));" && \
|
||||
cd /app/migrate && pnpm install --no-frozen-lockfile --ignore-scripts && \
|
||||
chown -R nextjs:nodejs /app/migrate
|
||||
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Copiar arquivos de build do Next.js
|
||||
# Copiar arquivos de build do Next.js (inclui node_modules standalone com next)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
@@ -68,22 +85,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
||||
|
||||
# Instalar apenas as deps necessárias para drizzle-kit migrate
|
||||
# Gera package.json mínimo a partir do original para evitar version drift
|
||||
COPY --from=builder /app/package.json /tmp/pkg.json
|
||||
RUN node -e "\
|
||||
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
|
||||
require('fs').writeFileSync('package.json',JSON.stringify({\
|
||||
name:'openmonetis',version:p.version,\
|
||||
dependencies:{\
|
||||
'drizzle-orm':p.dependencies['drizzle-orm'],\
|
||||
'pg':p.dependencies['pg']\
|
||||
},\
|
||||
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
|
||||
}));" && \
|
||||
pnpm install --no-frozen-lockfile --ignore-scripts && \
|
||||
chown nextjs:nodejs package.json
|
||||
|
||||
# Copiar entrypoint de migrations
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Rodando migrations do banco de dados..."
|
||||
./node_modules/.bin/drizzle-kit push
|
||||
echo "Migrations concluídas."
|
||||
echo "Rodando migrations..."
|
||||
RETRIES=5
|
||||
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
||||
RETRIES=$((RETRIES - 1))
|
||||
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$RETRIES" -eq 0 ]; then
|
||||
echo "Aviso: migrations nao foram aplicadas"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -44,7 +44,23 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "frame-ancestors 'none';",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://umami.felipecoutinho.com",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self' https://umami.felipecoutinho.com",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; "),
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "X-Permitted-Cross-Domain-Policies",
|
||||
value: "none",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
|
||||
4
public/.well-known/security.txt
Normal file
4
public/.well-known/security.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Contact: https://github.com/felipegcoutinho/openmonetis/security/advisories
|
||||
Expires: 2027-04-04T00:00:00.000Z
|
||||
Preferred-Languages: pt-BR, en
|
||||
Canonical: https://openmonetis.com/.well-known/security.txt
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { attachments } from "@/db/schema";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||
|
||||
@@ -13,7 +13,19 @@ export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ attachmentId: string }> },
|
||||
) {
|
||||
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
|
||||
const [session, { attachmentId }] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
params,
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
const [row] = await db
|
||||
.select({ fileKey: attachments.fileKey })
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
extractBearerToken,
|
||||
hashToken,
|
||||
refreshAccessToken,
|
||||
verifyJwt,
|
||||
} from "@/shared/lib/auth/api-token";
|
||||
@@ -59,11 +58,11 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar hash do token e último uso
|
||||
// Atualizar último uso e expiração (sem sobrescrever tokenHash,
|
||||
// pois o JWT é auto-verificável por assinatura)
|
||||
await db
|
||||
.update(apiTokens)
|
||||
.set({
|
||||
tokenHash: hashToken(result.accessToken),
|
||||
lastUsedAt: new Date(),
|
||||
lastUsedIp:
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
|
||||
import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -17,21 +17,21 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash lookup
|
||||
if (!token.startsWith("os_")) {
|
||||
// Verificar JWT (assinatura + expiração)
|
||||
const payload = verifyJwt(token);
|
||||
|
||||
if (!payload || payload.type !== "api_access") {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Formato de token inválido" },
|
||||
{ valid: false, error: "Token inválido ou expirado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Hash do token para buscar no DB
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
// Buscar token no banco
|
||||
// Buscar token no banco por tokenId para checar revogação
|
||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
eq(apiTokens.id, payload.tokenId),
|
||||
eq(apiTokens.userId, payload.sub),
|
||||
isNull(apiTokens.revokedAt),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Token inválido ou revogado" },
|
||||
{ valid: false, error: "Token revogado ou não encontrado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { version as APP_VERSION } from "@/package.json";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
/**
|
||||
@@ -20,7 +19,6 @@ export async function GET() {
|
||||
{
|
||||
status: "ok",
|
||||
name: "OpenMonetis",
|
||||
version: APP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 200 },
|
||||
@@ -33,7 +31,6 @@ export async function GET() {
|
||||
{
|
||||
status: "error",
|
||||
name: "OpenMonetis",
|
||||
version: APP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: "Database connection failed",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt),
|
||||
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt),
|
||||
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
fetchSavedInsights,
|
||||
savedInsightsPeriodSchema,
|
||||
} from "@/features/insights/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
|
||||
const PRIVATE_RESPONSE_HEADERS = {
|
||||
"Cache-Control": "private, no-store",
|
||||
@@ -25,8 +25,18 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const userId = await getUserId();
|
||||
const insights = await fetchSavedInsights(userId, validatedPeriod.data);
|
||||
const session = await getOptionalUserSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const insights = await fetchSavedInsights(
|
||||
session.user.id,
|
||||
validatedPeriod.data,
|
||||
);
|
||||
|
||||
return NextResponse.json(insights, {
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
|
||||
const PRIVATE_RESPONSE_HEADERS = {
|
||||
"Cache-Control": "private, no-store",
|
||||
@@ -10,7 +10,19 @@ export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ transactionId: string }> },
|
||||
) {
|
||||
const [userId, { transactionId }] = await Promise.all([getUserId(), params]);
|
||||
const [session, { transactionId }] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
params,
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const attachments = await fetchTransactionAttachments(userId, transactionId);
|
||||
|
||||
return NextResponse.json(attachments, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
|
||||
const PRIVATE_RESPONSE_HEADERS = {
|
||||
"Cache-Control": "private, no-store",
|
||||
@@ -11,7 +11,19 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ seriesId: string }> },
|
||||
) {
|
||||
try {
|
||||
const [userId, { seriesId }] = await Promise.all([getUserId(), params]);
|
||||
const [session, { seriesId }] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
params,
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
|
||||
|
||||
return NextResponse.json(anticipations, {
|
||||
|
||||
@@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: [
|
||||
"/dashboard",
|
||||
"/transactions",
|
||||
"/accounts",
|
||||
"/cards",
|
||||
"/categories",
|
||||
"/budgets",
|
||||
"/payers",
|
||||
"/notes",
|
||||
"/insights",
|
||||
"/calendar",
|
||||
"/attachments",
|
||||
"/settings",
|
||||
"/reports",
|
||||
"/inbox",
|
||||
"/login",
|
||||
"/signup",
|
||||
"/api/",
|
||||
],
|
||||
disallow: "/api/",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
||||
? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
|
||||
: "https://openmonetis.com";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
|
||||
@@ -649,7 +649,7 @@ export async function createApiTokenAction(
|
||||
name: validated.name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
expiresAt: null, // No expiration for now
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 ano
|
||||
})
|
||||
.returning({ id: apiTokens.id });
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
|
||||
const workbook = XLSX.read(new Uint8Array(buffer), {
|
||||
type: "array",
|
||||
cellDates: false,
|
||||
cellFormula: false,
|
||||
cellNF: false,
|
||||
});
|
||||
|
||||
if (!workbook.SheetNames.length) {
|
||||
|
||||
Reference in New Issue
Block a user