diff --git a/.gitignore b/.gitignore index b4af30f6..add824c1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ vendor/ *.log www/manual/* !www/manual/index.php +config/apache/htpasswd-standardebooks.org config/php/fpm/standardebooks.org-secrets.ini www/bulk-downloads/*/ diff --git a/composer.json b/composer.json index 87123d10..26463747 100644 --- a/composer.json +++ b/composer.json @@ -11,11 +11,12 @@ "php": "8.1.2" }, "require": { - "thecodingmachine/safe": "^1.3.3", + "thecodingmachine/safe": "^2.5.0", "phpmailer/phpmailer": "^6.6.0", "ramsey/uuid": "4.2.3", "gregwar/captcha": "^1.2.0", "php-webdriver/webdriver": "^1.12.1", - "pear/http2": "^2.0.0" + "pear/http2": "^2.0.0", + "erusev/parsedown": "^1.7.4" } } diff --git a/composer.lock b/composer.lock index 21d76490..728be8fa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "218ea78082c83c5386eacaf3dc607eed", + "content-hash": "4ea5d2eba58ce5fbb4162f1bdd585d73", "packages": [ { "name": "brick/math", @@ -67,17 +67,67 @@ "time": "2021-08-15T20:50:18+00:00" }, { - "name": "gregwar/captcha", - "version": "v1.2.0", + "name": "erusev/parsedown", + "version": "1.7.4", "source": { "type": "git", - "url": "https://github.com/Gregwar/Captcha.git", - "reference": "6e5b61b66ac89885b505153f4ef9a74ffa5b3074" + "url": "https://github.com/erusev/parsedown.git", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Gregwar/Captcha/zipball/6e5b61b66ac89885b505153f4ef9a74ffa5b3074", - "reference": "6e5b61b66ac89885b505153f4ef9a74ffa5b3074", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "Parsedown": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "Parser for Markdown.", + "homepage": "http://parsedown.org", + "keywords": [ + "markdown", + "parser" + ], + "support": { + "issues": "https://github.com/erusev/parsedown/issues", + "source": "https://github.com/erusev/parsedown/tree/1.7.x" + }, + "time": "2019-12-30T22:54:17+00:00" + }, + { + "name": "gregwar/captcha", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/Gregwar/Captcha.git", + "reference": "229d3cdfe33d6f1349e0aec94a26e9205a6db08e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Gregwar/Captcha/zipball/229d3cdfe33d6f1349e0aec94a26e9205a6db08e", + "reference": "229d3cdfe33d6f1349e0aec94a26e9205a6db08e", "shasum": "" }, "require": { @@ -119,9 +169,9 @@ ], "support": { "issues": "https://github.com/Gregwar/Captcha/issues", - "source": "https://github.com/Gregwar/Captcha/tree/v1.2.0" + "source": "https://github.com/Gregwar/Captcha/tree/v1.2.1" }, - "time": "2023-03-24T22:12:41+00:00" + "time": "2023-09-26T13:45:37+00:00" }, { "name": "pear/http2", @@ -172,16 +222,16 @@ }, { "name": "php-webdriver/webdriver", - "version": "1.14.0", + "version": "1.15.1", "source": { "type": "git", "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "3ea4f924afb43056bf9c630509e657d951608563" + "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/3ea4f924afb43056bf9c630509e657d951608563", - "reference": "3ea4f924afb43056bf9c630509e657d951608563", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/cd52d9342c5aa738c2e75a67e47a1b6df97154e8", + "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8", "shasum": "" }, "require": { @@ -190,7 +240,7 @@ "ext-zip": "*", "php": "^7.3 || ^8.0", "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0" + "symfony/process": "^5.0 || ^6.0 || ^7.0" }, "replace": { "facebook/webdriver": "*" @@ -232,22 +282,22 @@ ], "support": { "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.14.0" + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.1" }, - "time": "2023-02-09T12:12:19+00:00" + "time": "2023-10-20T12:21:20+00:00" }, { "name": "phpmailer/phpmailer", - "version": "v6.8.0", + "version": "v6.9.1", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "df16b615e371d81fb79e506277faea67a1be18f1" + "reference": "039de174cd9c17a8389754d3b877a2ed22743e18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/df16b615e371d81fb79e506277faea67a1be18f1", - "reference": "df16b615e371d81fb79e506277faea67a1be18f1", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/039de174cd9c17a8389754d3b877a2ed22743e18", + "reference": "039de174cd9c17a8389754d3b877a2ed22743e18", "shasum": "" }, "require": { @@ -257,16 +307,17 @@ "php": ">=5.5.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "doctrine/annotations": "^1.2.6 || ^1.13.3", "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", "phpcompatibility/php-compatibility": "^9.3.5", "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.7.1", + "squizlabs/php_codesniffer": "^3.7.2", "yoast/phpunit-polyfills": "^1.0.4" }, "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", "ext-openssl": "Needed for secure SMTP sending and DKIM signing", "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", @@ -306,7 +357,7 @@ "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.8.0" + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.1" }, "funding": [ { @@ -314,7 +365,7 @@ "type": "github" } ], - "time": "2023-03-06T14:43:22+00:00" + "time": "2023-11-25T22:23:28+00:00" }, { "name": "ramsey/collection", @@ -506,23 +557,23 @@ }, { "name": "symfony/finder", - "version": "v6.3.0", + "version": "v6.4.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "d9b01ba073c44cef617c7907ce2419f8d00d75e2" + "reference": "11d736e97f116ac375a81f96e662911a34cd50ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d9b01ba073c44cef617c7907ce2419f8d00d75e2", - "reference": "d9b01ba073c44cef617c7907ce2419f8d00d75e2", + "url": "https://api.github.com/repos/symfony/finder/zipball/11d736e97f116ac375a81f96e662911a34cd50ce", + "reference": "11d736e97f116ac375a81f96e662911a34cd50ce", "shasum": "" }, "require": { "php": ">=8.1" }, "require-dev": { - "symfony/filesystem": "^6.0" + "symfony/filesystem": "^6.0|^7.0" }, "type": "library", "autoload": { @@ -550,7 +601,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.3.0" + "source": "https://github.com/symfony/finder/tree/v6.4.0" }, "funding": [ { @@ -566,20 +617,20 @@ "type": "tidelift" } ], - "time": "2023-04-02T01:25:41+00:00" + "time": "2023-10-31T17:30:12+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "5bbc823adecdae860bb64756d639ecfec17b050a" + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a", - "reference": "5bbc823adecdae860bb64756d639ecfec17b050a", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", "shasum": "" }, "require": { @@ -594,7 +645,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -632,7 +683,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" }, "funding": [ { @@ -648,20 +699,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" + "reference": "42292d99c55abe617799667f454222c54c60e229" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", - "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", + "reference": "42292d99c55abe617799667f454222c54c60e229", "shasum": "" }, "require": { @@ -676,7 +727,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -715,7 +766,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" }, "funding": [ { @@ -731,20 +782,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-07-28T09:04:16+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" + "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", "shasum": "" }, "require": { @@ -753,7 +804,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -798,7 +849,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" }, "funding": [ { @@ -814,20 +865,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a" + "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/707403074c8ea6e2edaf8794b0157a0bfa52157a", - "reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/7581cd600fa9fd681b797d00b02f068e2f13263b", + "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b", "shasum": "" }, "require": { @@ -836,7 +887,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -877,7 +928,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.28.0" }, "funding": [ { @@ -893,20 +944,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/process", - "version": "v6.3.0", + "version": "v6.4.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628" + "reference": "c4b1ef0bc80533d87a2e969806172f1c2a980241" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/8741e3ed7fe2e91ec099e02446fb86667a0f1628", - "reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628", + "url": "https://api.github.com/repos/symfony/process/zipball/c4b1ef0bc80533d87a2e969806172f1c2a980241", + "reference": "c4b1ef0bc80533d87a2e969806172f1c2a980241", "shasum": "" }, "require": { @@ -938,7 +989,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.3.0" + "source": "https://github.com/symfony/process/tree/v6.4.2" }, "funding": [ { @@ -954,43 +1005,50 @@ "type": "tidelift" } ], - "time": "2023-05-19T08:06:44+00:00" + "time": "2023-12-22T16:42:54+00:00" }, { "name": "thecodingmachine/safe", - "version": "v1.3.3", + "version": "v2.5.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc" + "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc", - "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/3115ecd6b4391662b4931daac4eba6b07a2ac1f0", + "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0", "shasum": "" }, "require": { - "php": ">=7.2" + "php": "^8.0" }, "require-dev": { - "phpstan/phpstan": "^0.12", + "phpstan/phpstan": "^1.5", + "phpunit/phpunit": "^9.5", "squizlabs/php_codesniffer": "^3.2", - "thecodingmachine/phpstan-strict-rules": "^0.12" + "thecodingmachine/phpstan-strict-rules": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "0.1-dev" + "dev-master": "2.2.x-dev" } }, "autoload": { "files": [ "deprecated/apc.php", + "deprecated/array.php", + "deprecated/datetime.php", "deprecated/libevent.php", + "deprecated/misc.php", + "deprecated/password.php", "deprecated/mssql.php", "deprecated/stats.php", + "deprecated/strings.php", "lib/special_cases.php", + "deprecated/mysqli.php", "generated/apache.php", "generated/apcu.php", "generated/array.php", @@ -1011,6 +1069,7 @@ "generated/fpm.php", "generated/ftp.php", "generated/funchand.php", + "generated/gettext.php", "generated/gmp.php", "generated/gnupg.php", "generated/hash.php", @@ -1020,7 +1079,6 @@ "generated/image.php", "generated/imap.php", "generated/info.php", - "generated/ingres-ii.php", "generated/inotify.php", "generated/json.php", "generated/ldap.php", @@ -1029,20 +1087,14 @@ "generated/mailparse.php", "generated/mbstring.php", "generated/misc.php", - "generated/msql.php", "generated/mysql.php", - "generated/mysqli.php", - "generated/mysqlndMs.php", - "generated/mysqlndQc.php", "generated/network.php", "generated/oci8.php", "generated/opcache.php", "generated/openssl.php", "generated/outcontrol.php", - "generated/password.php", "generated/pcntl.php", "generated/pcre.php", - "generated/pdf.php", "generated/pgsql.php", "generated/posix.php", "generated/ps.php", @@ -1053,7 +1105,6 @@ "generated/sem.php", "generated/session.php", "generated/shmop.php", - "generated/simplexml.php", "generated/sockets.php", "generated/sodium.php", "generated/solr.php", @@ -1076,13 +1127,13 @@ "generated/zip.php", "generated/zlib.php" ], - "psr-4": { - "Safe\\": [ - "lib/", - "deprecated/", - "generated/" - ] - } + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "deprecated/Exceptions/", + "generated/Exceptions/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1091,24 +1142,24 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v1.3.3" + "source": "https://github.com/thecodingmachine/safe/tree/v2.5.0" }, - "time": "2020-10-28T17:51:34+00:00" + "time": "2023-04-05T11:54:14+00:00" } ], "packages-dev": [ { "name": "phpstan/phpstan", - "version": "1.10.21", + "version": "1.10.50", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "b2a30186be2e4d97dce754ae4e65eb0ec2f04eb5" + "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b2a30186be2e4d97dce754ae4e65eb0ec2f04eb5", - "reference": "b2a30186be2e4d97dce754ae4e65eb0ec2f04eb5", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/06a98513ac72c03e8366b5a0cb00750b487032e4", + "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4", "shasum": "" }, "require": { @@ -1157,7 +1208,7 @@ "type": "tidelift" } ], - "time": "2023-06-21T20:07:58+00:00" + "time": "2023-12-13T10:59:42+00:00" }, { "name": "thecodingmachine/phpstan-safe-rule", diff --git a/config/apache/standardebooks.org.conf b/config/apache/standardebooks.org.conf index aa26ed81..a1b8e7f8 100644 --- a/config/apache/standardebooks.org.conf +++ b/config/apache/standardebooks.org.conf @@ -295,6 +295,15 @@ Define webroot /standardebooks.org/web RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1 RewriteRule ^/bulk-downloads/([^/\.]+)$ /bulk-downloads/collection.php?class=$1 + # Rewrite rules for cover art + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^get$/" + RewriteRule ^/admin/artworks/([^/\.]+)$ /admin/artworks/get.php?artworkid=$1 [L] + + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/" + RewriteRule ^/admin/artworks/([^/\.]+)$ /admin/artworks/post.php?artworkid=$1 [L] + + RewriteRule ^/artworks/([^/\.]+)/([^/\.]+)$ /artworks/get.php?artist=$1&artwork=$2 [L] + # Specific config for /bulk-downloads # Both directives are required diff --git a/config/apache/standardebooks.test.conf b/config/apache/standardebooks.test.conf index 35cab4ed..78c68668 100644 --- a/config/apache/standardebooks.test.conf +++ b/config/apache/standardebooks.test.conf @@ -277,6 +277,15 @@ Define webroot /standardebooks.org/web RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1 RewriteRule ^/bulk-downloads/([^/\.]+)$ /bulk-downloads/collection.php?class=$1 + # Rewrite rules for cover art + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^get$/" + RewriteRule ^/admin/artworks/([^/\.]+)$ /admin/artworks/get.php?artworkid=$1 [L] + + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/" + RewriteRule ^/admin/artworks/([^/\.]+)$ /admin/artworks/post.php?artworkid=$1 [L] + + RewriteRule ^/artworks/([^/\.]+)/([^/\.]+)$ /artworks/get.php?artist=$1&artwork=$2 [L] + # Specific config for /bulk-downloads # Both directives are required diff --git a/config/php/fpm/standardebooks.org.ini b/config/php/fpm/standardebooks.org.ini index 16c45772..f2d6be81 100644 --- a/config/php/fpm/standardebooks.org.ini +++ b/config/php/fpm/standardebooks.org.ini @@ -10,6 +10,9 @@ allow_url_fopen = false allow_url_include = false expose_php = Off +post_max_size = 64M +upload_max_filesize = 32M + [Date] date.timezone = Etc/UTC diff --git a/config/php/fpm/standardebooks.test.ini b/config/php/fpm/standardebooks.test.ini index 5fe0a292..aed1b5f6 100644 --- a/config/php/fpm/standardebooks.test.ini +++ b/config/php/fpm/standardebooks.test.ini @@ -7,6 +7,9 @@ allow_url_fopen = false allow_url_include = false expose_php = Off +post_max_size = 64M +upload_max_filesize = 32M + [Date] date.timezone = Etc/UTC diff --git a/config/phpstan/phpstan.neon b/config/phpstan/phpstan.neon index c5b5d7c1..2a15c4cd 100644 --- a/config/phpstan/phpstan.neon +++ b/config/phpstan/phpstan.neon @@ -13,9 +13,6 @@ parameters: # Ignore errors caused by type hints that should be union types. Union types are not yet supported in PHP. - '#Function vd(s|d)?\(\) has parameter \$var with no type specified.#' - - '#Method Ebook::NullIfEmpty\(\) has parameter \$elements with no type specified.#' - - '#Method HttpInput::GetHttpVar\(\) has no return type specified.#' - - '#Method HttpInput::GetHttpVar\(\) has parameter \$default with no type specified.#' level: 8 paths: @@ -28,3 +25,8 @@ parameters: - DONATION_ALERT_ON - DONATION_DRIVE_ON - DONATION_DRIVE_COUNTER_ON + earlyTerminatingMethodCalls: + Template: + - Emit404 + - Emit403 + - RedirectToLogin diff --git a/config/sql/se/ArtistAlternateSpellings.sql b/config/sql/se/ArtistAlternateSpellings.sql new file mode 100644 index 00000000..c0d2915f --- /dev/null +++ b/config/sql/se/ArtistAlternateSpellings.sql @@ -0,0 +1,5 @@ +CREATE TABLE `ArtistAlternateSpellings` ( + `ArtistId` int(10) unsigned NOT NULL, + `AlternateSpelling` varchar(255) NOT NULL, + UNIQUE KEY `idxUnique` (`ArtistId`,`AlternateSpelling`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/Artists.sql b/config/sql/se/Artists.sql new file mode 100644 index 00000000..7915b97d --- /dev/null +++ b/config/sql/se/Artists.sql @@ -0,0 +1,10 @@ +CREATE TABLE `Artists` ( + `ArtistId` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(191) NOT NULL, + `UrlName` varchar(255) NOT NULL, + `DeathYear` smallint unsigned NULL, + `Created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `Updated` timestamp NOT NULL, + PRIMARY KEY (`ArtistId`), + UNIQUE KEY `idxUnique` (`UrlName`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/ArtworkTags.sql b/config/sql/se/ArtworkTags.sql new file mode 100644 index 00000000..263bcfc1 --- /dev/null +++ b/config/sql/se/ArtworkTags.sql @@ -0,0 +1,5 @@ +CREATE TABLE `ArtworkTags` ( + `ArtworkId` int(10) unsigned NOT NULL, + `TagId` int(10) unsigned NOT NULL, + UNIQUE KEY `idxUnique` (`ArtworkId`,`TagId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/Artworks.sql b/config/sql/se/Artworks.sql new file mode 100644 index 00000000..e7e7d4dc --- /dev/null +++ b/config/sql/se/Artworks.sql @@ -0,0 +1,24 @@ +CREATE TABLE `Artworks` ( + `ArtworkId` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ArtistId` int(10) unsigned NOT NULL, + `Name` varchar(255) NOT NULL, + `UrlName` varchar(255) NOT NULL, + `CompletedYear` smallint unsigned NULL, + `CompletedYearIsCirca` boolean NOT NULL DEFAULT FALSE, + `Created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `Updated` timestamp NOT NULL, + `Status` enum('unverified', 'approved', 'declined', 'in_use') DEFAULT 'unverified', + `ReviewerUserId` int(10) unsigned NULL, + `MuseumUrl` varchar(255) NULL, + `PublicationYear` smallint unsigned NULL, + `PublicationYearPageUrl` varchar(255) NULL, + `CopyrightPageUrl` varchar(255) NULL, + `ArtworkPageUrl` varchar(255) NULL, + `IsPublishedInUs` tinyint(1) NOT NULL DEFAULT FALSE, + `EbookWwwFilesystemPath` varchar(255) NULL, + `MimeType` varchar(64) NOT NULL, + `Exception` TEXT NULL DEFAULT NULL, + PRIMARY KEY (`ArtworkId`), + KEY `index1` (`Status`), + KEY `index2` (`UrlName`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/Benefits.sql b/config/sql/se/Benefits.sql index f1862baf..079d6bb3 100644 --- a/config/sql/se/Benefits.sql +++ b/config/sql/se/Benefits.sql @@ -3,6 +3,8 @@ CREATE TABLE `Benefits` ( `CanAccessFeeds` tinyint(1) unsigned NOT NULL, `CanVote` tinyint(1) unsigned NOT NULL, `CanBulkDownload` tinyint(1) unsigned NOT NULL, + `CanAddArtwork` tinyint(1) unsigned NOT NULL, + `CanReviewArtwork` tinyint(1) unsigned NOT NULL, PRIMARY KEY (`UserId`), KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/FeedUserAgents.sql b/config/sql/se/FeedUserAgents.sql index 94c6b8c8..227b56fe 100644 --- a/config/sql/se/FeedUserAgents.sql +++ b/config/sql/se/FeedUserAgents.sql @@ -1,5 +1,5 @@ CREATE TABLE `FeedUserAgents` ( - `UserAgentId` int(11) unsigned NOT NULL AUTO_INCREMENT, + `UserAgentId` int(10) unsigned NOT NULL AUTO_INCREMENT, `UserAgent` text NOT NULL, `Created` datetime NOT NULL, PRIMARY KEY (`UserAgentId`) diff --git a/config/sql/se/Museums.sql b/config/sql/se/Museums.sql new file mode 100644 index 00000000..2d71cdf3 --- /dev/null +++ b/config/sql/se/Museums.sql @@ -0,0 +1,6 @@ +CREATE TABLE `Museums` ( + `MuseumId` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(255) NOT NULL, + `Domain` varchar(255) NOT NULL, + PRIMARY KEY (`MuseumId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/PollVotes.sql b/config/sql/se/PollVotes.sql index 5171e6de..1686d131 100644 --- a/config/sql/se/PollVotes.sql +++ b/config/sql/se/PollVotes.sql @@ -1,6 +1,6 @@ CREATE TABLE `PollVotes` ( - `UserId` int(11) unsigned NOT NULL, - `PollItemId` int(11) unsigned NOT NULL, + `UserId` int(10) unsigned NOT NULL, + `PollItemId` int(10) unsigned NOT NULL, `Created` datetime NOT NULL, UNIQUE KEY `idxUnique` (`PollItemId`,`UserId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/Polls.sql b/config/sql/se/Polls.sql index aabaafc7..484bb28a 100644 --- a/config/sql/se/Polls.sql +++ b/config/sql/se/Polls.sql @@ -1,5 +1,5 @@ CREATE TABLE `Polls` ( - `PollId` int(11) unsigned NOT NULL AUTO_INCREMENT, + `PollId` int(10) unsigned NOT NULL AUTO_INCREMENT, `Created` datetime NOT NULL, `Name` varchar(255) NOT NULL, `UrlName` varchar(255) NOT NULL, diff --git a/config/sql/se/Tags.sql b/config/sql/se/Tags.sql new file mode 100644 index 00000000..4e232c34 --- /dev/null +++ b/config/sql/se/Tags.sql @@ -0,0 +1,6 @@ +CREATE TABLE `Tags` ( + `TagId` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(255) NOT NULL, + PRIMARY KEY (`TagId`), + UNIQUE KEY `idxUnique` (`Name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/lib/Artist.php b/lib/Artist.php new file mode 100644 index 00000000..8df1f1dd --- /dev/null +++ b/lib/Artist.php @@ -0,0 +1,153 @@ + $AlternateSpellings + */ +class Artist extends PropertiesBase{ + public $ArtistId; + public $Name; + public $DeathYear; + public $Created; + public $Updated; + protected $_UrlName; + protected $_AlternateSpellings; + + // ******* + // GETTERS + // ******* + + /** + * @return string + */ + protected function GetUrlName(): string{ + if($this->Name === null || $this->Name == ''){ + return ''; + } + + if($this->_UrlName === null){ + $this->_UrlName = Formatter::MakeUrlSafe($this->Name); + } + + return $this->_UrlName; + } + + /** + * @return array + */ + protected function GetAlternateSpellings(): array{ + if($this->_AlternateSpellings === null){ + $this->_AlternateSpellings = []; + + $result = Db::Query(' + SELECT * + from ArtistAlternateSpellings + where ArtistId = ? + ', [$this->ArtistId], 'stdClass'); + + foreach($result as $row){ + $this->_AlternateSpellings[] = $row->AlternateSpelling; + } + } + + return $this->_AlternateSpellings; + } + + // ******* + // METHODS + // ******* + + public function Validate(): void{ + $error = new Exceptions\ValidationException(); + + if($this->Name === null || $this->Name == ''){ + $error->Add(new Exceptions\ArtistNameRequiredException()); + } + + if($this->Name !== null && strlen($this->Name) > COVER_ARTWORK_MAX_STRING_LENGTH){ + $error->Add(new Exceptions\StringTooLongException('Artist Name')); + } + + if($this->DeathYear !== null && ($this->DeathYear <= 0 || $this->DeathYear > intval(date('Y')))){ + $error->Add(new Exceptions\InvalidDeathYearException()); + } + + if($error->HasExceptions){ + throw $error; + } + } + // *********** + // ORM METHODS + // *********** + + public static function Get(?int $artistId): Artist{ + if($artistId === null){ + throw new Exceptions\InvalidArtistException(); + } + + $result = Db::Query(' + SELECT * + from Artists + where ArtistId = ? + ', [$artistId], 'Artist'); + + if(sizeof($result) == 0){ + throw new Exceptions\InvalidArtistException(); + } + + return $result[0]; + } + + public function Create(): void{ + $this->Validate(); + Db::Query(' + INSERT into Artists (Name, UrlName, DeathYear) + values (?, + ?, + ?) + ', [$this->Name, $this->UrlName, $this->DeathYear]); + + $this->ArtistId = Db::GetLastInsertedId(); + } + + /** + * @throws \Exceptions\ValidationException + */ + public static function GetOrCreate(Artist $artist): Artist{ + $result = Db::Query(' + SELECT * + from Artists + where UrlName = ? + ', [$artist->UrlName], 'Artist'); + + if(isset($result[0])){ + return $result[0]; + } + else{ + $artist->Create(); + return $artist; + } + } + + public static function FindMatch(string $artistName): ?Artist{ + $result = Db::Query(' + SELECT a.* + from Artists a left join ArtistAlternateSpellings alt using (ArtistId) + where a.Name = ? or alt.AlternateSpelling = ? + order by a.DeathYear desc + limit 1; + ', [$artistName, $artistName], 'Artist'); + + return $result[0] ?? null; + } + + public function Delete(): void{ + Db::Query(' + DELETE + from Artists + where ArtistId = ? + ', [$this->ArtistId]); + } +} diff --git a/lib/Artwork.php b/lib/Artwork.php new file mode 100644 index 00000000..74b1b9da --- /dev/null +++ b/lib/Artwork.php @@ -0,0 +1,594 @@ + $Tags + * @property Artist $Artist + * @property string $ImageUrl + * @property string $ThumbUrl + * @property string $Thumb2xUrl + * @property string $ImageSize + * @property Ebook $Ebook + * @property Museum $Museum + * @property ?ImageMimeType $MimeType + */ +class Artwork extends PropertiesBase{ + public $Name; + public $ArtworkId; + public $ArtistId; + public $CompletedYear; + public $CompletedYearIsCirca; + public $Created; + public $Updated; + public $Status; + public $EbookWwwFilesystemPath; + public $ReviewerUserId; + public $MuseumUrl; + public $PublicationYear; + public $PublicationYearPageUrl; + public $CopyrightPageUrl; + public $ArtworkPageUrl; + public $IsPublishedInUs; + public $Exception; + protected $_UrlName; + protected $_Url; + protected $_AdminUrl; + protected $_Tags = null; + protected $_Artist = null; + protected $_ImageUrl = null; + protected $_ThumbUrl = null; + protected $_Thumb2xUrl = null; + protected $_ImageSize = null; + protected $_Ebook = null; + protected $_Museum = null; + protected ?ImageMimeType $_MimeType = null; + + // ******* + // GETTERS + // ******* + + protected function GetUrlName(): string{ + if($this->Name === null || $this->Name == ''){ + return ''; + } + + if($this->_UrlName === null){ + $this->_UrlName = Formatter::MakeUrlSafe($this->Name); + } + + return $this->_UrlName; + } + + protected function GetUrl(): string{ + if($this->_Url === null){ + $this->_Url = '/artworks/' . $this->Artist->UrlName . '/' . $this->UrlName; + } + + return $this->_Url; + } + + protected function GetAdminUrl(): string{ + if($this->_AdminUrl === null){ + $this->_AdminUrl = '/admin/artworks/' . $this->ArtworkId; + } + + return $this->_AdminUrl; + } + + /** + * @return array + */ + protected function GetTags(): array{ + if($this->_Tags === null){ + $this->_Tags = Db::Query(' + SELECT t.* + from Tags t + inner join ArtworkTags at using (TagId) + where ArtworkId = ? + ', [$this->ArtworkId], 'ArtworkTag'); + } + + return $this->_Tags; + } + + public function GetMuseum(): ?Museum{ + if($this->_Museum === null){ + $this->_Museum = Museum::GetByUrl($this->MuseumUrl); + } + + return $this->_Museum; + } + + public function ImplodeTags(): string{ + $tags = $this->Tags ?? []; + $tags = array_column($tags, 'Name'); + return trim(implode(', ', $tags)); + } + + /** + * @throws \Exceptions\InvalidArtworkException + */ + protected function GetImageUrl(): string{ + if($this->_ImageUrl === null){ + if($this->ArtworkId === null || $this->MimeType === null){ + throw new Exceptions\InvalidArtworkException(); + } + + $this->_ImageUrl = COVER_ART_UPLOAD_PATH . $this->ArtworkId . $this->MimeType->GetFileExtension(); + } + + return $this->_ImageUrl; + } + + /** + * @throws \Exceptions\ArtworkNotFoundException + */ + protected function GetThumbUrl(): string{ + if($this->_ThumbUrl === null){ + if($this->ArtworkId === null){ + throw new Exceptions\ArtworkNotFoundException(); + } + + $this->_ThumbUrl = COVER_ART_UPLOAD_PATH . $this->ArtworkId . '-thumb.jpg'; + } + + return $this->_ThumbUrl; + } + + protected function GetThumb2xUrl(): string{ + if($this->_Thumb2xUrl === null){ + if($this->ArtworkId === null){ + throw new Exceptions\ArtworkNotFoundException(); + } + + $this->_Thumb2xUrl = COVER_ART_UPLOAD_PATH . $this->ArtworkId . '-thumb@2x.jpg'; + } + + return $this->_Thumb2xUrl; + } + + protected function GetImageSize(): string{ + try{ + $bytes = @filesize(WEB_ROOT . $this->ImageUrl); + $sizes = 'BKMGTP'; + $factor = intval(floor((strlen((string)$bytes) - 1) / 3)); + $sizeNumber = sprintf('%.1f', $bytes / pow(1024, $factor)); + $sizeUnit = $sizes[$factor] ?? ''; + $this->_ImageSize = $sizeNumber . $sizeUnit; + + list($imageWidth, $imageHeight) = getimagesize(WEB_ROOT . $this->ImageUrl); + if($imageWidth && $imageHeight){ + $this->_ImageSize .= ' (' . $imageWidth . ' × ' . $imageHeight . ')'; + } + } + catch(Exception){ + // Image doesn't exist + $this->_ImageSize = ''; + } + + return $this->_ImageSize; + } + + protected function GetEbook(): ?Ebook{ + if($this->_Ebook === null){ + $this->_Ebook = Library::GetEbook($this->EbookWwwFilesystemPath); + } + + return $this->_Ebook; + } + + protected function SetMimeType(null|string|ImageMimeType $mimeType): void{ + if(is_string($mimeType)){ + $this->_MimeType = ImageMimeType::tryFrom($mimeType); + } + else{ + $this->_MimeType = $mimeType; + } + } + + // ******* + // METHODS + // ******* + /** + * @param array $uploadedFile + * @throws \Exceptions\ValidationException + */ + protected function Validate(array &$uploadedFile = []): void{ + $error = new Exceptions\ValidationException(); + + if($this->Artist === null){ + $error->Add(new Exceptions\InvalidArtworkException()); + } + + try{ + $this->Artist->Validate(); + } + catch(Exceptions\ValidationException $ex){ + $error->Add($ex); + } + + if(trim($this->Exception) == ''){ + $this->Exception = null; + } + + if($this->Name === null || $this->Name == ''){ + $error->Add(new Exceptions\ArtworkNameRequiredException()); + } + + if($this->Name !== null && strlen($this->Name) > COVER_ARTWORK_MAX_STRING_LENGTH){ + $error->Add(new Exceptions\StringTooLongException('Artwork Name')); + } + + if($this->CompletedYear !== null && ($this->CompletedYear <= 0 || $this->CompletedYear > intval(date('Y')))){ + $error->Add(new Exceptions\InvalidCompletedYearException()); + } + + if($this->CompletedYear === null && $this->CompletedYearIsCirca){ + $this->CompletedYearIsCirca = false; + } + + if($this->PublicationYear !== null && ($this->PublicationYear <= 0 || $this->PublicationYear > intval(date('Y')))){ + $error->Add(new Exceptions\InvalidPublicationYearException()); + } + + if($this->Status !== null && !in_array($this->Status, [COVER_ARTWORK_STATUS_UNVERIFIED, COVER_ARTWORK_STATUS_APPROVED, COVER_ARTWORK_STATUS_DECLINED, COVER_ARTWORK_STATUS_IN_USE])){ + $error->Add(new Exceptions\InvalidArtworkException('Invalid status.')); + } + + if($this->Status === COVER_ARTWORK_STATUS_IN_USE && $this->EbookWwwFilesystemPath === null){ + $error->Add(new Exceptions\MissingEbookException()); + } + + if($this->Tags === null || count($this->_Tags) == 0){ + // In-use artwork doesn't have user-provided tags. + if($this->Status !== COVER_ARTWORK_STATUS_IN_USE){ + $error->Add(new Exceptions\TagsRequiredException()); + } + } + + if($this->Tags !== null && count($this->_Tags) > COVER_ARTWORK_MAX_TAGS){ + $error->Add(new Exceptions\TooManyTagsException()); + } + + foreach($this->Tags as $tag){ + if(strlen($tag->Name) > COVER_ARTWORK_MAX_STRING_LENGTH){ + $error->Add(new Exceptions\StringTooLongException('Artwork Tag: '. $tag->Name)); + } + } + + if($this->MuseumUrl !== null && strlen($this->MuseumUrl) > 0 && filter_var($this->MuseumUrl, FILTER_VALIDATE_URL) === false){ + $error->Add(new Exceptions\InvalidMuseumUrlException()); + } + + if($this->MuseumUrl !== null && strlen($this->MuseumUrl) > COVER_ARTWORK_MAX_STRING_LENGTH){ + $error->Add(new Exceptions\StringTooLongException('Link to an approved museum page')); + } + + if($this->PublicationYearPageUrl !== null && strlen($this->PublicationYearPageUrl) > 0 && filter_var($this->PublicationYearPageUrl, FILTER_VALIDATE_URL) === false){ + $error->Add(new Exceptions\InvalidPublicationYearPageUrlException()); + } + + if($this->PublicationYearPageUrl !== null && strlen($this->PublicationYearPageUrl) > COVER_ARTWORK_MAX_STRING_LENGTH){ + $error->Add(new Exceptions\StringTooLongException('Link to page with year of publication')); + } + + if($this->CopyrightPageUrl !== null && strlen($this->CopyrightPageUrl) > 0 && filter_var($this->CopyrightPageUrl, FILTER_VALIDATE_URL) === false){ + $error->Add(new Exceptions\InvalidCopyrightPageUrlException()); + } + + if($this->CopyrightPageUrl !== null && strlen($this->CopyrightPageUrl) > COVER_ARTWORK_MAX_STRING_LENGTH){ + $error->Add(new Exceptions\StringTooLongException('Link to page with copyright details')); + } + + if($this->ArtworkPageUrl !== null && strlen($this->ArtworkPageUrl) > 0 && filter_var($this->ArtworkPageUrl, FILTER_VALIDATE_URL) === false){ + $error->Add(new Exceptions\InvalidArtworkPageUrlException()); + } + + if($this->ArtworkPageUrl !== null && strlen($this->ArtworkPageUrl) > COVER_ARTWORK_MAX_STRING_LENGTH){ + $error->Add(new Exceptions\StringTooLongException('Link to page with artwork')); + } + + $hasMuseumProof = $this->MuseumUrl !== null && $this->MuseumUrl != ''; + $hasBookProof = $this->PublicationYear !== null + && ($this->PublicationYearPageUrl !== null && $this->PublicationYearPageUrl != '') + && ($this->ArtworkPageUrl !== null && $this->ArtworkPageUrl != ''); + + if(!$hasMuseumProof && !$hasBookProof && $this->Exception === null){ + // In-use artwork has its public domain status tracked elsewhere, e.g., on the mailing list. + if($this->Status !== COVER_ARTWORK_STATUS_IN_USE){ + $error->Add(new Exceptions\MissingPdProofException()); + } + } + + if($this->MimeType === null){ + $error->Add(new Exceptions\InvalidMimeTypeException()); + } + + // Check for existing Artwork objects with the same URL but different Artwork IDs. + try{ + $existingArtwork = Artwork::GetByUrl($this->Artist->UrlName, $this->UrlName); + if($existingArtwork->ArtworkId != $this->ArtworkId){ + // Duplicate found, alert the user + $error->Add(new Exceptions\ArtworkAlreadyExistsException()); + } + } + catch(Exceptions\ArtworkNotFoundException){ + // No duplicates found, continue + } + + if(!is_writable(WEB_ROOT . COVER_ART_UPLOAD_PATH)){ + $error->Add(new Exceptions\InvalidImageUploadException('Upload path not writable.')); + } + + if(!empty($uploadedFile) && $this->MimeType !== null){ + $uploadError = $uploadedFile['error']; + if($uploadError > UPLOAD_ERR_OK){ + // see https://www.php.net/manual/en/features.file-upload.errors.php + $message = match($uploadError){ + UPLOAD_ERR_INI_SIZE => 'Image upload too large (maximum ' . ini_get('upload_max_filesize') . ').', + default => 'Image failed to upload (error code ' . $uploadError . ').', + }; + $error->Add(new Exceptions\InvalidImageUploadException($message)); + } + + if(!is_uploaded_file($uploadedFile['tmp_name'])){ + throw new Exceptions\InvalidImageUploadException(); + } + } + + if($error->HasExceptions){ + throw $error; + } + } + + /** @return array */ + public static function ParseTags(?string $tags): array{ + if(!$tags) return []; + + $tags = array_map('trim', explode(',', $tags)); + $tags = array_values(array_filter($tags)); + $tags = array_unique($tags); + + return array_map(function ($str){ + $tag = new ArtworkTag(); + $tag->Name = $str; + return $tag; + }, $tags); + } + + /** + * @param array $uploadedFile + * @throws \Exceptions\ValidationException + * @throws \Exceptions\InvalidImageUploadException + */ + public function Create(array $uploadedFile): void{ + $this->Validate($uploadedFile); + $this->Created = new DateTime(); + + // Can't assign directly to $this->Tags because it's hidden behind a getter + $tags = []; + foreach($this->Tags as $artworkTag){ + $tags[] = ArtworkTag::GetOrCreate($artworkTag); + } + $this->Tags = $tags; + + $this->Artist = Artist::GetOrCreate($this->Artist); + + Db::Query(' + INSERT into + Artworks (ArtistId, Name, UrlName, CompletedYear, CompletedYearIsCirca, Created, Status, ReviewerUserId, MuseumUrl, + PublicationYear, PublicationYearPageUrl, CopyrightPageUrl, ArtworkPageUrl, IsPublishedInUs, + EbookWwwFilesystemPath, MimeType, Exception) + values (?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?) + ', [$this->Artist->ArtistId, $this->Name, $this->UrlName, $this->CompletedYear, $this->CompletedYearIsCirca, + $this->Created, $this->Status, $this->ReviewerUserId, $this->MuseumUrl, $this->PublicationYear, $this->PublicationYearPageUrl, + $this->CopyrightPageUrl, $this->ArtworkPageUrl, $this->IsPublishedInUs, $this->EbookWwwFilesystemPath, $this->MimeType->value ?? null, $this->Exception] + ); + + $this->ArtworkId = Db::GetLastInsertedId(); + + foreach($this->Tags as $tag){ + Db::Query(' + INSERT into ArtworkTags (ArtworkId, TagId) + values (?, + ?) + ', [$this->ArtworkId, $tag->TagId]); + } + + // Save the source image and clean up metadata + $imageUploadPath = $uploadedFile['tmp_name']; + exec('exiftool -quiet -overwrite-_original -all= ' . escapeshellarg($imageUploadPath)); + copy($imageUploadPath, WEB_ROOT . $this->ImageUrl); + + // Generate the thumbnails + try{ + $image = new Image($imageUploadPath); + $image->Resize(WEB_ROOT . $this->ThumbUrl, COVER_THUMBNAIL_WIDTH, COVER_THUMBNAIL_HEIGHT); + $image->Resize(WEB_ROOT . $this->Thumb2xUrl, COVER_THUMBNAIL_WIDTH * 2, COVER_THUMBNAIL_HEIGHT * 2); + } + catch(\Safe\Exceptions\FilesystemException | \Safe\Exceptions\ImageException){ + throw new Exceptions\InvalidImageUploadException('Failed to generate thumbnail.'); + } + } + + /** + * @throws \Exceptions\ValidationException + */ + public function Save(): void{ + $this->Validate(); + + Db::Query(' + UPDATE Artworks + set + ArtistId = ?, + Name = ?, + UrlName = ?, + CompletedYear = ?, + CompletedYearIsCirca = ?, + Created = ?, + Status = ?, + ReviewerUserId = ?, + MuseumUrl = ?, + PublicationYear = ?, + PublicationYearPageUrl = ?, + CopyrightPageUrl = ?, + ArtworkPageUrl = ?, + IsPublishedInUs = ?, + EbookWwwFilesystemPath = ?, + MimeType = ?, + Exception = ? + where + ArtworkId = ? + ', [$this->Artist->ArtistId, $this->Name, $this->UrlName, $this->CompletedYear, $this->CompletedYearIsCirca, + $this->Created, $this->Status, $this->ReviewerUserId, $this->MuseumUrl, $this->PublicationYear, $this->PublicationYearPageUrl, + $this->CopyrightPageUrl, $this->ArtworkPageUrl, $this->IsPublishedInUs, $this->EbookWwwFilesystemPath, $this->MimeType->value ?? null, $this->Exception, + $this->ArtworkId] + ); + } + + public function MarkInUse(string $ebookWwwFilesystemPath): void{ + $this->EbookWwwFilesystemPath = $ebookWwwFilesystemPath; + $this->Status = COVER_ARTWORK_STATUS_IN_USE; + $this->Save(); + } + + public function Delete(): void{ + Db::Query(' + DELETE + from ArtworkTags + where ArtworkId = ? + ', [$this->ArtworkId]); + + Db::Query(' + DELETE + from Artworks + where ArtworkId = ? + ', [$this->ArtworkId]); + } + + public function Contains(string $query): bool{ + $searchString = $this->Name; + + $searchString .= ' ' . $this->Artist->Name; + $searchString .= ' ' . implode(' ', $this->Artist->AlternateSpellings); + + foreach($this->Tags as $tag){ + $searchString .= ' ' . $tag->Name; + } + + // Remove diacritics and non-alphanumeric characters + $searchString = trim(preg_replace('|[^a-zA-Z0-9 ]|ius', ' ', Formatter::RemoveDiacritics($searchString))); + $query = trim(preg_replace('|[^a-zA-Z0-9 ]|ius', ' ', Formatter::RemoveDiacritics($query))); + + if($query == ''){ + return false; + } + + if(mb_stripos($searchString, $query) !== false){ + return true; + } + + return false; + } + + public function HasMatchingMuseum(): bool{ + + return true; + } + + // *********** + // ORM METHODS + // *********** + + /** + * @throws \Exceptions\ArtworkNotFoundException + */ + public static function Get(?int $artworkId): Artwork{ + if($artworkId === null){ + throw new Exceptions\ArtworkNotFoundException(); + } + + $result = Db::Query(' + SELECT * + from Artworks + where ArtworkId = ? + ', [$artworkId], 'Artwork'); + + if(sizeof($result) == 0){ + throw new Exceptions\ArtworkNotFoundException(); + } + + return $result[0]; + } + + /** + * Looks up an existing artwork regardless of status (unlike GetByUrl()) in order to + * enforce that the Artist UrlName + Artwork UrlName combo is globally unique. + */ + /** + * @throws \Exceptions\InvalidArtworkException + */ + public static function GetByUrl(string $artistUrlName, string $artworkUrlName): Artwork{ + $result = Db::Query(' + SELECT Artworks.* + from Artworks + inner join Artists using (ArtistId) + where Artists.UrlName = ? and Artworks.UrlName = ? + ', [$artistUrlName, $artworkUrlName], 'Artwork'); + + if(sizeof($result) == 0){ + throw new Exceptions\ArtworkNotFoundException(); + } + + return $result[0]; + } + + /** + * Gets a publically available Artwork, i.e., with approved or in_use status. + * Artwork with status unverifed and declined aren't available by URL. + */ + /** + * @throws \Exceptions\InvalidArtworkException + */ + public static function GetByUrlAndIsApproved(string $artistUrlName, string $artworkUrlName): Artwork{ + $result = Db::Query(' + SELECT Artworks.* + from Artworks + inner join Artists using (ArtistId) + where Status in ("approved", "in_use") and + Artists.UrlName = ? and Artworks.UrlName = ? + ', [$artistUrlName, $artworkUrlName], 'Artwork'); + + if(sizeof($result) == 0){ + throw new Exceptions\ArtworkNotFoundException(); + } + + return $result[0]; + } +} diff --git a/lib/ArtworkTag.php b/lib/ArtworkTag.php new file mode 100644 index 00000000..af51197a --- /dev/null +++ b/lib/ArtworkTag.php @@ -0,0 +1,66 @@ +_Url === null){ + $this->_Url = '/artworks?query=' . Formatter::MakeUrlSafe($this->Name); + } + + return $this->_Url; + } + + // ******* + // METHODS + // ******* + protected function Validate(): void{ + $error = new Exceptions\ValidationException(); + + if($this->Name === null || strlen($this->Name) === 0){ + $error->Add(new Exceptions\InvalidArtworkTagException()); + } + + if($this->Url === null || strlen($this->Url) === 0){ + $error->Add(new Exceptions\InvalidArtworkTagException()); + } + + if($error->HasExceptions){ + throw $error; + } + } + + public function Create(): void{ + $this->Validate(); + + Db::Query(' + INSERT into Tags (Name) + values (?) + ', [$this->Name]); + $this->TagId = Db::GetLastInsertedId(); + } + + /** + * @throws \Exceptions\ValidationException + */ + public static function GetOrCreate(ArtworkTag $artworkTag): ArtworkTag{ + $result = Db::Query(' + SELECT * + from Tags + where Name = ? + ', [$artworkTag->Name], 'ArtworkTag'); + + if(isset($result[0])){ + return $result[0]; + } + else{ + $artworkTag->Create(); + return $artworkTag; + } + } +} diff --git a/lib/Benefits.php b/lib/Benefits.php index 18e2388a..230377c3 100644 --- a/lib/Benefits.php +++ b/lib/Benefits.php @@ -3,4 +3,6 @@ class Benefits{ public $CanAccessFeeds = false; public $CanVote = false; public $CanBulkDownload = false; + public $CanUploadArtwork = false; + public $CanReviewArtwork = false; } diff --git a/lib/Constants.php b/lib/Constants.php index 4456181f..a4f2bafa 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -24,6 +24,7 @@ const REPOS_PATH = SITE_ROOT . '/ebooks'; const TEMPLATES_PATH = SITE_ROOT . '/web/templates'; const MANUAL_PATH = WEB_ROOT . '/manual'; const EBOOKS_DIST_PATH = WEB_ROOT . '/ebooks/'; +const COVER_ART_UPLOAD_PATH = '/images/cover-uploads/'; const DATABASE_DEFAULT_DATABASE = 'se'; const DATABASE_DEFAULT_HOST = 'localhost'; @@ -34,6 +35,19 @@ const SORT_AUTHOR_ALPHA = 'author-alpha'; const SORT_READING_EASE = 'reading-ease'; const SORT_LENGTH = 'length'; +const COVER_THUMBNAIL_HEIGHT = 350; +const COVER_THUMBNAIL_WIDTH = 350; +const COVER_ARTWORK_PER_PAGE = 50; +const COVER_ARTWORK_STATUS_APPROVED = 'approved'; +const COVER_ARTWORK_STATUS_DECLINED = 'declined'; +const COVER_ARTWORK_STATUS_IN_USE = 'in_use'; +const COVER_ARTWORK_STATUS_UNVERIFIED = 'unverified'; +const COVER_ARTWORK_MAX_STRING_LENGTH = 191; +const COVER_ARTWORK_MAX_TAGS = 100; +const SORT_COVER_ARTWORK_CREATED_NEWEST = 'created-newest'; +const SORT_COVER_ARTIST_ALPHA = 'artist-alpha'; +const SORT_COVER_ARTWORK_COMPLETED_NEWEST = 'completed-newest'; + const CAPTCHA_IMAGE_HEIGHT = 72; const CAPTCHA_IMAGE_WIDTH = 230; const NO_REPLY_EMAIL_ADDRESS = 'admin@standardebooks.org'; @@ -92,6 +106,7 @@ const GITHUB_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-github.log'; // Mu const POSTMARK_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-postmark.log'; // Must be writable by `www-data` Unix user. const ZOHO_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-zoho.log'; // Must be writable by `www-data` Unix user. const DONATIONS_LOG_FILE_PATH = '/var/log/local/donations.log'; // Must be writable by `www-data` Unix user. +const ARTWORK_UPLOADS_LOG_FILE_PATH = '/var/log/local/artwork-uploads.log'; // Must be writable by `www-data` Unix user. define('PD_YEAR', intval(gmdate('Y')) - 96); define('PD_STRING', 'January 1, ' . (PD_YEAR + 1)); @@ -100,7 +115,7 @@ define('DONATION_HOLIDAY_ALERT_ON', time() > strtotime('November 15, ' . gmdate( define('DONATION_ALERT_ON', DONATION_HOLIDAY_ALERT_ON || rand(1, 4) == 2); // Controls the progress bar donation dialog -const DONATION_DRIVE_ON = false; +const DONATION_DRIVE_ON = true; const DONATION_DRIVE_START = 'December 11, 2023 00:00:00 America/New_York'; const DONATION_DRIVE_END = 'January 7, 2024 23:59:00 America/New_York'; diff --git a/lib/CoreFunctions.php b/lib/CoreFunctions.php index c065ad43..2d6dd1df 100644 --- a/lib/CoreFunctions.php +++ b/lib/CoreFunctions.php @@ -3,6 +3,7 @@ // These functions are broken out of Core.php to satisfy PHPStan use function Safe\ob_end_clean; +use function Safe\ob_start; // Convenience alias of var_dump. function vd($var): void{ diff --git a/lib/DbConnection.php b/lib/DbConnection.php index f608dfc5..2577edbb 100644 --- a/lib/DbConnection.php +++ b/lib/DbConnection.php @@ -1,6 +1,7 @@ errorInfo[1] == 1213 && $deadlockRetries < 3){ // InnoDB deadlock, this is normal and happens occasionally. All we have to do is retry the query. + if(isset($ex->errorInfo) && $ex->errorInfo[1] == 1213 && $deadlockRetries < 3){ // InnoDB deadlock, this is normal and happens occasionally. All we have to do is retry the query. $deadlockRetries++; usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds @@ -188,12 +189,17 @@ class DbConnection{ $object = new $class(); for($i = 0; $i < $handle->columnCount(); $i++){ + if($metadata[$i] === false){ + continue; + } + if($row[$i] === null){ $object->{$metadata[$i]['name']} = null; } else{ - switch($metadata[$i]['native_type']){ + switch($metadata[$i]['native_type'] ?? null){ case 'DATETIME': + case 'TIMESTAMP': $object->{$metadata[$i]['name']} = new DateTime($row[$i], new DateTimeZone('UTC')); break; @@ -228,7 +234,7 @@ class DbConnection{ catch(\PDOException $ex){ // HY000 is thrown when there is no result set, e.g. for an update operation. // If anything besides that is thrown, then send it up the stack - if($ex->errorInfo[0] != "HY000"){ + if(!isset($ex->errorInfo) || $ex->errorInfo[0] != "HY000"){ throw $ex; } } diff --git a/lib/Ebook.php b/lib/Ebook.php index 10317578..be61608c 100644 --- a/lib/Ebook.php +++ b/lib/Ebook.php @@ -7,6 +7,7 @@ use function Safe\glob; use function Safe\preg_match; use function Safe\preg_replace; use function Safe\sprintf; +use function Safe\shell_exec; use function Safe\substr; class Ebook{ @@ -77,8 +78,7 @@ class Ebook{ $this->RepoFilesystemPath = preg_replace('/\.git$/ius', '', $this->RepoFilesystemPath); } catch(Exception){ - // We may get an exception from preg_replace if the passed repo wwwFilesystemPath contains invalid UTF8 characters, - // which a common injection attack vector + // We may get an exception from preg_replace if the passed repo wwwFilesystemPath contains invalid UTF-8 characters, whichis a common injection attack vector throw new Exceptions\InvalidEbookException('Invalid repo filesystem path: ' . $this->RepoFilesystemPath); } } @@ -164,7 +164,7 @@ class Ebook{ } // Fill in the short history of this repo. - $historyEntries = explode("\n", shell_exec('cd ' . escapeshellarg($this->RepoFilesystemPath) . ' && git log -n5 --pretty=format:"%ct %H %s"') ?? ''); + $historyEntries = explode("\n", shell_exec('cd ' . escapeshellarg($this->RepoFilesystemPath) . ' && git log -n5 --pretty=format:"%ct %H %s"')); foreach($historyEntries as $entry){ $array = explode(' ', $entry, 3); @@ -221,7 +221,7 @@ class Ebook{ // Get SE tags foreach($xml->xpath('/package/metadata/meta[@property="se:subject"]') ?: [] as $tag){ - $this->Tags[] = new Tag($tag); + $this->Tags[] = new EbookTag($tag); } $includeToc = sizeof($xml->xpath('/package/metadata/meta[@property="se:is-a-collection"]') ?: []) > 0; @@ -742,6 +742,10 @@ class Ebook{ return $string; } + + /** + * @param array|false|null $elements + */ private function NullIfEmpty($elements): ?string{ if($elements === false){ return null; diff --git a/lib/EbookTag.php b/lib/EbookTag.php new file mode 100644 index 00000000..0091fc8c --- /dev/null +++ b/lib/EbookTag.php @@ -0,0 +1,21 @@ +Name = $name; + $this->UrlName = Formatter::MakeUrlSafe($this->Name); + $this->_Url = '/subjects/' . $this->UrlName; + } + + + // ******* + // GETTERS + // ******* + + protected function GetUrl(): string{ + if($this->_Url === null){ + $this->_Url = '/subjects/' . $this->UrlName; + } + + return $this->_Url; + } +} diff --git a/lib/Email.php b/lib/Email.php index 357e2ede..f7465bcf 100644 --- a/lib/Email.php +++ b/lib/Email.php @@ -14,7 +14,7 @@ class Email{ public $Subject = ''; public $Body = ''; public $TextBody = ''; - public $Attachments = array(); + public $Attachments = []; public $PostmarkStream = null; public function __construct(bool $isNoReplyEmail = false){ diff --git a/lib/Exceptions/ArtistNameRequiredException.php b/lib/Exceptions/ArtistNameRequiredException.php new file mode 100644 index 00000000..6b266f44 --- /dev/null +++ b/lib/Exceptions/ArtistNameRequiredException.php @@ -0,0 +1,6 @@ +Source = $source; + parent::__construct('String too long: ' . $source); + } +} diff --git a/lib/Exceptions/TagsRequiredException.php b/lib/Exceptions/TagsRequiredException.php new file mode 100644 index 00000000..42db4db1 --- /dev/null +++ b/lib/Exceptions/TagsRequiredException.php @@ -0,0 +1,6 @@ +setSafeMode(true); + return $parsedown->text($text); + } + public static function ToFileSize(?int $bytes): string{ // See https://stackoverflow.com/a/5501447 $output = ''; diff --git a/lib/HttpInput.php b/lib/HttpInput.php index ece627a9..a2caed57 100644 --- a/lib/HttpInput.php +++ b/lib/HttpInput.php @@ -1,5 +1,7 @@ $size * 1024 * 1024 * 1024, + 'm', 'M' => $size * 1024 * 1024, + 'k', 'K' => $size * 1024, + default => $size + }; + } + + public static function IsRequestTooLarge(): bool{ + if(empty($_POST) || empty($_FILES)){ + if($_SERVER['CONTENT_LENGTH'] > self::GetMaxPostSize()){ + return true; + } + } + + return false; + } + public static function RequestType(): int{ return preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT'] ?? '') ? WEB : REST; } @@ -60,7 +85,7 @@ class HttpInput{ return self::GetHttpVar($variable, HTTP_VAR_ARRAY, GET, $default); } - private static function GetHttpVar(string $variable, int $type, string $set, $default){ + private static function GetHttpVar(string $variable, int $type, string $set, mixed $default): mixed{ $vars = []; switch($set){ diff --git a/lib/Image.php b/lib/Image.php new file mode 100644 index 00000000..6bb38c79 --- /dev/null +++ b/lib/Image.php @@ -0,0 +1,94 @@ +Path = $path; + $this->MimeType = ImageMimeType::FromFile($path); + } + + /** + * @return resource + * @throws \Safe\Exceptions\ImageException + * @throws Exceptions\InvalidImageUploadException + */ + private function GetImageHandle(){ + switch($this->MimeType){ + case ImageMimeType::JPG: + $handle = \Safe\imagecreatefromjpeg($this->Path); + break; + case ImageMimeType::BMP: + $handle = \Safe\imagecreatefrombmp($this->Path); + break; + case ImageMimeType::PNG: + $handle = \Safe\imagecreatefrompng($this->Path); + break; + case ImageMimeType::TIFF: + $handle = $this->GetImageHandleFromTiff(); + break; + default: + throw new \Exceptions\InvalidImageUploadException(); + } + + return $handle; + } + + /** + * @return resource + * @throws \Exceptions\InvalidImageUploadException + */ + private function GetImageHandleFromTiff(){ + $tempFilename = sys_get_temp_dir() . '/se-' . pathinfo($this->Path)['filename'] . '.jpg'; + + try{ + exec('convert '. escapeshellarg($this->Path) . ' ' . escapeshellarg($tempFilename), $shellOutput, $resultCode); + + if($resultCode !== 0){ + throw new Exceptions\InvalidImageUploadException('Failed to convert TIFF to JPEG'); + } + + $handle = \Safe\imagecreatefromjpeg($tempFilename); + } + finally{ + try{ + unlink($tempFilename); + } + catch(Exception){ + // Pass if file doesn't exist + } + } + + return $handle; + } + + public function Resize(string $destImagePath, int $width, int $height): void{ + $imageDimensions = getimagesize($this->Path); + + $imageWidth = $imageDimensions[0]; + $imageHeight = $imageDimensions[1]; + + if($imageHeight > $imageWidth){ + $destinationHeight = $height; + $destinationWidth = intval($destinationHeight * ($imageWidth / $imageHeight)); + } + else{ + $destinationWidth = $width; + $destinationHeight = intval($destinationWidth * ($imageHeight / $imageWidth)); + } + + $srcImageHandle = $this->GetImageHandle(); + $thumbImageHandle = imagecreatetruecolor($destinationWidth, $destinationHeight); + + imagecopyresampled($thumbImageHandle, $srcImageHandle, 0, 0, 0, 0, $destinationWidth, $destinationHeight, $imageWidth, $imageHeight); + + imagejpeg($thumbImageHandle, $destImagePath); + } +} diff --git a/lib/ImageMimeType.php b/lib/ImageMimeType.php new file mode 100644 index 00000000..405cfc4e --- /dev/null +++ b/lib/ImageMimeType.php @@ -0,0 +1,47 @@ + '.jpg', + self::BMP => '.bmp', + self::PNG => '.png', + self::TIFF => '.tif', + }; + } + + public static function FromFile(?string $path): ?ImageMimeType{ + if($path === null || $path == ''){ + return null; + } + + $mimeType = mime_content_type($path); + + $mimeType = match($mimeType){ + 'image/x-ms-bmp', 'image/x-bmp' => 'image/bmp', + default => $mimeType, + }; + + if(!$mimeType){ + return null; + } + + return ImageMimeType::tryFrom($mimeType); + } + + /** + * @return array + */ + public static function Values(): array{ + return array_map(function(ImageMimeType $case){ + return $case->value; + }, ImageMimeType::cases()); + } +} diff --git a/lib/Library.php b/lib/Library.php index 417839d6..cb436c68 100644 --- a/lib/Library.php +++ b/lib/Library.php @@ -1,17 +1,17 @@ + */ + private static function GetBrowsableArtwork(): array{ + return Db::Query(' + SELECT * + FROM Artworks + WHERE Status IN ("approved", "in_use")', [], 'Artwork'); + } + + /** + * @param string $query + * @param string $status + * @param string $sort + * @return array + */ + public static function FilterArtwork(string $query = null, string $status = null, string $sort = null): array{ + $artworks = Library::GetBrowsableArtwork(); + $matches = $artworks; + + if($sort === null){ + $sort = SORT_COVER_ARTWORK_CREATED_NEWEST; + } + + if(in_array($status, [COVER_ARTWORK_STATUS_APPROVED, COVER_ARTWORK_STATUS_IN_USE], true)){ + $matches = []; + foreach($artworks as $artwork){ + if($status === $artwork->Status){ + $matches[] = $artwork; + } + } + } + else{ + $matches = []; + foreach($artworks as $artwork){ + if(in_array($artwork->Status, [COVER_ARTWORK_STATUS_APPROVED, COVER_ARTWORK_STATUS_IN_USE], true)){ + $matches[] = $artwork; + } + } + } + + if($query !== null){ + $filteredMatches = []; + + foreach($matches as $artwork){ + if($artwork->Contains($query)){ + $filteredMatches[] = $artwork; + } + } + + $matches = $filteredMatches; + } + + switch($sort){ + case SORT_COVER_ARTIST_ALPHA: + $collator = Collator::create('en_US'); // Used for sorting letters with diacritics like in artist names + if($collator === null){ + usort($matches, function($a, $b){ + return strcmp(mb_strtolower($a->Artist->Name), mb_strtolower($b->Artist->Name)); + }); + } + else{ + usort($matches, function($a, $b) use($collator){ + return $collator->compare($a->Artist->Name, $b->Artist->Name); + }); + } + + break; + + case SORT_COVER_ARTWORK_CREATED_NEWEST: + usort($matches, function($a, $b){ + if($a->Created > $b->Created){ + return -1; + } + elseif($a->Created == $b->Created){ + return 0; + } + else{ + return 1; + } + }); + + break; + + case SORT_COVER_ARTWORK_COMPLETED_NEWEST: + usort($matches, function($a, $b){ + if($a->CompletedYear > $b->CompletedYear){ + return -1; + } + elseif($a->CompletedYear == $b->CompletedYear){ + return 0; + } + else{ + return 1; + } + }); + + break; + } + + return $matches; + + } + /** * @return array */ @@ -187,6 +293,10 @@ class Library{ } } + if(!is_array($results)){ + $results = [$results]; + } + return $results; } @@ -211,7 +321,8 @@ class Library{ */ public static function GetEbooksFromFilesystem(?string $webRoot = WEB_ROOT): array{ $ebooks = []; - $contentFiles = explode("\n", trim(shell_exec('find ' . escapeshellarg($webRoot . '/ebooks/') . ' -name "content.opf" | sort') ?? '')); + + $contentFiles = explode("\n", trim(shell_exec('find ' . escapeshellarg($webRoot . '/ebooks/') . ' -name "content.opf" | sort'))); foreach($contentFiles as $path){ if($path == '') @@ -435,6 +546,21 @@ class Library{ return $retval; } + public static function GetEbook(?string $ebookWwwFilesystemPath): ?Ebook{ + if($ebookWwwFilesystemPath === null){ + return null; + } + + $result = self::GetFromApcu('ebook-' . $ebookWwwFilesystemPath); + + if(sizeof($result) > 0){ + return $result[0]; + } + else{ + return null; + } + } + public static function RebuildCache(): void{ // We check a lockfile because this can be a long-running command. // We don't want to queue up a bunch of these in case someone is refreshing the index constantly. @@ -459,7 +585,7 @@ class Library{ $authors = []; $tagsByName = []; - foreach(explode("\n", trim(shell_exec('find ' . EBOOKS_DIST_PATH . ' -name "content.opf"') ?? '')) as $filename){ + foreach(explode("\n", trim(shell_exec('find ' . EBOOKS_DIST_PATH . ' -name "content.opf"'))) as $filename){ try{ $ebookWwwFilesystemPath = preg_replace('|/content\.opf|ius', '', $filename); @@ -527,8 +653,8 @@ class Library{ foreach($ebooksByCollection as $collection => $sortItems){ // Sort the array by the ebook's ordinal in the collection. We use this custom sort function // because an ebook may share the same place in a collection with another ebook; see above. - usort($sortItems, function($a, $b) { - if ($a->Ordinal == $b->Ordinal) { + usort($sortItems, function($a, $b){ + if($a->Ordinal == $b->Ordinal){ return 0; } return ($a->Ordinal < $b->Ordinal) ? -1 : 1; @@ -562,4 +688,14 @@ class Library{ apcu_delete($lockVar); } + + /** + * @return array + */ + public static function GetAllArtists(): array{ + return Db::Query(' + SELECT * + from Artists + order by Name asc', [], 'Artist'); + } } diff --git a/lib/Museum.php b/lib/Museum.php new file mode 100644 index 00000000..5ef34de4 --- /dev/null +++ b/lib/Museum.php @@ -0,0 +1,18 @@ +Name = $name; - $this->UrlName = Formatter::MakeUrlSafe($this->Name); - $this->Url = '/subjects/' . $this->UrlName; - } +/** + * @property string $Url + */ +class Tag extends PropertiesBase{ + public $TagId; + public $Name; + public $UrlName; + protected $_Url; } diff --git a/lib/Template.php b/lib/Template.php index aae10cc6..899dbaa7 100644 --- a/lib/Template.php +++ b/lib/Template.php @@ -1,5 +1,6 @@ Mr. Smith' + * to: + * 'Mr. Smith' + */ +function StripInnerTags($elements): ?string{ + if($elements === false){ + return null; + } + + if(isset($elements[0])){ + return strip_tags($elements[0]->asXML()); + } + + return null; +} + +if(!$repoDir || !$workDir || !$ebookWwwFilesystemPath){ + print("Expected usage: upsert-to-cover-art-database [-v] --repoDir --workDir --ebookWwwFilesystemPath \n"); + exit(1); +} + +if($verbose){ + print("\nrepoDir: $repoDir\n"); + print("workDir: $workDir\n"); + print("ebookWwwFilesystemPath: $ebookWwwFilesystemPath\n"); +} + +chdir($repoDir); +$contentOpf = shell_exec("git show HEAD:src/epub/content.opf"); +$xml = new SimpleXMLElement(str_replace('xmlns=', 'ns=', $contentOpf)); +$xml->registerXPathNamespace('dc', 'http://purl.org/dc/elements/1.1/'); +$artistName = StripInnerTags($xml->xpath('/package/metadata/dc:contributor[@id="artist"]')); +if($artistName === null){ + // Some ebooks have an artist-1 and artist-2. Take artist-1, which isn't ideal, but is usually correct. + $artistName = StripInnerTags($xml->xpath('/package/metadata/dc:contributor[@id="artist-1"]')); + if($artistName === null){ + print($repoDir . " Error: Could not find artist name in content.opf\n"); + exit($repoDir . " Error: missing artistName\n"); + } +} + +if(!file_exists($ebookWwwFilesystemPath . '/text/colophon.xhtml')){ + exit($repoDir . ' Error: no text/colophon.xhtml at ' . $ebookWwwFilesystemPath . "\n"); +} + +$rawColophon = file_get_contents($ebookWwwFilesystemPath . '/text/colophon.xhtml'); +if(empty($rawColophon)){ + exit($repoDir . ' Error: empty colophon at ' . $ebookWwwFilesystemPath . "\n"); +} + +preg_match('|a painting completed \w+ (\d+)|ius', $rawColophon, $matches); +$completedYear = null; +if(sizeof($matches) == 2){ + $completedYear = (int)$matches[1]; +} + +$colophonXml = new SimpleXMLElement(str_replace('xmlns=', 'ns=', $rawColophon)); +$artworkName = StripInnerTags($colophonXml->xpath('/html/body/main/section/p/i[@epub:type="se:name.visual-art.painting"]')); +if($artworkName === null){ + print($repoDir . " Error: Could not find artwork name in colophon.xhtml\n"); + exit($repoDir . " Error: missing artworkName"); +} + +$artistUrlName = Formatter::MakeUrlSafe($artistName); +$artworkUrlName = Formatter::MakeUrlSafe($artworkName); +$artwork = null; + +if($verbose){ + print("artistName: $artistName\n"); + print("artistUrlName: $artistUrlName\n"); + print("completedYear: $completedYear\n"); + print("artworkName: $artworkName\n"); + print("artworkUrlName: $artworkUrlName\n"); +} + +try{ + $artwork = Artwork::GetByUrlAndIsApproved($artistUrlName, $artworkUrlName); +} +catch(Exceptions\ArtworkNotFoundException){ + // $artwork is null by default, just continue +} + +if($artwork === null){ + if($verbose){ + printf($repoDir . " No existing artwork found at %s/%s, inserting new artwork.\n", $artistUrlName, $artworkUrlName); + } + + // The ebook colophon provides the artist's name, but not their death year. + // Prefer matching an existing artist to creating a new record with a null death year if possible. + $artist = Artist::FindMatch($artistName); + if($artist === null){ + $artist = new Artist(); + $artist->Name = $artistName; + } + + $artwork = new Artwork(); + $artwork->Artist = $artist; + $artwork->Name = $artworkName; + $artwork->CompletedYear = $completedYear; + $artwork->CompletedYearIsCirca = false; + $artwork->Created = new DateTime(); + $artwork->Status = COVER_ARTWORK_STATUS_IN_USE; + $artwork->EbookWwwFilesystemPath = $ebookWwwFilesystemPath; + $artwork->MimeType = ImageMimeType::JPG; + + $coverSourceFile = tempnam($workDir, 'cover.source.'); + // Search for JPEG, PNG, and TIFF source files, in that order. + exec("git show HEAD:images/cover.source.jpg > $coverSourceFile.jpg", $shellOutput, $resultCode); + if($resultCode !== 0){ + // No JPEG, try PNG. + exec("git show HEAD:images/cover.source.png > $coverSourceFile.png", $shellOutput, $resultCode); + if($resultCode == 0){ + // Found PNG, convert it to JPEG. + exec("convert $coverSourceFile.png -resize '3750x>' -sampling-factor 4:2:0 -strip -quality 80 -colorspace RGB -interlace JPEG $coverSourceFile.jpg", $shellOutput, $resultCode); + if($resultCode !== 0){ + exit($repoDir . " Error: Failed to convert images/cover.source.png to JPEG\n"); + } + }else{ + // No JPEG or PNG, try TIFF. + exec("git show HEAD:images/cover.source.tif > $coverSourceFile.tif", $shellOutput, $resultCode); + if($resultCode == 0){ + // Found TIFF, convert it to JPEG. + exec("convert $coverSourceFile.tif -resize '3750x>' -sampling-factor 4:2:0 -strip -quality 80 -colorspace RGB -interlace JPEG $coverSourceFile.jpg", $shellOutput, $resultCode); + if($resultCode !== 0){ + exit($repoDir . " Error: Failed to convert images/cover.source.tif to JPEG\n"); + } + }else{ + exit($repoDir . " Error: no images/cover.source.jpg or images/cover.source.png or images/cover.source.tif\n"); + + } + } + } + + $uploadedFile = ['tmp_name' => $coverSourceFile . '.jpg', 'error' => UPLOAD_ERR_OK]; + $artwork->Create($uploadedFile); +} +else{ + if($verbose){ + printf($repoDir . " Existing artwork found at %s/%s, updating its status.\n", $artistUrlName, $artworkUrlName); + } + + if($artwork->CompletedYear != $completedYear){ + printf($repoDir . " Error: Existing database artwork completed year, %d, does not match ebook colophon completed year, %d. Not updating database.\n", $artwork->CompletedYear, $completedYear); + exit($repoDir . " Error: completed year\n"); + } + + if($artwork->Status === COVER_ARTWORK_STATUS_IN_USE){ + printf($repoDir . " Error: Existing database artwork already marked as 'in_use' by ebook '%s'. Not updating database.\n", $artwork->EbookWwwFilesystemPath); + exit($repoDir . " Error: in_use\n"); + } + + $artwork->MarkInUse($ebookWwwFilesystemPath); +} diff --git a/templates/ArtworkDetail.php b/templates/ArtworkDetail.php new file mode 100644 index 00000000..ec0233ee --- /dev/null +++ b/templates/ArtworkDetail.php @@ -0,0 +1,83 @@ + + +

Name) ?>

+ + + + + + + + + + +

Metadata

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

U.S. public domain proof

+MuseumUrl !== null){ ?> +

Museum page

+

MuseumUrl) ?>

+ Museum !== null){ ?> +
+

Approved museum: Museum->Name) ?> (Museum->Domain) ?>)

