diff --git a/README.md b/README.md index 59b26b2c..15caf538 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/config/apache/standardebooks.org.conf b/config/apache/standardebooks.org.conf index d4f063e3..bdb3b132 100644 --- a/config/apache/standardebooks.org.conf +++ b/config/apache/standardebooks.org.conf @@ -245,7 +245,7 @@ Define webroot /standardebooks.org/web RewriteRule ^/images/covers/(.+?)\-[a-z0-9]{8}\-(cover|hero)(@2x)?\.(jpg|avif)$ /images/covers/$1-$2$3.$4 RewriteRule ^/ebooks/([^\./]+?)$ /ebooks/author.php?url-path=$1 [QSA] - RewriteRule ^/ebooks/([^\./]+?)/downloads$ /bulk-downloads/get.php?author=$1 [QSA] + RewriteRule ^/ebooks/([^\./]+?)/downloads$ /bulk-downloads/get.php?author=$1 [QSA] RewriteRule ^/subjects/([^\./]+?)$ /ebooks/index.php?tags[]=$1 [QSA] RewriteRule ^/collections/([^\./]+?)$ /ebooks/index.php?collection=$1 [QSA] RewriteRule ^/collections/([^/]+?)/downloads$ /bulk-downloads/get.php?collection=$1 @@ -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) - + # HTTP Basic Auth configuration for /feeds + 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" # Specific config for /bulk-downloads - - - # Disable HTTP Basic auth for the index and 401 pages - Require all granted - - - - ErrorDocument 401 /bulk-downloads - - - - # Specific config for /polls/votes - - - # Disable HTTP Basic auth for the index page - Require all granted - + + # Both directives are required + XSendFile on + XSendFilePath /standardebooks.org/web/www/bulk-downloads # Specific config for /feeds diff --git a/config/apache/standardebooks.test.conf b/config/apache/standardebooks.test.conf index 8bf6b5d8..0cf5e487 100644 --- a/config/apache/standardebooks.test.conf +++ b/config/apache/standardebooks.test.conf @@ -227,7 +227,7 @@ Define webroot /standardebooks.org/web RewriteRule ^/images/covers/(.+?)\-[a-z0-9]{8}\-(cover|hero)(@2x)?\.(jpg|avif)$ /images/covers/$1-$2$3.$4 RewriteRule ^/ebooks/([^\./]+?)$ /ebooks/author.php?url-path=$1 [QSA] - RewriteRule ^/ebooks/([^\./]+?)/downloads$ /bulk-downloads/get.php?author=$1 [QSA] + RewriteRule ^/ebooks/([^\./]+?)/downloads$ /bulk-downloads/get.php?author=$1 [QSA] RewriteRule ^/subjects/([^\./]+?)$ /ebooks/index.php?tags[]=$1 [QSA] RewriteRule ^/collections/([^\./]+?)$ /ebooks/index.php?collection=$1 [QSA] RewriteRule ^/collections/([^/]+?)/downloads$ /bulk-downloads/get.php?collection=$1 @@ -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) - + # HTTP Basic Auth configuration for /feeds + 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" # Specific config for /bulk-downloads - - - # Disable HTTP Basic auth for the index and 401 pages - Require all granted - - - - ErrorDocument 401 /bulk-downloads - - - - # Specific config for /polls/votes - - - # Disable HTTP Basic auth for the index page - Require all granted - + + # Both directives are required + XSendFile on + XSendFilePath /standardebooks.org/web/www/bulk-downloads # Specific config for /feeds diff --git a/config/sql/se/ApiKeys.sql b/config/sql/se/ApiKeys.sql deleted file mode 100644 index 5c2d45f4..00000000 --- a/config/sql/se/ApiKeys.sql +++ /dev/null @@ -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; diff --git a/config/sql/se/Benefits.sql b/config/sql/se/Benefits.sql new file mode 100644 index 00000000..f1862baf --- /dev/null +++ b/config/sql/se/Benefits.sql @@ -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; diff --git a/config/sql/se/Sessions.sql b/config/sql/se/Sessions.sql new file mode 100644 index 00000000..a1901349 --- /dev/null +++ b/config/sql/se/Sessions.sql @@ -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; diff --git a/lib/Benefits.php b/lib/Benefits.php new file mode 100644 index 00000000..18e2388a --- /dev/null +++ b/lib/Benefits.php @@ -0,0 +1,6 @@ +Vote = $vote; + } } diff --git a/lib/HttpInput.php b/lib/HttpInput.php index 9ec3fa17..4a043504 100644 --- a/lib/HttpInput.php +++ b/lib/HttpInput.php @@ -73,6 +73,9 @@ class HttpInput{ case COOKIE: $vars = $_COOKIE; break; + case SESSION: + $vars = $_SESSION; + break; } if(isset($vars[$variable])){ diff --git a/lib/Patron.php b/lib/Patron.php index cf1ea936..f4511b45 100644 --- a/lib/Patron.php +++ b/lib/Patron.php @@ -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; diff --git a/lib/PollVote.php b/lib/PollVote.php index 866e380b..fef5d54b 100644 --- a/lib/PollVote.php +++ b/lib/PollVote.php @@ -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()); + // Do we already have a vote for this poll, from this user? + try{ + $vote = PollVote::Get($this->PollItem->Poll->UrlName, $this->UserId); + $error->Add(new Exceptions\PollVoteExistsException($vote)); } - 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()); - } + 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, diff --git a/lib/Session.php b/lib/Session.php new file mode 100644 index 00000000..1e7d4a8f --- /dev/null +++ b/lib/Session.php @@ -0,0 +1,82 @@ +_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]; + } +} diff --git a/lib/Template.php b/lib/Template.php index 79261146..b5cb82b7 100644 --- a/lib/Template.php +++ b/lib/Template.php @@ -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(); + } } diff --git a/lib/User.php b/lib/User.php index 79d84edc..0661fa63 100644 --- a/lib/User.php +++ b/lib/User.php @@ -4,6 +4,7 @@ use Safe\DateTime; /** * @property Array $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(); diff --git a/scripts/update-patrons-circle b/scripts/update-patrons-circle index 6ccafdd4..f0610614 100755 --- a/scripts/update-patrons-circle +++ b/scripts/update-patrons-circle @@ -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? diff --git a/templates/BulkDownloadTable.php b/templates/BulkDownloadTable.php index 93d8eb83..3a8b5031 100644 --- a/templates/BulkDownloadTable.php +++ b/templates/BulkDownloadTable.php @@ -15,7 +15,7 @@ UpdatedString) ?> ZipFiles as $item){ ?> - Type ?> + Type ?> (Size) ?>) diff --git a/templates/FeedHowTo.php b/templates/FeedHowTo.php index 72d84974..5238671f 100644 --- a/templates/FeedHowTo.php +++ b/templates/FeedHowTo.php @@ -1,13 +1,13 @@

