Create cookie-based login and authentication system

This commit is contained in:
Alex Cabal 2022-07-10 18:58:55 -05:00
parent 45221365b5
commit 0bc3dc3830
46 changed files with 528 additions and 195 deletions

View file

@ -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 php-curl php-zip apache2 apache2-utils libfcgi0ldbl task-spooler ipv6calc mariadb-server libaprutil1-dbd-mysql attr
sudo apt install -y git composer php-fpm php-cli php-gd php-xml php-apcu php-mbstring php-intl php-curl php-zip apache2 apache2-utils libfcgi0ldbl task-spooler ipv6calc mariadb-server libaprutil1-dbd-mysql attr libapache2-mod-xsendfile
# Create the site root and logs root and clone this repo into it.
sudo mkdir /standardebooks.org/
@ -26,7 +26,7 @@ echo -e "127.0.0.1\tstandardebooks.test" | sudo tee -a /etc/hosts
openssl req -x509 -nodes -days 99999 -newkey rsa:4096 -subj "/CN=standardebooks.test" -keyout /standardebooks.org/web/config/ssl/standardebooks.test.key -sha256 -out /standardebooks.org/web/config/ssl/standardebooks.test.crt
# Enable the necessary Apache modules.
sudo a2enmod headers expires ssl rewrite proxy proxy_fcgi authn_dbd
sudo a2enmod headers expires ssl rewrite proxy proxy_fcgi authn_dbd xsendfile
# Link and enable the SE Apache configuration file.
sudo ln -s /standardebooks.org/web/config/apache/standardebooks.test.conf /etc/apache2/sites-available/

View file

@ -279,14 +279,14 @@ Define webroot /standardebooks.org/web
RewriteCond %{QUERY_STRING} \bquery=
RewriteRule ^/feeds/(opds|atom|rss)/all.xml$ /feeds/$1/search.php [QSA]
# Rewrite rules for bulk downloads
RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1
# Enable mod_authn_dbd
DBDriver mysql
DBDParams "dbname=se user=www-data"
# HTTP Basic Auth configuration for:
# /bulk-downloads
# /feeds
# /polls/votes (we will allow access to view results at /polls/votes/index.php further down)
<DirectoryMatch "^${webroot}/www/(polls/votes|bulk-downloads|feeds/(opds|rss|atom))">
# HTTP Basic Auth configuration for /feeds
<DirectoryMatch "^${webroot}/www/feeds/(opds|rss|atom)">
AuthType Basic
AuthName "Enter your Patrons Circle email address and leave the password empty."
Require valid-user
@ -300,34 +300,14 @@ Define webroot /standardebooks.org/web
# The hash is simply the hash of a blank password. We're only interested in the username/API key.
# We have to do this tortured query instead of a cleaner one, because the AuthDBDUserPWQuery
# function will only replace %s EXACTLY ONCE. We cannot have more than one %s in the query string.
AuthDBDUserPWQuery "\
select '$apr1$13q1pnGf$vQnIj94BXP1EPdL/4ISba.' from \
( \
select Email, Uuid from Patrons p inner join Users u using (UserId) where p.Ended is null \
union \
select Email, Uuid from ApiKeys fu inner join Users u using (UserId) where fu.Ended is null \
) x where %s in (Email, Uuid) limit 1 \
"
AuthDBDUserPWQuery "select '$apr1$13q1pnGf$vQnIj94BXP1EPdL/4ISba.' from Users u inner join Benefits b using (UserId) where %s in (u.Email, u.Uuid) and b.CanAccessFeeds = true limit 1"
</DirectoryMatch>
# Specific config for /bulk-downloads
<DirectoryMatch "^${webroot}/www/bulk-downloads">
<FilesMatch "\.php$">
# Disable HTTP Basic auth for the index and 401 pages
Require all granted
</FilesMatch>
<FilesMatch "\.zip$">
ErrorDocument 401 /bulk-downloads
</FilesMatch>
</DirectoryMatch>
# Specific config for /polls/votes
<DirectoryMatch "^${webroot}/www/polls/votes">
<FilesMatch "index.php$">
# Disable HTTP Basic auth for the index page
Require all granted
</FilesMatch>
<DirectoryMatch "${webroot}/www/bulk-downloads">
# Both directives are required
XSendFile on
XSendFilePath /standardebooks.org/web/www/bulk-downloads
</DirectoryMatch>
# Specific config for /feeds

View file

