Added Backend
This commit is contained in:
51
backend/src/plugins/auth.m2m.ts
Normal file
51
backend/src/plugins/auth.m2m.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { secrets } from "../utils/secrets";
|
||||
|
||||
/**
|
||||
* Fastify Plugin für Machine-to-Machine Authentifizierung.
|
||||
*
|
||||
* Dieses Plugin prüft, ob der Header `x-api-key` vorhanden ist
|
||||
* und mit dem in der .env hinterlegten M2M_API_KEY übereinstimmt.
|
||||
*
|
||||
* Verwendung:
|
||||
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
|
||||
*/
|
||||
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
|
||||
//const allowedPrefix = opts.allowedPrefix || "/internal";
|
||||
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
try {
|
||||
// Nur prüfen, wenn Route unterhalb des Prefix liegt
|
||||
//if (!req.url.startsWith(allowedPrefix)) return;
|
||||
|
||||
const apiKey = req.headers["x-api-key"];
|
||||
|
||||
if (!apiKey || apiKey !== secrets.M2M_API_KEY) {
|
||||
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
// Zusatzinformationen im Request (z. B. interne Kennung)
|
||||
(req as any).m2m = {
|
||||
verified: true,
|
||||
type: "internal",
|
||||
key: apiKey,
|
||||
};
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err);
|
||||
return reply.status(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
m2m?: {
|
||||
verified: boolean;
|
||||
type: "internal";
|
||||
key: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
115
backend/src/plugins/auth.ts
Normal file
115
backend/src/plugins/auth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
import {
|
||||
authUserRoles,
|
||||
authRolePermissions,
|
||||
} from "../../db/schema"
|
||||
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
// 1️⃣ Token aus Header oder Cookie lesen
|
||||
const cookieToken = req.cookies?.token
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
const headerToken =
|
||||
authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null
|
||||
|
||||
const token =
|
||||
headerToken && headerToken.length > 10
|
||||
? headerToken
|
||||
: cookieToken || null
|
||||
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: "Authentication required" })
|
||||
}
|
||||
|
||||
try {
|
||||
// 2️⃣ JWT verifizieren
|
||||
const payload = jwt.verify(token, secrets.JWT_SECRET!) as {
|
||||
user_id: string
|
||||
email: string
|
||||
tenant_id: number | null
|
||||
}
|
||||
|
||||
if (!payload?.user_id) {
|
||||
return reply.code(401).send({ error: "Invalid token" })
|
||||
}
|
||||
|
||||
// Payload an Request hängen
|
||||
req.user = payload
|
||||
|
||||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||||
if (!req.user.tenant_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const tenantId = req.user.tenant_id
|
||||
const userId = req.user.user_id
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 3️⃣ Rolle des Nutzers im Tenant holen
|
||||
// --------------------------------------------------------
|
||||
const roleRows = await server.db
|
||||
.select()
|
||||
.from(authUserRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(authUserRoles.user_id, userId),
|
||||
eq(authUserRoles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (roleRows.length === 0) {
|
||||
return reply
|
||||
.code(403)
|
||||
.send({ error: "No role assigned for this tenant" })
|
||||
}
|
||||
|
||||
const roleId = roleRows[0].role_id
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 4️⃣ Berechtigungen der Rolle laden
|
||||
// --------------------------------------------------------
|
||||
const permissionRows = await server.db
|
||||
.select()
|
||||
.from(authRolePermissions)
|
||||
.where(eq(authRolePermissions.role_id, roleId))
|
||||
|
||||
const permissions = permissionRows.map((p) => p.permission)
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 5️⃣ An Request hängen für spätere Nutzung
|
||||
// --------------------------------------------------------
|
||||
req.role = roleId
|
||||
req.permissions = permissions
|
||||
req.hasPermission = (perm: string) => permissions.includes(perm)
|
||||
|
||||
} catch (err) {
|
||||
console.error("JWT verification error:", err)
|
||||
return reply.code(401).send({ error: "Invalid or expired token" })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fastify TypeScript Erweiterungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user: {
|
||||
user_id: string
|
||||
email: string
|
||||
tenant_id: number | null
|
||||
}
|
||||
role: string
|
||||
permissions: string[]
|
||||
hasPermission: (permission: string) => boolean
|
||||
}
|
||||
}
|
||||
22
backend/src/plugins/cors.ts
Normal file
22
backend/src/plugins/cors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import cors from "@fastify/cors";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
await server.register(cors, {
|
||||
origin: [
|
||||
"http://localhost:3000", // dein Nuxt-Frontend
|
||||
"http://localhost:3001", // dein Nuxt-Frontend
|
||||
"http://127.0.0.1:3000", // dein Nuxt-Frontend
|
||||
"http://192.168.1.227:3001", // dein Nuxt-Frontend
|
||||
"http://192.168.1.227:3000", // dein Nuxt-Frontend
|
||||
"https://beta.fedeo.de", // dein Nuxt-Frontend
|
||||
"https://app.fedeo.de", // dein Nuxt-Frontend
|
||||
"capacitor://localhost", // dein Nuxt-Frontend
|
||||
],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
|
||||
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
|
||||
credentials: true, // wichtig, falls du Cookies nutzt
|
||||
});
|
||||
});
|
||||
41
backend/src/plugins/dayjs.ts
Normal file
41
backend/src/plugins/dayjs.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import fp from "fastify-plugin"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
// 🧩 Plugins
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat.js";
|
||||
import isBetween from "dayjs/plugin/isBetween.js";
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import timezone from "dayjs/plugin/timezone"
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
|
||||
import isoWeek from "dayjs/plugin/isoWeek"
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat"
|
||||
|
||||
// 🔧 Erweiterungen aktivieren
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(isBetween)
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(localizedFormat)
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(isBetween)
|
||||
dayjs.extend(duration)
|
||||
|
||||
/**
|
||||
* Fastify Plugin: hängt dayjs an den Server an
|
||||
*/
|
||||
export default fp(async (server) => {
|
||||
server.decorate("dayjs", dayjs)
|
||||
})
|
||||
|
||||
/**
|
||||
* Typ-Erweiterung für TypeScript
|
||||
*/
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
dayjs: typeof dayjs
|
||||
}
|
||||
}
|
||||
34
backend/src/plugins/db.ts
Normal file
34
backend/src/plugins/db.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fp from "fastify-plugin"
|
||||
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
|
||||
import { Pool } from "pg"
|
||||
import * as schema from "../../db/schema"
|
||||
|
||||
export default fp(async (server, opts) => {
|
||||
const pool = new Pool({
|
||||
host: "100.102.185.225",
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
user: "postgres",
|
||||
password: "wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu",
|
||||
database: "fedeo",
|
||||
ssl: process.env.DB_DISABLE_SSL === "true" ? false : undefined,
|
||||
})
|
||||
|
||||
// Drizzle instance
|
||||
const db = drizzle(pool, { schema })
|
||||
|
||||
// Dekorieren -> überall server.db
|
||||
server.decorate("db", db)
|
||||
|
||||
// Graceful Shutdown
|
||||
server.addHook("onClose", async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
server.log.info("Drizzle database connected")
|
||||
})
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
db:NodePgDatabase<typeof schema>
|
||||
}
|
||||
}
|
||||
125
backend/src/plugins/queryconfig.ts
Normal file
125
backend/src/plugins/queryconfig.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { FastifyPluginAsync, FastifyRequest } from 'fastify'
|
||||
|
||||
export interface QueryConfigPagination {
|
||||
page: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface QueryConfigSort {
|
||||
field: string
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface QueryConfig {
|
||||
pagination: QueryConfigPagination | null
|
||||
sort: QueryConfigSort[]
|
||||
filters: Record<string, string>
|
||||
paginationDisabled: boolean
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
queryConfig: QueryConfig
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryConfigPluginOptions {
|
||||
routes?: string[]
|
||||
}
|
||||
|
||||
function matchRoutePattern(currentPath: string, patterns: string[]): boolean {
|
||||
return patterns.some(pattern => {
|
||||
// Beispiel: /users/:id -> /^\/users\/[^/]+$/
|
||||
const regex = new RegExp(
|
||||
'^' +
|
||||
pattern
|
||||
.replace(/\*/g, '.*') // wildcard
|
||||
.replace(/:[^/]+/g, '[^/]+') +
|
||||
'$'
|
||||
)
|
||||
return regex.test(currentPath)
|
||||
})
|
||||
}
|
||||
|
||||
const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
|
||||
fastify,
|
||||
opts
|
||||
) => {
|
||||
const routePatterns = opts.routes || []
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
const path = req.routeOptions.url || req.raw.url || ''
|
||||
|
||||
if (!matchRoutePattern(path, routePatterns)) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = req.query as Record<string, any>
|
||||
|
||||
console.log(query)
|
||||
|
||||
// Pagination deaktivieren?
|
||||
const disablePagination =
|
||||
query.noPagination === 'true' ||
|
||||
query.pagination === 'false' ||
|
||||
query.limit === '0'
|
||||
|
||||
// Pagination berechnen
|
||||
let pagination: QueryConfigPagination | null = null
|
||||
if (!disablePagination) {
|
||||
const page = Math.max(parseInt(query.page) || 1, 1)
|
||||
const limit = Math.max(parseInt(query.limit) || 25, 1)
|
||||
const offset = (page - 1) * limit
|
||||
pagination = { page, limit, offset }
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
const sort: QueryConfigSort[] = []
|
||||
if (typeof query.sort === 'string') {
|
||||
const items = query.sort.split(',')
|
||||
for (const item of items) {
|
||||
const [field, direction] = item.split(':')
|
||||
sort.push({
|
||||
field: field.trim(),
|
||||
direction: (direction || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filterung
|
||||
const filters: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
const match = key.match(/^filter\[(.+)\]$/)
|
||||
if (!match) continue
|
||||
|
||||
const filterKey = match[1]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Split bei Komma → mehrere Werte
|
||||
const parts = value.split(',').map(v => v.trim()).filter(Boolean)
|
||||
|
||||
// Automatische Typkonvertierung je Element
|
||||
const parsedValues = parts.map(v => {
|
||||
if (v === 'true') return true
|
||||
if (v === 'false') return false
|
||||
if (v === 'null') return null
|
||||
return v
|
||||
})
|
||||
|
||||
filters[filterKey] = parsedValues.length > 1 ? parsedValues : parsedValues[0]
|
||||
}
|
||||
}
|
||||
|
||||
req.queryConfig = {
|
||||
pagination,
|
||||
sort,
|
||||
filters,
|
||||
paginationDisabled: disablePagination
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default fp(queryConfigPlugin, { name: 'query-config' })
|
||||
24
backend/src/plugins/services.ts
Normal file
24
backend/src/plugins/services.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// /plugins/services.ts
|
||||
import fp from "fastify-plugin";
|
||||
import { bankStatementService } from "../modules/cron/bankstatementsync.service";
|
||||
//import {initDokuboxClient, syncDokubox} from "../modules/cron/dokuboximport.service";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
services: {
|
||||
bankStatements: ReturnType<typeof bankStatementService>;
|
||||
//dokuboxSync: ReturnType<typeof syncDokubox>;
|
||||
prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async function servicePlugin(server: FastifyInstance) {
|
||||
server.decorate("services", {
|
||||
bankStatements: bankStatementService(server),
|
||||
//dokuboxSync: syncDokubox(server),
|
||||
prepareIncomingInvoices: prepareIncomingInvoices(server),
|
||||
});
|
||||
});
|
||||
19
backend/src/plugins/supabase.ts
Normal file
19
backend/src/plugins/supabase.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
import {secrets} from "../utils/secrets";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
const supabaseUrl = secrets.SUPABASE_URL
|
||||
const supabaseServiceKey = secrets.SUPABASE_SERVICE_ROLE_KEY
|
||||
const supabase: SupabaseClient = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fastify um supabase erweitern
|
||||
server.decorate("supabase", supabase);
|
||||
});
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
supabase: SupabaseClient;
|
||||
}
|
||||
}
|
||||
30
backend/src/plugins/swagger.ts
Normal file
30
backend/src/plugins/swagger.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import swagger from "@fastify/swagger";
|
||||
import swaggerUi from "@fastify/swagger-ui";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
await server.register(swagger, {
|
||||
mode: "dynamic", // wichtig: generiert echtes OpenAPI JSON
|
||||
openapi: {
|
||||
info: {
|
||||
title: "Multi-Tenant API",
|
||||
description: "API Dokumentation für dein Backend",
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: [{ url: "http://localhost:3000" }],
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
await server.register(swaggerUi, {
|
||||
routePrefix: "/docs", // UI erreichbar unter http://localhost:3000/docs
|
||||
swagger: {
|
||||
info: {
|
||||
title: "Multi-Tenant API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
exposeRoute: true,
|
||||
});
|
||||
});
|
||||
41
backend/src/plugins/tenant.ts
Normal file
41
backend/src/plugins/tenant.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
const host = req.headers.host?.split(":")[0]; // Domain ohne Port
|
||||
if (!host) {
|
||||
reply.code(400).send({ error: "Missing host header" });
|
||||
return;
|
||||
}
|
||||
// Tenant aus DB laden
|
||||
const { data: tenant } = await server.supabase
|
||||
.from("tenants")
|
||||
.select("*")
|
||||
.eq("portalDomain", host)
|
||||
.single();
|
||||
|
||||
|
||||
if(!tenant) {
|
||||
// Multi Tenant Mode
|
||||
(req as any).tenant = null;
|
||||
}else {
|
||||
// Tenant ins Request-Objekt hängen
|
||||
(req as any).tenant = tenant;
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// Typ-Erweiterung
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
tenant?: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain?: string;
|
||||
subdomain?: string;
|
||||
settings?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user