+
+ +
+

Not an approved museum.

+
+ + + +PublicationYear !== null){ ?> +

Page scans

+
    +
  • Year book was published: PublicationYear !== null){ ?>PublicationYear ?>Not provided
  • +
  • Was book published in the U.S.: IsPublishedInUs){ ?>YesNo
  • +
  • Page scan of book publication year: PublicationYearPageUrl !== null){ ?>LinkNot provided
  • +
  • Page scan of rights statement page: CopyrightPageUrl !== null){ ?>LinkNot provided
  • +
  • Page scan of artwork: ArtworkPageUrl !== null){ ?>LinkNot provided
  • +
+ + +Exception !== null){ ?> +

Special public domain exception

+ Exception) ?> + diff --git a/templates/ArtworkList.php b/templates/ArtworkList.php new file mode 100644 index 00000000..eccc8cec --- /dev/null +++ b/templates/ArtworkList.php @@ -0,0 +1,37 @@ + +
    + + + AdminUrl; ?> + + Url; ?> + +
  1. + +

    Name) ?>

    +

    + Artist->Name) ?> + Artist->AlternateSpellings) > 0){ ?>(AKA , ', array_map('Formatter::ToPlainText', $artwork->Artist->AlternateSpellings)) ?>) +

    +
    +

    Year completed: CompletedYear === null){ ?>(unknown)CompletedYear ?>CompletedYearIsCirca){ ?> (circa)

    +

    Status: $artwork]) ?>

    + Tags) > 0){ ?> +

    Tags:

    + + +
    +
  2. + +
diff --git a/templates/ArtworkSearchForm.php b/templates/ArtworkSearchForm.php new file mode 100644 index 00000000..ac574ca4 --- /dev/null +++ b/templates/ArtworkSearchForm.php @@ -0,0 +1,33 @@ + diff --git a/templates/ArtworkStatus.php b/templates/ArtworkStatus.php new file mode 100644 index 00000000..b29bcc03 --- /dev/null +++ b/templates/ArtworkStatus.php @@ -0,0 +1,10 @@ + + +Status === COVER_ARTWORK_STATUS_APPROVED){ ?>Approved +Status === COVER_ARTWORK_STATUS_DECLINED){ ?>Declined +Status === COVER_ARTWORK_STATUS_UNVERIFIED){ ?>Unverified +Status === COVER_ARTWORK_STATUS_IN_USE){ ?>In useEbook !== null && $artwork->Ebook->Url !== null){ ?> by Ebook->Title) ?> + diff --git a/templates/DonationProgress.php b/templates/DonationProgress.php index 02523ecb..380e2208 100644 --- a/templates/DonationProgress.php +++ b/templates/DonationProgress.php @@ -3,7 +3,7 @@ $start = new DateTime(DONATION_DRIVE_START); $end = new DateTime(DONATION_DRIVE_END); $totalCurrent = 0; -$baseTarget = 30; +$baseTarget = 50; $stretchCurrent = 0; $stretchTarget = 20; diff --git a/templates/Header.php b/templates/Header.php index 9f732455..7a556e06 100644 --- a/templates/Header.php +++ b/templates/Header.php @@ -4,11 +4,12 @@ $title = $title ?? ''; $highlight = $highlight ?? ''; $description = $description ?? ''; $manual = $manual ?? false; +$artwork = $artwork ?? false; $colorScheme = $_COOKIE['color-scheme'] ?? 'auto'; $isXslt = $isXslt ?? false; $feedUrl = $feedUrl ?? null; $feedTitle = $feedTitle ?? ''; -$is404 = $is404 ?? false; +$isErrorPage = $isErrorPage ?? false; // As of Sep 2022, all versions of Safari have a bug where if the page is served as XHTML, // then elements download all s instead of the first supported match. @@ -38,7 +39,7 @@ if(!$isXslt){ - + @@ -50,6 +51,9 @@ if(!$isXslt){ + + + @@ -67,7 +71,7 @@ if(!$isXslt){ - + diff --git a/templates/ImageCopyrightNotice.php b/templates/ImageCopyrightNotice.php new file mode 100644 index 00000000..31947a96 --- /dev/null +++ b/templates/ImageCopyrightNotice.php @@ -0,0 +1 @@ +

