mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31485eec8f | ||
|
|
3be64aa8d0 | ||
|
|
85f6dcfc22 | ||
|
|
df996df93d | ||
|
|
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:
|
env:
|
||||||
DOCKER_IMAGE_NAME: openmonetis
|
DOCKER_IMAGE_NAME: openmonetis
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
quality:
|
||||||
|
|||||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -5,6 +5,9 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -7,6 +7,45 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.3.4] - 2026-04-05
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
|
||||||
|
|
||||||
|
## [2.3.3] - 2026-04-05
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
|
||||||
|
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
|
||||||
|
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
|
||||||
|
|
||||||
|
## [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
|
## [2.3.0] - 2026-04-03
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
35
Dockerfile
35
Dockerfile
@@ -56,10 +56,27 @@ WORKDIR /app
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
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
|
# Copiar apenas arquivos necessários para produção
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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/drizzle.config.ts ./drizzle.config.ts
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
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
|
# Copiar entrypoint de migrations
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
|
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Rodando migrations do banco de dados..."
|
echo "Rodando migrations..."
|
||||||
./node_modules/.bin/drizzle-kit push
|
RETRIES=5
|
||||||
echo "Migrations concluídas."
|
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 "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { NextConfig } from "next";
|
|||||||
// Carregar variáveis de ambiente explicitamente
|
// Carregar variáveis de ambiente explicitamente
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
cacheComponents: true,
|
cacheComponents: true,
|
||||||
@@ -44,7 +46,23 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Content-Security-Policy",
|
key: "Content-Security-Policy",
|
||||||
value: "frame-ancestors 'none';",
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} 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 ${process.env.S3_ENDPOINT ? new URL(process.env.S3_ENDPOINT).origin : ""}`.trim(),
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join("; "),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Referrer-Policy",
|
||||||
|
value: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Permitted-Cross-Domain-Policies",
|
||||||
|
value: "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Permissions-Policy",
|
key: "Permissions-Policy",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.3.0",
|
"version": "2.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
@@ -87,7 +88,6 @@
|
|||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
646
pnpm-lock.yaml
generated
646
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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 { and, eq } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { attachments } from "@/db/schema";
|
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 { db } from "@/shared/lib/db";
|
||||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
@@ -13,7 +13,19 @@ export async function GET(
|
|||||||
_request: Request,
|
_request: Request,
|
||||||
{ params }: { params: Promise<{ attachmentId: string }> },
|
{ 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
|
const [row] = await db
|
||||||
.select({ fileKey: attachments.fileKey })
|
.select({ fileKey: attachments.fileKey })
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { apiTokens } from "@/db/schema";
|
|
||||||
import {
|
|
||||||
extractBearerToken,
|
|
||||||
hashToken,
|
|
||||||
refreshAccessToken,
|
|
||||||
verifyJwt,
|
|
||||||
} from "@/shared/lib/auth/api-token";
|
|
||||||
import { db } from "@/shared/lib/db";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
// Extrair refresh token do header
|
|
||||||
const authHeader = request.headers.get("Authorization");
|
|
||||||
const token = extractBearerToken(authHeader);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Refresh token não fornecido" },
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar refresh token
|
|
||||||
const payload = verifyJwt(token);
|
|
||||||
|
|
||||||
if (!payload || payload.type !== "api_refresh") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Refresh token inválido ou expirado" },
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se token não foi revogado
|
|
||||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(apiTokens.id, payload.tokenId),
|
|
||||||
eq(apiTokens.userId, payload.sub),
|
|
||||||
isNull(apiTokens.revokedAt),
|
|
||||||
gt(apiTokens.expiresAt, new Date()),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenRecord) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Token revogado ou não encontrado" },
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gerar novo access token
|
|
||||||
const result = refreshAccessToken(token);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Não foi possível renovar o token" },
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atualizar hash do token e último uso
|
|
||||||
await db
|
|
||||||
.update(apiTokens)
|
|
||||||
.set({
|
|
||||||
tokenHash: hashToken(result.accessToken),
|
|
||||||
lastUsedAt: new Date(),
|
|
||||||
lastUsedIp:
|
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
||||||
request.headers.get("x-real-ip") ||
|
|
||||||
null,
|
|
||||||
expiresAt: result.expiresAt,
|
|
||||||
})
|
|
||||||
.where(eq(apiTokens.id, payload.tokenId));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
expiresAt: result.expiresAt.toISOString(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[API] Error refreshing device token:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Erro ao renovar token" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { connection, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { apiTokens } from "@/db/schema";
|
|
||||||
import {
|
|
||||||
generateTokenPair,
|
|
||||||
getTokenPrefix,
|
|
||||||
hashToken,
|
|
||||||
} from "@/shared/lib/auth/api-token";
|
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
|
||||||
import { db } from "@/shared/lib/db";
|
|
||||||
|
|
||||||
const createTokenSchema = z.object({
|
|
||||||
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
|
|
||||||
deviceId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
|
||||||
// Validar body
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, deviceId } = createTokenSchema.parse(body);
|
|
||||||
|
|
||||||
// Gerar par de tokens
|
|
||||||
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
|
|
||||||
session.user.id,
|
|
||||||
deviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Salvar hash do token no banco
|
|
||||||
await db.insert(apiTokens).values({
|
|
||||||
id: tokenId,
|
|
||||||
userId: session.user.id,
|
|
||||||
name,
|
|
||||||
tokenHash: hashToken(accessToken),
|
|
||||||
tokenPrefix: getTokenPrefix(accessToken),
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Retornar tokens (mostrados apenas uma vez)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
tokenId,
|
|
||||||
name,
|
|
||||||
expiresAt: expiresAt.toISOString(),
|
|
||||||
message:
|
|
||||||
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
|
|
||||||
},
|
|
||||||
{ status: 201 },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("[API] Error creating device token:", error);
|
|
||||||
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,15 +17,14 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash lookup
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Formato de token inválido" },
|
{ valid: false, error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash do token para buscar no DB
|
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
// Buscar token no banco
|
// Buscar token no banco
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { version as APP_VERSION } from "@/package.json";
|
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +19,6 @@ export async function GET() {
|
|||||||
{
|
{
|
||||||
status: "ok",
|
status: "ok",
|
||||||
name: "OpenMonetis",
|
name: "OpenMonetis",
|
||||||
version: APP_VERSION,
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
@@ -33,7 +31,6 @@ export async function GET() {
|
|||||||
{
|
{
|
||||||
status: "error",
|
status: "error",
|
||||||
name: "OpenMonetis",
|
name: "OpenMonetis",
|
||||||
version: APP_VERSION,
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
message: "Database connection failed",
|
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 { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
|
|||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt),
|
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 { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
|
|||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt),
|
isNull(apiTokens.revokedAt),
|
||||||
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
|
gt(apiTokens.expiresAt, new Date()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
fetchSavedInsights,
|
fetchSavedInsights,
|
||||||
savedInsightsPeriodSchema,
|
savedInsightsPeriodSchema,
|
||||||
} from "@/features/insights/queries";
|
} from "@/features/insights/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
const PRIVATE_RESPONSE_HEADERS = {
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
"Cache-Control": "private, no-store",
|
"Cache-Control": "private, no-store",
|
||||||
@@ -25,8 +25,18 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = await getUserId();
|
const session = await getOptionalUserSession();
|
||||||
const insights = await fetchSavedInsights(userId, validatedPeriod.data);
|
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, {
|
return NextResponse.json(insights, {
|
||||||
headers: PRIVATE_RESPONSE_HEADERS,
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
|
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 = {
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
"Cache-Control": "private, no-store",
|
"Cache-Control": "private, no-store",
|
||||||
@@ -10,7 +10,19 @@ export async function GET(
|
|||||||
_request: Request,
|
_request: Request,
|
||||||
{ params }: { params: Promise<{ transactionId: string }> },
|
{ 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);
|
const attachments = await fetchTransactionAttachments(userId, transactionId);
|
||||||
|
|
||||||
return NextResponse.json(attachments, {
|
return NextResponse.json(attachments, {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
|
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 = {
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
"Cache-Control": "private, no-store",
|
"Cache-Control": "private, no-store",
|
||||||
@@ -11,7 +11,19 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ seriesId: string }> },
|
{ params }: { params: Promise<{ seriesId: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
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);
|
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
|
||||||
|
|
||||||
return NextResponse.json(anticipations, {
|
return NextResponse.json(anticipations, {
|
||||||
|
|||||||
@@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
{
|
{
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
allow: "/",
|
allow: "/",
|
||||||
disallow: [
|
disallow: "/api/",
|
||||||
"/dashboard",
|
|
||||||
"/transactions",
|
|
||||||
"/accounts",
|
|
||||||
"/cards",
|
|
||||||
"/categories",
|
|
||||||
"/budgets",
|
|
||||||
"/payers",
|
|
||||||
"/notes",
|
|
||||||
"/insights",
|
|
||||||
"/calendar",
|
|
||||||
"/attachments",
|
|
||||||
"/settings",
|
|
||||||
"/reports",
|
|
||||||
"/inbox",
|
|
||||||
"/login",
|
|
||||||
"/signup",
|
|
||||||
"/api/",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
const BASE_URL = process.env.PUBLIC_DOMAIN
|
||||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
|
||||||
: "https://openmonetis.com";
|
: "https://openmonetis.com";
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface CategoryReportExportProps {
|
|||||||
filters: FilterState;
|
filters: FilterState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadXlsx = () => import("xlsx");
|
const loadExcelJS = () => import("exceljs");
|
||||||
|
|
||||||
const loadPdfDeps = async () => {
|
const loadPdfDeps = async () => {
|
||||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
@@ -134,7 +134,7 @@ export function CategoryReportExport({
|
|||||||
const exportToExcel = async () => {
|
const exportToExcel = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const XLSX = await loadXlsx();
|
const ExcelJS = await loadExcelJS();
|
||||||
|
|
||||||
// Build data array
|
// Build data array
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -179,20 +179,32 @@ export function CategoryReportExport({
|
|||||||
totalsRow.push(formatCurrency(data.grandTotal));
|
totalsRow.push(formatCurrency(data.grandTotal));
|
||||||
rows.push(totalsRow);
|
rows.push(totalsRow);
|
||||||
|
|
||||||
// Create worksheet
|
// Create workbook and worksheet
|
||||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet("Relatório de Categorias");
|
||||||
|
|
||||||
|
ws.addRows([headers, ...rows]);
|
||||||
|
|
||||||
// Set column widths
|
// Set column widths
|
||||||
ws["!cols"] = [
|
ws.getColumn(1).width = 20;
|
||||||
{ wch: 20 }, // Category
|
for (let i = 0; i < data.periods.length; i++) {
|
||||||
...data.periods.map(() => ({ wch: 15 })), // Periods
|
ws.getColumn(i + 2).width = 15;
|
||||||
{ wch: 15 }, // Total
|
}
|
||||||
];
|
ws.getColumn(data.periods.length + 2).width = 15;
|
||||||
|
|
||||||
// Create workbook and download
|
// Download
|
||||||
const wb = XLSX.utils.book_new();
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Relatório de Categorias");
|
const blob = new Blob([buffer], {
|
||||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = getFileName("xlsx");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Relatório exportado em Excel com sucesso!");
|
toast.success("Relatório exportado em Excel com sucesso!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function generateSecureToken(): string {
|
function generateSecureToken(): string {
|
||||||
const prefix = "os";
|
const prefix = "opm";
|
||||||
const randomPart = randomBytes(32).toString("base64url");
|
const randomPart = randomBytes(32).toString("base64url");
|
||||||
return `${prefix}_${randomPart}`;
|
return `${prefix}_${randomPart}`;
|
||||||
}
|
}
|
||||||
@@ -649,7 +649,7 @@ export async function createApiTokenAction(
|
|||||||
name: validated.name,
|
name: validated.name,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
tokenPrefix,
|
tokenPrefix,
|
||||||
expiresAt: null, // No expiration for now
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 ano
|
||||||
})
|
})
|
||||||
.returning({ id: apiTokens.id });
|
.returning({ id: apiTokens.id });
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
reader.readAsText(file, "windows-1252");
|
reader.readAsText(file, "windows-1252");
|
||||||
} else {
|
} else {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = async (e) => {
|
||||||
try {
|
try {
|
||||||
const buffer = e.target?.result as ArrayBuffer;
|
const buffer = e.target?.result as ArrayBuffer;
|
||||||
const statement = parseXls(buffer);
|
const statement = await parseXls(buffer);
|
||||||
onParsed(statement);
|
onParsed(statement);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -62,8 +62,8 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = async () => {
|
||||||
const bytes = generateXlsTemplate();
|
const bytes = await generateXlsTemplate();
|
||||||
const blob = new Blob([bytes], {
|
const blob = new Blob([bytes], {
|
||||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ interface LancamentosExportProps {
|
|||||||
exportContext?: TransactionsExportContext;
|
exportContext?: TransactionsExportContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadXlsx = () => import("xlsx");
|
const loadExcelJS = () => import("exceljs");
|
||||||
|
|
||||||
const loadPdfDeps = async () => {
|
const loadPdfDeps = async () => {
|
||||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
@@ -158,7 +158,7 @@ export function TransactionsExport({
|
|||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const transactions = await loadTransactions();
|
const transactions = await loadTransactions();
|
||||||
const XLSX = await loadXlsx();
|
const ExcelJS = await loadExcelJS();
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
"Data",
|
"Data",
|
||||||
@@ -188,23 +188,28 @@ export function TransactionsExport({
|
|||||||
rows.push(row);
|
rows.push(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet("Lançamentos");
|
||||||
|
|
||||||
ws["!cols"] = [
|
ws.addRows([headers, ...rows]);
|
||||||
{ wch: 12 }, // Data
|
|
||||||
{ wch: 42 }, // Nome
|
|
||||||
{ wch: 15 }, // Tipo
|
|
||||||
{ wch: 15 }, // Condição
|
|
||||||
{ wch: 20 }, // Pagamento
|
|
||||||
{ wch: 15 }, // Valor
|
|
||||||
{ wch: 20 }, // Category
|
|
||||||
{ wch: 20 }, // Conta/Cartão
|
|
||||||
{ wch: 20 }, // Payer
|
|
||||||
];
|
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
const colWidths = [12, 42, 15, 15, 20, 15, 20, 20, 20];
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
|
colWidths.forEach((w, i) => {
|
||||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
ws.getColumn(i + 1).width = w;
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = getFileName("xlsx");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Lançamentos exportados em Excel com sucesso!");
|
toast.success("Lançamentos exportados em Excel com sucesso!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,149 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
function getJwtSecret(): string {
|
|
||||||
const secret = process.env.BETTER_AUTH_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error(
|
|
||||||
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return secret;
|
|
||||||
}
|
|
||||||
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
|
|
||||||
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
|
||||||
sub: string; // userId
|
|
||||||
type: "api_access" | "api_refresh";
|
|
||||||
tokenId: string;
|
|
||||||
deviceId?: string;
|
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenPair {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
tokenId: string;
|
|
||||||
expiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// JWT UTILITIES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64URL encode a string
|
|
||||||
*/
|
|
||||||
function base64UrlEncode(str: string): string {
|
|
||||||
return Buffer.from(str)
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64URL decode a string
|
|
||||||
*/
|
|
||||||
function base64UrlDecode(str: string): string {
|
|
||||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const pad = str.length % 4;
|
|
||||||
if (pad) {
|
|
||||||
str += "=".repeat(4 - pad);
|
|
||||||
}
|
|
||||||
return Buffer.from(str, "base64").toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create HMAC-SHA256 signature
|
|
||||||
*/
|
|
||||||
function createSignature(data: string): string {
|
|
||||||
return crypto
|
|
||||||
.createHmac("sha256", getJwtSecret())
|
|
||||||
.update(data)
|
|
||||||
.digest("base64")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a JWT token
|
|
||||||
*/
|
|
||||||
export function createJwt(
|
|
||||||
payload: Omit<JwtPayload, "iat" | "exp">,
|
|
||||||
expiresIn: number,
|
|
||||||
): string {
|
|
||||||
const header = { alg: "HS256", typ: "JWT" };
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const fullPayload: JwtPayload = {
|
|
||||||
...payload,
|
|
||||||
iat: now,
|
|
||||||
exp: now + expiresIn,
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerEncoded = base64UrlEncode(JSON.stringify(header));
|
|
||||||
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
|
|
||||||
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
|
|
||||||
|
|
||||||
return `${headerEncoded}.${payloadEncoded}.${signature}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify and decode a JWT token
|
|
||||||
* @returns The decoded payload or null if invalid
|
|
||||||
*/
|
|
||||||
export function verifyJwt(token: string): JwtPayload | null {
|
|
||||||
try {
|
|
||||||
const parts = token.split(".");
|
|
||||||
if (parts.length !== 3) return null;
|
|
||||||
|
|
||||||
const [headerEncoded, payloadEncoded, signature] = parts;
|
|
||||||
const expectedSignature = createSignature(
|
|
||||||
`${headerEncoded}.${payloadEncoded}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
|
||||||
if (
|
|
||||||
!crypto.timingSafeEqual(
|
|
||||||
Buffer.from(signature),
|
|
||||||
Buffer.from(expectedSignature),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
|
|
||||||
|
|
||||||
// Check expiration
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
if (payload.exp < now) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TOKEN GENERATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a random token ID
|
|
||||||
*/
|
|
||||||
export function generateTokenId(): string {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hash a token using SHA-256
|
* Hash a token using SHA-256
|
||||||
*/
|
*/
|
||||||
@@ -151,74 +7,6 @@ export function hashToken(token: string): string {
|
|||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the display prefix of a token (first 8 chars after prefix)
|
|
||||||
*/
|
|
||||||
export function getTokenPrefix(token: string): string {
|
|
||||||
// Remove "os_" prefix and get first 8 chars
|
|
||||||
const withoutPrefix = token.replace(/^os_/, "");
|
|
||||||
return `os_${withoutPrefix.substring(0, 8)}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a complete token pair (access + refresh)
|
|
||||||
*/
|
|
||||||
export function generateTokenPair(
|
|
||||||
userId: string,
|
|
||||||
deviceId?: string,
|
|
||||||
): TokenPair {
|
|
||||||
const tokenId = generateTokenId();
|
|
||||||
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
|
|
||||||
|
|
||||||
const accessToken = createJwt(
|
|
||||||
{ sub: userId, type: "api_access", tokenId, deviceId },
|
|
||||||
ACCESS_TOKEN_EXPIRY,
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshToken = createJwt(
|
|
||||||
{ sub: userId, type: "api_refresh", tokenId, deviceId },
|
|
||||||
REFRESH_TOKEN_EXPIRY,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
tokenId,
|
|
||||||
expiresAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh an access token using a refresh token
|
|
||||||
*/
|
|
||||||
export function refreshAccessToken(
|
|
||||||
refreshToken: string,
|
|
||||||
): { accessToken: string; expiresAt: Date } | null {
|
|
||||||
const payload = verifyJwt(refreshToken);
|
|
||||||
|
|
||||||
if (!payload || payload.type !== "api_refresh") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
|
|
||||||
|
|
||||||
const accessToken = createJwt(
|
|
||||||
{
|
|
||||||
sub: payload.sub,
|
|
||||||
type: "api_access",
|
|
||||||
tokenId: payload.tokenId,
|
|
||||||
deviceId: payload.deviceId,
|
|
||||||
},
|
|
||||||
ACCESS_TOKEN_EXPIRY,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { accessToken, expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// VALIDATION HELPERS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract bearer token from Authorization header
|
* Extract bearer token from Authorization header
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import * as XLSX from "xlsx";
|
import ExcelJS from "exceljs";
|
||||||
import type {
|
import type {
|
||||||
ImportedTransaction,
|
ImportedTransaction,
|
||||||
ImportStatement,
|
ImportStatement,
|
||||||
} from "@/shared/lib/import/types";
|
} from "@/shared/lib/import/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converte serial number do Excel (1900 date system) para ano/mês/dia.
|
||||||
|
* Excel trata 1900 como bissexto (serial 60 = 29/02/1900 inexistente).
|
||||||
|
*/
|
||||||
|
function excelSerialToDate(
|
||||||
|
serial: number,
|
||||||
|
): { y: number; m: number; d: number } | null {
|
||||||
|
if (serial < 1) return null;
|
||||||
|
let adjusted = serial;
|
||||||
|
if (serial > 60) adjusted--;
|
||||||
|
const baseDate = new Date(1899, 11, 31);
|
||||||
|
const date = new Date(baseDate.getTime() + adjusted * 86400000);
|
||||||
|
return {
|
||||||
|
y: date.getFullYear(),
|
||||||
|
m: date.getMonth() + 1,
|
||||||
|
d: date.getDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseDateValue(value: unknown): string | null {
|
function parseDateValue(value: unknown): string | null {
|
||||||
if (value == null || value === "") return null;
|
if (value == null || value === "") return null;
|
||||||
|
|
||||||
// Excel date serial number
|
// Excel date serial number
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
const date = XLSX.SSF.parse_date_code(value);
|
const date = excelSerialToDate(value);
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
const y = date.y;
|
const y = date.y;
|
||||||
const m = String(date.m).padStart(2, "0");
|
const m = String(date.m).padStart(2, "0");
|
||||||
@@ -17,6 +36,14 @@ function parseDateValue(value: unknown): string | null {
|
|||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExcelJS pode retornar Date objects
|
||||||
|
if (value instanceof Date) {
|
||||||
|
const y = value.getFullYear();
|
||||||
|
const m = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(value.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
const str = String(value).trim();
|
const str = String(value).trim();
|
||||||
|
|
||||||
// DD/MM/YYYY
|
// DD/MM/YYYY
|
||||||
@@ -43,52 +70,37 @@ function parseAmountValue(value: unknown): number | null {
|
|||||||
return Number.isNaN(num) ? null : Math.abs(num);
|
return Number.isNaN(num) ? null : Math.abs(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseXls(buffer: ArrayBuffer): ImportStatement {
|
export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
|
||||||
const workbook = XLSX.read(new Uint8Array(buffer), {
|
const workbook = new ExcelJS.Workbook();
|
||||||
type: "array",
|
await workbook.xlsx.load(buffer);
|
||||||
cellDates: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workbook.SheetNames.length) {
|
if (workbook.worksheets.length === 0) {
|
||||||
throw new Error("Arquivo sem abas.");
|
throw new Error("Arquivo sem abas.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetName = workbook.SheetNames[0];
|
const sheet = workbook.worksheets[0];
|
||||||
const sheet = workbook.Sheets[sheetName];
|
|
||||||
|
|
||||||
if (!sheet) {
|
if (!sheet || sheet.rowCount < 2) {
|
||||||
throw new Error(`Aba "${sheetName}" não encontrada.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = sheet["!ref"];
|
|
||||||
if (!range) {
|
|
||||||
throw new Error("Planilha vazia (sem intervalo de células).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
|
||||||
header: 1,
|
|
||||||
defval: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rows.length < 2) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Planilha vazia ou sem dados (${rows.length} linha(s) encontrada(s)).`,
|
`Planilha vazia ou sem dados (${sheet?.rowCount ?? 0} linha(s) encontrada(s)).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactions: ImportedTransaction[] = [];
|
const transactions: ImportedTransaction[] = [];
|
||||||
|
|
||||||
for (let i = 1; i < rows.length; i++) {
|
sheet.eachRow((row, rowNumber) => {
|
||||||
const row = rows[i] as unknown[];
|
if (rowNumber === 1) return; // skip header
|
||||||
if (!row || row.every((cell) => cell == null || cell === "")) continue;
|
|
||||||
|
|
||||||
const date = parseDateValue(row[0]);
|
// ExcelJS row.values é 1-indexed (values[0] é undefined)
|
||||||
const description = row[1] != null ? String(row[1]).trim() : "";
|
const values = row.values as unknown[];
|
||||||
const amount = parseAmountValue(row[2]);
|
const date = parseDateValue(values[1]);
|
||||||
const typeRaw = row[3] != null ? String(row[3]).toLowerCase().trim() : "";
|
const description = values[2] != null ? String(values[2]).trim() : "";
|
||||||
|
const amount = parseAmountValue(values[3]);
|
||||||
|
const typeRaw =
|
||||||
|
values[4] != null ? String(values[4]).toLowerCase().trim() : "";
|
||||||
const transactionType = typeRaw === "receita" ? "income" : "expense";
|
const transactionType = typeRaw === "receita" ? "income" : "expense";
|
||||||
|
|
||||||
if (!date || !description || amount === null || amount <= 0) continue;
|
if (!date || !description || amount === null || amount <= 0) return;
|
||||||
|
|
||||||
transactions.push({
|
transactions.push({
|
||||||
externalId: null,
|
externalId: null,
|
||||||
@@ -97,7 +109,7 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
|
|||||||
description,
|
description,
|
||||||
transactionType,
|
transactionType,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
if (transactions.length === 0) {
|
if (transactions.length === 0) {
|
||||||
throw new Error("Nenhuma transação válida encontrada na planilha.");
|
throw new Error("Nenhuma transação válida encontrada na planilha.");
|
||||||
@@ -115,31 +127,31 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateXlsTemplate(): ArrayBuffer {
|
export async function generateXlsTemplate(): Promise<ArrayBuffer> {
|
||||||
const wb = XLSX.utils.book_new();
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet("Lançamentos");
|
||||||
|
|
||||||
const data = [
|
ws.addRows([
|
||||||
["Data", "Descrição", "Valor", "Tipo"],
|
["Data", "Descrição", "Valor", "Tipo"],
|
||||||
["01/03/2026", "Ingressos São Januário", 160, "despesa"],
|
["01/03/2026", "Ingressos São Januário", 160, "despesa"],
|
||||||
["01/03/2026", "Salário", 3000.0, "receita"],
|
["01/03/2026", "Salário", 3000.0, "receita"],
|
||||||
["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa"],
|
["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa"],
|
||||||
];
|
]);
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
ws.getColumn(1).width = 14;
|
||||||
|
ws.getColumn(2).width = 32;
|
||||||
|
ws.getColumn(3).width = 12;
|
||||||
|
ws.getColumn(4).width = 10;
|
||||||
|
|
||||||
ws["!cols"] = [{ wch: 14 }, { wch: 32 }, { wch: 12 }, { wch: 10 }];
|
// Dropdown para coluna Tipo (D2:D100)
|
||||||
|
for (let i = 2; i <= 100; i++) {
|
||||||
|
ws.getCell(`D${i}`).dataValidation = {
|
||||||
|
type: "list",
|
||||||
|
allowBlank: true,
|
||||||
|
formulae: ['"despesa,receita"'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Dropdown para coluna Tipo (D2:D1000)
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
if (!ws["!dataValidations"]) ws["!dataValidations"] = [];
|
return buffer as ArrayBuffer;
|
||||||
(ws["!dataValidations"] as object[]).push({
|
|
||||||
type: "list",
|
|
||||||
sqref: "D2:D1000",
|
|
||||||
formula1: '"despesa,receita"',
|
|
||||||
showDropDown: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
|
|
||||||
|
|
||||||
const raw = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as number[];
|
|
||||||
return new Uint8Array(raw).buffer as ArrayBuffer;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user