Merge branch 'master' into new-maps

This commit is contained in:
Will Freeman
2026-04-22 09:02:50 -06:00
11 changed files with 410 additions and 150 deletions
+4
View File
@@ -0,0 +1,4 @@
ZAMMAD_URL=https://pigeon.deflock.org
GITHUB_TOKEN=
ZAMMAD_TOKEN=
TURNSTILE_SECRET_KEY=
+31 -1
View File
@@ -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");
@@ -77,6 +81,7 @@ const start = async () => {
},
response: {
200: NominatimResultSchema,
404: { type: 'object', properties: { error: { type: 'string' } } },
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
@@ -84,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;
});
@@ -131,6 +139,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();
});
+3 -3
View File
@@ -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(),
});
+25
View File
@@ -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<boolean> {
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;
}
}
+81
View File
@@ -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<typeof ContactMessageBodySchema>;
const TOPIC_GROUP_MAP: Record<ContactTopic, string> = {
'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<void> {
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}`);
}
}
}
+10
View File
@@ -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",
+1
View File
@@ -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": {
+14
View File
@@ -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;
}
+239 -144
View File
@@ -1,161 +1,256 @@
<template>
<DefaultLayout>
<v-container>
<v-row justify="center" class="mb-8">
<v-col cols="12" md="8" class="text-center">
<h1 class="text-h3 font-weight-bold mb-4">Contact Us</h1>
<p class="text-h6 text-medium-emphasis">
How can we help you today?
</p>
</v-col>
</v-row>
<DefaultLayout>
<v-container>
<v-row justify="center" class="mb-6">
<v-col cols="12" md="8" class="text-center">
<h1 class="text-h3 font-weight-bold mb-4">Contact Us</h1>
<p class="text-h6 text-medium-emphasis">
We'd love to hear from you.
</p>
</v-col>
</v-row>
<v-row justify="center">
<v-col cols="12" md="10" lg="8">
<v-row>
<!-- Report Camera Option -->
<v-col cols="12" md="6">
<v-card
variant="outlined"
class="mx-auto h-100 d-flex flex-column"
max-width="400"
hover
@click="$router.push('/report')"
style="cursor: pointer;"
>
<v-card-item class="text-center pa-6">
<v-icon
icon="mdi-camera"
size="48"
color="primary"
class="mb-4"
></v-icon>
<v-card-title class="text-h5 font-weight-bold card-title-wrap">
Report a Camera
</v-card-title>
<v-card-subtitle class="text-body-1 mt-2 card-subtitle-wrap">
Found an ALPR camera? Help us map it and protect your community's privacy.
</v-card-subtitle>
</v-card-item>
<v-card-actions class="justify-center pb-6">
<v-row justify="center">
<v-col cols="12" md="8" lg="6">
<!-- Success state -->
<v-alert
v-if="isSuccess"
type="success"
variant="tonal"
class="mb-6"
title="Message sent!"
>
Thanks for reaching out. We'll get back to you as soon as we can.
</v-alert>
<!-- Error state -->
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mb-6"
title="Something went wrong"
>
We couldn't send your message. Please try again, or email us directly at
<a href="mailto:contact@deflock.org" class="text-decoration-none font-weight-bold">contact@deflock.org</a>.
</v-alert>
<v-form ref="form" @submit.prevent="handleSubmit">
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="fields.name"
label="Name or alias"
:rules="[rules.required]"
variant="outlined"
density="comfortable"
autocomplete="name"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="fields.email"
label="Email"
type="email"
:rules="[rules.required, rules.email]"
variant="outlined"
density="comfortable"
autocomplete="email"
></v-text-field>
</v-col>
<v-col cols="12">
<v-select
v-model="fields.topic"
label="What can we help with?"
:items="topicItems"
item-title="label"
item-value="value"
:rules="[rules.required]"
variant="outlined"
density="comfortable"
></v-select>
</v-col>
<v-col v-if="fields.topic === REPORT_CAMERA_TOPIC" cols="12">
<v-alert type="info" variant="tonal" icon="mdi-camera">
We don't accept camera submissions by email but you can
<router-link :to="{ name: 'report' }">report a camera yourself</router-link>
directly on the site. If you're having trouble with the reporting tool, feel free to send us a message below.
</v-alert>
</v-col>
<v-col cols="12">
<v-text-field
v-model="fields.subject"
label="Subject"
:rules="[rules.required]"
variant="outlined"
density="comfortable"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
v-model="fields.message"
label="Message"
:rules="[rules.required]"
variant="outlined"
rows="5"
auto-grow
></v-textarea>
</v-col>
<v-col cols="12">
<VueTurnstile
ref="turnstileRef"
site-key="0x4AAAAAADApg9O-hmUZCotn"
v-model="turnstileToken"
theme="auto"
@error="onTurnstileError"
@expired="onTurnstileExpired"
/>
<p v-if="turnstileError" class="text-caption text-error mt-1">
Please complete the security check.
</p>
</v-col>
<v-col cols="12">
<v-btn
type="submit"
color="primary"
size="large"
append-icon="mdi-arrow-right"
:loading="isSubmitting"
:disabled="isSubmitting"
block
>
Start Report
Send Message
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-col>
</v-row>
</v-form>
<!-- General Inquiry Option -->
<v-col cols="12" md="6">
<v-card
variant="outlined"
class="mx-auto h-100 d-flex flex-column"
max-width="400"
:hover="!showContactOptions"
@click="!showContactOptions ? showContactOptions = true : null"
:style="showContactOptions ? 'cursor: default;' : 'cursor: pointer;'"
>
<v-card-item class="text-center pa-6">
<v-icon
:icon="showContactOptions ? 'mdi-message-text' : 'mdi-message-text'"
size="48"
color="secondary"
class="mb-4"
></v-icon>
<v-card-title class="text-h5 font-weight-bold card-title-wrap">
{{ showContactOptions ? 'Get in Touch' : 'General Inquiry' }}
</v-card-title>
<v-card-subtitle class="text-body-1 mt-2 card-subtitle-wrap">
{{ showContactOptions ? 'Send us an email with your questions' : 'Have questions about DeFlock, need help, or want to get involved?' }}
</v-card-subtitle>
</v-card-item>
<v-card-actions class="justify-center pb-6">
<div v-if="!showContactOptions">
<v-btn
color="secondary"
size="large"
append-icon="mdi-arrow-right"
>
Contact Options
</v-btn>
</div>
<div v-else class="d-flex flex-column gap-3 w-100">
<div class="text-center">
<p class="text-body-1 mb-2">Send us an email at:</p>
<a
href="mailto:contact@deflock.org"
class="text-h6 font-weight-bold text-decoration-none"
style="color: rgb(var(--v-theme-secondary));"
>
contact@deflock.org
</a>
</div>
</div>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-col>
</v-row>
<p class="text-center text-body-2 text-medium-emphasis mt-8">
<b>Media inquiries?</b> Visit our
<router-link to="/press" class="text-decoration-none">
press page
</router-link>.
</p>
</v-col>
</v-row>
<!-- Additional Help Section -->
<v-row justify="center">
<v-col cols="12" md="8" class="text-center">
<v-divider class="mb-6"></v-divider>
<p class="text-body-2 text-medium-emphasis">
Need to identify if something is an ALPR camera?<br>
<router-link to="/identify" class="text-decoration-none">
Check our camera gallery
</router-link>
or learn more about
<router-link to="/what-is-an-alpr" class="text-decoration-none">
what ALPRs are
</router-link>.
</p>
</v-col>
</v-row>
</v-container>
</DefaultLayout>
<v-divider class="mt-12" />
<v-row>
<v-col cols="12" md="8" class="mx-auto text-center">
<h2 class="text-h5 font-weight-bold mb-3">Other Ways to Reach Us</h2>
<p class="text-body-1 mb-4">
If you prefer, you can also email us directly at
<a href="mailto:contact@deflock.org" class="text-decoration-none font-weight-bold">contact@deflock.org</a>.
</p>
</v-col>
</v-row>
</v-container>
</DefaultLayout>
</template>
<script setup lang="ts">
import DefaultLayout from '@/layouts/DefaultLayout.vue';
import { useTheme } from 'vuetify';
import { computed, ref } from 'vue';
import { postContactMessage } from '@/services/apiService';
import VueTurnstile from 'vue-turnstile';
import { nextTick, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
const theme = useTheme();
const isDark = computed(() => theme.name.value === 'dark');
const showContactOptions = ref(false);
const route = useRoute();
const topicItems = [
{ label: 'Questions & Comments', value: 'questions-comments' },
{ label: 'Technical Support - Website/Map', value: 'website-support' },
{ label: 'Technical Support - DeFlock App', value: 'app-support' },
{ label: 'Reporting a Camera', value: 'report-camera' },
{ label: 'Local Groups', value: 'local-groups' },
{ label: 'Media/Press', value: 'media' },
] as const;
type TopicValue = typeof topicItems[number]['value'];
const REPORT_CAMERA_TOPIC = 'report-camera' as const;
const validTopics = topicItems.map((t) => t.value) as string[];
const initialTopic = (): TopicValue | null => {
const q = route.query.topic as string | undefined;
return q && validTopics.includes(q) ? (q as TopicValue) : null;
};
const fields = reactive({
name: '',
email: '',
topic: initialTopic(),
subject: '',
message: '',
});
const rules = {
required: (v: unknown) => (v !== null && v !== undefined && String(v).trim() !== '') || 'This field is required.',
email: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Please enter a valid email address.',
};
const form = ref<{ validate: () => Promise<{ valid: boolean }>; resetValidation: () => void } | null>(null);
const turnstileRef = ref<InstanceType<typeof VueTurnstile> | null>(null);
const turnstileToken = ref('');
const turnstileError = ref(false);
const isSubmitting = ref(false);
const isSuccess = ref(false);
const isError = ref(false);
const onTurnstileError = () => {
turnstileToken.value = '';
};
const onTurnstileExpired = () => {
turnstileToken.value = '';
};
const resetTurnstile = () => {
turnstileRef.value?.reset();
turnstileToken.value = '';
};
const handleSubmit = async () => {
isError.value = false;
isSuccess.value = false;
const { valid } = await form.value!.validate();
if (!valid) return;
if (!turnstileToken.value) {
turnstileError.value = true;
return;
}
turnstileError.value = false;
isSubmitting.value = true;
try {
await postContactMessage({
name: fields.name,
email: fields.email,
topic: fields.topic === REPORT_CAMERA_TOPIC ? 'app-support' : fields.topic!,
subject: fields.subject,
message: fields.message,
turnstileToken: turnstileToken.value,
});
isSuccess.value = true;
// Reset form
fields.name = '';
fields.email = '';
fields.topic = null;
fields.subject = '';
fields.message = '';
await nextTick();
form.value!.resetValidation();
resetTurnstile();
} catch {
isError.value = true;
resetTurnstile();
} finally {
isSubmitting.value = false;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
</script>
<style lang="css" scoped>
.card-title-wrap {
white-space: normal !important;
overflow: visible !important;
text-overflow: unset !important;
}
.card-subtitle-wrap {
white-space: normal !important;
overflow: visible !important;
text-overflow: unset !important;
line-height: 1.4 !important;
}
.gap-3 > * + * {
margin-top: 12px;
}
</style>
+1 -1
View File
@@ -56,7 +56,7 @@
<v-alert>
<p class="text-h5 my-0 pt-0 font-weight-bold">Don't see a group near you?</p>
<p>
Write to us at <a href="mailto:groups@deflock.org">groups@deflock.org</a> for info on starting your own chapter.
<router-link to="/contact?topic=local-groups">Send us a message</router-link> for info on starting your own chapter.
</p>
</v-alert>
</div>
+1 -1
View File
@@ -33,7 +33,7 @@
<h2>Contact Us</h2>
<p>
For media inquiries and interview requests, send us an email at <a href="mailto:media@deflock.org">media@deflock.org</a>.
For media inquiries and interview requests, send us an email <router-link to="/contact?topic=media">using this form</router-link> or directly to <a href="mailto:media@deflock.org">media@deflock.org</a>.
</p>
</v-container>