These images are thought to be free of copyright restrictions in the United States. They may still be under copyright in other countries. If you’re not located in the United States, you must check your local laws to verify that these images are free of copyright restrictions in the country you’re located in before accessing, downloading, or using them.

diff --git a/www/403.php b/www/403.php new file mode 100644 index 00000000..5c8a517b --- /dev/null +++ b/www/403.php @@ -0,0 +1,17 @@ + 'You Don’t Have Permission to View This Page', 'highlight' => '', 'description' => 'You don’t have permission to view this page.', 'isErrorPage' => true]) ?> +
+
+
+

You Don’t Have Permission to View This Page

+

This is 403 error.

+
+ + + + Baroque-era guards gather around a table in a room. + +

Your account doesn’t have permission to view this page.

+

If you arrived here in error, contact us so that we can fix it.

+
+
+ diff --git a/www/404.php b/www/404.php index 2004cf69..fae63e1d 100644 --- a/www/404.php +++ b/www/404.php @@ -1,4 +1,4 @@ - 'We Couldn’t Find That Document', 'highlight' => '', 'description' => 'We couldn’t find that document.', 'is404' => true]) ?> + 'We Couldn’t Find That Document', 'highlight' => '', 'description' => 'We couldn’t find that document.', 'isErrorPage' => true]) ?>
diff --git a/www/admin/artworks/get.php b/www/admin/artworks/get.php new file mode 100644 index 00000000..b1c91458 --- /dev/null +++ b/www/admin/artworks/get.php @@ -0,0 +1,48 @@ +Status == COVER_ARTWORK_STATUS_APPROVED || $artwork->Status == COVER_ARTWORK_STATUS_IN_USE){ + http_response_code(302); + header('Location: ' . $artwork->Url); + exit(); + } + + // We got here because an artwork submission had errors and the user has to try again + if($exception){ + http_response_code(422); + session_unset(); + } +} +catch(Exceptions\AppException){ + Template::Emit404(); +} + +?> 'Review Artwork', 'artwork' => true, 'highlight' => '', 'description' => 'Unverified artwork.']) ?> +
+ $exception]) ?> +
+ $artwork]) ?> + Status == COVER_ARTWORK_STATUS_DECLINED){ ?> +

Status

+

This artwork has been declined by a reviewer.

+ + Status == COVER_ARTWORK_STATUS_UNVERIFIED){ ?> +

Review

+

Review the metadata and PD proof for this artwork submission. Approve to make it available for future producers.

+
+ + + +
+ +
+
+ diff --git a/www/admin/artworks/index.php b/www/admin/artworks/index.php new file mode 100644 index 00000000..d9134b93 --- /dev/null +++ b/www/admin/artworks/index.php @@ -0,0 +1,88 @@ +Benefits->CanReviewArtwork){ + throw new Exceptions\InvalidPermissionsException(); + } + + if($status !== null){ + $artwork = Artwork::Get(HttpInput::Int(SESSION, 'artwork-id')); + + http_response_code(201); + session_unset(); + } + + if($page <= 0){ + $page = 1; + } + + $unverifiedArtworks = Db::Query(' + SELECT * + from Artworks + where Status = "unverified" + order by Created asc + limit ?, ? + ', [($page - 1) * $perPage, $perPage + 1], 'Artwork'); + + if(sizeof($unverifiedArtworks) > $perPage){ + array_pop($unverifiedArtworks); + $hasNextPage = true; + } +} +catch(Exceptions\LoginRequiredException){ + Template::RedirectToLogin(); +} +catch(Exceptions\InvalidPermissionsException){ + Template::Emit403(); // No permissions to submit artwork +} + +?> 'Artwork Review Queue', 'artwork' => true, 'highlight' => '', 'description' => 'The queue of unverified artwork.']) ?> +
+
+

Artwork Review Queue

+ + +

+ + Name) ?> approved. + + Artwork approved. + +

