Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
Deploy stack su Proxmox / deploy (push) Failing after 3s
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA), apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/; infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e workflow Gitea Actions per il deploy via SSH a ogni push su master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { createHash, createHmac } from "crypto"
|
||||
import type { Logger } from "@medusajs/framework/types"
|
||||
|
||||
export const SIGNATURE_HEADER = "x-signature"
|
||||
export const WEBHOOK_PATH = "/webhooks/medusa"
|
||||
|
||||
const MAX_ATTEMPTS = 5
|
||||
const REQUEST_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_RETRY_BASE_MS = 2_000
|
||||
|
||||
/** Namespace (UUID) per gli id evento derivati: costante, non cambiarlo o gli id cambiano. */
|
||||
const EVENT_ID_NAMESPACE = "6f1d2c3a-8b7e-4f5a-9c0d-1e2f3a4b5c6d"
|
||||
|
||||
export type WebhookPayload = {
|
||||
id: string
|
||||
event: string
|
||||
occurred_at: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID v5 deterministico da nome evento + dati dell'evento.
|
||||
* Lo stesso evento (es. order.placed dello stesso ordine) ha sempre lo stesso id, anche se il
|
||||
* subscriber viene rieseguito: apps/api lo usa per l'idempotenza (tabella webhook_events).
|
||||
*/
|
||||
export function deriveEventId(event: string, eventData: unknown): string {
|
||||
const ns = Buffer.from(EVENT_ID_NAMESPACE.replace(/-/g, ""), "hex")
|
||||
const hash = createHash("sha1")
|
||||
.update(ns)
|
||||
.update(`${event}:${JSON.stringify(eventData)}`)
|
||||
.digest()
|
||||
hash[6] = (hash[6] & 0x0f) | 0x50 // versione 5
|
||||
hash[8] = (hash[8] & 0x3f) | 0x80 // variante RFC 4122
|
||||
const hex = hash.subarray(0, 16).toString("hex")
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
/** Firma: HMAC-SHA256 del body grezzo (stringa JSON inviata) con WEBHOOK_SECRET, in esadecimale. */
|
||||
export function signBody(body: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(body).digest("hex")
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Invia il payload a {FASTIFY_WEBHOOK_URL}/webhooks/medusa firmato in x-signature.
|
||||
* Fino a 5 tentativi con backoff esponenziale (base WEBHOOK_RETRY_BASE_MS: 2s, 4s, 8s, 16s)
|
||||
* se la risposta non è 2xx o la richiesta fallisce. Non lancia mai: gli errori finiscono nei log
|
||||
* (l'ultimo con il payload completo, per un eventuale reinvio manuale).
|
||||
*/
|
||||
export async function deliverWebhook(
|
||||
payload: WebhookPayload,
|
||||
logger: Logger
|
||||
): Promise<boolean> {
|
||||
const baseUrl = process.env.FASTIFY_WEBHOOK_URL
|
||||
const secret = process.env.WEBHOOK_SECRET
|
||||
const tag = `[webhook ${payload.event} ${payload.id}]`
|
||||
|
||||
if (!baseUrl || !secret) {
|
||||
logger.warn(`${tag} FASTIFY_WEBHOOK_URL o WEBHOOK_SECRET non impostati: invio saltato`)
|
||||
return false
|
||||
}
|
||||
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}${WEBHOOK_PATH}`
|
||||
const body = JSON.stringify(payload)
|
||||
const signature = signBody(body, secret)
|
||||
const retryBaseMs = Number(process.env.WEBHOOK_RETRY_BASE_MS) || DEFAULT_RETRY_BASE_MS
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
let outcome: string
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", [SIGNATURE_HEADER]: signature },
|
||||
body,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
if (res.ok) {
|
||||
logger.info(`${tag} consegnato (tentativo ${attempt}/${MAX_ATTEMPTS}, HTTP ${res.status})`)
|
||||
return true
|
||||
}
|
||||
outcome = `HTTP ${res.status}`
|
||||
} catch (e) {
|
||||
const err = e as Error & { cause?: { code?: string } }
|
||||
outcome = err.cause?.code ?? err.name ?? err.message
|
||||
}
|
||||
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
const delay = retryBaseMs * 2 ** (attempt - 1)
|
||||
logger.warn(
|
||||
`${tag} tentativo ${attempt}/${MAX_ATTEMPTS} fallito (${outcome}), nuovo tentativo tra ${delay} ms`
|
||||
)
|
||||
await sleep(delay)
|
||||
} else {
|
||||
logger.error(
|
||||
`${tag} tentativo ${attempt}/${MAX_ATTEMPTS} fallito (${outcome}): consegna abbandonata. Payload: ${body}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createHash, timingSafeEqual } from "crypto"
|
||||
import type {
|
||||
MedusaNextFunction,
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "@medusajs/framework/http"
|
||||
|
||||
export const INTERNAL_SECRET_HEADER = "x-internal-secret"
|
||||
|
||||
const digest = (value: string) => createHash("sha256").update(value).digest()
|
||||
|
||||
/**
|
||||
* Protegge le route /internal/*: chiamabili solo da apps/api (Fastify) con il segreto condiviso
|
||||
* INTERNAL_API_SECRET nell'header x-internal-secret.
|
||||
* Il confronto avviene sui digest SHA-256 (stessa lunghezza) con timingSafeEqual,
|
||||
* così né il contenuto né la lunghezza del segreto trapelano dai tempi di risposta.
|
||||
* Se INTERNAL_API_SECRET non è configurato, tutte le richieste vengono rifiutate.
|
||||
*/
|
||||
export function requireInternalSecret(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse,
|
||||
next: MedusaNextFunction
|
||||
) {
|
||||
const expected = process.env.INTERNAL_API_SECRET
|
||||
const provided = req.headers[INTERNAL_SECRET_HEADER]
|
||||
|
||||
if (
|
||||
!expected ||
|
||||
typeof provided !== "string" ||
|
||||
!timingSafeEqual(digest(provided), digest(expected))
|
||||
) {
|
||||
res.status(401).json({ type: "unauthorized", message: "Unauthorized" })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
Reference in New Issue
Block a user