@ -261,14 +261,14 @@ Define webroot /standardebooks.org/web
RewriteCond %{QUERY_STRING} \bquery=
RewriteRule ^/feeds/(opds|atom|rss)/all.xml$ /feeds/$1/search.php [QSA]
# Rewrite rules for bulk downloads
RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1
# Enable mod_authn_dbd
DBDriver mysql
DBDParams "dbname=se user=www-data"
# HTTP Basic Auth configuration for:
# /bulk-downloads
# /feeds
# /polls/votes (we will allow access to view results at /polls/votes/index.php further down)
<DirectoryMatch "^${webroot}/www/(polls/votes|bulk-downloads|feeds/(opds|rss|atom))">
# HTTP Basic Auth configuration for /feeds
<DirectoryMatch "^${webroot}/www/feeds/(opds|rss|atom)">
AuthType Basic
AuthName "Enter your Patrons Circle email address and leave the password empty."
Require valid-user
@ -282,34 +282,14 @@ Define webroot /standardebooks.org/web
# The hash is simply the hash of a blank password. We're only interested in the username/API key.
# We have to do this tortured query instead of a cleaner one, because the AuthDBDUserPWQuery
# function will only replace %s EXACTLY ONCE. We cannot have more than one %s in the query string.
AuthDBDUserPWQuery "\
select '$apr1$13q1pnGf$vQnIj94BXP1EPdL/4ISba.' from \
( \
select Email, Uuid from Patrons p inner join Users u using (UserId) where p.Ended is null \
union \
select Email, Uuid from ApiKeys fu inner join Users u using (UserId) where fu.Ended is null \
) x where %s in (Email, Uuid) limit 1 \
"
AuthDBDUserPWQuery "select '$apr1$13q1pnGf$vQnIj94BXP1EPdL/4ISba.' from Users u inner join Benefits b using (UserId) where %s in (u.Email, u.Uuid) and b.CanAccessFeeds = true limit 1"
</DirectoryMatch>
# Specific config for /bulk-downloads
<DirectoryMatch "^${webroot}/www/bulk-downloads">
<FilesMatch "\.php$">
# Disable HTTP Basic auth for the index and 401 pages
Require all granted
</FilesMatch>
<FilesMatch "\.zip$">
ErrorDocument 401 /bulk-downloads
</FilesMatch>
</DirectoryMatch>
# Specific config for /polls/votes
<DirectoryMatch "^${webroot}/www/polls/votes">
<FilesMatch "index.php$">
# Disable HTTP Basic auth for the index page
Require all granted
</FilesMatch>
<DirectoryMatch "${webroot}/www/bulk-downloads">
# Both directives are required
XSendFile on
XSendFilePath /standardebooks.org/web/www/bulk-downloads
</DirectoryMatch>
# Specific config for /feeds

View file

