Skip to content

Commit

Permalink
Add newsletter management functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
acabal committed Mar 20, 2022
1 parent 90ee0a9 commit b0197d1
Show file tree
Hide file tree
Showing 57 changed files with 1,017 additions and 143 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ PHP 7+ is required.

```shell
# Install Apache, PHP, PHP-FPM, and various other dependencies.
sudo apt install -y git composer php-fpm php-cli php-gd php-xml php-apcu php-mbstring php-intl apache2 apache2-utils libfcgi0ldbl task-spooler ipv6calc
sudo apt install -y git composer php-fpm php-cli php-gd php-xml php-apcu php-mbstring php-intl php-curl apache2 apache2-utils libfcgi0ldbl task-spooler ipv6calc mariadb-server

# Create the site root and logs root and clone this repo into it.
sudo mkdir /standardebooks.org/
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"files": ["lib/Constants.php", "lib/CoreFunctions.php"]
},
"require": {
"thecodingmachine/safe": "^1.0.0"
"thecodingmachine/safe": "^1.0.0",
"phpmailer/phpmailer": "6.6.0",
"ramsey/uuid": "4.2.3",
"gregwar/captcha": "1.1.9"
}
}
11 changes: 7 additions & 4 deletions config/apache/standardebooks.org.conf
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,9 @@ Define webroot /standardebooks.org/web
# In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first
# match in RewriteRule
# Rewrite POST /some/url -> POST /some/url/post.php
RewriteCond %{REQUEST_METHOD} ^POST$
RewriteCond %{DOCUMENT_ROOT}/$1/ -d
RewriteCond %{DOCUMENT_ROOT}/$1/post.php -f
RewriteRule ^([^\.]+)$ $1/post.php [L]
RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^(post|delete|put)$/"
RewriteCond %{DOCUMENT_ROOT}/$1/%1.php -f
RewriteRule ^([^\.]+)$ $1/%1.php [L]

# In case of 404, serve the 404 page specified by ErrorDocument, not the default FPM error page.
# Note that we can't use `ProxyErrorOverride on` because that catches ALL 4xx and 5xx HTTP headers
Expand Down Expand Up @@ -243,6 +242,10 @@ Define webroot /standardebooks.org/web
# If we ask for /opds/all?query=xyz, rewrite that to the search page.
RewriteCond %{QUERY_STRING} ^query=
RewriteRule ^/opds/all.xml$ /opds/search.php [QSA]

# Newsletter
RewriteRule ^/newsletter$ /newsletter/subscribers/new.php
RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1
</VirtualHost>

<VirtualHost *:80>
Expand Down
11 changes: 7 additions & 4 deletions config/apache/standardebooks.test.conf
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,9 @@ Define webroot /standardebooks.org/web
# In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first
# match in RewriteRule
# Rewrite POST /some/url -> POST /some/url/post.php
RewriteCond %{REQUEST_METHOD} ^POST$
RewriteCond %{DOCUMENT_ROOT}/$1/ -d
RewriteCond %{DOCUMENT_ROOT}/$1/post.php -f
RewriteRule ^([^\.]+)$ $1/post.php [L]
RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^(post|delete|put)$/"
RewriteCond %{DOCUMENT_ROOT}/$1/%1.php -f
RewriteRule ^([^\.]+)$ $1/%1.php [L]

# In case of 404, serve the 404 page specified by ErrorDocument, not the default FPM error page.
# Note that we can't use `ProxyErrorOverride on` because that catches ALL 4xx and 5xx HTTP headers
Expand Down Expand Up @@ -242,4 +241,8 @@ Define webroot /standardebooks.org/web
# If we ask for /opds/all?query=xyz, rewrite that to the search page.
RewriteCond %{QUERY_STRING} ^query=
RewriteRule ^/opds/all.xml$ /opds/search.php [QSA]

# Newsletter
RewriteRule ^/newsletter$ /newsletter/subscribers/new.php
RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1
</VirtualHost>
14 changes: 14 additions & 0 deletions config/sql/se/NewsletterSubscribers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE `NewsletterSubscribers` (
`NewsletterSubscriberId` int(10) unsigned NOT NULL AUTO_INCREMENT,
`Email` varchar(80) NOT NULL,
`Uuid` char(36) NOT NULL,
`FirstName` varchar(80) DEFAULT NULL,
`LastName` varchar(80) DEFAULT NULL,
`IsConfirmed` tinyint(1) unsigned NOT NULL DEFAULT 0,
`IsSubscribedToNewsletter` tinyint(1) unsigned NOT NULL DEFAULT 1,
`IsSubscribedToSummary` tinyint(1) unsigned NOT NULL DEFAULT 1,
`Timestamp` datetime NOT NULL,
PRIMARY KEY (`NewsletterSubscriberId`),
UNIQUE KEY `Uuid_UNIQUE` (`Uuid`),
UNIQUE KEY `Email_UNIQUE` (`Email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
48 changes: 34 additions & 14 deletions lib/Constants.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
<?
// Auto-included by Composer in composer.json to satisfy PHPStan
use function Safe\define;
use function Safe\file_get_contents;
use function Safe\gmdate;
use function Safe\strtotime;

