diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..1dc8ace --- /dev/null +++ b/api/.env.example @@ -0,0 +1,4 @@ +ZAMMAD_URL=https://pigeon.deflock.org +GITHUB_TOKEN= +ZAMMAD_TOKEN= +TURNSTILE_SECRET_KEY= diff --git a/api/server.ts b/api/server.ts index 67d011a..f228856 100644 --- a/api/server.ts +++ b/api/server.ts @@ -3,6 +3,8 @@ import Fastify, { FastifyInstance, FastifyError } from 'fastify'; import cors from '@fastify/cors'; import { NominatimClient, NominatimResultSchema } from './services/NominatimClient'; import { GithubClient, SponsorsResponseSchema } from './services/GithubClient'; +import { TurnstileClient } from './services/TurnstileClient'; +import { ZammadClient, ContactMessageBodySchema, ContactMessageBody } from './services/ZammadClient'; const start = async () => { const server: FastifyInstance = Fastify({ @@ -51,11 +53,13 @@ const start = async () => { cb(null, false); } }, - methods: ['GET', 'HEAD'], + methods: ['GET', 'HEAD', 'POST'], }); const nominatim = new NominatimClient(); const githubClient = new GithubClient(); + const turnstileClient = new TurnstileClient(); + const zammadClient = new ZammadClient(); const shutdown = async () => { server.log.info("Shutting down"); @@ -131,6 +135,28 @@ const start = async () => { return result; }); + server.post('/contact/message', { + schema: { + body: ContactMessageBodySchema, + response: { + 201: { type: 'object', properties: {} }, + 400: { type: 'object', properties: { error: { type: 'string' } } }, + 500: { type: 'object', properties: { error: { type: 'string' } } }, + }, + }, + }, async (request, reply) => { + const { name, email, topic, subject, message, turnstileToken } = request.body as ContactMessageBody; + + const remoteIp = request.ip; + const valid = await turnstileClient.verify(turnstileToken, remoteIp); + if (!valid) { + return reply.status(400).send({ error: 'Invalid captcha' }); + } + + await zammadClient.createTicket({ name, email, topic, subject, message }); + return reply.status(201).send({}); + }); + server.head('/healthcheck', async (request, reply) => { reply.status(200).send(); }); diff --git a/api/services/TurnstileClient.ts b/api/services/TurnstileClient.ts new file mode 100644 index 0000000..856a017 --- /dev/null +++ b/api/services/TurnstileClient.ts @@ -0,0 +1,25 @@ +const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || ''; +const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +export class TurnstileClient { + async verify(token: string, remoteIp?: string): Promise { + const body = new URLSearchParams({ + secret: TURNSTILE_SECRET_KEY, + response: token, + ...(remoteIp ? { remoteip: remoteIp } : {}), + }); + + const response = await fetch(SITEVERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + if (!response.ok) { + throw new Error(`Turnstile siteverify request failed: ${response.status}`); + } + + const json = await response.json() as { success: boolean }; + return json.success === true; + } +} diff --git a/api/services/ZammadClient.ts b/api/services/ZammadClient.ts new file mode 100644 index 0000000..39a533d --- /dev/null +++ b/api/services/ZammadClient.ts @@ -0,0 +1,81 @@ +import { Type, Static } from '@sinclair/typebox'; + +const ZAMMAD_URL = process.env.ZAMMAD_URL || ''; +const ZAMMAD_TOKEN = process.env.ZAMMAD_TOKEN || ''; + +export type ContactTopic = + | 'website-support' + | 'app-support' + | 'local-groups' + | 'media' + | 'questions-comments'; + +export const ContactMessageBodySchema = Type.Object({ + name: Type.String({ minLength: 1 }), + email: Type.String({ format: 'email' }), + topic: Type.Union([ + Type.Literal('website-support'), + Type.Literal('app-support'), + Type.Literal('local-groups'), + Type.Literal('media'), + Type.Literal('questions-comments'), + ]), + subject: Type.String({ minLength: 1 }), + message: Type.String({ minLength: 1 }), + turnstileToken: Type.String({ minLength: 1 }), +}); + +export type ContactMessageBody = Static; + +const TOPIC_GROUP_MAP: Record = { + 'website-support': 'Website Support', + 'app-support': 'App Support', + 'local-groups': 'Local Groups', + 'media': 'Media', + 'questions-comments': 'General Support', +}; + +export interface CreateTicketPayload { + name: string; + email: string; + topic: ContactTopic; + subject: string; + message: string; +} + +export class ZammadClient { + async createTicket(payload: CreateTicketPayload): Promise { + const { name, email, topic, subject, message } = payload; + const group = TOPIC_GROUP_MAP[topic]; + + const body = JSON.stringify({ + title: subject, + group, + priority: topic === 'media' ? '3 high' : '2 normal', + customer: email, + article: { + subject, + body: message, + type: 'email', + sender: 'Customer', + from: `${name} <${email}>`, + to: 'contact@deflock.org', + internal: false, + }, + }); + + const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets`, { + method: 'POST', + headers: { + 'Authorization': `Token token=${ZAMMAD_TOKEN}`, + 'Content-Type': 'application/json', + }, + body, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Zammad ticket creation failed: ${response.status} ${text}`); + } + } +}