From deafac1a9eb6b8860f4a0352bc8a1ba870c917ba Mon Sep 17 00:00:00 2001 From: Will Freeman Date: Sun, 19 Apr 2026 16:35:45 -0600 Subject: [PATCH 1/5] pretty banner --- webapp/src/views/Landing.vue | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/webapp/src/views/Landing.vue b/webapp/src/views/Landing.vue index 4a3500a..a2c22a8 100644 --- a/webapp/src/views/Landing.vue +++ b/webapp/src/views/Landing.vue @@ -11,15 +11,11 @@ close-icon="mdi-close" style="position: sticky; top: 0; z-index: 1000; --v-alert-close-color: #000000;" > -
- - Join DeFlock for a national week of action. Learn more:  - - + Join DeFlock for a national week of action. Learn more: NoALPRs.com
From c298a3f5d49be042160ced34c4eb6dc888327372 Mon Sep 17 00:00:00 2001 From: Will Freeman Date: Tue, 21 Apr 2026 11:36:24 -0600 Subject: [PATCH 2/5] create helpdesk tickets from form submission (#111) --- api/.env.example | 4 ++ api/server.ts | 28 +++++++++++- api/services/TurnstileClient.ts | 25 ++++++++++ api/services/ZammadClient.ts | 81 +++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 api/.env.example create mode 100644 api/services/TurnstileClient.ts create mode 100644 api/services/ZammadClient.ts 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}`); + } + } +} From 7c27235400888d67e5276df3c82378b5e546e9d6 Mon Sep 17 00:00:00 2001 From: Will Freeman Date: Tue, 21 Apr 2026 11:49:46 -0600 Subject: [PATCH 3/5] zip code fix (#112) --- api/server.ts | 4 ++++ api/services/NominatimClient.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/server.ts b/api/server.ts index f228856..d3082e7 100644 --- a/api/server.ts +++ b/api/server.ts @@ -81,6 +81,7 @@ const start = async () => { }, response: { 200: NominatimResultSchema, + 404: { type: 'object', properties: { error: { type: 'string' } } }, 500: { type: 'object', properties: { error: { type: 'string' } } }, }, }, @@ -88,6 +89,9 @@ const start = async () => { const { query } = request.query as { query: string }; reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400'); const result = await nominatim.geocodeSingleResult(query); + if (!result) { + return reply.status(404).send({ error: 'No results found' }); + } return result; }); diff --git a/api/services/NominatimClient.ts b/api/services/NominatimClient.ts index 529302b..255a3ae 100644 --- a/api/services/NominatimClient.ts +++ b/api/services/NominatimClient.ts @@ -17,9 +17,9 @@ export const NominatimResultSchema = Type.Object({ licence: Type.String(), lon: Type.String(), name: Type.String(), - osm_id: Type.Number(), - osm_type: Type.String(), - place_id: Type.Number(), + osm_id: Type.Optional(Type.Number()), + osm_type: Type.Optional(Type.String()), + place_id: Type.Optional(Type.Number()), place_rank: Type.Number(), type: Type.String(), }); From 5316246c0ee93dde8671ab87950a53f203196405 Mon Sep 17 00:00:00 2001 From: Will Freeman Date: Tue, 21 Apr 2026 12:16:18 -0600 Subject: [PATCH 4/5] Contact Form (#113) * contact form on frontend * note about reporting cameras via email * cleanup --- webapp/package-lock.json | 10 + webapp/package.json | 1 + webapp/src/services/apiService.ts | 14 ++ webapp/src/views/ContactView.vue | 383 +++++++++++++++++++----------- webapp/src/views/Press.vue | 2 +- 5 files changed, 265 insertions(+), 145 deletions(-) diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 6630492..fd8df01 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -17,6 +17,7 @@ "pinia": "^2.3.0", "vue": "^3.4.29", "vue-router": "^4.3.3", + "vue-turnstile": "^1.0.11", "vuetify": "^3.7.2" }, "devDependencies": { @@ -2181,6 +2182,15 @@ "typescript": ">=5.0.0" } }, + "node_modules/vue-turnstile": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/vue-turnstile/-/vue-turnstile-1.0.11.tgz", + "integrity": "sha512-iaTBoZ5oUqtNRto6bmbn6FQvW0h/sK7mPUJc1Qn4em+cELXN59U2FQTcpWfKssV3OY6lEZzmCpcn/zrb7htK3A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.45" + } + }, "node_modules/vuetify": { "version": "3.11.2", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.2.tgz", diff --git a/webapp/package.json b/webapp/package.json index a917be8..9013819 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -21,6 +21,7 @@ "pinia": "^2.3.0", "vue": "^3.4.29", "vue-router": "^4.3.3", + "vue-turnstile": "^1.0.11", "vuetify": "^3.7.2" }, "devDependencies": { diff --git a/webapp/src/services/apiService.ts b/webapp/src/services/apiService.ts index c46e0f0..2d0247e 100644 --- a/webapp/src/services/apiService.ts +++ b/webapp/src/services/apiService.ts @@ -87,3 +87,17 @@ export const geocodeQuery = async (query: string) => { const result = (await apiService.get(`/geocode?query=${encodedQuery}`)).data; return result; } + +export interface ContactMessagePayload { + name: string; + email: string; + topic: string; + subject: string; + message: string; + turnstileToken: string; +} + +export const postContactMessage = async (payload: ContactMessagePayload) => { + const response = await apiService.post("/contact/message", payload); + return response.data; +} diff --git a/webapp/src/views/ContactView.vue b/webapp/src/views/ContactView.vue index 677be62..6c82567 100644 --- a/webapp/src/views/ContactView.vue +++ b/webapp/src/views/ContactView.vue @@ -1,161 +1,256 @@ - - diff --git a/webapp/src/views/Press.vue b/webapp/src/views/Press.vue index a375d67..d33d4df 100644 --- a/webapp/src/views/Press.vue +++ b/webapp/src/views/Press.vue @@ -43,7 +43,7 @@

Contact Us

- For media inquiries and interview requests, send us an email at media@deflock.org. + For media inquiries and interview requests, send us an email using this form.

From 7cf14e6e92ea216ca789d508d4b0b34b3d67b94f Mon Sep 17 00:00:00 2001 From: Will Freeman Date: Tue, 21 Apr 2026 16:44:00 -0600 Subject: [PATCH 5/5] media@deflock.org on press page, groups link to populated contact form --- webapp/src/views/Groups.vue | 2 +- webapp/src/views/Press.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/views/Groups.vue b/webapp/src/views/Groups.vue index 2e684c3..46e077e 100644 --- a/webapp/src/views/Groups.vue +++ b/webapp/src/views/Groups.vue @@ -56,7 +56,7 @@

Don't see a group near you?

- Write to us at groups@deflock.org for info on starting your own chapter. + Send us a message for info on starting your own chapter.

diff --git a/webapp/src/views/Press.vue b/webapp/src/views/Press.vue index d33d4df..f5c3224 100644 --- a/webapp/src/views/Press.vue +++ b/webapp/src/views/Press.vue @@ -43,7 +43,7 @@

Contact Us

- For media inquiries and interview requests, send us an email using this form. + For media inquiries and interview requests, send us an email using this form or directly to media@deflock.org.