const SITE_STATUS_LIVE = 'live';
const SITE_STATUS_DEV = 'dev';
define('SITE_STATUS', getenv('SITE_STATUS') ?: SITE_STATUS_DEV); // Set in the PHP FPM pool configuration. Have to use define() and not const so we can use a function.

// No trailing slash on any of the below constants.
if(SITE_STATUS == SITE_STATUS_LIVE){
define('SITE_URL', 'https://standardebooks.org');
}
else{
define('SITE_URL', 'https://standardebooks.test');
}

const SITE_ROOT = '/standardebooks.org';
const WEB_ROOT = SITE_ROOT . '/web/www';
const REPOS_PATH = SITE_ROOT . '/ebooks';
const TEMPLATES_PATH = SITE_ROOT . '/web/templates';
const MANUAL_PATH = WEB_ROOT . '/manual';
const EBOOKS_DIST_PATH = WEB_ROOT . '/ebooks/';

const DATABASE_DEFAULT_DATABASE = 'se';
const DATABASE_DEFAULT_HOST = 'localhost';

Expand All @@ -16,9 +33,21 @@
const SORT_READING_EASE = 'reading-ease';
const SORT_LENGTH = 'length';

const GET = 0;
const POST = 1;
const COOKIE = 2;
const CAPTCHA_IMAGE_HEIGHT = 72;
const CAPTCHA_IMAGE_WIDTH = 230;

const NO_REPLY_EMAIL_ADDRESS = '[email protected]';
const EMAIL_SMTP_HOST = 'smtp-broadcasts.postmarkapp.com';
define('EMAIL_SMTP_USERNAME', trim(file_get_contents(SITE_ROOT . '/config/secrets/postmarkapp.com')) ?: '');
const EMAIL_SMTP_PASSWORD = EMAIL_SMTP_USERNAME;
const EMAIL_POSTMARK_STREAM_BROADCAST = 'the-standard-ebooks-newsletter';

const REST = 0;
const WEB = 1;

const GET = 'GET';
const POST = 'POST';
const COOKIE = 'COOKIE';

const HTTP_VAR_INT = 0;
const HTTP_VAR_STR = 1;
Expand Down Expand Up @@ -47,17 +76,8 @@
define('DONATION_ALERT_ON', DONATION_HOLIDAY_ALERT_ON || rand(1, 4) == 2);
define('DONATION_DRIVE_ON', false);

// No trailing slash on any of the below constants.
const SITE_URL = 'https://standardebooks.org';
const SITE_ROOT = '/standardebooks.org';
const WEB_ROOT = SITE_ROOT . '/web/www';
const REPOS_PATH = SITE_ROOT . '/ebooks';
const TEMPLATES_PATH = SITE_ROOT . '/web/templates';
const MANUAL_PATH = WEB_ROOT . '/manual';
const EBOOKS_DIST_PATH = WEB_ROOT . '/ebooks/';

const GITHUB_SECRET_FILE_PATH = SITE_ROOT . '/config/secrets/[email protected]'; // Set in the GitHub organization global webhook settings.
const GITHUB_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-github.log'; // Must be writable by `www-data` Unix user.
const GITHUB_IGNORED_REPOS = ['tools', 'manual', 'web']; // If we get GitHub push requests featuring these repos, silently ignore instead of returning an error.

