double-opt-in (#45)

This commit is contained in:
DrMaxNix 2024-02-17 22:15:43 +01:00
parent cd4f2b10b2
commit 10f211c240
12 changed files with 585 additions and 36 deletions

View File

@ -2,6 +2,7 @@
declare(strict_types = 1);
namespace Kimendisch\Sbgg_Jetzt;
use Flake\Error;
use Flake\Id64;
// DECODE REQUEST //
// get json string
@ -35,17 +36,23 @@
die();
}
// language
$language = $request["language"] ?? "";
if(!in_array($language, ["de", "en"])){
// verify key
$verify_key = $request["verify_key"] ?? null;
if(!Id64::is_valid($verify_key)){
http_response_code(400);
echo("invalid language");
echo("invalid verify key");
die();
}
// ADD TO MAILING LIST //
Newsletter::subscribe(mail_address: $mail_address, language: $language);
// TRY SUBSCRIBING //
if(!Newsletter::subscribe(mail_address: $mail_address, verify_key: $verify_key)){
http_response_code(200);
echo(json_encode([
"success" => false
]));
die();
}
// POSITIVE RESPONSE //

56
api/newsletter/verify.php Normal file
View File

@ -0,0 +1,56 @@
<?php
declare(strict_types = 1);
namespace Kimendisch\Sbgg_Jetzt;
use Flake\Error;
// DECODE REQUEST //
// get json string
$json_body = file_get_contents("php://input");
if(strlen($json_body) <= 0){
http_response_code(400);
echo("malformed request body");
die();
}
// try decoding json
$request = json_decode($json_body, true);
if(json_last_error() != JSON_ERROR_NONE){
http_response_code(400);
echo("malformed request body");
die();
}
// VALIDATE VALUES //
// mail address
$mail_address = $request["mail_address"] ?? "";
if(!is_string($mail_address)){
http_response_code(400);
echo("invalid mail address");
die();
}
if(!preg_match("/^[a-zA-Z0-9\.\-\_\+]+@([a-z0-9\-]+\.)+[a-z0-9\-]{2,}$/", $mail_address)){
http_response_code(400);
echo("invalid mail address");
die();
}
// language
$language = $request["language"] ?? "";
if(!in_array($language, ["de", "en"])){
http_response_code(400);
echo("invalid language");
die();
}
// VERIFY //
Newsletter::verify(mail_address: $mail_address, language: $language);
// POSITIVE RESPONSE //
http_response_code(200);
echo(json_encode([
"success" => true
]));
?>

View File

@ -32,10 +32,12 @@
static::$route["sbgg.jetzt"] = [
["path" => "", "target" => "page/start"],
["path" => ":lang", "target" => "page/start"],
["path" => "newsletter/subscribe", "target" => "page/newsletter/subscribe"],
["path" => "newsletter/unsubscribe", "target" => "page/newsletter/unsubscribe"],
["path" => "static/:filename", "target" => "api/static"],
["path" => "api/newsletter/verify", "target" => "api/newsletter/verify.php"],
["path" => "api/newsletter/subscribe", "target" => "api/newsletter/subscribe.php"],
["path" => "api/newsletter/unsubscribe", "target" => "api/newsletter/unsubscribe.php"],

View File

@ -0,0 +1,25 @@
<?php
return [
"subject" => [
"de" => "SBGG.jetzt: Anmeldung Newsletter",
"en" => "SBGG.jetzt: Newsletter Subscription"
],
"main" => [
"de" => <<<HTML
<h2>Anmeldung Bestätigen</h2>
<p>Du hast soeben die Anmeldung zum SBGG.jetzt Newsletter beantragt. Aus Datenschutz-Gründen musst Du deine E-Mail Adresse verifizieren.</p>
<p>Um Dich für den Newsletter anzumelden, klicke bitte auf den <i>Verifizieren</i> Link unten.</p>
HTML,
"en" => <<<HTML
<h2>Confirm Subscription</h2>
<p>You have just requested to subscribe to the SBGG.jetzt newsletter. For privacy reasons, you have to verify your mail address.</p>
<p>In order to subscribe to the newsletter, please click the <i>Verify</i> link below.</p>
HTML
]
];
?>

View File

@ -82,7 +82,8 @@
{{main}}
<hr />
<center><a href="{{const:url_prefix}}/newsletter/unsubscribe?mail_address={{dataset:mail_address_urlencoded}}&key={{dataset:unsubscribe_key}}&lang={{dataset:language}}">{{text_unsubscribe}}</a></center>
{{template-if-isset-dataset:unsubscribe_key,unsubscribe_link}}
{{template-if-isset-dataset:verify_key,verify_link}}
</div>
<div class="footer">

View File

@ -0,0 +1 @@
<center><a href="{{const:url_prefix}}/newsletter/unsubscribe?mail_address={{dataset:mail_address_urlencoded}}&key={{dataset:unsubscribe_key}}&lang={{dataset:language}}">{{text_unsubscribe}}</a></center>

View File

@ -0,0 +1 @@
<center><a href="{{const:url_prefix}}/newsletter/subscribe?mail_address={{dataset:mail_address_urlencoded}}&key={{dataset:verify_key}}&lang={{dataset:language}}">{{text_verify}}</a></center>

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types = 1);
namespace Kimendisch\Sbgg_Jetzt;
use Flake\Lang;
use Flake\Lang_Dict;
use Flake\Page;
use Flake\Hidden;
use Flake\File;
use Flake\Project;
use Flake\Url;
use Flake\Url_Redirect;
use Flake\Request;
use Flake\Id64;
// COLLECT REQUEST DATA //
// mail address
$mail_address = $_GET["mail_address"] ?? null;
if(!is_string($mail_address)){
Url_Redirect::location("http" . (Request::has_ssl() ? "s" : "") . "://" . Request::domain_raw_full());
}
// LANGUAGE MANAGER //
// hack: fake get param from url path
$param_lang = $_GET["lang"] ?? "de";
$_GET["lang"] = $param_lang;
// initialize
$lang = new Lang(list: ["de", "en"], default: "de");
// load dict
$dict = new Lang_Dict($lang);
require("./page/strings.php");
// PAGE INIT //
Page::start();
Page::title($dict->get("newsletter_subscribe_page_title"));
Page::icon("./asset/logo-256.png");
Page::lang($lang->get());
Page::viewport(scale: 1, zoom: true);
Page::robots(index: false, follow: false);
Page::author("Kim Endisch");
Page::$head["analytics"] = '<script defer data-domain="sbgg.jetzt" src="https://analytics.tjdev.de/js/script.js"></script>';
Page::css("./page/start/style.css");
Page::js(__DIR__ . "/main.js");
Page::font("ubuntu");
Page::font("tabler");
?>
<div class="page-container">
<div class="page">
<div id="news" class="section">
<div class="content rows">
<div id="newsletter" class="box">
<span class="title"><?= $dict->get("newsletter_subscribe_title") ?></span>
<div id="newsletter-signup-form-container">
<div id="newsletter-signup-form">
<div class="key-value-pair">
<div class="key">
<span class="ti ti-at"></span>
</div>
<div class="value-list">
<div class="inputwrapper">
<input id="newsletter-signup-form-mail-address" class="value" type="text" value="<?= htmlspecialchars($mail_address) ?>" disabled />
</div>
</div>
</div>
<button id="newsletter-signup-form-submit" class="button primary">
<span class="text"><?= $dict->get("newsletter_subscribe_submit") ?></span>
<span class="icon ti ti-chevron-right"></span>
</button>
</div>
<div id="newsletter-signup-form-feedback" class="gone">
<div id="newsletter-signup-form-feedback-wait" class="centertext gone">
<span class="icon spinning ti ti-loader-2"></span>
<span class="text"><?= $dict->get("newsletter_subscribe_feedback_wait") ?></span>
</div>
<div id="newsletter-signup-form-feedback-success" class="centertext gone">
<span class="icon ti ti-check"></span>
<span class="text"><?= $dict->get("newsletter_subscribe_feedback_success") ?></span>
</div>
<div id="newsletter-signup-form-feedback-failure" class="centertext gone">
<span class="icon ti ti-x"></span>
<span class="text"><?= $dict->get("newsletter_subscribe_feedback_failure") ?></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<div class="brand">
<img src="<?= File::file("./asset/logo-256.png") ?>" alt="logo" />
<span>SBGG.jetzt</span>
<a href="https://git.tjdev.de/kimendisch/sbgg.jetzt" target="_blank"><?= $dict->get("text_sourcecode") ?> <i class="ti ti-external-link"></i></a>
<span class="version">v<?= Project::version() ?></span>
</div>
<div class="lang">
<span><i class="ti ti-world"></i></span>
<a <?= ($lang->get() === "de" ? "class=\"selected\"" : "") ?> href="<?= Url::query_modify(remove: ["lang"], add: ["lang=de"]) ?>">DE</a>
<span class="delimiter">|</span>
<a <?= ($lang->get() === "en" ? "class=\"selected\"" : "") ?> href="<?= Url::query_modify(remove: ["lang"], add: ["lang=en"]) ?>">EN</a>
</div>
<div class="legal">
<span>&copy; 2024 Kim Endisch</span>
<span class="delimiter">|</span>
<a href="<?= $dict->get("link_imprint") ?>" target="_blank"><?= $dict->get("text_imprint") ?> <i class="ti ti-external-link"></i></a>
<span class="delimiter">|</span>
<a href="<?= $dict->get("link_privacy_policy") ?>" target="_blank"><?= $dict->get("text_privacy_policy") ?> <i class="ti ti-external-link"></i></a>
</div>
</div>

View File

@ -0,0 +1,93 @@
"use strict";
let newsletter_form;
let newsletter_input_submit;
let newsletter_feedback;
let newsletter_feedback_wait;
let newsletter_feedback_success;
let newsletter_feedback_failure;
window.addEventListener("load", function(){
// STORE ELEMENTS //
newsletter_form = document.getElementById("newsletter-signup-form");
newsletter_input_submit = document.getElementById("newsletter-signup-form-submit");
newsletter_feedback = document.getElementById("newsletter-signup-form-feedback");
newsletter_feedback_wait = document.getElementById("newsletter-signup-form-feedback-wait");
newsletter_feedback_success = document.getElementById("newsletter-signup-form-feedback-success");
newsletter_feedback_failure = document.getElementById("newsletter-signup-form-feedback-failure");
// INITIALIZE INPUTS //
newsletter_init_submit();
});
/**
* HELPER: Initialize submit button input.
*/
async function newsletter_init_submit(){
// REGISTER CLICK HANDLER //
newsletter_input_submit.addEventListener("click", newsletter_submit);
}
/**
* CALLBACK: Maybe submit the form.
*/
async function newsletter_submit(){
// SHOW WAIT FEEDBACK //
newsletter_feedback.classList.remove("hidden", "gone");
newsletter_form.classList.add("hidden");
newsletter_feedback_wait.classList.remove("hidden", "gone");
// COLLECT VALUES //
// mail address
const url_params = new URLSearchParams(window.location.search);
let mail_address = url_params.get("mail_address");
// verify_key
let verify_key = url_params.get("key");
// SEND API REQUEST //
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/newsletter/subscribe", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({
mail_address: mail_address,
verify_key: verify_key
}));
xhr.onload = function(){
let success = true;
// validate http status code
if(xhr.status !== 200) success = false;
// check response
if(success){
let response = null;
try {
response = JSON.parse(xhr.response);
} catch(e){}
if(typeof response !== "object") success = false;
if(success && response === null) success = false;
if(success && response.success !== true) success = false;
}
// positive feedback
if(success){
newsletter_feedback_wait.classList.add("gone");
newsletter_feedback_success.classList.remove("hidden", "gone");
return;
}
// negative feedback
newsletter_feedback_wait.classList.add("gone");
newsletter_feedback_failure.classList.remove("hidden", "gone");
}
}