Accessing the feeds

Our New Releases feeds are accessible by the public. Access to our other, more detailed feeds is available to our Patrons Circle supporters and our corporate sponsors.

+

If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to access a feed.

Access for individuals

  • Join the Patrons Circle by making a donation to get access to all of our ebook feeds for the duration of your gift.

  • Produce an ebook for Standard Ebooks to get lifetime access to our ebook feeds. If you’ve already done that, contact us to enable your access.

-

To access a feed, when prompted enter your email address and leave the password field blank.

Access for organizations and projects

diff --git a/www/bulk-downloads/authors/index.php b/www/bulk-downloads/authors/index.php index c5d3c182..6eb6ec5b 100644 --- a/www/bulk-downloads/authors/index.php +++ b/www/bulk-downloads/authors/index.php @@ -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){

Downloads by Author

- - $forbiddenException]) ?> + +

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

-

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

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 which file format to download.

-

If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.

'Author', 'collections' => $authors]); ?>
diff --git a/www/bulk-downloads/collections/index.php b/www/bulk-downloads/collections/index.php index be1e93fa..6abcb417 100644 --- a/www/bulk-downloads/collections/index.php +++ b/www/bulk-downloads/collections/index.php @@ -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){

Downloads by Collection

- - $forbiddenException]) ?> + +

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

-

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

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 which file format to download.

-

If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.

'Collection', 'collections' => $collections]); ?>
diff --git a/www/bulk-downloads/download.php b/www/bulk-downloads/download.php new file mode 100644 index 00000000..25392d68 --- /dev/null +++ b/www/bulk-downloads/download.php @@ -0,0 +1,65 @@ +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(); +} + +?> 'Download ', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?> +
+
+

Downloading ebook collections

+

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

+
+
+ diff --git a/www/bulk-downloads/get.php b/www/bulk-downloads/get.php index 824b529e..69351734 100644 --- a/www/bulk-downloads/get.php +++ b/www/bulk-downloads/get.php @@ -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){

Download the Label ?> Collection

- $exception]) ?> - -

Patrons circle members can download zip files containing all of the ebooks in a collection. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

-

If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download this collection.

- +

Select the ebook format in which you’d like to download this collection.

You can also read about which ebook format to download.

+ +

Patrons circle members can download zip files containing all of the ebooks in a collection. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

