From c6dc22c85999b039419c696e8e67a5700e5e406b Mon Sep 17 00:00:00 2001 From: hwinkel Date: Sun, 15 Mar 2026 20:58:24 +0100 Subject: [PATCH] ADD: fixed e rechnung --- .env.example | 15 +++++- app/lib/rate-limiter.server.ts | 21 ++++++++ app/root.tsx | 2 +- app/routes/api.companies.$id.ts | 4 +- app/routes/api.customers.$id.ts | 2 +- app/routes/api.invoices.$id.ts | 8 +-- app/routes/api.invoices.$id.xml.ts | 54 +++++++++++++++++-- app/routes/api.services.$id.ts | 2 +- .../companies.$id.invoices.$invoiceId.tsx | 34 ++++++++++-- app/routes/login.tsx | 4 ++ app/session.server.ts | 12 +++-- docker-compose.yml | 14 ++--- package-lock.json | 6 +++ package.json | 1 + 14 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 app/lib/rate-limiter.server.ts diff --git a/.env.example b/.env.example index 44a8592..b3e1215 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,14 @@ +# Datenbank (für lokale Entwicklung) DATABASE_URL="mysql://user:password@localhost:3306/annas_rechnungsmanager" -AUTH_SECRET="your-random-secret-here" -NEXTAUTH_URL="http://localhost:3000" + +# Session-Secret – zufälligen Wert generieren: openssl rand -base64 32 +AUTH_SECRET="HIER_ZUFAELLIGEN_WERT_EINSETZEN" + +# Docker-Compose: Datenbank-Credentials +DB_ROOT_PASSWORD="sicheres_root_passwort" +DB_USER="annas_user" +DB_PASSWORD="sicheres_db_passwort" +DB_NAME="annas_rechnungen" + +# Docker-Compose: Admin-Passwort (nur beim ersten Start relevant) +ADMIN_PASSWORD="sicheres_admin_passwort" diff --git a/app/lib/rate-limiter.server.ts b/app/lib/rate-limiter.server.ts new file mode 100644 index 0000000..4665065 --- /dev/null +++ b/app/lib/rate-limiter.server.ts @@ -0,0 +1,21 @@ +import { RateLimiterMemory } from "rate-limiter-flexible"; + +// Max. 5 Loginversuche pro IP innerhalb von 15 Minuten +const loginLimiter = new RateLimiterMemory({ + points: 5, + duration: 60 * 15, +}); + +export async function checkLoginRateLimit(request: Request): Promise { + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + + try { + await loginLimiter.consume(ip); + return null; + } catch { + return "Zu viele Loginversuche. Bitte 15 Minuten warten."; + } +} diff --git a/app/root.tsx b/app/root.tsx index 3b626e4..cab0814 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -15,7 +15,7 @@ export function ErrorBoundary() {

Fehler