View File

@ -190,7 +190,7 @@ async function newsletter_submit(){
// SEND API REQUEST //
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/newsletter/subscribe", true);
xhr.open("POST", "/api/newsletter/verify", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({
mail_address: mail_address,

View File

@ -379,16 +379,16 @@
"en" => "Note: Depending on your mail provider, the newsletter may end up in your spam folder."
],
"news_newsletter_feedback_wait" => [
"de" => "Wird angemeldet",
"en" => "Signing up"
"de" => "Verifizierungs-Mail wird gesendet",
"en" => "Sending verification mail"
],
"news_newsletter_feedback_success" => [
"de" => "Erfolgreich angemeldet",
"en" => "Successfully signed up"
"de" => "Verifizierungs-Mail versendet",
"en" => "Verification mail sent"
],
"news_newsletter_feedback_failure" => [
"de" => "Fehler bei der Anmeldung",
"en" => "Failed to sign up"
"de" => "Fehler bei der Versendung der Verifizierungs-Mail",
"en" => "Failed to send verification mail"
],
"news_social_media_title" => [
@ -468,6 +468,36 @@
"newsletter_subscribe_page_title" => [
"de" => "SBGG.jetzt - Newsletter Abonnieren",
"en" => "SBGG.jetzt - Subscribe to Newsletter"
],
"newsletter_subscribe_title" => [
"de" => "Newsletter Abonnieren",
"en" => "Subscribe to Newsletter"
],
"newsletter_subscribe_submit" => [
"de" => "Abonnieren",
"en" => "Subscribe"
],
"newsletter_subscribe_feedback_wait" => [
"de" => "Wird angemeldet",
"en" => "Subscribing"
],
"newsletter_subscribe_feedback_success" => [
"de" => "Erfolgreich angemeldet",
"en" => "Successfully subscribed"
],
"newsletter_subscribe_feedback_failure" => [
"de" => "Fehler bei der Anmeldung",
"en" => "Failed to subscribe"
],
"newsletter_unsubscribe_page_title" => [
"de" => "SBGG.jetzt - Newsletter Abbestellen",
"en" => "SBGG.jetzt - Unsubscribe from Newsletter"

View File

@ -22,6 +22,10 @@
"de" => "Abbestellen",
"en" => "Unsubscribe"
],
"text_verify" => [
"de" => "Verifizieren",
"en" => "Verify"
],
"text_imprint" => [
"de" => "Impressum",
@ -52,12 +56,12 @@
/**
* Add a new mail address to the mailing list.
* Start a newsletter subscription verification process.
*
* @param string $mail_address Recipient mail address.
* @param string $language Desired newsletter language.
*/
public static function subscribe(string $mail_address, string $language): void {
public static function verify(string $mail_address, string $language): void {
// OPEN DATABASE //
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
@ -67,35 +71,205 @@
// MAYBE INITIALIZE DATABASE //
if(!is_array($newsletter_db->get("list"))){
// initialize as empty array
$newsletter_db->set("list", []);
self::db_init(db: $newsletter_db);
// VERIFY LOGIC //
// cleanup of verify list
self::verify_list_cleanup(db: $newsletter_db);
// decide whether verification should be attempted
$do_verify = self::verify_decide(db: $newsletter_db, mail_address: $mail_address, language: $language);
// read current verify key
if($do_verify) $verify_key = $newsletter_db->get(["verify_list", sha1($mail_address), "verify_key"]);
// FINALIZE //
// close database
$newsletter_db->write_close();
if($do_verify){
// send verify mail
$content = self::content_file_read("0000-00-00-verify");
self::send(mail_address: $mail_address, content: $content, dataset: [
"language" => $language,
"verify_key" => $verify_key
]);
} else {
// fake delay
usleep(rand(200, 2000) * 1000);
}
// release runlock
ignore_user_abort($old_ignore_user_abort);
}
/**
* HELPER: Remove stale entries in verify list.
*
* @param Dat $db Newsletter database object.
*/
private static function verify_list_cleanup(Dat $db): void {
// GET FULL DATABASE //
$db_verify_list = $db->get("verify_list");
$time = time();
// REMOVE STALE ATTEMPT TIMESTAMPS //
foreach($db_verify_list as $one_key => $one_dataset){
$db_verify_list[$one_key]["attempt_list"] = array_values(array_filter($one_dataset["attempt_list"], function($one_attempt) use ($time): bool {
// only keep if younger than 24h
return ($one_attempt + (24 * 3600) > $time);
}));
}
// UPDATE DATABASE //
$entry_key = ["list", $mail_address];
// REMOVE STALE DATASETS //
$db_verify_list = array_filter($db_verify_list, function($one_dataset): bool {
// check whether there are recent attempts
if(sizeof($one_dataset["attempt_list"]) > 0) return true;
// no reason for this dataset to stay
return false;
});
// generate unsubscribe key
$unsubscribe_key = Id64::new(length: 16);
// only allow new entries
$is_new = ($newsletter_db->get($entry_key) === null);
// SET NEW VALUES //
$db->set("verify_list", $db_verify_list);
}
/**
* HELPER: Manage verify list and decide whether a verify attempt should be made.
*
* @param Dat $db Newsletter database object.
* @param string $mail_address Recipient mail address.
* @param string $language Desired newsletter language.
*
* @return bool Whether verify attempt should be made.
*/
private static function verify_decide(Dat $db, string $mail_address, string $language): bool {
// CHECK MEMBER STATUS //
if($db->get(["list", $mail_address]) !== null) return false;
// maybe add to database
if($is_new){
$newsletter_db->set($entry_key, [
"unsubscribe_key" => $unsubscribe_key,
"language" => $language
// MAYBE CREATE DATASET //
$verify_list_entry_key = ["verify_list", sha1($mail_address)];
if($db->get($verify_list_entry_key) === null){
$db->set($verify_list_entry_key, [
"attempt_list" => [],
]);
}
// CHECK COOLDOWN //
// get previous attempts
$attempt_list = $db->get($verify_list_entry_key)["attempt_list"];
$attempt_count = sizeof($attempt_list);
// get last attempt timestamp
$last_attempt_timestamp = end($attempt_list);
// get cooldown duration from attempt count
$cooldown_duration = ([
0 => 0,
1 => (5 * 60),
2 => (30 * 60),
3 => (2 * 3600),
4 => (6 * 3600),
])[$attempt_count] ?? (12 * 3600);
// check whether still within cooldown
$time = time();
if($last_attempt_timestamp + $cooldown_duration > $time) return false;
// UPDATE DATASET //
// get old values
$dataset = $db->get($verify_list_entry_key);
// generate new verify key
$dataset["verify_key"] = Id64::new(length: 16);
// add attempt
$dataset["attempt_list"][] = $time;
// update language
$dataset["language"] = $language;
// set new values
$db->set($verify_list_entry_key, $dataset);
// SEND VERIFY MAIL //
return true;
}
/**
* Check a verify key and add a member to the newsletter.
*
* @param string $mail_address Member mail address.
* @param string $verify_key User-provided verify key.
*
* @return bool Whether member has successfully been subscribed.
*/
public static function subscribe(string $mail_address, string $verify_key): bool {
// OPEN DATABASE //
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
// obtain handle
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ_WRITE);
// MAYBE INITIALIZE DATABASE //
self::db_init(db: $newsletter_db);
// VERIFY LOGIC //
// cleanup of verify list
self::verify_list_cleanup(db: $newsletter_db);
// find dataset in verify list
$verify_list_dataset = $newsletter_db->get(["verify_list", sha1($mail_address)]);
$success = ($verify_list_dataset !== null);
// maybe check verify key
$success = ($success and $verify_list_dataset["verify_key"] === $verify_key);
// MAYBE ADD MEMBER //
if($success){
$entry_key = ["list", $mail_address];
// generate unsubscribe key
$unsubscribe_key = Id64::new(length: 16);
// only allow new entries
$success = ($newsletter_db->get($entry_key) === null);
// maybe add to database
if($success){
$newsletter_db->set($entry_key, [
"unsubscribe_key" => $unsubscribe_key,
"language" => $verify_list_dataset["language"],
]);
}
}
// FINALIZE //
// close database
$newsletter_db->write_close();
if($is_new){
if($success){
// send welcome mail
$content = self::content_file_read("0000-00-00-welcome");
self::send(mail_address: $mail_address, content: $content);
@ -107,6 +281,9 @@
// release runlock
ignore_user_abort($old_ignore_user_abort);
// return
return $success;
}
@ -129,10 +306,7 @@
// MAYBE INITIALIZE DATABASE //
if(!is_array($newsletter_db->get("list"))){
// initialize as empty array
$newsletter_db->set("list", []);
}
self::db_init(db: $newsletter_db);
// UPDATE DATABASE //
@ -166,6 +340,25 @@
/**
* HELPER: Initialize the database with empty values.
*
* @param Dat $db Newsletter database object.
*/
private static function db_init(Dat $db): void {
// verify list
if(!is_array($db->get("verify_list"))){
$db->set("verify_list", []);
}
// member list
if(!is_array($db->get("list"))){
$db->set("list", []);
}
}
/**
* HELPER: Read one content file.
*
@ -234,6 +427,7 @@
$callback = function(array $matches) use ($content, $dataset): string {
$key = $matches["key"];
$value = $matches["value"];
$value2 = $matches["value2"] ?? "";
// content value
if($key === ""){
@ -247,6 +441,13 @@
return self::content_render(content: $content, dataset: $dataset, root: $value);
}
// conditional template
if($key === "template-if-isset-dataset"){
if(!isset($dataset[$value])) return "";
if(in_array($value2, self::$pending_render)) Error::error(message: "Recursive template rendering", data: ["pending_render" => self::$pending_render, "template" => $value2]);
return self::content_render(content: $content, dataset: $dataset, root: $value2);
}
// dataset value
if($key === "dataset"){
if(!isset($dataset[$value])) Error::error(message: "Unable to substitute dataset placeholder", data: ["matches" => $matches, "dataset" => $dataset]);
@ -263,7 +464,7 @@
// unknown key
Error::error(message: "Unknown placeholder key", data: ["key" => $key, "value" => $value]);
};
$html = preg_replace_callback("/(?:{{)(?:(?<key>[0-9a-z-_\.]+)\:)?(?<value>[0-9a-z-_\.]+)(?:}})/", $callback, $html);
$html = preg_replace_callback("/(?:{{)(?:(?<key>[0-9a-z-_\.]+)\:)?(?<value>[0-9a-z-_\.]+)(?:\,(?<value2>[0-9a-z-_\.]+))?(?:}})/", $callback, $html);
// DONE //