+ + +

+ + Name) ?> declined. + + Artwork declined. + +

+ + +

No artwork to review.

+ + $unverifiedArtworks, 'useAdminUrl' => true]) ?> + +
+ + +
+ diff --git a/www/admin/artworks/post.php b/www/admin/artworks/post.php new file mode 100644 index 00000000..005784de --- /dev/null +++ b/www/admin/artworks/post.php @@ -0,0 +1,45 @@ +Benefits->CanReviewArtwork){ + throw new Exceptions\InvalidPermissionsException(); + } + + $artwork = Artwork::Get(HttpInput::Int(GET, 'artworkid')); + $artwork->Status = HttpInput::Str(POST, 'status', false); + $artwork->ReviewerUserId = $GLOBALS['User']->UserId; + $artwork->Save(); + + $_SESSION['artwork-id'] = $artwork->ArtworkId; + $_SESSION['status'] = $artwork->Status; + + http_response_code(303); + header('Location: /admin/artworks'); +} +catch(Exceptions\InvalidRequestException){ + http_response_code(405); +} +catch(Exceptions\LoginRequiredException){ + Template::RedirectToLogin(); +} +catch(Exceptions\InvalidPermissionsException){ + Template::Emit403(); // No permissions to submit artwork +} +catch(Exceptions\ArtworkNotFoundException){ + Template::Emit404(); +} +catch(Exceptions\AppException $exception){ + $_SESSION['exception'] = $exception; + + http_response_code(303); + header('Location: /admin/artworks/' . $artwork->ArtworkId); +} diff --git a/www/artworks/get.php b/www/artworks/get.php new file mode 100644 index 00000000..606f0055 --- /dev/null +++ b/www/artworks/get.php @@ -0,0 +1,19 @@ + $artwork->Name, 'artwork' => true]) ?> +
+
+ $artwork]) ?> +
+
+ diff --git a/www/artworks/index.php b/www/artworks/index.php new file mode 100644 index 00000000..27bb5767 --- /dev/null +++ b/www/artworks/index.php @@ -0,0 +1,88 @@ + 1){ + $pageTitle .= ', page ' . $page; +} + +$pageDescription = 'Page ' . $page . ' of artwork'; + +if($query != ''){ + $queryString .= '&query=' . urlencode($query); +} + +if($status !== null){ + $queryString .= '&status=' . urlencode($status); +} + +if($sort !== null){ + $queryString .= '&sort=' . urlencode($sort); +} + +if($perPage !== COVER_ARTWORK_PER_PAGE){ + $queryString .= '&per-page=' . urlencode((string)$perPage); +} + + +$queryString = preg_replace('/^&/ius', '', $queryString); +?> $pageTitle, 'artwork' => true, 'description' => $pageDescription]) ?> +
+
+

