Add admin form to view and edit users

This commit is contained in:
Alex Cabal 2024-11-23 13:13:31 -06:00
parent 32607fb220
commit 8ad3291a35
35 changed files with 902 additions and 72 deletions

View file

@ -0,0 +1,6 @@
RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/"
RewriteRule ^/users/([\d]+)$ /users/post.php?user-id=$1 [L]
RewriteRule ^/users/([^/]+)$ /users/get.php?user-identifier=$1 [L]
RewriteRule ^/users/([\d]+)/edit$ /users/edit.php?user-id=$1 [L]

View file

@ -216,6 +216,7 @@ Define conf_rewrite_root ${web_root}/config/apache/rewrites
Include ${conf_rewrite_root}/newsletters.conf
Include ${conf_rewrite_root}/artworks.conf
Include ${conf_rewrite_root}/polls.conf
Include ${conf_rewrite_root}/users.conf
# Specific config for /ebooks/<author>/<ebook>/downloads
<DirectoryMatch "^${web_root}/www/ebooks/.+">

View file

@ -198,6 +198,7 @@ Define conf_rewrite_root ${web_root}/config/apache/rewrites
Include ${conf_rewrite_root}/newsletters.conf
Include ${conf_rewrite_root}/artworks.conf
Include ${conf_rewrite_root}/polls.conf
Include ${conf_rewrite_root}/users.conf
# Specific config for /ebooks/<author>/<ebook>/downloads
<DirectoryMatch "^${web_root}/www/ebooks/.+">

View file