// If we get GitHub push requests featuring these repos, silently ignore instead of returning an error.
const GITHUB_IGNORED_REPOS = ['tools', 'manual', 'web'];
const POSTMARK_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-postmark.log'; // Must be writable by `www-data` Unix user.
19 changes: 9 additions & 10 deletions lib/DbConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ class DbConnection{
public function __construct(?string $defaultDatabase = null, string $host = 'localhost', ?string $user = null, string$password = '', bool $forceUtf8 = true, bool $require = true){
if($user === null){
// Get the user running the script for local socket login
$user = posix_getpwuid(posix_geteuid())['name'];
$user = posix_getpwuid(posix_geteuid());
if($user){
$user = $user['name'];
}
}

$connectionString = 'mysql:';
Expand Down Expand Up @@ -125,9 +128,9 @@ public function Query(string $sql, array $params = [], string $class = 'stdClass

usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds
}
elseif(stripos($ex->getMessage(), '1064 offset out of bounds') !== false){
$done = true;
// We reach here if Sphinx tries to get a record past its page limit. Just silently do nothing.
elseif($ex->getCode() == '23000'){
// Duplicate key, bubble this up without logging it so the business logic can handle it
throw($ex);
}
else{
$done = true;
Expand All @@ -139,16 +142,12 @@ public function Query(string $sql, array $params = [], string $class = 'stdClass
Logger::WriteErrorLogEntry($ex->getMessage());
Logger::WriteErrorLogEntry($preparedSql);
Logger::WriteErrorLogEntry(vds($params));
throw($ex);
}
}
}
}

// If only one rowset is returned, change the result object
if(sizeof($result) == 1){
$result = $result[0];
}

return $result;
}

Expand All @@ -168,7 +167,7 @@ private function ExecuteQuery(PDOStatement $handle, string $class = 'stdClass'):