Browse U.S. Public Domain Artwork

+ + $query, 'status' => $status, 'sort' => $sort, 'perPage' => $perPage]) ?> + + +

No artwork matched your filters. You can try different filters, or browse all artwork.

+ + $artworks, 'useAdminUrl' => false]) ?> + + 0){ ?> + + +
+
+ diff --git a/www/artworks/new.php b/www/artworks/new.php new file mode 100644 index 00000000..7fac2590 --- /dev/null +++ b/www/artworks/new.php @@ -0,0 +1,243 @@ +Benefits->CanUploadArtwork){ + throw new Exceptions\InvalidPermissionsException(); + } + + // We got here because an artwork was successfully submitted + if($created){ + http_response_code(201); + $artwork = null; + session_unset(); + } + + // We got here because an artwork submission had errors and the user has to try again + if($exception){ + http_response_code(422); + session_unset(); + } + + if($artwork === null){ + $artwork = new Artwork(); + $artwork->Artist = new Artist(); + + if($GLOBALS['User']->Benefits->CanReviewArtwork){ + $artwork->Status = COVER_ARTWORK_STATUS_APPROVED; + } + } +} +catch(Exceptions\LoginRequiredException){ + Template::RedirectToLogin(); +} +catch(Exceptions\InvalidPermissionsException){ + Template::Emit403(); // No permissions to submit artwork +} + +?> + 'Submit an Artwork', + 'artwork' => true, + 'highlight' => '', + 'description' => 'Submit public domain artwork to the database for use as cover art.' + ] +) ?> +
+
+