@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS `Benefits` (
`CanUploadArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanReviewArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanReviewOwnArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanEditUsers` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`UserId`),
KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -3,8 +3,13 @@ CREATE TABLE IF NOT EXISTS `Users` (
`Email` varchar(80) DEFAULT NULL,
`Name` varchar(255) DEFAULT NULL,
`Created` timestamp NOT NULL DEFAULT current_timestamp(),
`Updated` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),
`Uuid` char(36) NOT NULL DEFAULT (uuid()),
`PasswordHash` varchar(255) NULL,
PRIMARY KEY (`UserId`),
UNIQUE KEY `idxEmail` (`Email`)
UNIQUE KEY `idxEmail` (`Email`,`Uuid`,`UserId`),
UNIQUE KEY `idxUniqueEmail` (`Email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE `se`.`Users`
ADD INDEX `idxUniqueEmail` (`Email` ASC) VISIBLE;
;

View file

@ -1,9 +1,84 @@
<?
/**
* @property bool $HasBenefits Are any of the benefits in this object **`TRUE`**?
* @property bool $RequiresPassword Do any of the benefits in this object require the `User` to have a password set?
*/
class Benefits{
use Traits\Accessor;
use Traits\PropertyFromHttp;
public int $UserId;
public bool $CanAccessFeeds = false;
public bool $CanVote = false;
public bool $CanBulkDownload = false;
public bool $CanUploadArtwork = false;
public bool $CanReviewArtwork = false;
public bool $CanReviewOwnArtwork = false;
public bool $CanEditUsers = false;
protected bool $_HasBenefits;
protected function GetRequiresPassword(): bool{
if(
$this->CanUploadArtwork
||
$this->CanReviewArtwork
||
$this->CanReviewOwnArtwork
||
$this->CanEditUsers
){
return true;
}
return false;
}
protected function GetHasBenefits(): bool{
if(!isset($this->_HasBenefits)){
$this->_HasBenefits = false;
/** @phpstan-ignore-next-line */
foreach($this as $property => $value){
$rp = new ReflectionProperty(self::class, $property);
$type = $rp->getType();
if($type !== null && ($type instanceof \ReflectionNamedType)){
$typeName = $type->getName();
if($typeName == 'bool' && $value == true){
$this->_HasBenefits = true;
break;
}
}
}
}
return $this->_HasBenefits;
}
public function Create(): void{
Db::Query('
INSERT into Benefits (UserId, CanAccessFeeds, CanVote, CanBulkDownload, CanUploadArtwork, CanReviewArtwork, CanReviewOwnArtwork, CanEditUsers)
values (?, ?, ?, ?, ?, ?, ?, ?)
', [$this->UserId, $this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers]);
}
public function Save(): void{
Db::Query('
UPDATE Benefits
set CanAccessFeeds = ?, CanVote = ?, CanBulkDownload = ?, CanUploadArtwork = ?, CanReviewArtwork = ?, CanReviewOwnArtwork = ?, CanEditUsers = ?
where
UserId = ?
', [$this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->UserId]);
}
public function FillFromHttpPost(): void{
$this->PropertyFromHttp('CanAccessFeeds');
$this->PropertyFromHttp('CanVote');
$this->PropertyFromHttp('CanBulkDownload');
$this->PropertyFromHttp('CanUploadArtwork');
$this->PropertyFromHttp('CanReviewArtwork');
$this->PropertyFromHttp('CanReviewOwnArtwork');
$this->PropertyFromHttp('CanEditUsers');
}
}

View file

@ -0,0 +1,8 @@
<?
namespace Enums;
enum PasswordActionType: string{
case Edit = 'edit';
case None = 'none';
case Delete = 'delete';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class BenefitsRequirePasswordException extends AppException{
/** @var string $message */
protected $message = 'One or more of the selected benefits require that the user have a password set.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class EmailRequiredException extends AppException{
/** @var string $message */
protected $message = 'An email is required.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidUserException extends ValidationException{
/** @var string $message */
protected $message = 'User is invalid.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidUuidException extends AppException{
/** @var string $message */
protected $message = 'Invalid UUID.';
}

View file

@ -0,0 +1,12 @@
<?
namespace Exceptions;
class SeeOtherException extends AppException{
public string $Url;
public function __construct(string $url){
$this->Url = $url;
parent::__construct();
}
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class UuidRequiredException extends AppException{
/** @var string $message */
protected $message = 'A UUID is required.';
}

View file

@ -42,18 +42,18 @@ class NewsletterSubscription{
public function Create(?string $expectedCaptcha = null, ?string $receivedCaptcha = null): void{
$this->Validate($expectedCaptcha, $receivedCaptcha);
// Do we need to create a user?
// Do we need to create a `User`?
try{
$this->User = User::GetByEmail($this->User->Email);
}
catch(Exceptions\UserNotFoundException){
// User doesn't exist, create the user
// User doesn't exist, create the `User`.
try{
$this->User->Create();
}
catch(Exceptions\UserExistsException){
// User exists, pass
catch(Exceptions\UserExistsException | Exceptions\InvalidUserException){
// `User` exists, pass.
}
}
@ -75,7 +75,7 @@ class NewsletterSubscription{
throw new Exceptions\NewsletterSubscriptionExistsException();
}
// Send the double opt-in confirmation email
// Send the double opt-in confirmation email.
$this->SendConfirmationEmail();
}
@ -162,13 +162,27 @@ class NewsletterSubscription{
throw new Exceptions\NewsletterSubscriptionNotFoundException();
}
$result = Db::Query('
return Db::Query('
SELECT ns.*
from NewsletterSubscriptions ns
inner join Users u using(UserId)
where u.Uuid = ?
', [$uuid], NewsletterSubscription::class);
', [$uuid], NewsletterSubscription::class)[0] ?? throw new Exceptions\NewsletterSubscriptionNotFoundException();
}
return $result[0] ?? throw new Exceptions\NewsletterSubscriptionNotFoundException();
/**
* @throws Exceptions\NewsletterSubscriptionNotFoundException
*/
public static function GetByUserId(?int $userId): NewsletterSubscription{
if($userId === null){
throw new Exceptions\NewsletterSubscriptionNotFoundException();
}
return Db::Query('
SELECT ns.*
from NewsletterSubscriptions ns
inner join Users u using(UserId)
where u.UserId = ?
', [$userId], NewsletterSubscription::class)[0] ?? throw new Exceptions\NewsletterSubscriptionNotFoundException();
}
}

View file

@ -45,18 +45,18 @@ class Payment{
*/
public function Create(): void{
if($this->UserId === null){
// Check if we have to create a new user in the database
// Check if we have to create a new `User` in the database.
// If the User object isn't null, then check if we already have this user in our system
// If the `User` isn't **null**, then check if we already have this user in our system.
if($this->User !== null && $this->User->Email !== null){
try{
$user = User::GetByEmail($this->User->Email);
// User exists, use their data
// `User` exists, use their data
$user->Name = $this->User->Name;
$this->User = $user;
// Update their name in case we have their email (but not name) recorded from a newsletter subscription
// Update their name in case we have their email (but not name) recorded from a newsletter subscription.
Db::Query('
UPDATE Users
set Name = ?
@ -64,13 +64,13 @@ class Payment{
', [$this->User->Name, $this->User->UserId]);
}
catch(Exceptions\UserNotFoundException){
// User doesn't exist, create it now
// User doesn't exist, create it now.
try{
$this->User->Create();
}
catch(Exceptions\UserExistsException){
// User already exists, pass
catch(Exceptions\UserExistsException | Exceptions\InvalidUserException){
// `User` already exists, pass.
}
}

View file

@ -2,18 +2,26 @@
use Ramsey\Uuid\Uuid;
use Safe\DateTimeImmutable;
use function Safe\preg_match;
/**
* @property array<Payment> $Payments
* @property bool $IsRegistered
* @property bool $IsRegistered A user is "registered" if they have an entry in the `Benefits` table; a password is required to log in.
* @property Benefits $Benefits
* @property string $Url
* @property bool $IsPatron
* @property ?Patron $Patron
* @property ?NewsletterSubscription $NewsletterSubscription
*/
class User{
use Traits\Accessor;
use Traits\PropertyFromHttp;
public int $UserId;
public ?string $Name = null;
public ?string $Email = null;
public DateTimeImmutable $Created;
public DateTimeImmutable $Updated;
public string $Uuid;
public ?string $PasswordHash = null;
@ -21,12 +29,60 @@ class User{
/** @var array<Payment> $_Payments */
protected array $_Payments;
protected Benefits $_Benefits;
protected string $_Url;
protected bool $_IsPatron;
protected ?Patron $_Patron;
protected ?NewsletterSubscription $_NewsletterSubscription;
// *******
// GETTERS
// *******
protected function GetIsPatron(): bool{
if(!isset($this->_IsPatron)){
$this->GetPatron();
}
return $this->_IsPatron;
}
protected function GetNewsletterSubscription(): ?NewsletterSubscription{
if(!isset($this->_NewsletterSubscription)){
try{
$this->_NewsletterSubscription = NewsletterSubscription::GetByUserId($this->UserId);
}
catch(Exceptions\NewsletterSubscriptionNotFoundException){
$this->_NewsletterSubscription = null;
}
}
return $this->_NewsletterSubscription;
}
protected function GetPatron(): ?Patron{
if(!isset($this->_Patron)){
try{
$this->_Patron = Patron::Get($this->UserId);
$this->IsPatron = true;
}
catch(Exceptions\PatronNotFoundException){
$this->_Patron = null;
$this->IsPatron = false;
}
}
return $this->_Patron;
}
protected function GetUrl(): string{
if(!isset($this->_Url)){
$this->_Url = '/users/' . $this->UserId;
}
return $this->_Url;
}
/**
* @return array<Payment>
*/
@ -66,7 +122,7 @@ class User{
protected function GetIsRegistered(): ?bool{
if(!isset($this->_IsRegistered)){
// A user is "registered" if they have a benefits entry in the table.
// A user is "registered" if they have an entry in the `Benefits` table.
// This function will fill it out for us.
$this->GetBenefits();
}
@ -80,11 +136,61 @@ class User{
// *******
/**
* @throws Exceptions\InvalidUserException
*/
public function Validate(): void{
$error = new Exceptions\InvalidUserException();
if(!isset($this->Email)){
$error->Add(new Exceptions\EmailRequiredException());
}
else{
if(filter_var($this->Email, FILTER_VALIDATE_EMAIL) === false){
$error->Add(new Exceptions\InvalidEmailException('Email is invalid.'));
}
}
if(!isset($this->Uuid)){
$error->Add(new Exceptions\UuidRequiredException());
}
else{
if(!preg_match('/^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$/', $this->Uuid)){
$error->Add(new Exceptions\InvalidUuidException());
}
}
if(trim($this->Name ?? '') == ''){
$this->Name = null;
}
if(trim($this->PasswordHash ?? '') == ''){
$this->PasswordHash = null;
}
// Some benefits require this `User` to have a password set.
if($this->Benefits->RequiresPassword && $this->PasswordHash === null){
$error->Add(new Exceptions\BenefitsRequirePasswordException());
}
if($error->HasExceptions){
throw $error;
}
}
public function GenerateUuid(): void{
$uuid = Uuid::uuid4();
$this->Uuid = $uuid->toString();
}
/**
* @throws Exceptions\InvalidUserException
* @throws Exceptions\UserExistsException
*/
public function Create(?string $password = null): void{
$uuid = Uuid::uuid4();
$this->Uuid = $uuid->toString();
$this->GenerateUuid();
$this->Validate();
$this->Created = NOW;
@ -110,6 +216,37 @@ class User{
$this->UserId = Db::GetLastInsertedId();
}
/**
* @throws Exceptions\InvalidUserException
* @throws Exceptions\UserExistsException
*/
public function Save(): void{
$this->Validate();
$this->Updated = NOW;
try{
Db::Query('
UPDATE Users
set Email = ?, Name = ?, Uuid = ?, Updated = ?, PasswordHash = ?
where
UserId = ?
', [$this->Email, $this->Name, $this->Uuid, $this->Updated, $this->PasswordHash, $this->UserId]);
if($this->IsRegistered){
$this->Benefits->Save();
}
elseif($this->Benefits->HasBenefits){
$this->Benefits->UserId = $this->UserId;
$this->Benefits->Create();
$this->IsRegistered = true;
}
}
catch(Exceptions\DuplicateDatabaseKeyException){
throw new Exceptions\UserExistsException();
}
}
// ***********
// ORM METHODS
@ -130,6 +267,30 @@ class User{
', [$userId], User::class)[0] ?? throw new Exceptions\UserNotFoundException();
}
/**
* Get a `User` based on either a `UserId`, `Email`, or `Uuid`.
*
* @throws Exceptions\UserNotFoundException
*/
public static function GetByIdentifier(?string $identifier): User{
if($identifier === null){
throw new Exceptions\UserNotFoundException();
}
if(ctype_digit($identifier)){
return User::Get(intval($identifier));
}
elseif(mb_stripos($identifier, '@') !== false){
return User::GetByEmail($identifier);
}
elseif(mb_stripos($identifier, '-') !== false){
return User::GetByUuid($identifier);
}
else{
throw new Exceptions\UserNotFoundException();
}
}
/**
* @throws Exceptions\UserNotFoundException
*/
@ -145,6 +306,21 @@ class User{
', [$email], User::class)[0] ?? throw new Exceptions\UserNotFoundException();
}
/**
* @throws Exceptions\UserNotFoundException
*/
public static function GetByUuid(?string $uuid): User{
if($uuid === null){
throw new Exceptions\UserNotFoundException();
}
return Db::Query('
SELECT *
from Users
where Uuid = ?
', [$uuid], User::class)[0] ?? throw new Exceptions\UserNotFoundException();
}
/**
* Get a `User` if they are considered "registered".
*
@ -169,7 +345,7 @@ class User{
', [$identifier, $identifier], User::class)[0] ?? throw new Exceptions\UserNotFoundException();
if($user->PasswordHash !== null && $password === null){
// Indicate that a password is required before we log in
// Indicate that a password is required before we log in.
throw new Exceptions\PasswordRequiredException();
}
@ -179,4 +355,12 @@ class User{
return $user;
}
public function FillFromHttpPost(): void{
$this->PropertyFromHttp('Name');
$this->PropertyFromHttp('Email');
$this->PropertyFromHttp('Uuid');
$this->Benefits->FillFromHttpPost();
}
}

View file

@ -196,5 +196,11 @@ $isEditForm = $isEditForm ?? false;
</fieldset>
<? } ?>
<div class="footer">
<button><? if($isEditForm){ ?>Save changes<? }else{ ?>Submit<? } ?></button>
<button>
<? if($isEditForm){ ?>
Save changes
<? }else{ ?>
Submit
<? } ?>
</button>
</div>

View file

@ -41,7 +41,7 @@ $digits = str_split(str_pad((string)$current, 3, "0", STR_PAD_LEFT))
?>
<aside class="donation counter closable">
<? if($autoHide){ ?>
<form action="/settings" method="post">
<form action="/settings" method="<?= Enums\HttpMethod::Post->value ?>">
<input type="hidden" name="hide-donation-alert" value="true" />
<button class="close" title="Close this box">Close this box</button>
</form>

View file

@ -43,7 +43,7 @@ else{
?>
<aside class="donation closable">
<? if($autoHide){ ?>
<form action="/settings" method="post">
<form action="/settings" method="<?= Enums\HttpMethod::Post->value ?>">
<input type="hidden" name="hide-donation-alert" value="true" />
<button class="close" title="Close this box">Close this box</button>
</form>

View file

@ -5,7 +5,6 @@ $title = $title ?? '';
$highlight = $highlight ?? '';
$description = $description ?? '';
$manual = $manual ?? false;
$artwork = $artwork ?? false;
$colorScheme = $_COOKIE['color-scheme'] ?? 'auto';
$isXslt = $isXslt ?? false;
$feedUrl = $feedUrl ?? null;
@ -13,11 +12,11 @@ $feedTitle = $feedTitle ?? '';
$isErrorPage = $isErrorPage ?? false;
$downloadUrl = $downloadUrl ?? null;
$canonicalUrl = $canonicalUrl ?? null;
$css = $css ?? [];
// As of Sep 2022, all versions of Safari have a bug where if the page is served as XHTML,
// then <picture> elements download all <source>s instead of the first supported match.
// So, we try to detect Safari here, and don't use multiple <source> if we find Safari.
// See https://bugs.webkit.org/show_bug.cgi?id=245411
// As of Sep. 2022, all versions of Safari have a bug where if the page is served as XHTML, then `<picture>` elements download all `<source>`s instead of the first supported match.
// So, we try to detect Safari here, and don't use multiple `<source>` if we find Safari.
// See <https://bugs.webkit.org/show_bug.cgi?id=245411>.
$isSafari = stripos($_SERVER['HTTP_USER_AGENT'] ?? '', 'safari') !== false;
if(!$isXslt){
@ -56,8 +55,8 @@ if(!$isXslt){
<link href="/css/manual-dark.css?version=<?= filemtime(WEB_ROOT . '/css/manual-dark.css') ?>" media="screen<? if($colorScheme == 'auto'){ ?> and (prefers-color-scheme: dark)<? } ?>" rel="stylesheet" type="text/css"/>
<? } ?>
<? } ?>
<? if($artwork){ ?>
<link href="/css/artwork.css?version=<?= filemtime(WEB_ROOT . '/css/artwork.css') ?>" media="screen" rel="stylesheet" type="text/css"/>
<? foreach($css as $url){ ?>
<link href="<?= Formatter::EscapeHtml($url) ?>?version=<?= filemtime(WEB_ROOT . $url) ?>" media="screen" rel="stylesheet" type="text/css"/>
<? } ?>
<? if($canonicalUrl){ ?>
<link rel="canonical" href="<?= Formatter::EscapeHtml($canonicalUrl) ?>" />

149
templates/UserForm.php Normal file
View file

@ -0,0 +1,149 @@
<?
$user = $user ?? new User();
$isEditForm = $isEditForm ?? false;
$generateNewUuid = $generateNewUuid ?? false;
$passwordAction = $passwordAction ?? Enums\PasswordActionType::None;
?>
<label class="email">
Email
<input
type="email"
name="user-email"
required="required"
value="<?= Formatter::EscapeHtml($user->Email) ?>"
/>
</label>
<label class="user">
Name
<input
type="text"
name="user-name"
value="<?= Formatter::EscapeHtml($user->Name) ?>"
/>
</label>
<fieldset>
<label>
UUID
<input
type="text"
name="user-uuid"
value="<?= Formatter::EscapeHtml($user->Uuid) ?>"
/>
</label>
<label>
<input type="hidden" name="generate-new-uuid" value="false" />
<input type="checkbox" name="generate-new-uuid" value="true"<? if($generateNewUuid){ ?> checked="checked"<? } ?> />
Generate a new UUID
</label>
</fieldset>
<fieldset>
<ul>
<li>
<label>
<input type="radio" name="password-action" value="<?= Enums\PasswordActionType::None->value ?>"<? if($passwordAction == Enums\PasswordActionType::None){ ?> checked="checked"<? } ?> />Dont change password
</label>
</li>
<? if($user->PasswordHash === null){ ?>
<li>
<fieldset>
<label>
<input type="radio" name="password-action" value="<?= Enums\PasswordActionType::Edit->value ?>"<? if($passwordAction == Enums\PasswordActionType::Edit){ ?> checked="checked"<? } ?> />Create a new password
</label>
<label>
Password
<input
type="password"
name="user-password"
/>
</label>
</fieldset>
</li>
<? }else{ ?>
<li>
<label>
<input type="radio" name="password-action" value="<?= Enums\PasswordActionType::Delete->value ?>"<? if($passwordAction == Enums\PasswordActionType::Delete){ ?> checked="checked"<? } ?> />Remove password
</label>
</li>
<li>
<fieldset>
<label>
<input type="radio" name="password-action" value="<?= Enums\PasswordActionType::Edit->value ?>"<? if($passwordAction == Enums\PasswordActionType::Edit){ ?> checked="checked"<? } ?> />Change password
</label>
<label>
Password
<input
type="password"
name="user-password"
/>
</label>
</fieldset>
</li>
<? } ?>
</ul>
</fieldset>
<fieldset>
<legend>Benefits</legend>
<ul>
<li>
<label>
<input type="hidden" name="benefits-can-access-feeds" value="false" />
<input type="checkbox" name="benefits-can-access-feeds" value="true"<? if($user->Benefits->CanAccessFeeds){ ?> checked="checked"<? } ?> />
Can access feeds
</label>
</li>
<li>
<label>
<input type="hidden" name="benefits-can-vote" value="false" />
<input type="checkbox" name="benefits-can-vote" value="true"<? if($user->Benefits->CanVote){ ?> checked="checked"<? } ?> />
Can vote in polls
</label>
</li>
<li>
<label>
<input type="hidden" name="benefits-can-bulk-download" value="false" />
<input type="checkbox" name="benefits-can-bulk-download" value="true"<? if($user->Benefits->CanBulkDownload){ ?> checked="checked"<? } ?> />
Can access bulk downloads
</label>
</li>
<li>
<label>
<input type="hidden" name="benefits-can-upload-artwork" value="false" />
<input type="checkbox" name="benefits-can-upload-artwork" value="true"<? if($user->Benefits->CanUploadArtwork){ ?> checked="checked"<? } ?> />
Can upload artwork
</label>
</li>
<li>
<label>
<input type="hidden" name="benefits-can-review-artwork" value="false" />
<input type="checkbox" name="benefits-can-review-artwork" value="true"<? if($user->Benefits->CanReviewArtwork){ ?> checked="checked"<? } ?> />
Can review artwork
</label>
</li>
<li>
<label>
<input type="hidden" name="benefits-can-review-own-artwork" value="false" />
<input type="checkbox" name="benefits-can-review-own-artwork" value="true"<? if($user->Benefits->CanReviewOwnArtwork){ ?> checked="checked"<? } ?> />
Can review own artwork
</label>
</li>
<li>
<label>
<input type="hidden" name="benefits-can-edit-users" value="false" />
<input type="checkbox" name="benefits-can-edit-users" value="true"<? if($user->Benefits->CanEditUsers){ ?> checked="checked"<? } ?> />
Can edit users
</label>
</li>
</ul>
</fieldset>
<div class="footer">
<button>
<? if($isEditForm){ ?>
Save changes
<? }else{ ?>
Submit
<? } ?>
</button>
</div>

View file

@ -23,7 +23,7 @@ try{
catch(Exceptions\ArtistNotFoundException){
Template::Emit404();
}
?><?= Template::Header(['title' => 'Artwork by ' . $artworks[0]->Artist->Name, 'artwork' => true]) ?>
?><?= Template::Header(['title' => 'Artwork by ' . $artworks[0]->Artist->Name, 'css' => ['/css/artwork.css']]) ?>
<main class="artworks">
<section class="narrow">
<h1>Artwork by <?= Formatter::EscapeHtml($artworks[0]->Artist->Name) ?></h1>

View file

@ -3,10 +3,8 @@ use function Safe\session_unset;
session_start();
/** @var ?\Exception $exception */
$exception = $_SESSION['exception'] ?? null;
/** @var ?Artwork $artwork */
$artwork = $_SESSION['artwork'] ?? null;
$exception = HttpInput::SessionObject('exception', Exceptions\AppException::class);
$artwork = HttpInput::SessionObject('artwork', Artwork::class);
try{
if(Session::$User === null){
@ -41,7 +39,7 @@ catch(Exceptions\InvalidPermissionsException){
<?= Template::Header(
[
'title' => 'Edit ' . $artwork->Name . ', by ' . $artwork->Artist->Name,
'artwork' => true,
'css' => ['/css/artwork.css'],
'highlight' => '',
'description' => 'Edit ' . $artwork->Name . ', by ' . $artwork->Artist->Name . ' in the Standard Ebooks cover art database.'
]
@ -57,8 +55,8 @@ catch(Exceptions\InvalidPermissionsException){
<img src="<?= $artwork->ThumbUrl ?>" alt="" property="schema:image"/>
</picture>
<form class="create-update-artwork" method="post" action="<?= $artwork->Url ?>" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="_method" value="PUT" />
<form class="create-update-artwork" method="<?= Enums\HttpMethod::Post->value ?>" action="<?= $artwork->Url ?>" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="_method" value="<?= Enums\HttpMethod::Put->value ?>" />
<?= Template::ArtworkForm(['artwork' => $artwork, 'isEditForm' => true]) ?>
</form>
</section>

View file

@ -4,8 +4,7 @@ use function Safe\session_unset;
session_start();
$isSaved = HttpInput::Bool(SESSION, 'is-artwork-saved') ?? false;
/** @var ?\Exception $exception */
$exception = $_SESSION['exception'] ?? null;
$exception = HttpInput::SessionObject('exception', Exceptions\AppException::class);
try{
try{
@ -65,7 +64,7 @@ catch(Exceptions\InvalidPermissionsException){
Template::Emit403();
}
?><?= Template::Header(['title' => $artwork->Name, 'artwork' => true]) ?>
?><?= Template::Header(['title' => $artwork->Name, 'css' => ['/css/artwork.css']]) ?>
<main class="artworks">
<section class="narrow">
<h1><?= Formatter::EscapeHtml($artwork->Name) ?></h1>
@ -173,8 +172,8 @@ catch(Exceptions\InvalidPermissionsException){
<? if($artwork->CanStatusBeChangedBy(Session::$User)){ ?>
<p>Review the metadata and PD proof for this artwork submission. Approve to make it available for future producers. Once an artwork is approved, it can no longer be edited.</p>
<? } ?>
<form method="post" action="<?= $artwork->Url ?>" autocomplete="off">
<input type="hidden" name="_method" value="PATCH" />
<form method="<?= Enums\HttpMethod::Post->value ?>" action="<?= $artwork->Url ?>" autocomplete="off">
<input type="hidden" name="_method" value="<?= Enums\HttpMethod::Patch->value ?>" />
<? if($artwork->CanStatusBeChangedBy(Session::$User)){ ?>
<label>
<span>Artwork approval status</span>

View file

@ -135,7 +135,7 @@ catch(Exceptions\PageOutOfBoundsException){
header('Location: ' . $url);
exit();
}
?><?= Template::Header(['title' => $pageTitle, 'artwork' => true, 'description' => $pageDescription, 'canonicalUrl' => $canonicalUrl]) ?>
?><?= Template::Header(['title' => $pageTitle, 'css' => ['/css/artwork.css'], 'description' => $pageDescription, 'canonicalUrl' => $canonicalUrl]) ?>
<main class="artworks">
<section class="narrow">
<h1>Browse U.S. Public Domain Artwork</h1>

View file

@ -4,10 +4,8 @@ use function Safe\session_unset;
session_start();
$isCreated = HttpInput::Bool(SESSION, 'is-artwork-created') ?? false;
/** @var ?\Exception $exception */
$exception = $_SESSION['exception'] ?? null;
/** @var ?Artwork $artwork */
$artwork = $_SESSION['artwork'] ?? null;
$exception = HttpInput::SessionObject('exception', Exceptions\AppException::class);
$artwork = HttpInput::SessionObject('artwork', Artwork::class);
try{
if(Session::$User === null){
@ -51,7 +49,7 @@ catch(Exceptions\InvalidPermissionsException){
<?= Template::Header(
[
'title' => 'Submit an Artwork',
'artwork' => true,
'css' => ['/css/artwork.css'],
'highlight' => '',
'description' => 'Submit public domain artwork to the database for use as cover art.'
]
@ -66,7 +64,7 @@ catch(Exceptions\InvalidPermissionsException){
<p class="message success">Artwork submitted!</p>
<? } ?>
<form class="create-update-artwork" method="post" action="/artworks" enctype="multipart/form-data" autocomplete="off">
<form class="create-update-artwork" method="<?= Enums\HttpMethod::Post->value ?>" action="/artworks" enctype="multipart/form-data" autocomplete="off">
<?= Template::ArtworkForm(['artwork' => $artwork]) ?>
</form>
</section>

View file

@ -835,7 +835,7 @@ input[type="number"]:focus,
input[type="url"]:focus,
input[type="search"]:focus,
input[type="file"]:focus,
label.checkbox:focus-within,
label:has(input[type="radio"]):focus-within,
label:has(input[type="checkbox"]):focus-within,
select:focus,
button:focus,
@ -1829,20 +1829,24 @@ label span + span i{
input[type="file"],
label:has(input[type="file"]),
label:has(input[type="radio"]),
label:has(input[type="checkbox"]){
cursor: pointer;
}
label + label:has(input[type="radio"]),
label + label:has(input[type="checkbox"]){
margin-top: 1px; /* So we can see the top outline on focus */
}
label:has(input[type="radio"]):has(> span),
label:has(input[type="checkbox"]):has(> span){
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
}
label:has(input[type="radio"]):has(> span) input,
label:has(input[type="checkbox"]):has(> span) input{
grid-row: 1 / span 2;
justify-self: center;
@ -1897,6 +1901,7 @@ label.tags,
label.picture,
label.user,
label:has(input[type="password"]){
display: block;
position: relative;
max-width: 100%;
}
@ -2483,10 +2488,12 @@ fieldset{
border: none;
}
fieldset p{
fieldset p,
fieldset legend{
border-bottom: 1px dashed var(--input-border);
}
fieldset legend,
fieldset p:has(+ ul){
font-weight: bold;
}
@ -2495,7 +2502,8 @@ input[type="checkbox"] + span:has(+ span){
font-weight: bold;
}
label.checkbox{
label:has(input[type="radio"]),
label:has(input[type="checkbox"]){
display: inline-flex;
align-items: flex-start;
text-align: left;
@ -2503,20 +2511,24 @@ label.checkbox{
cursor: pointer;
}
label.checkbox input{
label:has(input[type="radio"]) input,
label:has(input[type="checkbox"]) input{
margin-right: .25rem;
}
label.checkbox span{
label:has(input[type="radio"]) span,
label:has(input[type="checkbox"]) span{
display: block;
}
label.checkbox span > span{
label:has(input[type="radio"]) span > span,
label:has(input[type="checkbox"]) span > span{
line-height: 1.6;
margin-top: .25rem;
}
label.checkbox span{
label:has(input[type="radio"]) span,
label:has(input[type="checkbox"]) span{
font-weight: normal;
}

34
www/css/user.css Normal file
View file

@ -0,0 +1,34 @@
label:has(input[name="password-action"]:not(:checked)) + label{
display: none;
}
label:has(input[name="password-action"]) + label{
margin-left: 1.5rem;
margin-top: .5rem;
}
table td{
padding: .5rem;
vertical-align: top;
}
table td:first-child{
font-weight: bold;
text-align: right;
white-space: nowrap;
}
table td + td{
width: 100%;
}
form{
display: flex;
flex-direction: column;
gap: 2rem;
}
legend{
width: 100%;
}

View file

@ -29,7 +29,7 @@ if($exception){
<?= Template::Error(['exception' => $exception]) ?>
<form action="/newsletter/subscriptions" method="post">
<form action="/newsletter/subscriptions" method="<?= Enums\HttpMethod::Post->value ?>">
<label class="automation-test"><? /* Test for spam bots filling out all fields */ ?>
<input type="text" name="automationtest" value="" maxlength="80" />
</label>
@ -47,10 +47,10 @@ if($exception){
<p>What kind of email would you like to receive?</p>
<ul>
<li>
<label class="checkbox"><input type="checkbox" value="1" name="issubscribedtonewsletter"<? if($subscription->IsSubscribedToNewsletter){ ?> checked="checked"<? } ?> />The occasional Standard Ebooks newsletter</label>
<label><input type="checkbox" value="1" name="issubscribedtonewsletter"<? if($subscription->IsSubscribedToNewsletter){ ?> checked="checked"<? } ?> />The occasional Standard Ebooks newsletter</label>
</li>
<li>
<label class="checkbox"><input type="checkbox" value="1" name="issubscribedtosummary"<? if($subscription->IsSubscribedToSummary){ ?> checked="checked"<? } ?> />A monthly summary of new ebook releases</label>
<label><input type="checkbox" value="1" name="issubscribedtosummary"<? if($subscription->IsSubscribedToSummary){ ?> checked="checked"<? } ?> />A monthly summary of new ebook releases</label>
</li>
</ul>
</fieldset>

View file

@ -4,20 +4,14 @@ use function Safe\session_unset;
session_start();
$poll = new Poll();
$vote = new PollVote();
/** @var ?\Exception $exception */
$exception = $_SESSION['exception'] ?? null;
$vote = HttpInput::SessionObject('vote', PollVote::class) ?? new PollVote();
$exception = HttpInput::SessionObject('exception', Exceptions\AppException::class);
try{
if(Session::$User === null){
throw new Exceptions\LoginRequiredException();
}
if(isset($_SESSION['vote'])){
/** @var PollVote $vote */
$vote = $_SESSION['vote'];
}
if(!isset($vote->UserId)){
$vote->UserId = Session::$User->UserId;
$vote->User = Session::$User;
@ -60,7 +54,7 @@ catch(Exceptions\PollVoteExistsException $ex){
<section class="narrow">
<h1>Vote in the <?= Formatter::EscapeHtml($poll->Name) ?> Poll</h1>
<?= Template::Error(['exception' => $exception]) ?>
<form method="post" action="<?= Formatter::EscapeHtml($poll->Url) ?>/votes">
<form method="<?= Enums\HttpMethod::Post->value ?>" action="<?= Formatter::EscapeHtml($poll->Url) ?>/votes">
<input type="hidden" name="email" value="<?= Formatter::EscapeHtml($vote->User->Email) ?>" maxlength="80" required="required" />
<fieldset>
<p>Select one of these options.</p>

View file

@ -39,7 +39,7 @@ if($exception){
<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>
<p><strong>Important:</strong> When making your donation, you must have selected either “List my name publicly” or “Dont list publicly, but reveal to project” on the donation form; otherwise, your email address isnt shared with us, and we cant include you in our login system.</p>
<? } ?>
<form method="post" action="/sessions" class="single-row">
<form method="<?= Enums\HttpMethod::Post->value ?>" action="/sessions" class="single-row">
<input type="hidden" name="redirect" value="<?= Formatter::EscapeHtml($redirect) ?>" />
<? if($passwordRequired){ ?>
<input type="hidden" name="email" value="<?= Formatter::EscapeHtml($email) ?>" maxlength="80" required="required" />

View file

@ -4,7 +4,7 @@ $colorScheme = $_COOKIE['color-scheme'] ?? 'auto';
?><?= Template::Header(['title' => 'Website Settings', 'description' => 'Adjust your settings for viewing the Standard Ebooks website.']) ?>
<main>
<h1>Website Settings</h1>
<form action="/settings" method="post">
<form action="/settings" method="<?= Enums\HttpMethod::Post->value ?>">
<label>
<span>Color scheme</span>
<span>

59
www/users/edit.php Normal file
View file

@ -0,0 +1,59 @@
<?
use function Safe\session_unset;
session_start();
$exception = HttpInput::SessionObject('exception', Exceptions\AppException::class);
$user = HttpInput::SessionObject('user', User::class);
$generateNewUuid = HttpInput::Bool(SESSION, 'generate-new-uuid') ?? false;
$passwordAction = HttpInput::SessionObject('password-action', Enums\PasswordActionType::class) ?? Enums\PasswordActionType::None;
try{
if($user === null){
$user = User::Get(HttpInput::Int(GET, 'user-id'));
}
if(Session::$User === null){
throw new Exceptions\LoginRequiredException();
}
if(!Session::$User->Benefits->CanReviewOwnArtwork){
throw new Exceptions\InvalidPermissionsException();
}
// We got here because a `User` update had errors and the user has to try again.
if($exception){
http_response_code(Enums\HttpCode::UnprocessableContent->value);
session_unset();
}
}
catch(Exceptions\UserNotFoundException){
Template::Emit404();
}
catch(Exceptions\LoginRequiredException){
Template::RedirectToLogin();
}
catch(Exceptions\InvalidPermissionsException){
Template::Emit403(); // No permissions to edit artwork.
}
?>
<?= Template::Header(
[
'title' => 'Edit user #' . $user->UserId,
'css' => ['/css/user.css'],
'highlight' => ''
]
) ?>
<main>
<section class="narrow">
<h1>Edit User #<?= $user->UserId ?></h1>
<?= Template::Error(['exception' => $exception]) ?>
<form class="create-update-artwork" method="<?= Enums\HttpMethod::Post->value ?>" action="<?= $user->Url ?>" autocomplete="off">
<input type="hidden" name="_method" value="<?= Enums\HttpMethod::Patch->value ?>" />
<?= Template::UserForm(['user' => $user, 'isEditForm' => true, 'generateNewUuid' => $generateNewUuid, 'passwordAction' => $passwordAction]) ?>
</form>
</section>
</main>
<?= Template::Footer() ?>

157
www/users/get.php Normal file
View file

@ -0,0 +1,157 @@
<?
use function Safe\session_unset;
session_start();
$isSaved = HttpInput::Bool(SESSION, 'is-user-saved') ?? false;
try{
$user = User::GetByIdentifier(HttpInput::Str(GET, 'user-identifier'));
// Even though the identifier can be either an email, user ID, or UUID, we want the URL of this page to be based on a user ID only.
if(!ctype_digit(HttpInput::Str(GET, 'user-identifier'))){
throw new Exceptions\SeeOtherException($user->Url);
}
if(Session::$User === null){
throw new Exceptions\LoginRequiredException();
}
if(!Session::$User->Benefits->CanEditUsers){
throw new Exceptions\InvalidPermissionsException();
}
// We got here because a `User` was successfully saved.
if($isSaved){
session_unset();
}
}
catch(Exceptions\UserNotFoundException){
Template::Emit404();
}
catch(Exceptions\LoginRequiredException){
Template::RedirectToLogin();
}
catch(Exceptions\InvalidPermissionsException){
Template::Emit403();
}
catch(Exceptions\SeeOtherException $ex){
http_response_code(Enums\HttpCode::SeeOther->value);
header('Location: ' . $ex->Url);
}
?><?= Template::Header(['title' => 'User #' . $user->UserId, 'css' => ['/css/user.css']]) ?>
<main>
<section class="narrow">
<h1>User #<?= $user->UserId ?></h1>
<? if($isSaved){ ?>
<p class="message success">User saved!</p>
<? } ?>
<a href="<?= $user->Url ?>/edit">Edit user</a>
<h2>Basics</h2>
<table>
<tbody>
<tr>
<td>Email:</td>
<td><?= Formatter::EscapeHtml($user->Email) ?></td>
</tr>
<tr>
<td>Name:</td>
<td><?= Formatter::EscapeHtml($user->Name) ?></td>
</tr>
<tr>
<td>UUID:</td>
<td><?= Formatter::EscapeHtml($user->Uuid) ?></td>
</tr>
<tr>
<td>Created:</td>
<td><?= $user->Created->Format(Enums\DateTimeFormat::FullDateTime->value) ?></td>
</tr>
</tbody>
</table>
<h2>Patron info</h2>
<table>
<tbody>
<tr>
<td>Is Patron:</td>
<td><? if($user->IsPatron){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<? if($user->IsPatron && $user->Patron !== null){ ?>
<tr>
<td>Created:</td>
<td><?= $user->Patron->Created->format(Enums\DateTimeFormat::FullDateTime->value) ?></td>
</tr>
<tr>
<td>Is anonymous:</td>
<td><? if($user->Patron->IsAnonymous){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<? if($user->Patron->AlternateName !== null){ ?>
<tr>
<td>Alternate credit:</td>
<td><?= Formatter::EscapeHtml($user->Patron->AlternateName) ?></td>
</tr>
<? } ?>
<? } ?>
</tbody>
</table>
<h2>Newsletter subscriptions</h2>
<? if($user->NewsletterSubscription === null || (!$user->NewsletterSubscription->IsSubscribedToNewsletter && !$user->NewsletterSubscription->IsSubscribedToSummary)){ ?>
<p>None.</p>
<? }else{ ?>
<ul>
<? if($user->NewsletterSubscription->IsSubscribedToNewsletter){ ?>
<li>General newsletter</li>
<? } ?>
<? if($user->NewsletterSubscription->IsSubscribedToSummary){ ?>
<li>Monthly summary newsletter</li>
<? } ?>
</ul>
<? } ?>
<h2>Registration info</h2>
<table>
<tbody>
<tr>
<td>Is registered:</td>
<td><? if($user->IsRegistered){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<? if($user->IsRegistered){ ?>
<tr>
<td>Can access feeds:</td>
<td><? if($user->Benefits->CanAccessFeeds){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<tr>
<td>Can vote:</td>
<td><? if($user->Benefits->CanVote){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<tr>
<td>Can bulk download:</td>
<td><? if($user->Benefits->CanBulkDownload){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<tr>
<td>Can upload artwork:</td>
<td><? if($user->Benefits->CanUploadArtwork){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<tr>
<td>Can review artwork:</td>
<td><? if($user->Benefits->CanReviewArtwork){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<tr>
<td>Can review own artwork:</td>
<td><? if($user->Benefits->CanReviewOwnArtwork){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<tr>
<td>Can edit users:</td>
<td><? if($user->Benefits->CanEditUsers){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<? } ?>
</tbody>
</table>
</section>
</main>
<?= Template::Footer() ?>

83
www/users/post.php Normal file
View file

@ -0,0 +1,83 @@
<?
try{
session_start();
$httpMethod = HttpInput::ValidateRequestMethod([Enums\HttpMethod::Patch]);
$exceptionRedirectUrl = '/users';
$user = User::Get(HttpInput::Int(GET, 'user-id'));
if(Session::$User === null){
throw new Exceptions\LoginRequiredException();
}
if(!Session::$User->Benefits->CanEditUsers){
throw new Exceptions\InvalidPermissionsException();
}
// PATCHing a `User`.
if($httpMethod == Enums\HttpMethod::Patch){
$exceptionRedirectUrl = $user->Url . '/edit';
$user->FillFromHttpPost();
$generateNewUuid = HttpInput::Bool(POST, 'generate-new-uuid') ?? false;
if($generateNewUuid){
$oldUuid = $user->Uuid;
$user->GenerateUuid();
}
$passwordAction = Enums\PasswordActionType::tryFrom(HttpInput::Str(POST, 'password-action') ?? '') ?? Enums\PasswordActionType::None;
$oldPasswordHash = $user->PasswordHash;
switch($passwordAction){
case Enums\PasswordActionType::Delete:
$user->PasswordHash = null;
break;
case Enums\PasswordActionType::Edit:
$password = HttpInput::Str(POST, 'user-password');
if($password !== null){
$user->PasswordHash = password_hash($password, PASSWORD_DEFAULT);
}
break;
}
$user->Save();
$_SESSION['is-user-saved'] = true;
http_response_code(Enums\HttpCode::SeeOther->value);
header('Location: ' . $user->Url);
}
}
catch(Exceptions\LoginRequiredException){
Template::RedirectToLogin();
}
catch(Exceptions\InvalidPermissionsException){
Template::Emit403();
}
catch(Exceptions\UserNotFoundException){
Template::Emit404();
}
catch(Exceptions\InvalidUserException | Exceptions\UserExistsException $ex){
if($generateNewUuid){
$user->Uuid = $oldUuid;
$_SESSION['generate-new-uuid'] = $generateNewUuid;
}
$_SESSION['password-action'] = $passwordAction;
if($ex instanceof Exceptions\InvalidUserException && $ex->Has(Exceptions\BenefitsRequirePasswordException::class)){
$user->PasswordHash = $oldPasswordHash;
}
$_SESSION['user'] = $user;
$_SESSION['exception'] = $ex;
http_response_code(Enums\HttpCode::SeeOther->value);
header('Location: ' . $exceptionRedirectUrl);
}