'Collection', 'collections' => [$collection]]); ?>
diff --git a/www/bulk-downloads/index.php b/www/bulk-downloads/index.php index dea1f6ec..1cc0b991 100644 --- a/www/bulk-downloads/index.php +++ b/www/bulk-downloads/index.php @@ -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){ A gentleman in regency-era dress buys books from a bookseller. - - $forbiddenException]) ?> - -

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

+

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

  • Downloads by subject

    diff --git a/www/bulk-downloads/months/index.php b/www/bulk-downloads/months/index.php index e5226d8d..885d8be2 100644 --- a/www/bulk-downloads/months/index.php +++ b/www/bulk-downloads/months/index.php @@ -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){

    Downloads by Month

    - - $forbiddenException]) ?> + +

    Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

    -

    Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

    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 which file format to download.

    -

    If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.

    $months){ @@ -55,7 +51,7 @@ catch(Safe\Exceptions\ApcuException $ex){ ZipFiles as $item){ ?> - + diff --git a/www/bulk-downloads/subjects/index.php b/www/bulk-downloads/subjects/index.php index 3da3e9dd..8824f20c 100644 --- a/www/bulk-downloads/subjects/index.php +++ b/www/bulk-downloads/subjects/index.php @@ -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){

    Downloads by Subject

    - - $forbiddenException]) ?> + +

    Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

    -

    Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

    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 which file format to download.

    -

    If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.

    'Subject', 'collections' => $subjects]); ?>
    diff --git a/www/contribute/wanted-ebooks.php b/www/contribute/wanted-ebooks.php index c1fcbf59..ee30df62 100644 --- a/www/contribute/wanted-ebooks.php +++ b/www/contribute/wanted-ebooks.php @@ -9,7 +9,7 @@ require_once('Core.php');

    If you want to suggest a different book to produce, please carefully review the kinds of work we do and don’t accept.

    Add a book to this list

    Patrons Circle members may submit ebooks for inclusion on this list.

    -

    Patrons Circle members periodically vote on a selection from this list to pick one ebook for immediate production. You can join the Patrons Circle to have a voice in the future of the Standard Ebooks catalog.

    +

    Patrons Circle members periodically vote on a selection from this list to pick one ebook for immediate production. You can join the Patrons Circle to have a voice in the future of the Standard Ebooks catalog.

    For your first production

    If nothing on the list below interests you, you can pitch us something else you’d like to work on.

    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.

    diff --git a/www/css/core.css b/www/css/core.css index 0f17b40b..a1a65eeb 100644 --- a/www/css/core.css +++ b/www/css/core.css @@ -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; diff --git a/www/images/county-election.avif b/www/images/county-election.avif new file mode 100644 index 00000000..d32c9c5f Binary files /dev/null and b/www/images/county-election.avif differ diff --git a/www/images/county-election.jpg b/www/images/county-election.jpg new file mode 100644 index 00000000..bd5b873d Binary files /dev/null and b/www/images/county-election.jpg differ diff --git a/www/images/county-election@2x.avif b/www/images/county-election@2x.avif new file mode 100644 index 00000000..e8106cb8 Binary files /dev/null and b/www/images/county-election@2x.avif differ diff --git a/www/images/county-election@2x.jpg b/www/images/county-election@2x.jpg new file mode 100644 index 00000000..df0657d0 Binary files /dev/null and b/www/images/county-election@2x.jpg differ diff --git a/www/newsletter/subscriptions/new.php b/www/newsletter/subscriptions/new.php index 48cfdb72..244ec3ed 100644 --- a/www/newsletter/subscriptions/new.php +++ b/www/newsletter/subscriptions/new.php @@ -39,7 +39,7 @@ if($exception){ diff --git a/www/newsletter/subscriptions/post.php b/www/newsletter/subscriptions/post.php index 2fd68d1a..8bf4e8ef 100644 --- a/www/newsletter/subscriptions/post.php +++ b/www/newsletter/subscriptions/post.php @@ -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(); diff --git a/www/polls/get.php b/www/polls/get.php index 6aaf0d6c..2afdbc27 100644 --- a/www/polls/get.php +++ b/www/polls/get.php @@ -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){ End !== null){ ?>

    This poll closes on End->format('F j, Y g:i A') ?>.

    -

    If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to vote.

    + +

    You’ve already voted in this poll.

    +

    + Vote now + View results

    diff --git a/www/polls/index.php b/www/polls/index.php index 050fcace..040d9d65 100644 --- a/www/polls/index.php +++ b/www/polls/index.php @@ -7,8 +7,16 @@ $openPolls = Db::Query('select * from Polls where (End is null or utc_timestamp( ?> 'Polls', 'highlight' => '', 'description' => 'The various polls active at Standard Ebooks.']) ?>
    -
    -

    Polls

    +
    +
    +

    Vote in Our Polls

    +

    and decide the direction of the Standard Ebooks catalog

    +
    + + + + Voters step up to cast votes in a county poll. +

    Periodically members of the Standard Ebooks Patrons Circle vote on the next ebook from the Wanted Ebook List to enter immediate production.

    Anyone can join the Patrons Circle by making a small donation in support of our mission of producing beautiful digital literature, for free distribution.

    0){ ?> diff --git a/www/polls/votes/get.php b/www/polls/votes/get.php index de487a34..f2580df7 100644 --- a/www/polls/votes/get.php +++ b/www/polls/votes/get.php @@ -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){ ?> 'Thank you for voting!', 'highlight' => '', 'description' => 'Thank you for voting in a Standard Ebooks poll!']) ?>
    -

    Thank you for voting!

    -

    Your vote in the PollItem->Poll->Name) ?> poll has been recorded.

    +

    Your vote has been recorded!

    + +

    Thank you for voting in the PollItem->Poll->Name) ?> poll.

    + +

    Your vote in the PollItem->Poll->Name) ?> poll was submitted on Created->format('F j, Y g:i A') ?>.

    + +

    view results

    diff --git a/www/polls/votes/index.php b/www/polls/votes/index.php index e9e02c90..62d42dfa 100644 --- a/www/polls/votes/index.php +++ b/www/polls/votes/index.php @@ -24,19 +24,20 @@ catch(Exceptions\SeException $ex){
    UpdatedString) ?> Type ?>Type ?> (Size) ?>)
    - PollItemsByWinner as $pollItem){ ?> - - - + + - - + + +
    Name) ?> -
    -
    Name) ?> +
    + + +
    - - -
    diff --git a/www/polls/votes/new.php b/www/polls/votes/new.php index 43f6f1d0..85fc6e32 100644 --- a/www/polls/votes/new.php +++ b/www/polls/votes/new.php @@ -5,41 +5,60 @@ use function Safe\session_unset; session_start(); +$poll = new Poll(); $vote = new PollVote(); - -if(isset($_SESSION['vote'])){ - $vote = $_SESSION['vote']; -} -else{ - $vote->User = new User(); - $vote->User->Email = $_SERVER['PHP_AUTH_USER'] ?? null; -} - $exception = $_SESSION['exception'] ?? null; -$poll = new Poll(); - try{ + if($GLOBALS['User'] === null){ + throw new Exceptions\LoginRequiredException(); + } + + if(isset($_SESSION['vote'])){ + $vote = $_SESSION['vote']; + } + else{ + $vote->User = $GLOBALS['User']; + } + + $poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false)); + + try{ + $vote = PollVote::Get($poll->UrlName, $GLOBALS['User']->UserId); + throw new Exceptions\PollVoteExistsException($vote); + } + catch(Exceptions\InvalidPollVoteException $ex){ + // User hasn't voted yet, do nothing + } + + if($exception){ + http_response_code(400); + session_unset(); + } } -catch(Exceptions\SeException $ex){ +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; + } -if($exception){ - http_response_code(400); - session_unset(); + header('Location: ' . $redirect); + exit(); } - ?> $poll->Name . ' - Vote Now', 'highlight' => '', 'description' => 'Vote in the ' . $poll->Name . ' poll']) ?>

    Vote in the Name) ?> Poll

    $exception]) ?>
    - +

    Select one of these options

      diff --git a/www/polls/votes/post.php b/www/polls/votes/post.php index 21b34041..aee36b01 100644 --- a/www/polls/votes/post.php +++ b/www/polls/votes/post.php @@ -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)); diff --git a/www/sessions/new.php b/www/sessions/new.php new file mode 100644 index 00000000..5f23944a --- /dev/null +++ b/www/sessions/new.php @@ -0,0 +1,40 @@ + 'Log In', 'highlight' => '', 'description' => 'Log in to your Standard Ebooks Patrons Circle account.']) ?> +
      +
      +

      Log in to the Patrons Circle

      +

      Enter your email address to log in to your Patrons Circle account. Once you’re logged in, you’ll be able to vote in our occasional polls, access our bulk ebook downloads, and access our ebook feeds.

      +

      Anyone can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

      + $exception]) ?> + + + + + +
      +
      + diff --git a/www/sessions/post.php b/www/sessions/post.php new file mode 100644 index 00000000..81f49f1f --- /dev/null +++ b/www/sessions/post.php @@ -0,0 +1,54 @@ +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); + } +}