Submit an Artwork

+ + $exception]) ?> + + +

Artwork submitted!

+ + +
+
+ Artist details + + +
+
+ Artwork details + +
+ + +
+ + +
+
+ Proof of U.S. public domain status +

See the US-PD clearance section of the SEMoS for details on this section.

+

PD proof must take the form of either:

+
+ +
+

or proof that the artwork was reproduced in a book published before , with all of the following:

+
+ + + + + +
+

or a special reason for an exception:

+
+ +
+
+ Benefits->CanReviewArtwork){ ?> +
+ Reviewer options + +
+ + +
+
+
+ diff --git a/www/artworks/post.php b/www/artworks/post.php new file mode 100644 index 00000000..5df39dce --- /dev/null +++ b/www/artworks/post.php @@ -0,0 +1,74 @@ +Benefits->CanUploadArtwork){ + throw new Exceptions\InvalidPermissionsException(); + } + + $artwork = new Artwork(); + + $artwork->Artist = new Artist(); + $artwork->Artist->Name = HttpInput::Str(POST, 'artist-name', false); + $artwork->Artist->DeathYear = HttpInput::Int(POST, 'artist-year-of-death'); + + $artwork->Name = HttpInput::Str(POST, 'artwork-name', false); + $artwork->CompletedYear = HttpInput::Int(POST, 'artwork-year'); + $artwork->CompletedYearIsCirca = HttpInput::Bool(POST, 'artwork-year-is-circa', false); + $artwork->Tags = Artwork::ParseTags(HttpInput::Str(POST, 'artwork-tags', false)); + $artwork->Status = HttpInput::Str(POST, 'artwork-status', false) ?? COVER_ARTWORK_STATUS_UNVERIFIED; + $artwork->IsPublishedInUs = HttpInput::Bool(POST, 'artwork-is-published-in-us', false); + $artwork->PublicationYear = HttpInput::Int(POST, 'artwork-publication-year'); + $artwork->PublicationYearPageUrl = HttpInput::Str(POST, 'artwork-publication-year-page-url', false); + $artwork->CopyrightPageUrl = HttpInput::Str(POST, 'artwork-copyright-page-url', false); + $artwork->ArtworkPageUrl = HttpInput::Str(POST, 'artwork-artwork-page-url', false); + $artwork->MuseumUrl = HttpInput::Str(POST, 'artwork-museum-url', false); + $artwork->MimeType = ImageMimeType::FromFile($_FILES['artwork-image']['tmp_name'] ?? null); + $artwork->Exception = HttpInput::Str(POST, 'artwork-exception', false); + + // Only approved reviewers can set the status to anything but unverified when uploading + if($artwork->Status != COVER_ARTWORK_STATUS_UNVERIFIED && !$GLOBALS['User']->Benefits->CanReviewArtwork){ + throw new Exceptions\InvalidPermissionsException(); + } + + $artwork->Create($_FILES['artwork-image'] ?? []); + + $_SESSION['artwork'] = $artwork; + $_SESSION['artwork-created'] = true; + + http_response_code(303); + header('Location: /artworks/new'); +} +catch(Exceptions\LoginRequiredException){ + Template::RedirectToLogin(); +} +catch(Exceptions\InvalidPermissionsException){ + Template::Emit403(); +} +catch(Exceptions\InvalidRequestException){ + http_response_code(405); +} +catch(Exceptions\AppException $exception){ + $_SESSION['artwork'] = $artwork; + $_SESSION['exception'] = $exception; + + http_response_code(303); + header('Location: /artworks/new'); +} diff --git a/www/css/artwork.css b/www/css/artwork.css new file mode 100644 index 00000000..bb4d7f3d --- /dev/null +++ b/www/css/artwork.css @@ -0,0 +1,496 @@ +@font-face{ + font-family: "Fira Mono"; + src: local("Fira Mono"), url("/fonts/fira-mono.woff2") format("woff2"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face{ + font-family: "Fira Mono"; + src: local("Fira Mono"), url("/fonts/fira-mono-bold.woff2") format("woff2"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +form button.decline-button{ + background-color: #777; +} + +form button.decline-button:hover{ + background-color: #aaa; +} + +.artworks nav > a{ + border: 1px solid rgba(0, 0, 0, .5); + font-style: normal; + box-sizing: border-box; + background-color: var(--button); + border-radius: 5px; + padding: 1rem 2rem; + color: #fff; + text-decoration: none; + font-family: "League Spartan", Arial, sans-serif; + text-shadow: 1px 1px 0 rgba(0, 0, 0, .5); + box-shadow: 2px 2px 0 rgba(0, 0, 0, .5), 1px 1px 0px rgba(255,255,255, .5) inset; + position: relative; + text-transform: lowercase; + cursor: pointer; + white-space: nowrap; + font-size: 1rem; + hyphens: none; + line-height: 1.2; +} + +.message{ + background: rgba(0, 0, 0, .2); +} + +.artworks nav li.highlighted a:focus{ + outline: 1px dashed var(--input-outline); +} + +.artworks nav > a[href]:active{ + top: 2px; + left: 2px; + box-shadow: none; +} + +.artworks nav > a:last-child::after{ + font-family: "Fork Awesome"; + content: "\f061"; + transition: all 200ms ease; + position: relative; + left: 0; + margin-left: .5rem; +} + +.artworks nav > a:last-child[href]:hover::after{ + left: .25rem; + position: relative; + transition: all 200ms ease; +} + +.artworks nav > a:first-child:before{ + font-family: "Fork Awesome"; + content: "\f060"; + transition: all 200ms ease; + position: relative; + right: 0; + margin-right: .5rem; +} + +.artworks nav > a:first-child[href]:hover::before{ + right: .25rem; + position: relative; + transition: all 200ms ease; +} + +.artworks nav > a:last-child[href]:hover::after{ + left: .25rem; + position: relative; + transition: all 200ms ease; +} + +main.artworks nav ol li a:hover, +main.artworks nav ol li.highlighted a:hover, +.artworks nav > a:hover{ + background-color: var(--button-highlight); +} + +button:disabled, +button:disabled:hover, +.artworks nav > a:not([href]){ + cursor: default; + color: #efefef; + box-shadow: none; + background: #bbb url("/images/stripes-dark.svg"); +} + +ol.artwork-list.list{ + display: flex; + flex-direction: column; + gap: 0; + margin-bottom: 4rem; + margin-left: auto; + margin-right: auto; + width: 100%; + max-width: 40rem; +} + +ol.artwork-list.list > li{ + border-radius: .25rem; + display: grid; + grid-template-columns: 16rem 1fr; + grid-column-gap: 2rem; + grid-template-rows: auto auto 1fr; + padding: 1rem; +} + +ol.artwork-list.list > li.in_use{ + background-color: rgba(0, 0, 0, .2); + background-image: url("/images/stripes.svg"); +} + +ol.artwork-list.list > li + li{ + margin-top: 1rem; +} + +ol.artwork-list.list > li .thumbnail-container{ + grid-row: 1 / span 3; + margin-left: auto; +} + +ol.artwork-list.list > li p{ + text-align: left; +} + +ol.artwork-list > li p{ + margin: 0; +} + +ol.artwork-list > li img{ + box-sizing: border-box; + max-width: 100%; + height: auto; + border: 1px solid var(--border); + border-radius: .25rem; +} + +ol.artwork-list > li > p:nth-of-type(1) > a{ + font-weight: bold; + text-decoration: none; +} + +ol.artwork-list > li .author{ + font-style: italic; +} + +main.artworks nav ol{ + list-style: none; + display: flex; + margin: 0 .5rem; +} + +main.artworks nav{ + display: flex; + justify-content: center; + align-items: center; + max-width: none; +} + +main.artworks nav ol li{ + margin: 0; + white-space: nowrap; +} + +main.artworks nav ol li a{ + font-size: 1rem; + line-height: 1.4; + padding: 1rem; + margin: 0 .5rem; + border-radius: .25rem; + font-variant-numeric: normal; + height: calc(1.4rem + 2rem + 2px); + display: inline-block; + box-sizing: border-box; +} + +main.artworks nav ol li.highlighted a{ + background: var(--button); + border: 1px solid rgba(0, 0, 0, .5); +} + +main.artworks nav ol li.highlighted a, +main.artworks nav ol li a:hover{ + color: #fff; + text-decoration: none; + text-shadow: 1px 1px 0 rgba(0, 0, 0, .5); +} + +.artworks form[action="/artworks"]{ + align-items: end; + display: grid; + grid-gap: 1rem; + grid-template-columns: auto auto auto auto 1fr; + margin: 0 1rem; + margin-bottom: 4rem; + max-width: calc(100% - 2rem); +} + +form[action="/artworks"] > fieldset{ + display: grid; + gap: 1rem; +} + +form[action="/artworks"] > fieldset + fieldset{ + margin-top: 2rem; +} + +/* Artist details */ +form[action="/artworks"] > fieldset:nth-of-type(1){ + grid-template-columns: 1fr 1fr; +} + +/* Artwork details */ +form[action="/artworks"] > fieldset:nth-of-type(2){ + grid-template-columns: 1fr 200px; +} + +form[action="/artworks"] > fieldset label:has(input[name="artwork-tags"]), +form[action="/artworks"] > fieldset label:has(input[name="artwork-image"]){ + grid-column: 1 / span 2; +} + +form[action="/artworks"] fieldset fieldset:has(input[name="artwork-publication-year"]){ + display: grid; + grid-template-columns: 200px 1fr; + gap: 1rem; +} + +form[action="/artworks"] fieldset fieldset:has(input[name="artwork-publication-year"]) label ~ label{ + grid-column: 1 / span 2; +} + +form[action="/artworks"] #pd-proof > fieldset{ + border-left-color: var(--body-text); + border-left-width: 3px; + border-left-style: dotted; + padding-left: .75rem; +} + +form[action="/artworks"] fieldset p{ + font-style: italic; + margin: 0; + border: none; +} + +form[action="/artworks"] fieldset p:first-of-type{ + margin-top: 0; +} + +form[action="/artworks"] legend{ + font-size: 1.2rem; + font-weight: bold; + margin: 0.5rem 0; +} + +form[action="/artworks"] label{ + display: block; +} + +form[action="/artworks"] div.footer{ + margin-top: 1rem; + text-align: right; +} + +main.artworks nav ol li:not(:first-child):not(:last-child):not(.highlighted){ + display: none; +} + +main.artworks nav ol li.highlighted::before{ + content: "⋯"; + padding-right: 1rem; +} + +main.artworks nav ol li.highlighted::after{ + content: "⋯"; + padding-left: 1rem; +} + +main.artworks nav ol li.highlighted:first-child::before, +main.artworks nav ol li.highlighted:last-child::after, +main.artworks nav ol li.highlighted:nth-child(2)::before, +main.artworks nav ol li.highlighted:nth-last-child(2)::after{ + display: none; +} + +.artwork-metadata{ + margin-top: 1rem; +} + +.artwork-metadata td:first-child{ + font-weight: bold; + width: 25%; +} + +.artwork-metadata td:last-child{ + width: 75%; +} + +.artwork-metadata td{ + padding: 0.5rem; +} + +.artworks h1 + a{ + width: auto; +} + +.artworks aside.tip{ + font-style: italic; + margin: 1rem auto; + position: relative; + padding: 1rem; + padding-left: 2rem; + padding-top: 2rem; + background: rgba(0, 0, 0, .2); +} + +.artworks aside.tip::before{ + font-family: "Fork Awesome"; + content: "\f0eb"; + font-size: 1rem; + position: absolute; + font-style: normal; + left: .75rem; + top: .25rem; +} + +.artworks aside.tip::after{ + content: "Tip"; + font-family: "League Spartan"; + text-transform: uppercase; + font-size: .75rem; + position: absolute; + font-style: normal; + left: 2rem; + top: .5rem; +} + +.artworks .tags{ + justify-content: unset; +} + +@media(max-width: 730px){ + main.artwork nav ol li:not(.highlighted){ + display: none; + } +} + +@media(max-width: 680px){ + main.artworks nav ol li.highlighted::before, + main.artworks nav ol li.highlighted::after{ + display: none; + } +} + +@media(max-width: 500px){ + main.artworks nav{ + margin-top: .5rem; + } + + main.artworks nav > a{ + margin-bottom: 0; + font-size: 0; + } + + main.artworks nav > a::before, + main.artworks nav > a::after{ + font-size: 1rem; + margin: 0 !important; + } + + main.artworks nav a[rel]::before, + main.artworks nav a[rel]::after{ + font-size: 1rem; + margin: 0; + } +} + +@media(max-width: 400px){ + /* Artist details */ + form[action="/artworks"] > fieldset:nth-of-type(1){ + grid-template-columns: 1fr; + } + + form[action="/artworks"] > fieldset:nth-of-type(2){ + grid-template-columns: 1fr; + } + + form[action="/artworks"] > fieldset label:has(input[name="artwork-tags"]), + form[action="/artworks"] > fieldset label:has(input[name="artwork-image"]){ + grid-column: 1; + } +} + +@media(max-width: 380px){ + main.artworks nav > a{ + padding: 1rem; + } +} + +@media(prefers-reduced-motion: reduce){ + .artworks nav > a:last-child::after, + .artworks nav > a:last-child[href]:hover::after, + .artworks nav > a:first-child:before, + .artworks nav > a:first-child[href]:hover::before, + .artworks nav > a:last-child[href]:hover::after{ + transition: none; + } +} + +.artworks figure p{ + background: #333; + border: 1px solid var(--border); + border-radius: .25rem; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + color: #fff !important; /* in case code highlighting fails */ + flex-grow: 1; + margin: 0; + padding: 1rem; + text-align: left; +} + +.artworks figure.wrong::before{ + background: #762729; + font-family: "Fork Awesome"; + content: "\f00d"; + font-size: 1.5rem; + width: 1.5rem; + line-height: 1; + margin-right: .5rem; + color: #CF4647; + text-shadow: 1px 1px 0 rgba(0, 0, 0, .1); + display: inline-block; + flex-shrink: 0; +} + +.artworks figure.corrected::before{ + background: #406451; + font-family: "Fork Awesome"; + content: "\f00c"; + font-size: 1.5rem; + line-height: 1; + width: 1.5rem; + color: #79BD9A; + text-shadow: 1px 1px 0 rgba(0, 0, 0, .1); + flex-shrink: 0; +} + +.artworks figure.wrong::before, +.artworks figure.corrected::before{ + display: flex; + margin-right: 0; + align-self: stretch; + align-items: center; + justify-content: center; + padding: 0 .5rem; + border-top-left-radius: .25rem; + border-bottom-left-radius: .25rem; + border: 1px solid var(--border); + border-right: none; +} + +.artworks figure.wrong, +.artworks figure.corrected{ + display: flex; + align-items: center; +} + +.artworks code{ + font-variant-numeric: normal; + font-family: "Fira Mono", monospace; + font-size: .8rem; +} diff --git a/www/css/core.css b/www/css/core.css index 98c1995b..85f6d0d5 100644 --- a/www/css/core.css +++ b/www/css/core.css @@ -823,11 +823,16 @@ a.button:focus, input[type="email"]:focus, input[type="text"]:focus, input[type="month"]:focus, +input[type="number"]:focus, +input[type="url"]:focus, input[type="search"]:focus, +input[type="file"]:focus, label.checkbox:focus-within, +label:has(input[type="checkbox"]):focus-within, select:focus, button:focus, -nav a[rel]:focus{ +nav a[rel]:focus, +textarea:focus{ outline: 1px dashed var(--input-outline); } @@ -1703,8 +1708,11 @@ label.search{ } select, +textarea, input[type="text"], input[type="month"], +input[type="number"], +input[type="url"], input[type="email"], input[type="search"]{ -webkit-appearance: none; @@ -1736,6 +1744,42 @@ select{ display: block; } +label span{ + display: block; +} + +label span + span{ + font-style: italic; +} + +label span + span i{ + font-style: normal; +} + +input[type="file"], +label:has(input[type="file"]), +label:has(input[type="checkbox"]){ + cursor: pointer; +} + +label + label:has(input[type="checkbox"]){ + margin-top: 1px; /* So we can see the top outline on focus */ +} + +label:has(input[type="checkbox"]):has(> span){ + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr; +} + +label:has(input[type="checkbox"]):has(> span) input{ + grid-row: 1 / span 2; + justify-self: center; + align-self: start; + margin-top: 10px; + margin-right: .5rem; +} + select[multiple]{ padding: 1rem; } @@ -1809,6 +1853,8 @@ a.button, button, input[type="text"], input[type="month"], +input[type="number"], +input[type="url"], input[type="email"], input[type="search"], select{ @@ -1826,10 +1872,16 @@ input[type="text"]:focus, input[type="text"]:hover, input[type="month"]:focus, input[type="month"]:hover, +input[type="number"]:focus, +input[type="number"]:hover, +input[type="url"]:focus, +input[type="url"]:hover, input[type="email"]:focus, input[type="email"]:hover, input[type="search"]:focus, input[type="search"]:hover, +textarea:focus, +textarea:hover, select:focus, select:hover{ border-color: var(--input-outline); @@ -1839,6 +1891,8 @@ select:hover{ input[type="text"]:user-invalid, input[type="month"]:user-invalid, +input[type="number"]:user-invalid, +input[type="url"]:user-invalid, input[type="email"]:user-invalid, input[type="search"]:user-invalid{ border-color: #ff0000; @@ -1847,6 +1901,8 @@ input[type="search"]:user-invalid{ input[type="text"]:-moz-ui-invalid, input[type="month"]:-moz-ui-invalid, +input[type="number"]:-moz-ui-invalid, +input[type="url"]:-moz-ui-invalid, input[type="email"]:-moz-ui-invalid, input[type="search"]:-moz-ui-invalid{ border-color: #ff0000; @@ -3535,12 +3591,15 @@ ul.feed p{ label.select:hover > span + span::after, label.email::before, label.search::before, + textarea:hover, nav li.highlighted a, nav a[rel], a.button, button, input[type="text"], input[type="month"], + input[type="number"], + input[type="url"], input[type="email"], input[type="search"], select, @@ -3552,6 +3611,10 @@ ul.feed p{ input[type="text"]:hover, input[type="month"]:focus, input[type="month"]:hover, + input[type="number"]:focus, + input[type="number"]:hover, + input[type="url"]:focus, + input[type="url"]:hover, input[type="email"]:focus, input[type="email"]:hover, input[type="search"]:focus, diff --git a/www/css/dark.css b/www/css/dark.css index 222084ae..4f3ae32c 100644 --- a/www/css/dark.css +++ b/www/css/dark.css @@ -58,6 +58,8 @@ article.ebook section .donation{ select, input[type="text"], input[type="month"], +input[type="number"], +input[type="url"], input[type="email"], input[type="search"]{ box-shadow: 1px 1px 0 rgba(0, 0, 0, .5) inset; diff --git a/www/ebooks/index.php b/www/ebooks/index.php index baf84426..7e544697 100644 --- a/www/ebooks/index.php +++ b/www/ebooks/index.php @@ -145,13 +145,13 @@ catch(Exceptions\InvalidCollectionException){ 0 && $collection === null){ ?> diff --git a/www/feeds/get.php b/www/feeds/get.php index 8bf9a76c..867cbfbb 100644 --- a/www/feeds/get.php +++ b/www/feeds/get.php @@ -1,4 +1,6 @@ Write('Processing ebook `' . $repoName . '` located at `' . $dir . '`.'); // Check the local repo's last commit. If it matches this push, then don't do anything; we're already up to date. - $lastCommitSha1 = trim(shell_exec('git -C ' . escapeshellarg($dir) . ' rev-parse HEAD 2>&1') ?? ''); + + $lastCommitSha1 = trim(shell_exec('git -C ' . escapeshellarg($dir) . ' rev-parse HEAD 2>&1')); if($lastCommitSha1 == ''){ $log->Write('Error getting last local commit. Output: ' . $lastCommitSha1); diff --git a/www/webhooks/postmark.php b/www/webhooks/postmark.php index 61874b9f..c2e2462a 100644 --- a/www/webhooks/postmark.php +++ b/www/webhooks/postmark.php @@ -9,6 +9,7 @@ use function Safe\substr; $log = new Log(POSTMARK_WEBHOOK_LOG_FILE_PATH); try{ + /** @var string $smtpUsername */ $smtpUsername = get_cfg_var('se.secrets.postmark.username'); $log->Write('Received Postmark webhook.'); diff --git a/www/webhooks/zoho.php b/www/webhooks/zoho.php index db011ebc..8279d765 100644 --- a/www/webhooks/zoho.php +++ b/www/webhooks/zoho.php @@ -21,7 +21,10 @@ try{ $post = file_get_contents('php://input'); // Validate the Zoho secret. - if(!hash_equals($_SERVER['HTTP_X_HOOK_SIGNATURE'], base64_encode(hash_hmac('sha256', $post, get_cfg_var('se.secrets.zoho.webhook_secret'), true)))){ + /** @var string $zohoWebhookSecret */ + $zohoWebhookSecret = get_cfg_var('se.secrets.zoho.webhook_secret'); + + if(!hash_equals($_SERVER['HTTP_X_HOOK_SIGNATURE'], base64_encode(hash_hmac('sha256', $post, $zohoWebhookSecret, true)))){ throw new Exceptions\InvalidCredentialsException(); }