for($i = 0; $i < $columnCount; $i++){
$metadata[$i] = $handle->getColumnMeta($i);
if(preg_match('/^(Is|Has|Can)[A-Z]/u', $metadata[$i]['name']) === 1){
if($metadata[$i] && preg_match('/^(Is|Has|Can)[A-Z]/u', $metadata[$i]['name']) === 1){
// MySQL doesn't have a native boolean type, so fake it here if the column
// name starts with Is, Has, or Can and is followed by an uppercase letter
$metadata[$i]['native_type'] = 'BOOL';
Expand Down
12 changes: 6 additions & 6 deletions lib/Ebook.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,15 @@ public function __construct(string $wwwFilesystemPath){
}

if(!is_dir($wwwFilesystemPath)){
throw new InvalidEbookException('Invalid www filesystem path: ' . $wwwFilesystemPath);
throw new Exceptions\InvalidEbookException('Invalid www filesystem path: ' . $wwwFilesystemPath);
}

if(!is_dir($this->RepoFilesystemPath)){
throw new InvalidEbookException('Invalid repo filesystem path: ' . $this->RepoFilesystemPath);
throw new Exceptions\InvalidEbookException('Invalid repo filesystem path: ' . $this->RepoFilesystemPath);
}

if(!is_file($wwwFilesystemPath . '/content.opf')){
throw new InvalidEbookException('Invalid content.opf file: ' . $wwwFilesystemPath . '/content.opf');
throw new Exceptions\InvalidEbookException('Invalid content.opf file: ' . $wwwFilesystemPath . '/content.opf');
}

$this->WwwFilesystemPath = $wwwFilesystemPath;
Expand All @@ -88,7 +88,7 @@ public function __construct(string $wwwFilesystemPath){
// Get the SE identifier.
preg_match('|<dc:identifier[^>]*?>(.+?)</dc:identifier>|ius', $rawMetadata, $matches);
if(sizeof($matches) != 2){
throw new EbookParsingException('Invalid <dc:identifier> element.');
throw new Exceptions\EbookParsingException('Invalid <dc:identifier> element.');
}
$this->Identifier = (string)$matches[1];

Expand Down Expand Up @@ -175,7 +175,7 @@ public function __construct(string $wwwFilesystemPath){

$this->Title = $this->NullIfEmpty($xml->xpath('/package/metadata/dc:title'));
if($this->Title === null){
throw new EbookParsingException('Invalid <dc:title> element.');
throw new Exceptions\EbookParsingException('Invalid <dc:title> element.');
}

$this->Title = str_replace('\'', '', $this->Title);
Expand Down Expand Up @@ -256,7 +256,7 @@ public function __construct(string $wwwFilesystemPath){
}

if(sizeof($this->Authors) == 0){
throw new EbookParsingException('Invalid <dc:creator> element.');
throw new Exceptions\EbookParsingException('Invalid <dc:creator> element.');
}

$this->AuthorsUrl = preg_replace('|url:https://standardebooks.org/ebooks/([^/]+)/.*|ius', '/ebooks/\1', $this->Identifier);
Expand Down
3 changes: 0 additions & 3 deletions lib/EbookParsingException.php

This file was deleted.

88 changes: 88 additions & 0 deletions lib/Email.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

class Email{
public $To = '';
public $From = '';
public $FromName = '';
public $ReplyTo = '';
public $Subject = '';
public $Body = '';
public $TextBody = '';
public $Attachments = array();
public $PostmarkStream = null;

public function Send(): bool{
if($this->ReplyTo == ''){
$this->ReplyTo = $this->From;
}

if($this->To === null || $this->To == ''){
return false;
}

$phpMailer = new PHPMailer(true);

try{
$phpMailer->SetFrom($this->From, $this->FromName);
$phpMailer->AddReplyTo($this->ReplyTo);
$phpMailer->AddAddress($this->To);
$phpMailer->Subject = $this->Subject;
$phpMailer->CharSet = 'UTF-8';
if($this->TextBody !== null && $this->TextBody != ''){
$phpMailer->IsHTML(true);
$phpMailer->Body = $this->Body;
$phpMailer->AltBody = $this->TextBody;
}
else{
$phpMailer->MsgHTML($this->Body);
}

foreach($this->Attachments as $attachment){
if(is_array($attachment)){
$phpMailer->addStringAttachment($attachment['contents'], $attachment['filename']);
}
}

$phpMailer->IsSMTP();
$phpMailer->SMTPAuth = true;
$phpMailer->SMTPSecure = 'tls';
$phpMailer->Port = 587;
$phpMailer->Host = EMAIL_SMTP_HOST;
$phpMailer->Username = EMAIL_SMTP_USERNAME;
$phpMailer->Password = EMAIL_SMTP_PASSWORD;

if($this->PostmarkStream !== null){
$phpMailer->addCustomHeader('X-PM-Message-Stream', $this->PostmarkStream);
}

if(SITE_STATUS == SITE_STATUS_DEV){
Logger::WriteErrorLogEntry('Sending mail to ' . $this->To . ' from ' . $this->From);
Logger::WriteErrorLogEntry('Subject: ' . $this->Subject);
Logger::WriteErrorLogEntry($this->Body);
Logger::WriteErrorLogEntry($this->TextBody);
}
else{
$phpMailer->Send();
}
}
catch(Exception $ex){
if(SITE_STATUS != SITE_STATUS_DEV){
Logger::WriteErrorLogEntry('Failed sending email to ' . $this->To . ' Exception: ' . $ex->errorMessage() . "\n" . ' Subject: ' . $this->Subject . "\nBody:\n" . $this->Body);
}

return false;
}

return true;
}

public function __construct(bool $isNoReplyEmail = false){
if($isNoReplyEmail){
$this->From = NO_REPLY_EMAIL_ADDRESS;
$this->FromName = 'Standard Ebooks';
$this->ReplyTo = NO_REPLY_EMAIL_ADDRESS;
}
}
}
5 changes: 5 additions & 0 deletions lib/Exceptions/EbookParsingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?
namespace Exceptions;

class EbookParsingException extends SeException{
}
5 changes: 5 additions & 0 deletions lib/Exceptions/InvalidAuthorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?
namespace Exceptions;

class InvalidAuthorException extends SeException{
}
6 changes: 6 additions & 0 deletions lib/Exceptions/InvalidCaptchaException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?
namespace Exceptions;

class InvalidCaptchaException extends SeException{
protected $message = 'We couldn’t validate your CAPTCHA response.';
}
5 changes: 5 additions & 0 deletions lib/Exceptions/InvalidCollectionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?
namespace Exceptions;

class InvalidCollectionException extends SeException{
}
6 changes: 6 additions & 0 deletions lib/Exceptions/InvalidCredentialsException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?
namespace Exceptions;

class InvalidCredentialsException extends SeException{
protected $message = 'Invalid credentials.';
}
5 changes: 5 additions & 0 deletions lib/Exceptions/InvalidEbookException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?
namespace Exceptions;

class InvalidEbookException extends SeException{
}
6 changes: 6 additions & 0 deletions lib/Exceptions/InvalidEmailException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?
namespace Exceptions;

class InvalidEmailException extends SeException{
protected $message = 'We couldn’t understand your email address.';
}
Loading

0 comments on commit b0197d1

Please sign in to comment.