Skip to content

Commit

Permalink
Add form admin UI (#46)
Browse files Browse the repository at this point in the history
* Return form submissions

* Added form submission page

* Add table CSS

* Various tweaks

* Add single submission view
  • Loading branch information
BenjaminEHowe authored Jun 24, 2024
1 parent ae77e36 commit 7adb2b4
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 4 deletions.
64 changes: 64 additions & 0 deletions _assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ code, pre {
font-family: monospace;
}

table {
border: solid 1px #000;
border-collapse: collapse;
border-spacing: 0;
}

thead th {
border: solid 1px #000;
font-weight: bold;
padding: 10px;
text-align: left;
}

tbody td {
border: solid 1px #000;
padding: 10px;
}

/* header */

header, footer {
Expand Down Expand Up @@ -163,7 +181,13 @@ label {

/* professional experience (about page) */

table#professional-experience {
border: none;
}

table#professional-experience td {
border: none;
padding: 0;
vertical-align: top;
}

Expand All @@ -190,6 +214,46 @@ table#professional-experience tr + tr td {
padding-top: 0.75rem;
}

/* spinner used while waiting for fetch, see https://loading.io/css/ */

.lds-dual-ring {
color: #961c1c;
display: block;
margin: 0 auto;
}

.lds-dual-ring,
.lds-dual-ring:after {
box-sizing: border-box;
}

.lds-dual-ring {
display: inline-block;
width: 80px;
height: 80px;
}

.lds-dual-ring:after {
content: " ";
display: block;
width: 64px;
height: 64px;
margin: 8px;
border-radius: 50%;
border: 6.4px solid currentColor;
border-color: currentColor transparent currentColor transparent;
animation: lds-dual-ring 1.2s linear infinite;
}

@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

/* footer */

#main {
Expand Down
2 changes: 1 addition & 1 deletion contact-urgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: Urgent Contact

If you have an access token then you can send me an urgent message using this form:

<form id="contact-form" method="post" action="/api/contact-urgent">
<form id="contact-form" method="post" action="/api/form/contact-urgent">
<fieldset style="display:none">
<label for="name">Leave blank if you're human:</label>
<input type="text" name="name" id="name" placeholder="Leave blank if you're human">
Expand Down
2 changes: 1 addition & 1 deletion contact.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: Contact

If you'd like to get in touch then you can contact me using the below form.

<form id="contact-form" method="post" action="/api/contact">
<form id="contact-form" method="post" action="/api/form/contact">
<fieldset style="display:none">
<label for="name">Leave blank if you're human:</label>
<input type="text" name="name" id="name" placeholder="Leave blank if you're human">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { handleForm } from "../../functions-src/forms.js"
import { handleForm } from "../../../functions-src/forms.js"

