newsletter unsubscribe page and api (#45)

This commit is contained in:
DrMaxNix 2024-02-11 21:48:26 +01:00
parent 7c13cc3487
commit cf063c7e4f
8 changed files with 386 additions and 6 deletions

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types = 1);
namespace Kimendisch\Sbgg_Jetzt;
use Flake\Error;
use Flake\Id64;
// 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();
}
// unsubscribe key
$unsubscribe_key = $request["unsubscribe_key"] ?? null;
if(!Id64::is_valid($unsubscribe_key)){
http_response_code(400);
echo("invalid unsubscribe key");
die();
}
// REMOVE FROM MAILING LIST //
if(!Newsletter::unsubscribe(mail_address: $mail_address, unsubscribe_key: $unsubscribe_key)){
http_response_code(200);
echo(json_encode([
"success" => false
]));
die();
}
// POSITIVE RESPONSE //
http_response_code(200);
echo(json_encode([
"success" => true
]));
?>

View File

@ -14,6 +14,7 @@
static::$ext[] = "project";
static::$ext[] = "excuse";
static::$ext[] = "error";
static::$ext[] = "url";
// ROUTES //
@ -27,11 +28,13 @@
// pages
static::$route["sbgg.jetzt"] = [
["path" => "", "target" => "page/start"],
["path" => ":lang", "target" => "page/start"],
["path" => "", "target" => "page/start"],
["path" => ":lang", "target" => "page/start"],
["path" => "newsletter/unsubscribe", "target" => "page/newsletter/unsubscribe"],
["path" => "api/newsletter/subscribe", "target" => "api/newsletter/subscribe.php"]
["path" => "static/:filename", "target" => "api/static"],
["path" => "static/:filename", "target" => "api/static"],
["path" => "api/newsletter/subscribe", "target" => "api/newsletter/subscribe.php"],
["path" => "api/newsletter/unsubscribe", "target" => "api/newsletter/unsubscribe.php"],
];
?>

View File

@ -82,7 +82,7 @@
{{main}}
<hr />
<center><a href="{{const:url_prefix}}/api/newsletter/unsubscribe?mail_address={{dataset:mail_address_urlencoded}}&key={{dataset:unsubscribe_key}}&lang={{dataset:language}}">{{text_unsubscribe}}</a></center>
<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>
</div>
<div class="footer">

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_unsubscribe_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_unsubscribe_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_unsubscribe_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_unsubscribe_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_unsubscribe_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_unsubscribe_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");
// unsubscribe_key
let unsubscribe_key = url_params.get("key");
// SEND API REQUEST //
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/newsletter/unsubscribe", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({
mail_address: mail_address,
unsubscribe_key: unsubscribe_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

@ -501,6 +501,10 @@ span.inline {
gap: 1rem;
}
#newsletter-signup-form-mail-address {
min-width: 22rem;
}
#newsletter-signup-form-feedback {
position: absolute;
top: 0;

View File

@ -462,6 +462,36 @@
"copylink_hint_text" => [
"de" => "Bereichs-Link kopieren",
"en" => "Copy section link"
]
],
"newsletter_unsubscribe_page_title" => [
"de" => "SBGG.jetzt - Newsletter Abbestellen",
"en" => "SBGG.jetzt - Unsubscribe from Newsletter"
],
"newsletter_unsubscribe_title" => [
"de" => "Newsletter Abbestellen",
"en" => "Unsubscribe from Newsletter"
],
"newsletter_unsubscribe_submit" => [
"de" => "Abbestellen",
"en" => "Unsubscribe"
],
"newsletter_unsubscribe_feedback_wait" => [
"de" => "Wird abgemeldet",
"en" => "Unsubscribing"
],
"newsletter_unsubscribe_feedback_success" => [
"de" => "Erfolgreich abgemeldet",
"en" => "Successfully unsubscribed"
],
"newsletter_unsubscribe_feedback_failure" => [
"de" => "Fehler bei der Abmeldung",
"en" => "Failed to unsubscribe"
],
]);
?>

View File

@ -111,6 +111,61 @@
/**
* Remove a mail address from the mailing list.
*
* @param string $mail_address Member mail address.
* @param string $unsubscribe_key Unsubscribe key (used for authorization).
*
* @return bool Whether a dataset has successfully been removed.
*/
public static function unsubscribe(string $mail_address, string $unsubscribe_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 //
if(!is_array($newsletter_db->get("list"))){
// initialize as empty array
$newsletter_db->set("list", []);
}
// UPDATE DATABASE //
$entry_key = ["list", $mail_address];
// check whether mail address is member of newsletter
$dataset = $newsletter_db->get($entry_key);
$is_member = ($dataset !== null);
// validate unsubscribe key
$unsubscribe_key_valid = (($dataset["unsubscribe_key"] ?? null) === $unsubscribe_key);
// maybe remove from database
$has_removed = false;
if($is_member and $unsubscribe_key_valid){
$newsletter_db->unset($entry_key);
$has_removed = true;
}
// FINALIZE //
// close database
$newsletter_db->write_close();
// release runlock
ignore_user_abort($old_ignore_user_abort);
// return
return $has_removed;
}
/**
* HELPER: Read one content file.
*