@ -1,7 +0,0 @@
CREATE TABLE `ApiKeys` (
`UserId` int(10) unsigned NOT NULL,
`Created` datetime NOT NULL,
`Ended` datetime DEFAULT NULL,
`Notes` text DEFAULT NULL,
KEY `idxUserId` (`UserId`,`Ended`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,8 @@
CREATE TABLE `Benefits` (
`UserId` int(10) unsigned NOT NULL,
`CanAccessFeeds` tinyint(1) unsigned NOT NULL,
`CanVote` tinyint(1) unsigned NOT NULL,
`CanBulkDownload` tinyint(1) unsigned NOT NULL,
PRIMARY KEY (`UserId`),
KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,7 @@
CREATE TABLE `Sessions` (
`UserId` int(10) unsigned NOT NULL,
`Created` datetime NOT NULL,
`SessionId` char(36) NOT NULL,
KEY `idxUserId` (`UserId`),
KEY `idxSessionId` (`SessionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

6
lib/Benefits.php Normal file
View file

@ -0,0 +1,6 @@
<?
class Benefits{
public $CanAccessFeeds = false;
public $CanVote = false;
public $CanBulkDownload = false;
}

View file

@ -11,12 +11,13 @@ define('SITE_STATUS', get_cfg_var('se.site_status') ?: SITE_STATUS_DEV); // Set
// No trailing slash on any of the below constants.
if(SITE_STATUS == SITE_STATUS_LIVE){
define('SITE_URL', 'https://standardebooks.org');
define('SITE_DOMAIN', 'standardebooks.org');
}
else{
define('SITE_URL', 'https://standardebooks.test');
define('SITE_DOMAIN', 'standardebooks.test');
}
const SITE_URL = 'https://' . SITE_DOMAIN;
const SITE_ROOT = '/standardebooks.org';
const WEB_ROOT = SITE_ROOT . '/web/www';
const REPOS_PATH = SITE_ROOT . '/ebooks';
@ -49,6 +50,7 @@ const WEB = 1;
const GET = 'GET';
const POST = 'POST';
const COOKIE = 'COOKIE';
const SESSION = 'SESSION';
const HTTP_VAR_INT = 0;
const HTTP_VAR_STR = 1;

View file

@ -26,3 +26,5 @@ if(SITE_STATUS == SITE_STATUS_LIVE){
throw $ex; // Send the exception back to PHP for its usual logging routine.
});
}
$GLOBALS['User'] = Session::GetLoggedInUser();

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class InvalidFileException extends SeException{
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidPermissionsException extends SeException{
protected $message = 'You dont have permission to perform that action.';
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class InvalidSessionException extends SeException{
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class LoginRequiredException extends SeException{
}

View file

@ -2,5 +2,10 @@
namespace Exceptions;
class PollVoteExistsException extends SeException{
public $Vote = null;
protected $message = 'Youve already voted in this poll.';
public function __construct(?\PollVote $vote = null){
$this->Vote = $vote;
}
}

View file

@ -73,6 +73,9 @@ class HttpInput{
case COOKIE:
$vars = $_COOKIE;
break;
case SESSION:
$vars = $_SESSION;
break;
}
if(isset($vars[$variable])){

View file

@ -22,6 +22,9 @@ class Patron extends PropertiesBase{
$this->Created = new DateTime();
Db::Query('INSERT into Patrons (Created, UserId, IsAnonymous, AlternateName, IsSubscribedToEmails) values(?, ?, ?, ?, ?);', [$this->Created, $this->UserId, $this->IsAnonymous, $this->AlternateName, $this->IsSubscribedToEmails]);
Db::Query('INSERT into Benefits (UserId, CanVote, CanAccessFeeds, CanBulkDownload) values (?, true, true, true) on duplicate key update CanVote = true, CanAccessFeeds = true, CanBulkDownload = true', [$this->UserId]);
// If this is a patron for the first time, send the first-time patron email.
// Otherwise, send the returning patron email.
$isReturning = Db::QueryInt('SELECT count(*) from Patrons where UserId = ?', [$this->UserId]) > 1;

View file

@ -35,8 +35,8 @@ class PollVote extends PropertiesBase{
protected function Validate(): void{
$error = new Exceptions\ValidationException();
if($this->UserId === null){
$error->Add(new Exceptions\InvalidPatronException());
if($this->UserId === null || $this->User === null){
$error->Add(new Exceptions\InvalidUserException());
}
if($this->PollItemId === null){
@ -56,17 +56,17 @@ class PollVote extends PropertiesBase{
// Basic sanity checks done, now check if we've already voted
// in this poll
if($this->User === null){
$error->Add(new Exceptions\InvalidPatronException());
}
else{
// Do we already have a vote for this poll, from this user?
if(Db::QueryInt('
SELECT count(*) from PollVotes pv inner join
(select PollItemId from PollItems pi inner join Polls p using (PollId)) x
using (PollItemId) where pv.UserId = ?', [$this->UserId]) > 0){
$error->Add(new Exceptions\PollVoteExistsException());
try{
$vote = PollVote::Get($this->PollItem->Poll->UrlName, $this->UserId);
$error->Add(new Exceptions\PollVoteExistsException($vote));
}
catch(Exceptions\InvalidPollVoteException $ex){
// User hasn't voted yet, carry on
}
if(!$this->User->Benefits->CanVote){
$error->Add(new Exceptions\InvalidPatronException());
}
}
@ -78,11 +78,10 @@ class PollVote extends PropertiesBase{
public function Create(?string $email = null): void{
if($email !== null){
try{
$patron = Patron::GetByEmail($email);
$this->UserId = $patron->UserId;
$this->User = $patron->User;
$this->User = User::GetByEmail($email);
$this->UserId = $this->User->UserId;
}
catch(Exceptions\InvalidPatronException $ex){
catch(Exceptions\InvalidUserException $ex){
// Can't validate patron email - do nothing for now,
// this will be caught later when we validate the vote during creation.
// Save the email in the User object in case we want it later,

82
lib/Session.php Normal file
View file

@ -0,0 +1,82 @@
<?
use Ramsey\Uuid\Uuid;
use Safe\DateTime;
/**
* @property User $User
* @property PollItem $PollItem
* @property string $Url
*/
class Session extends PropertiesBase{
public $UserId;
protected $_User = null;
public $Created;
public $SessionId;
public $_Url;
// *******
// GETTERS
// *******
protected function GetUrl(): string{
if($this->_Url === null){
$this->_Url = '/sessions/' . $this->SessionId;
}
return $this->_Url;
}
// *******
// METHODS
// *******
public function Create(?string $email = null): void{
$this->User = User::GetIfRegistered($email);
$this->UserId = $this->User->UserId;
$existingSessions = Db::Query('SELECT SessionId, Created from Sessions where UserId = ?', [$this->UserId]);
if(sizeof($existingSessions) > 0){
$this->SessionId = $existingSessions[0]->SessionId;
$this->Created = $existingSessions[0]->Created;
}
else{
$uuid = Uuid::uuid4();
$this->SessionId = $uuid->toString();
$this->Created = new DateTime();
Db::Query('INSERT into Sessions (UserId, SessionId, Created) values (?, ?, ?)', [$this->UserId, $this->SessionId, $this->Created]);
}
}
public static function GetLoggedInUser(): ?User{
$sessionId = HttpInput::Str(COOKIE, 'sessionid');
if($sessionId !== null){
$result = Db::Query('select u.* from Users u inner join Sessions s using (UserId) where s.SessionId = ?', [$sessionId], 'User');
if(sizeof($result) > 0){
// Refresh the login cookie for another 2 weeks
setcookie('sessionid', $sessionId, time() + 60 * 60 * 24 * 14 * 1, '/', SITE_DOMAIN, true, false); // Expires in two weeks
return $result[0];
}
}
return null;
}
public static function Get(?string $sessionId): Session{
if($sessionId === null){
throw new Exceptions\InvalidSessionException();
}
$result = Db::Query('SELECT * from Sessions where SessionId = ?', [$sessionId], 'Session');
if(sizeof($result) == 0){
throw new Exceptions\InvalidSessionException();
}
return $result[0];
}
}

View file

@ -39,4 +39,19 @@ class Template{
include(WEB_ROOT . '/404.php');
exit();
}
public static function RedirectToLogin(bool $redirectToDestination = true, string $destinationUrl = null): void{
if($redirectToDestination){
if($destinationUrl === null){
$destinationUrl = $_SERVER['SCRIPT_URL'];
}
header('Location: /sessions/new?redirect=' . urlencode($destinationUrl));
}
else{
header('Location: /sessions/new');
}
exit();
}
}

View file

@ -4,6 +4,7 @@ use Safe\DateTime;
/**
* @property Array<Payment> $Payments
* @property Benefits $Benefits
*/
class User extends PropertiesBase{
public $UserId;
@ -11,7 +12,9 @@ class User extends PropertiesBase{
public $Email;
public $Created;
public $Uuid;
protected $_IsRegistered = null;
protected $_Payments = null;
protected $_Benefits = null;
// *******
@ -29,6 +32,33 @@ class User extends PropertiesBase{
return $this->_Payments;
}
protected function GetBenefits(): Benefits{
if($this->_Benefits === null){
$result = Db::Query('select * from Benefits where UserId = ?', [$this->UserId], 'Benefits');
if(sizeof($result) == 0){
$this->_Benefits = new Benefits();
$this->_IsRegistered = false;
}
else{
$this->_Benefits = $result[0];
$this->_IsRegistered = true;
}
}
return $this->_Benefits;
}
protected function GetIsRegistered(): bool{
if($this->_IsRegistered === null){
// A user is "registered" if they have a benefits entry in the table.
// This function will fill it out for us.
$this->GetBenefits();
}
return $this->_IsRegistered;
}
// *******
// METHODS
@ -84,15 +114,14 @@ class User extends PropertiesBase{
return $result[0];
}
// Get a user if by either email or uuid, ONLY IF they're either a patron or have a valid API key.
public static function GetByPatronIdentifier(?string $identifier): User{
if($identifier === null){
public static function GetIfRegistered(?string $email): User{
// We consider a user "registered" if they have a row in the Benefits table.
// Emails without that row may only be signed up for the newsletter and thus are not "registered" users
if($email === null){
throw new Exceptions\InvalidUserException();
}
$result = Db::Query('SELECT u.* from Patrons p inner join Users u using (UserId) where p.Ended is null and (u.Email = ? or u.Uuid = ?)
union
select u.* from ApiKeys fu inner join Users u using (UserId) where fu.Ended is null and (u.Email = ? or u.Uuid = ?)', [$identifier, $identifier, $identifier, $identifier], 'User');
$result = Db::Query('SELECT u.* from Users u inner join Benefits using (UserId) where u.Email = ?', [$email], 'User');
if(sizeof($result) == 0){
throw new Exceptions\InvalidUserException();

View file

@ -47,6 +47,7 @@ if(sizeof($expiredPatrons) > 0){
foreach($expiredPatrons as $patron){
Db::Query('update Patrons set Ended = ? where UserId = ?', [$now, $patron->UserId]);
Db::Query('update Benefits set CanAccessFeeds = false, CanVote = false, CanBulkDownload = false where UserId = ?', [$patron->UserId]);
// Email the patron to notify them their term has ended
// Is the patron a recurring subscriber?

View file

@ -15,7 +15,7 @@
<td class="number"><?= Formatter::ToPlainText($collection->UpdatedString) ?></td>
<? foreach($collection->ZipFiles as $item){ ?>
<td class="download"><a href="<?= $item->Url ?>" download=""><?= $item->Type ?></a></td>
<td class="download"><a href="<?= $item->Url ?>"><?= $item->Type ?></a></td>
<td>(<?= Formatter::ToPlainText($item->Size) ?>)</td>
<? } ?>
</tr>

View file

@ -1,13 +1,13 @@
<section id="accessing-the-feeds">
<h2>Accessing the feeds</h2>
<p>Our New Releases feeds are accessible by the public. Access to our other, more detailed feeds is available to our <a href="/about#patrons-circle">Patrons Circle supporters</a> and our <a href="/about#corporate-sponsors">corporate sponsors</a>.</p>
<p><i>If youre a Patrons Circle member, when prompted enter your email address and leave the password field blank to access a feed.</i></p>
<section id="individuals">
<h3>Access for individuals</h3>
<ul>
<li><p>Join the <a href="/donate#patrons-circle">Patrons Circle</a> by making a donation to get access to all of our ebook feeds for the duration of your gift.</p></li>
<li><p><a href="/contribute">Produce an ebook</a> for Standard Ebooks to get lifetime access to our ebook feeds. If youve already done that, <a href="/about#editor-in-chief">contact us</a> to enable your access.</p></li>
</ul>
<p>To access a feed, when prompted enter your email address and leave the password field blank.</p>
</section>
<section id="organizations-and-projects">
<h3>Access for organizations and projects</h3>

View file

@ -3,12 +3,10 @@ require_once('Core.php');
use function Safe\apcu_fetch;
$forbiddenException = null;
$canDownload = false;
if(isset($_SERVER['PHP_AUTH_USER'])){
// We get here if the user entered an invalid HTTP Basic Auth username,
// and this page was served as the 401 page.
$forbiddenException = new Exceptions\InvalidPatronException();
if($GLOBALS['User'] !== null && $GLOBALS['User']->Benefits->CanBulkDownload){
$canDownload = true;
}
$authors = [];
@ -25,12 +23,10 @@ catch(Safe\Exceptions\ApcuException $ex){
<main>
<section class="bulk-downloads">
<h1>Downloads by Author</h1>
<? if($forbiddenException !== null){ ?>
<?= Template::Error(['exception' => $forbiddenException]) ?>
<? } ?>
<? if(!$canDownload){ ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<? } ?>
<p>These zip files contain each ebook in every format we offer, and are updated once daily with the latest versions of each ebook. Read about <a href="/help/how-to-use-our-ebooks#which-file-to-download">which file format to download</a>.</p>
<p>If youre a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
<?= Template::BulkDownloadTable(['label' => 'Author', 'collections' => $authors]); ?>
</section>
</main>

View file

@ -3,12 +3,10 @@ require_once('Core.php');
use function Safe\apcu_fetch;
$forbiddenException = null;
$canDownload = false;
if(isset($_SERVER['PHP_AUTH_USER'])){
// We get here if the user entered an invalid HTTP Basic Auth username,
// and this page was served as the 401 page.
$forbiddenException = new Exceptions\InvalidPatronException();
if($GLOBALS['User'] !== null && $GLOBALS['User']->Benefits->CanBulkDownload){
$canDownload = true;
}
$collections = [];
@ -25,12 +23,10 @@ catch(Safe\Exceptions\ApcuException $ex){
<main>
<section class="bulk-downloads">
<h1>Downloads by Collection</h1>
<? if($forbiddenException !== null){ ?>
<?= Template::Error(['exception' => $forbiddenException]) ?>
<? } ?>
<? if(!$canDownload){ ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<? } ?>
<p>These zip files contain each ebook in every format we offer, and are updated once daily with the latest versions of each ebook. Read about <a href="/help/how-to-use-our-ebooks#which-file-to-download">which file format to download</a>.</p>
<p>If youre a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
<?= Template::BulkDownloadTable(['label' => 'Collection', 'collections' => $collections]); ?>
</section>
</main>

View file

@ -0,0 +1,65 @@
<?
require_once('Core.php');
use function Safe\preg_match;
$path = HttpInput::Str(GET, 'path', false) ?? '';
try{
$path = '/bulk-downloads/' . $path;
if(!is_file(WEB_ROOT . $path)){
throw new Exceptions\InvalidFileException();
}
if($GLOBALS['User'] === null){
throw new Exceptions\LoginRequiredException();
}
if(!preg_match('/\.zip$/ius', $path)){
throw new Exceptions\InvalidPermissionsException();
}
if(!$GLOBALS['User']->Benefits->CanBulkDownload){
throw new Exceptions\InvalidPermissionsException();
}
// Everything OK, serve the file using Apache.
// The xsendfile Apache module tells Apache to serve the file, including not-modified or etag headers.
// Much more efficien than reading it in PHP and outputting it that way.
header('X-Sendfile: ' . WEB_ROOT . $path);
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
exit();
}
catch(Exceptions\LoginRequiredException $ex){
if(isset($_SERVER['HTTP_REFERER'])){
Template::RedirectToLogin(true, $_SERVER['HTTP_REFERER']);
}
else{
preg_match('|(^/bulk-downloads/[^/]+?)/|ius', $path, $matches);
if(sizeof($matches) == 2){
// If we arrived from the bulk-downloads page,
// Make the login form redirect to the bulk download root, instead of refreshing directly into a download
Template::RedirectToLogin(true, $matches[1]);
}
else{
Template::RedirectToLogin();
}
}
}
catch(Exceptions\InvalidPermissionsException $ex){
http_response_code(403);
}
catch(Exceptions\InvalidFileException $ex){
Template::Emit404();
}
?><?= Template::Header(['title' => 'Download ', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?>
<main>
<section class="narrow">
<h1>Downloading ebook collections</h1>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
</section>
</main>
<?= Template::Footer() ?>

View file

@ -7,12 +7,11 @@ $collection = null;
$collectionUrlName = HttpInput::Str(GET, 'collection', false);
$collection = null;
$authorUrlName = HttpInput::Str(GET, 'author', false);
$exception = null;
$user = null;
$canDownload = false;
try{
if(isset($_SERVER['PHP_AUTH_USER'])){
$user = User::GetByPatronIdentifier($_SERVER['PHP_AUTH_USER']);
if($GLOBALS['User'] !== null && $GLOBALS['User']->Benefits->CanBulkDownload){
$canDownload = true;
}
if($collectionUrlName !== null){
@ -66,7 +65,7 @@ try{
catch(Exceptions\InvalidUserException $ex){
$exception = new Exceptions\InvalidPatronException();
}
catch(Exceptions\InvalidCollectionException $ex){
catch(Exceptions\InvalidAuthorException $ex){
Template::Emit404();
}
catch(Exceptions\InvalidCollectionException $ex){
@ -77,13 +76,11 @@ catch(Exceptions\InvalidCollectionException $ex){
<main>
<section class="bulk-downloads">
<h1>Download the <?= $collection->Label ?> Collection</h1>
<?= Template::Error(['exception' => $exception]) ?>
<? if($user === null){ ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks in a collection. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<p>If youre a Patrons Circle member, when prompted enter your email address and leave the password field blank to download this collection.</p>
<? }else{ ?>
<? if($canDownload){ ?>
<p>Select the ebook format in which youd like to download this collection.</p>
<p>You can also read about <a href="/help/how-to-use-our-ebooks#which-file-to-download">which ebook format to download</a>.</p>
<? }else{ ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks in a collection. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<? } ?>
<?= Template::BulkDownloadTable(['label' => 'Collection', 'collections' => [$collection]]); ?>
</section>

View file

@ -3,12 +3,9 @@ require_once('Core.php');
use function Safe\apcu_fetch;
$forbiddenException = null;
if(isset($_SERVER['PHP_AUTH_USER'])){
// We get here if the user entered an invalid HTTP Basic Auth username,
// and this page was served as the 401 page.
$forbiddenException = new Exceptions\InvalidPatronException();
$canDownload = false;
if($GLOBALS['User'] !== null && $GLOBALS['User']->Benefits->CanBulkDownload){
$canDownload = true;
}
$years = [];
@ -39,10 +36,7 @@ catch(Safe\Exceptions\ApcuException $ex){
<source srcset="/images/the-shop-of-the-bookdealer@2x.jpg 2x, /images/the-shop-of-the-bookdealer.jpg 1x" type="image/jpg"/>
<img src="/images/the-shop-of-the-bookdealer@2x.jpg" alt="A gentleman in regency-era dress buys books from a bookseller."/>
</picture>
<? if($forbiddenException !== null){ ?>
<?= Template::Error(['exception' => $forbiddenException]) ?>
<? } ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history.<? if(!$canDownload){ ?> You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.<? } ?></p>
<ul>
<li>
<p><a href="/bulk-downloads/subjects">Downloads by subject</a></p>

View file

@ -3,12 +3,10 @@ require_once('Core.php');
use function Safe\apcu_fetch;
$forbiddenException = null;
$canDownload = false;
if(isset($_SERVER['PHP_AUTH_USER'])){
// We get here if the user entered an invalid HTTP Basic Auth username,
// and this page was served as the 401 page.
$forbiddenException = new Exceptions\InvalidPatronException();
if($GLOBALS['User'] !== null && $GLOBALS['User']->Benefits->CanBulkDownload){
$canDownload = true;
}
$years = [];
@ -25,12 +23,10 @@ catch(Safe\Exceptions\ApcuException $ex){
<main>
<section class="bulk-downloads">
<h1>Downloads by Month</h1>
<? if($forbiddenException !== null){ ?>
<?= Template::Error(['exception' => $forbiddenException]) ?>
<? } ?>
<? if(!$canDownload){ ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<? } ?>
<p>These zip files contain each ebook in every format we offer, and are updated once daily with the latest versions of each ebook. Read about <a href="/help/how-to-use-our-ebooks#which-file-to-download">which file format to download</a>.</p>
<p>If youre a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
<table class="download-list">
<tbody>
<? foreach($years as $year => $months){
@ -55,7 +51,7 @@ catch(Safe\Exceptions\ApcuException $ex){
<td class="number" headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-updated"><?= Formatter::ToPlainText($collection->UpdatedString) ?></td>
<? foreach($collection->ZipFiles as $item){ ?>
<td headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-download" class="download"><a href="<?= $item->Url ?>" download=""><?= $item->Type ?></a></td>
<td headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-download" class="download"><a href="<?= $item->Url ?>"><?= $item->Type ?></a></td>
<td headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-download">(<?= Formatter::ToPlainText($item->Size) ?>)</td>
<? } ?>
</tr>

View file

@ -3,12 +3,10 @@ require_once('Core.php');
use function Safe\apcu_fetch;
$forbiddenException = null;
$canDownload = false;
if(isset($_SERVER['PHP_AUTH_USER'])){
// We get here if the user entered an invalid HTTP Basic Auth username,
// and this page was served as the 401 page.
$forbiddenException = new Exceptions\InvalidPatronException();
if($GLOBALS['User'] !== null && $GLOBALS['User']->Benefits->CanBulkDownload){
$canDownload = true;
}
$subjects = [];
@ -25,12 +23,10 @@ catch(Safe\Exceptions\ApcuException $ex){
<main>
<section class="bulk-downloads">
<h1>Downloads by Subject</h1>
<? if($forbiddenException !== null){ ?>
<?= Template::Error(['exception' => $forbiddenException]) ?>
<? } ?>
<? if(!$canDownload){ ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<? } ?>
<p>These zip files contain each ebook in every format we offer, and are updated once daily with the latest versions of each ebook. Read about <a href="/help/how-to-use-our-ebooks#which-file-to-download">which file format to download</a>.</p>
<p>If youre a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
<?= Template::BulkDownloadTable(['label' => 'Subject', 'collections' => $subjects]); ?>
</section>
</main>

View file

@ -9,7 +9,7 @@ require_once('Core.php');
<p>If you want to suggest a different book to produce, please carefully review <a href="/contribute/collections-policy">the kinds of work we do and dont accept</a>.</p>
<h2>Add a book to this list</h2>
<p><a href="/donate#patrons-circle">Patrons Circle members</a> may submit ebooks for inclusion on this list.</p>
<p>Patrons Circle members periodically vote on a selection from this list to pick one ebook for immediate production. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> to have a voice in the future of the Standard Ebooks catalog.</p>
<p>Patrons Circle members <a href="/polls">periodically vote on a selection from this list</a> to pick one ebook for immediate production. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> to have a voice in the future of the Standard Ebooks catalog.</p>
<h2>For your first production</h2>
<p>If nothing on the list below interests you, you can pitch us something else youd like to work on.</p>
<p>First productions should be on the shorter side (less than 100,000 words maximum) and without too many complex formatting issues like illustrations, significant endnotes, letters, poems, etc. Most short plain fiction novels fall in this category.</p>

View file

@ -1891,6 +1891,20 @@ form[action="/ebooks"] button{
margin-top: 1.4rem;
}
form.single-row{
display: flex;
flex-direction: column;
}
form.single-row label{
width: 100%;
}
form.single-row button{
align-self: flex-end;
margin-top: 1rem;
}
article nav ol li:not(:first-child):not(:last-child):not(.highlighted),
main.ebooks nav ol li:not(:first-child):not(:last-child):not(.highlighted){
display: none;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View file

@ -39,7 +39,7 @@ if($exception){
<label class="captcha">
Type the letters in the <abbr class="acronym">CAPTCHA</abbr> image
<div>
<input type="text" name="captcha" required="required" />
<input type="text" name="captcha" required="required" autocomplete="off" />
<img src="/images/captcha" alt="A visual CAPTCHA." height="<?= CAPTCHA_IMAGE_HEIGHT ?>" width="<?= CAPTCHA_IMAGE_WIDTH ?>" />
</div>
</label>

View file

@ -40,7 +40,7 @@ try{
$subscription->IsSubscribedToNewsletter = HttpInput::Bool(POST, 'issubscribedtonewsletter', false);
$subscription->IsSubscribedToSummary = HttpInput::Bool(POST, 'issubscribedtosummary', false);
$captcha = $_SESSION['captcha'] ?? '';
$captcha = HttpInput::Str(SESSION, 'captcha', false) ?? '';
$exception = new Exceptions\ValidationException();

View file

@ -4,9 +4,21 @@ require_once('Core.php');
use Safe\DateTime;
$poll = new Poll();
$canVote = true; // Allow non-logged-in users to see the 'vote' button
try{
$poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false));
if(isset($GLOBALS['User'])){
$canVote = false; // User is logged in, hide the vote button unless they haven't voted yet
try{
PollVote::Get($poll->UrlName, $GLOBALS['User']->UserId);
}
catch(Exceptions\SeException $ex){
// User has already voted
$canVote = true;
}
}
}
catch(Exceptions\SeException $ex){
Template::Emit404();
@ -21,9 +33,13 @@ catch(Exceptions\SeException $ex){
<? if($poll->End !== null){ ?>
<p class="center-notice">This poll closes on <?= $poll->End->format('F j, Y g:i A') ?>.</p>
<? } ?>
<p><i>If youre a Patrons Circle member, when prompted enter your email address and leave the password field blank to vote.</i></p>
<? if(!$canVote){ ?>
<p class="center-notice">Youve already voted in this poll.</p>
<? } ?>
<p class="button-row narrow">
<? if($canVote){ ?>
<a href="<?= $poll->Url ?>/votes/new" class="button">Vote now</a>
<? } ?>
<a href="<?= $poll->Url ?>/votes" class="button">View results</a>
</p>
<? }else{ ?>

View file

@ -7,8 +7,16 @@ $openPolls = Db::Query('select * from Polls where (End is null or utc_timestamp(
?><?= Template::Header(['title' => 'Polls', 'highlight' => '', 'description' => 'The various polls active at Standard Ebooks.']) ?>
<main>
<section class="narrow">
<h1>Polls</h1>
<section class="narrow has-hero">
<hgroup>
<h1>Vote in Our Polls</h1>
<h2>and decide the direction of the Standard Ebooks catalog</h2>
</hgroup>
<picture>
<source srcset="/images/county-election@2x.avif 2x, /images/county-election.avif 1x" type="image/avif"/>
<source srcset="/images/county-election@2x.jpg 2x, /images/county-election.jpg 1x" type="image/jpg"/>
<img src="/images/county-election@2x.jpg" alt="Voters step up to cast votes in a county poll."/>
</picture>
<p>Periodically members of the <a href="/about#patrons-circle">Standard Ebooks Patrons Circle</a> vote on the next ebook from the <a href="/contribute/wanted-ebooks">Wanted Ebook List</a> to enter immediate production.</p>
<p>Anyone can <a href="/donate#patrons-circle">join the Patrons Circle</a> by making a small donation in support of our mission of producing beautiful digital literature, for free distribution.</p>
<? if(sizeof($openPolls) > 0){ ?>

View file

@ -6,11 +6,13 @@ use function Safe\session_unset;
session_start();
$vote = new PollVote();
$created = false;
try{
$vote = PollVote::Get(HttpInput::Str(GET, 'pollurlname'), HttpInput::Int(GET, 'userid'));
if(isset($_SESSION['vote-created']) && $_SESSION['vote-created'] == $vote->UserId){
$created = true;
http_response_code(201);
session_unset();
}
@ -22,8 +24,13 @@ catch(Exceptions\SeException $ex){
?><?= Template::Header(['title' => 'Thank you for voting!', 'highlight' => '', 'description' => 'Thank you for voting in a Standard Ebooks poll!']) ?>
<main>
<section class="narrow">
<h1>Thank you for voting!</h1>
<p class="center-notice">Your vote in the <a href="<?= $vote->PollItem->Poll->Url ?>"><?= Formatter::ToPlainText($vote->PollItem->Poll->Name) ?> poll</a> has been recorded.</p>
<h1>Your vote has been recorded!</h1>
<? if($created){ ?>
<p class="center-notice">Thank you for voting in the <a href="<?= $vote->PollItem->Poll->Url ?>"><?= Formatter::ToPlainText($vote->PollItem->Poll->Name) ?> poll</a>.</p>
<? }else{ ?>
<p class="center-notice">Your vote in the <a href="<?= $vote->PollItem->Poll->Url ?>"><?= Formatter::ToPlainText($vote->PollItem->Poll->Name) ?> poll</a> was submitted on <?= $vote->Created->format('F j, Y g:i A') ?>.</p>
<? } ?>
<p class="button-row narrow"><a class="button" href="<?= $vote->PollItem->Poll->Url ?>/votes"> view results</a></p>
</section>
</main>

View file

@ -32,7 +32,8 @@ catch(Exceptions\SeException $ex){
<div aria-hidden="true">
<p><?= number_format($pollItem->VoteCount) ?></p>
</div>
<meter min="0" max="<?= $poll->VoteCount ?>" value="<?= $pollItem->VoteCount ?>"></meter>
<? /* @max must be at least 1, otherwise 0/0 will appear as 100% */ ?>
<meter min="0" max="<?= $poll->VoteCount ?: 1 ?>" value="<?= $pollItem->VoteCount ?>"></meter>
</div>
</td>
</tr>

View file

@ -5,41 +5,60 @@ use function Safe\session_unset;
session_start();
$poll = new Poll();
$vote = new PollVote();
$exception = $_SESSION['exception'] ?? null;
try{
if($GLOBALS['User'] === null){
throw new Exceptions\LoginRequiredException();
}
if(isset($_SESSION['vote'])){
$vote = $_SESSION['vote'];
}
else{
$vote->User = new User();
$vote->User->Email = $_SERVER['PHP_AUTH_USER'] ?? null;
$vote->User = $GLOBALS['User'];
}
$exception = $_SESSION['exception'] ?? null;
$poll = new Poll();
$poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false));
try{
$poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false));
$vote = PollVote::Get($poll->UrlName, $GLOBALS['User']->UserId);
throw new Exceptions\PollVoteExistsException($vote);
}
catch(Exceptions\SeException $ex){
Template::Emit404();
catch(Exceptions\InvalidPollVoteException $ex){
// User hasn't voted yet, do nothing
}
if($exception){
http_response_code(400);
session_unset();
}
}
catch(Exceptions\LoginRequiredException $ex){
Template::RedirectToLogin();
}
catch(Exceptions\InvalidPollException $ex){
Template::Emit404();
}
catch(Exceptions\PollVoteExistsException $ex){
$redirect = $poll->Url;
if($ex->Vote !== null){
$redirect = $ex->Vote->Url;
}
header('Location: ' . $redirect);
exit();
}
?><?= Template::Header(['title' => $poll->Name . ' - Vote Now', 'highlight' => '', 'description' => 'Vote in the ' . $poll->Name . ' poll']) ?>
<main>
<section class="narrow">
<h1>Vote in the <?= Formatter::ToPlainText($poll->Name) ?> Poll</h1>
<?= Template::Error(['exception' => $exception]) ?>
<form method="post" action="<?= Formatter::ToPlainText($poll->Url) ?>/votes">
<label class="email">Your email address
<input type="email" name="email" value="<? if($vote->User !== null){ ?><?= Formatter::ToPlainText($vote->User->Email) ?><? } ?>" maxlength="80" required="required" readonly="readonly" />
</label>
<input type="hidden" name="email" value="<? if($vote->User !== null){ ?><?= Formatter::ToPlainText($vote->User->Email) ?><? } ?>" maxlength="80" required="required" />
<fieldset>
<p>Select one of these options</p>
<ul>

View file

@ -16,8 +16,6 @@ $requestType = HttpInput::RequestType();
$vote = new PollVote();
try{
$error = new Exceptions\ValidationException();
$vote->PollItemId = HttpInput::Int(POST, 'pollitemid');
$vote->Create(HttpInput::Str(POST, 'email', false));

40
www/sessions/new.php Normal file
View file

@ -0,0 +1,40 @@
<?
require_once('Core.php');
use function Safe\session_unset;
session_start();
if($GLOBALS['User'] !== null){
header('Location: /');
exit();
}
$email = HttpInput::Str(SESSION, 'email', false);
$redirect = HttpInput::Str(SESSION, 'redirect', false) ?? HttpInput::Str(GET, 'redirect', false);
$exception = $_SESSION['exception'] ?? null;
http_response_code(401);
if($exception){
http_response_code(400);
session_unset();
}
?><?= Template::Header(['title' => 'Log In', 'highlight' => '', 'description' => 'Log in to your Standard Ebooks Patrons Circle account.']) ?>
<main>
<section class="narrow">
<h1>Log in to the Patrons Circle</h1>
<p>Enter your email address to log in to your Patrons Circle account. Once youre logged in, youll be able to <a href="/polls">vote in our occasional polls</a>, access our <a href="/bulk-downloads">bulk ebook downloads</a>, and access our <a href="/feeds">ebook feeds</a>.</p>
<p>Anyone can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<?= Template::Error(['exception' => $exception]) ?>
<form method="post" action="/sessions" class="single-row">
<input type="hidden" name="redirect" value="<?= Formatter::ToPlainText($redirect) ?>" />
<label class="email">Your email address
<input type="email" name="email" value="<?= Formatter::ToPlainText($email) ?>" maxlength="80" required="required" />
</label>
<button>Log in</button>
</form>
</section>
</main>
<?= Template::Footer() ?>

54
www/sessions/post.php Normal file
View file

@ -0,0 +1,54 @@
<?
require_once('Core.php');
use function Safe\preg_match;
use function Safe\session_unset;
if(HttpInput::RequestMethod() != HTTP_POST){
http_response_code(405);
exit();
}
session_start();
$requestType = HttpInput::RequestType();
$session = new Session();
$email = HttpInput::Str(POST, 'email', false);
$redirect = HttpInput::Str(POST, 'redirect', false);
try{
if($redirect === null){
$redirect = '/';
}
$session->Create($email);
setcookie('sessionid', $session->SessionId, time() + 60 * 60 * 24 * 14 * 1, '/', SITE_DOMAIN, true, false); // Expires in two weeks
if($requestType == WEB){
http_response_code(303);
header('Location: ' . $redirect);
}
else{
// Access via REST api; 201 CREATED with location
http_response_code(201);
header('Location: ' . $session->Url);
}
}
catch(Exceptions\SeException $ex){
// Login failed
if($requestType == WEB){
$_SESSION['email'] = $email;
$_SESSION['redirect'] = $redirect;
$_SESSION['exception'] = $ex;
// Access via form; 303 redirect to the form, which will emit a 400 BAD REQUEST
http_response_code(303);
header('Location: /sessions/new');
}
else{
// Access via REST api; 400 BAD REQUEST
http_response_code(400);
}
}