{message}
- {stack &&
{stack}
} + {import.meta.env.DEV && stack &&
{stack}
} diff --git a/app/routes/api.companies.$id.ts b/app/routes/api.companies.$id.ts index d5433a9..94b45d8 100644 --- a/app/routes/api.companies.$id.ts +++ b/app/routes/api.companies.$id.ts @@ -1,5 +1,6 @@ import { getApiUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; +import { log } from "@/lib/logger.server"; import { z } from "zod"; const companySchema = z.object({ @@ -39,7 +40,8 @@ export async function action({ request, params }: { request: Request; params: { if (!company) return Response.json({ error: "Not found" }, { status: 404 }); if (request.method === "DELETE") { - await prisma.company.delete({ where: { id: params.id } }); + await prisma.company.delete({ where: { id: params.id, userId: user.id } }); + await log({ userId: user.id, action: "DELETE_COMPANY", entity: "Company", entityId: params.id, request }); return Response.json({ ok: true }); } diff --git a/app/routes/api.customers.$id.ts b/app/routes/api.customers.$id.ts index 5e9d74c..0e6c08b 100644 --- a/app/routes/api.customers.$id.ts +++ b/app/routes/api.customers.$id.ts @@ -35,7 +35,7 @@ export async function action({ request, params }: { request: Request; params: { if (!customer) return Response.json({ error: "Not found" }, { status: 404 }); if (request.method === "DELETE") { - await prisma.customer.delete({ where: { id: params.id } }); + await prisma.customer.delete({ where: { id: params.id, company: { userId: user.id } } }); return Response.json({ ok: true }); } diff --git a/app/routes/api.invoices.$id.ts b/app/routes/api.invoices.$id.ts index c8a23d4..919ff5b 100644 --- a/app/routes/api.invoices.$id.ts +++ b/app/routes/api.invoices.$id.ts @@ -1,6 +1,7 @@ import { getApiUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { generateInvoiceNumber } from "@/lib/invoice-number.server"; +import { log } from "@/lib/logger.server"; import { InvoiceStatus } from "@prisma/client"; import { z } from "zod"; @@ -43,9 +44,9 @@ const updateSchema = z.object({ notes: z.string().optional(), kleinunternehmer: z.boolean().optional().default(false), items: z.array(itemSchema).min(1), - netTotal: z.number(), - taxTotal: z.number(), - grossTotal: z.number(), + netTotal: z.number().nonnegative(), + taxTotal: z.number().nonnegative(), + grossTotal: z.number().nonnegative(), }); export async function action({ request, params }: { request: Request; params: { id: string } }) { @@ -92,6 +93,7 @@ export async function action({ request, params }: { request: Request; params: { ); } await prisma.invoice.delete({ where: { id: params.id } }); + await log({ userId: user.id, action: "DELETE_INVOICE", entity: "Invoice", entityId: params.id, request }); return Response.json({ ok: true }); } diff --git a/app/routes/api.invoices.$id.xml.ts b/app/routes/api.invoices.$id.xml.ts index 9740bc3..6c323ba 100644 --- a/app/routes/api.invoices.$id.xml.ts +++ b/app/routes/api.invoices.$id.xml.ts @@ -35,6 +35,17 @@ export async function loader({ request, params }: { request: Request; params: { if (!invoice) return Response.json({ error: "Not found" }, { status: 404 }); + const missingFields: string[] = []; + if (!invoice.company.email && !invoice.company.phone) { + missingFields.push("Firma: E-Mail oder Telefon (Kontaktdaten, BR-DE-2)"); + } + if (missingFields.length > 0) { + return Response.json( + { error: "Pflichtfelder für E-Rechnung fehlen", missingFields }, + { status: 422 } + ); + } + const { zugferd } = await import("node-zugferd"); const { EN16931 } = await import("node-zugferd/profile"); @@ -73,7 +84,8 @@ export async function loader({ request, params }: { request: Request; params: { rateApplicablePercent: Number(rate), })); - const lines = invoice.items.map((item) => ({ + const lines = invoice.items.map((item, index) => ({ + identifier: String(index + 1), tradeProduct: { name: item.description }, tradeDelivery: { billedQuantity: { @@ -95,11 +107,14 @@ export async function loader({ request, params }: { request: Request; params: { })); const doc = z.create({ + businessProcessType: "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0", + specificationIdentifier: "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0", number: invoice.number ?? invoice.id, typeCode: "380", issueDate: invoice.issueDate, transaction: { tradeAgreement: { + buyerReference: invoice.number ?? invoice.id, seller: { name: invoice.company.name, postalAddress: { @@ -108,6 +123,18 @@ export async function loader({ request, params }: { request: Request; params: { ...(invoice.company.city ? { city: invoice.company.city } : {}), countryCode: "DE", }, + ...(invoice.company.email || invoice.company.phone + ? { + tradeContact: { + name: invoice.company.name, + ...(invoice.company.email ? { emailAddress: invoice.company.email } : {}), + ...(invoice.company.phone ? { phoneNumber: invoice.company.phone } : {}), + }, + } + : {}), + ...(invoice.company.email + ? { electronicAddress: { value: invoice.company.email, schemeIdentifier: "EM" as const } } + : {}), taxRegistration: { ...(invoice.company.vatId ? { vatIdentifier: invoice.company.vatId } : {}), ...(invoice.company.taxId ? { localIdentifier: invoice.company.taxId } : {}), @@ -121,6 +148,9 @@ export async function loader({ request, params }: { request: Request; params: { ...(invoice.customer.city ? { city: invoice.customer.city } : {}), countryCode: "DE", }, + ...(invoice.customer.email + ? { electronicAddress: { value: invoice.customer.email, schemeIdentifier: "EM" as const } } + : {}), }, }, tradeDelivery: { @@ -131,6 +161,18 @@ export async function loader({ request, params }: { request: Request; params: { tradeSettlement: { currencyCode: "EUR", paymentTerms: { dueDate: invoice.dueDate }, + ...(invoice.company.bankIban + ? { + paymentInstruction: { + typeCode: "58" as const, + transfers: [{ paymentAccountIdentifier: invoice.company.bankIban }], + }, + } + : { + paymentInstruction: { + typeCode: "ZZZ" as const, + }, + }), vatBreakdown, monetarySummation: { lineTotalAmount: netTotal, @@ -144,9 +186,15 @@ export async function loader({ request, params }: { request: Request; params: { }, }); - const xml = await doc.toXML(); + let xml: string; + try { + xml = await doc.toXML() as string; + } catch (err) { + const message = err instanceof Error ? err.message : "Unbekannter Fehler"; + return Response.json({ error: `E-Rechnung konnte nicht erstellt werden: ${message}` }, { status: 422 }); + } - return new Response(xml as string, { + return new Response(xml, { status: 200, headers: { "Content-Type": "application/xml; charset=utf-8", diff --git a/app/routes/api.services.$id.ts b/app/routes/api.services.$id.ts index 608aed9..6951d1a 100644 --- a/app/routes/api.services.$id.ts +++ b/app/routes/api.services.$id.ts @@ -20,7 +20,7 @@ export async function action({ request, params }: { request: Request; params: { if (!service) return Response.json({ error: "Not found" }, { status: 404 }); if (request.method === "DELETE") { - await prisma.service.delete({ where: { id: params.id } }); + await prisma.service.delete({ where: { id: params.id, company: { userId: user.id } } }); return Response.json({ ok: true }); } diff --git a/app/routes/companies.$id.invoices.$invoiceId.tsx b/app/routes/companies.$id.invoices.$invoiceId.tsx index 8b5c67a..a0cbd87 100644 --- a/app/routes/companies.$id.invoices.$invoiceId.tsx +++ b/app/routes/companies.$id.invoices.$invoiceId.tsx @@ -170,7 +170,17 @@ export default function InvoiceDetailPage() { */ async function downloadFile(url: string, filename: string) { const res = await fetch(url); - if (!res.ok) return; + if (!res.ok) { + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = await res.json() as { error?: string; missingFields?: string[] }; + const detail = data.missingFields?.length + ? `\n\n• ${data.missingFields.join("\n• ")}` + : ""; + alert(`${data.error ?? "Fehler"}${detail}`); + } + return; + } const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -184,6 +194,11 @@ export default function InvoiceDetailPage() { return downloadFile(`/api/invoices/${invoice.id}/pdf`, `rechnung-${invoice.number ?? invoice.id}.pdf`); } + const xmlMissingFields: string[] = []; + if (!invoice.company.email && !invoice.company.phone) { + xmlMissingFields.push("E-Mail oder Telefon der Firma fehlt"); + } + function downloadXml() { return downloadFile(`/api/invoices/${invoice.id}/xml`, `rechnung-${invoice.number ?? invoice.id}.xml`); } @@ -215,9 +230,20 @@ export default function InvoiceDetailPage() { - + 0 ? `Pflichtfelder fehlen:\n• ${xmlMissingFields.join("\n• ")}` : undefined} + className="inline-flex" + > + + )} {invoice.status === "DRAFT" && ( diff --git a/app/routes/login.tsx b/app/routes/login.tsx index cd95fd2..dae97cf 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,5 +1,6 @@ import { Form, useActionData, useNavigation, redirect } from "react-router"; import { login, createUserSession, getUserSession } from "@/session.server"; +import { checkLoginRateLimit } from "@/lib/rate-limiter.server"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -13,6 +14,9 @@ export async function loader({ request }: { request: Request }) { } export async function action({ request }: { request: Request }) { + const rateLimitError = await checkLoginRateLimit(request); + if (rateLimitError) return { error: rateLimitError }; + const formData = await request.formData(); const identifier = formData.get("identifier") as string; const password = formData.get("password") as string; diff --git a/app/session.server.ts b/app/session.server.ts index 0df7a93..b261466 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -3,15 +3,19 @@ import bcrypt from "bcryptjs"; import prisma from "@/lib/prisma.server"; import { log } from "@/lib/logger.server"; +if (!process.env.AUTH_SECRET) { + throw new Error("AUTH_SECRET environment variable is required"); +} + const sessionStorage = createCookieSessionStorage({ cookie: { name: "__session", httpOnly: true, - maxAge: process.env.NODE_ENV === "development" ? 60 * 60 * 24 * 30 : 60 * 60 * 4, + maxAge: 60 * 60 * 4, // 4 Stunden path: "/", sameSite: "lax", - secrets: [process.env.AUTH_SECRET ?? "fallback-secret-change-in-production"], - secure: process.env.SESSION_SECURE === "true", + secrets: [process.env.AUTH_SECRET], + secure: process.env.NODE_ENV === "production", }, }); @@ -28,6 +32,8 @@ export async function login( }); if (!user) { + // Dummy-Vergleich verhindert Timing-Angriffe zur Benutzernamen-Enumeration + await bcrypt.compare(password, "$2a$12$dummyhashfortimingattackprevention000000000000000000000"); await log({ action: "LOGIN_FAILED", metadata: { identifier }, request }); return null; } diff --git a/docker-compose.yml b/docker-compose.yml index ef59e7c..9ad19c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,10 @@ services: container_name: annas_mariadb restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_DATABASE: annas_rechnungen - MYSQL_USER: annas_user - MYSQL_PASSWORD: annas_password + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME:-annas_rechnungen} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} volumes: - mariadb_data:/var/lib/mysql networks: @@ -27,12 +27,12 @@ services: ports: - "3000:3000" environment: - DATABASE_URL: mysql://annas_user:annas_password@mariadb:3306/annas_rechnungen - AUTH_SECRET: changeme123 + DATABASE_URL: mysql://${DB_USER}:${DB_PASSWORD}@mariadb:3306/${DB_NAME:-annas_rechnungen} + AUTH_SECRET: ${AUTH_SECRET} NODE_ENV: production # Beim ersten Start wird der Admin-Benutzer (username: admin) mit diesem Passwort angelegt. # Nach dem ersten Login in der App ändern und hier leer lassen oder entfernen. - ADMIN_PASSWORD: changeme123 + ADMIN_PASSWORD: ${ADMIN_PASSWORD} depends_on: mariadb: condition: service_healthy diff --git a/package-lock.json b/package-lock.json index 30f4886..c908a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "node-cron": "^4.2.1", "node-zugferd": "^0.1.1-beta.1", "prisma": "^5.22.0", + "rate-limiter-flexible": "^10.0.1", "react": "^19", "react-dom": "^19", "react-hook-form": "^7.71.2", @@ -5922,6 +5923,11 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-10.0.1.tgz", + "integrity": "sha512-3G6GMFz5Oz5nVnDv9gQ1LLMdExR4B1lOjogPIjehtgyxPMIkY09BGyk2eCYt36/OkV/0t12GEt6J6HpTl6RzZg==" + }, "node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", diff --git a/package.json b/package.json index 0d435a0..87ddee8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "node-cron": "^4.2.1", "node-zugferd": "^0.1.1-beta.1", "prisma": "^5.22.0", + "rate-limiter-flexible": "^10.0.1", "react": "^19", "react-dom": "^19", "react-hook-form": "^7.71.2",