export async function onRequest(context) {
if (context.request.method !== "POST") {
Expand Down
2 changes: 1 addition & 1 deletion functions/api/contact.js → functions/api/form/contact.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { handleForm } from "../../functions-src/forms.js"
import { handleForm } from "../../../functions-src/forms.js"

export async function onRequest(context) {
if (context.request.method !== "POST") {
Expand Down
27 changes: 27 additions & 0 deletions functions/secure/api/form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export async function onRequest(context) {
if (context.request.method !== "GET") {
return new Response("Invalid request method", { status: 405 });
}

const submissionsQuery = await context.env.DB_FORMS.prepare("SELECT submission_id, form_id, submitted_ts, spam_reasons, json_extract(headers, '$.cf-connecting-ip') as ip, json_extract(cf, '$.asn') as asn, json_extract(cf, '$.country') as country FROM submissions WHERE submitted_ts > ? ORDER BY submitted_ts DESC")
.bind(
new Date().subtractDays(30).toISOString(),
)
.all();
const rows = submissionsQuery
.results
.map((row) => {
row.spam_reasons = JSON.parse(row.spam_reasons);
row.asn = row.asn.toString();
return row;
});

return Response.json(rows);
}

// TODO: this is bad practice, consider replacing (see https://www.reddit.com/r/learnjavascript/comments/qgtut6/comment/hi8jg6w/)
Date.prototype.subtractDays = function(days) {
var date = new Date(this.valueOf());
date.setDate(date.getDate() - days);
return date;
}
28 changes: 28 additions & 0 deletions functions/secure/api/form/[submission].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export async function onRequest(context) {
if (context.request.method !== "GET") {
return new Response("Invalid request method", { status: 405 });
}

const submissionsQuery = await context.env.DB_FORMS.prepare("SELECT * FROM submissions WHERE submission_id = ? ORDER BY submitted_ts DESC")
.bind(
context.params.submission,
)
.all();
const rows = submissionsQuery
.results
.map((row) => {
row.fields = JSON.parse(row.fields);
row.spam_reasons = JSON.parse(row.spam_reasons);
row.cf = JSON.parse(row.cf);
row.headers = JSON.parse(row.headers);
return row;
});

if (rows.length === 0) {
return new Response("Submission not found", { status: 404 });
} else if (rows.length === 1) {
return Response.json(rows[0]);
} else {
return new Response(`Multiple submissions found for ID "${context.params.submission}"`, { status: 500 });
}
}
134 changes: 134 additions & 0 deletions secure/form-submissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
layout: default
title: Form Submissions
---

<div id="description"></div>
<div id="content"><div class="lds-dual-ring"></div></div>

<script>
const description = document.getElementById("description");
const content = document.getElementById("content");
if (window.location.hash) {
displaySubmission(window.location.hash.substring(1));
} else {
displaySubmissions();
}

function displaySubmission(id) {
description.innerHTML = `
<p><a href="javascript:displaySubmissions()">Go back to submissions</a></p>
<h2>Form submission ${id}
`;
content.innerHTML = '<div class="lds-dual-ring"></div>';
fetch(`/secure/api/form/${id}`, {
method: "GET",
headers: {
"accept": "application/json",
"content-type": "application/json",
},
})
.then(res => res.json())
.then(submission => {
const submitted = new Date(submission.submitted_ts);
content.innerHTML = `
<h3>Form ID</h3>
<p>${submission.form_id}</p>
<h3>Submitted</h3>
<p>${submitted.toLocaleDateString()} ${submitted.toLocaleTimeString()}</p>
`;
if (submission.reply_email) {
const replyEmail = submission.reply_email;
content.innerHTML += `
<h3>Email address (reply-to)</h3>
<p><a href="mailto:${replyEmail}">${replyEmail}</a></p>
`;
}
if (submission.spam_reasons.length) {
content.innerHTML += "<h3>Spam reasons</h3><ul>";
submission.spam_reasons.forEach(reason => {
content.innerHTML += `<li>${reason}</li>`;
})
content.innerHTML += "</ul>";
}
content.innerHTML += `
<h3>Form fields</h3>
<p>${escapeHtml(JSON.stringify(submission.fields))}</p>
<h3>cf object</h3>
<p>${JSON.stringify(submission.cf)}</p>
<h3>HTTP headers</h3>
<p>${JSON.stringify(submission.headers)}</p>
`;
window.location.hash = id;
})
}

function displaySubmissions() {
description.innerHTML = "<p>This table shows form submissions over the last 30 days:</p>";
content.innerHTML = '<div class="lds-dual-ring"></div>';
fetch("/secure/api/form", {
method: "GET",
headers: {
"accept": "application/json",
"content-type": "application/json",
},
})
.then(res => res.json())
.then(submissions => {
const table = document.createElement("table");
const thead = table.createTHead();
const headings = thead.insertRow();
[
"Submission ID",
"Form ID",
"Submitted",
"Spam Reasons",
"IP Address",
"ASN",
].forEach(heading => {
const th = document.createElement("th");
th.innerHTML = heading;
headings.appendChild(th);
});
const tbody = table.createTBody();
submissions.forEach(row => {
const tr = tbody.insertRow();

const tdSubmissionId = tr.insertCell();
const id = row.submission_id;
tdSubmissionId.innerHTML = `<a href="javascript:displaySubmission('${id}')">${id}</a>`;

const tdFormId = tr.insertCell();
tdFormId.innerHTML = row.form_id;

const tdSubmitted = tr.insertCell();
const submitted = new Date(row.submitted_ts);
tdSubmitted.innerHTML = `${submitted.toLocaleDateString()} ${submitted.toLocaleTimeString()}`;

const tdSpamReasons = tr.insertCell();
tdSpamReasons.innerHTML = row.spam_reasons.join([separator = ', ']);

const tdIpAddress = tr.insertCell();
tdIpAddress.innerHTML = `${getFlagEmoji(row.country)} <a href="https://cleantalk.org/blacklists/${row.ip}">${row.ip}</a>`;

const tdAsn = tr.insertCell();
const asn = row.asn;
tdAsn.innerHTML = `<a href="https://bgp.tools/as/${asn}">AS${asn}</a>`;
});
content.innerHTML = table.outerHTML;
history.pushState("", document.title, window.location.pathname + window.location.search);
});
}

function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

// https://www.bqst.fr/country-code-to-flag-emoji/
const getFlagEmoji = countryCode=>String.fromCodePoint(...[...countryCode.toUpperCase()].map(x=>0x1f1a5+x.charCodeAt()));
</script>
1 change: 1 addition & 0 deletions secure/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ layout: default
title: "Secure area: index"
---

- [(Contact) form submissions](form-submissions)
- [Token generator (for urgent contact form)](token-generator)

0 comments on commit 7adb2b4

Please sign in to comment.