Add cover art database

Co-authored-by: Job Curtis <job.curtis@gmail.com>
Co-authored-by: Alex Cabal <alex@standardebooks.org>
This commit is contained in:
Mike Colagrosso 2023-12-28 16:38:39 -06:00 committed by Alex Cabal
parent 74f747df76
commit 6a5c05511a
92 changed files with 3174 additions and 146 deletions

1
.gitignore vendored
View file

@ -12,5 +12,6 @@ vendor/
*.log *.log
www/manual/* www/manual/*
!www/manual/index.php !www/manual/index.php
config/apache/htpasswd-standardebooks.org
config/php/fpm/standardebooks.org-secrets.ini config/php/fpm/standardebooks.org-secrets.ini
www/bulk-downloads/*/ www/bulk-downloads/*/

View file

@ -11,11 +11,12 @@
"php": "8.1.2" "php": "8.1.2"
}, },
"require": { "require": {
"thecodingmachine/safe": "^1.3.3", "thecodingmachine/safe": "^2.5.0",
"phpmailer/phpmailer": "^6.6.0", "phpmailer/phpmailer": "^6.6.0",
"ramsey/uuid": "4.2.3", "ramsey/uuid": "4.2.3",
"gregwar/captcha": "^1.2.0", "gregwar/captcha": "^1.2.0",
"php-webdriver/webdriver": "^1.12.1", "php-webdriver/webdriver": "^1.12.1",
"pear/http2": "^2.0.0" "pear/http2": "^2.0.0",
"erusev/parsedown": "^1.7.4"
} }
} }

239
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "218ea78082c83c5386eacaf3dc607eed", "content-hash": "4ea5d2eba58ce5fbb4162f1bdd585d73",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@ -67,17 +67,67 @@
"time": "2021-08-15T20:50:18+00:00" "time": "2021-08-15T20:50:18+00:00"
}, },
{ {
"name": "gregwar/captcha", "name": "erusev/parsedown",
"version": "v1.2.0", "version": "1.7.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Gregwar/Captcha.git", "url": "https://github.com/erusev/parsedown.git",
"reference": "6e5b61b66ac89885b505153f4ef9a74ffa5b3074" "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Gregwar/Captcha/zipball/6e5b61b66ac89885b505153f4ef9a74ffa5b3074", "url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3",
"reference": "6e5b61b66ac89885b505153f4ef9a74ffa5b3074", "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": "" "shasum": ""
}, },
"require": { "require": {
@ -119,9 +169,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/Gregwar/Captcha/issues", "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", "name": "pear/http2",
@ -172,16 +222,16 @@
}, },
{ {
"name": "php-webdriver/webdriver", "name": "php-webdriver/webdriver",
"version": "1.14.0", "version": "1.15.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/php-webdriver/php-webdriver.git", "url": "https://github.com/php-webdriver/php-webdriver.git",
"reference": "3ea4f924afb43056bf9c630509e657d951608563" "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/3ea4f924afb43056bf9c630509e657d951608563", "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/cd52d9342c5aa738c2e75a67e47a1b6df97154e8",
"reference": "3ea4f924afb43056bf9c630509e657d951608563", "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -190,7 +240,7 @@
"ext-zip": "*", "ext-zip": "*",
"php": "^7.3 || ^8.0", "php": "^7.3 || ^8.0",
"symfony/polyfill-mbstring": "^1.12", "symfony/polyfill-mbstring": "^1.12",
"symfony/process": "^5.0 || ^6.0" "symfony/process": "^5.0 || ^6.0 || ^7.0"
}, },
"replace": { "replace": {
"facebook/webdriver": "*" "facebook/webdriver": "*"
@ -232,22 +282,22 @@
], ],
"support": { "support": {
"issues": "https://github.com/php-webdriver/php-webdriver/issues", "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", "name": "phpmailer/phpmailer",
"version": "v6.8.0", "version": "v6.9.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git", "url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "df16b615e371d81fb79e506277faea67a1be18f1" "reference": "039de174cd9c17a8389754d3b877a2ed22743e18"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/df16b615e371d81fb79e506277faea67a1be18f1", "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/039de174cd9c17a8389754d3b877a2ed22743e18",
"reference": "df16b615e371d81fb79e506277faea67a1be18f1", "reference": "039de174cd9c17a8389754d3b877a2ed22743e18",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -257,16 +307,17 @@
"php": ">=5.5.0" "php": ">=5.5.0"
}, },
"require-dev": { "require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.7.2", "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3", "doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2", "php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^9.3.5", "phpcompatibility/php-compatibility": "^9.3.5",
"roave/security-advisories": "dev-latest", "roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7.1", "squizlabs/php_codesniffer": "^3.7.2",
"yoast/phpunit-polyfills": "^1.0.4" "yoast/phpunit-polyfills": "^1.0.4"
}, },
"suggest": { "suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing", "ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", "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", "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": { "support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues", "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": [ "funding": [
{ {
@ -314,7 +365,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2023-03-06T14:43:22+00:00" "time": "2023-11-25T22:23:28+00:00"
}, },
{ {
"name": "ramsey/collection", "name": "ramsey/collection",
@ -506,23 +557,23 @@
}, },
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v6.3.0", "version": "v6.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/finder.git", "url": "https://github.com/symfony/finder.git",
"reference": "d9b01ba073c44cef617c7907ce2419f8d00d75e2" "reference": "11d736e97f116ac375a81f96e662911a34cd50ce"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/d9b01ba073c44cef617c7907ce2419f8d00d75e2", "url": "https://api.github.com/repos/symfony/finder/zipball/11d736e97f116ac375a81f96e662911a34cd50ce",
"reference": "d9b01ba073c44cef617c7907ce2419f8d00d75e2", "reference": "11d736e97f116ac375a81f96e662911a34cd50ce",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.1" "php": ">=8.1"
}, },
"require-dev": { "require-dev": {
"symfony/filesystem": "^6.0" "symfony/filesystem": "^6.0|^7.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@ -550,7 +601,7 @@
"description": "Finds files and directories via an intuitive fluent interface", "description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/finder/tree/v6.3.0" "source": "https://github.com/symfony/finder/tree/v6.4.0"
}, },
"funding": [ "funding": [
{ {
@ -566,20 +617,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2023-04-02T01:25:41+00:00" "time": "2023-10-31T17:30:12+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
"version": "v1.27.0", "version": "v1.28.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git", "url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "5bbc823adecdae860bb64756d639ecfec17b050a" "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a", "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
"reference": "5bbc823adecdae860bb64756d639ecfec17b050a", "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -594,7 +645,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "1.27-dev" "dev-main": "1.28-dev"
}, },
"thanks": { "thanks": {
"name": "symfony/polyfill", "name": "symfony/polyfill",
@ -632,7 +683,7 @@
"portable" "portable"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0" "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0"
}, },
"funding": [ "funding": [
{ {
@ -648,20 +699,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2022-11-03T14:55:06+00:00" "time": "2023-01-26T09:26:14+00:00"
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
"version": "v1.27.0", "version": "v1.28.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git", "url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" "reference": "42292d99c55abe617799667f454222c54c60e229"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229",
"reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", "reference": "42292d99c55abe617799667f454222c54c60e229",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -676,7 +727,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "1.27-dev" "dev-main": "1.28-dev"
}, },
"thanks": { "thanks": {
"name": "symfony/polyfill", "name": "symfony/polyfill",
@ -715,7 +766,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0"
}, },
"funding": [ "funding": [
{ {
@ -731,20 +782,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2022-11-03T14:55:06+00:00" "time": "2023-07-28T09:04:16+00:00"
}, },
{ {
"name": "symfony/polyfill-php80", "name": "symfony/polyfill-php80",
"version": "v1.27.0", "version": "v1.28.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php80.git", "url": "https://github.com/symfony/polyfill-php80.git",
"reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
"reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -753,7 +804,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "1.27-dev" "dev-main": "1.28-dev"
}, },
"thanks": { "thanks": {
"name": "symfony/polyfill", "name": "symfony/polyfill",
@ -798,7 +849,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0"
}, },
"funding": [ "funding": [
{ {
@ -814,20 +865,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2022-11-03T14:55:06+00:00" "time": "2023-01-26T09:26:14+00:00"
}, },
{ {
"name": "symfony/polyfill-php81", "name": "symfony/polyfill-php81",
"version": "v1.27.0", "version": "v1.28.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php81.git", "url": "https://github.com/symfony/polyfill-php81.git",
"reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a" "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/707403074c8ea6e2edaf8794b0157a0bfa52157a", "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/7581cd600fa9fd681b797d00b02f068e2f13263b",
"reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a", "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -836,7 +887,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "1.27-dev" "dev-main": "1.28-dev"
}, },
"thanks": { "thanks": {
"name": "symfony/polyfill", "name": "symfony/polyfill",
@ -877,7 +928,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.27.0" "source": "https://github.com/symfony/polyfill-php81/tree/v1.28.0"
}, },
"funding": [ "funding": [
{ {
@ -893,20 +944,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2022-11-03T14:55:06+00:00" "time": "2023-01-26T09:26:14+00:00"
}, },
{ {
"name": "symfony/process", "name": "symfony/process",
"version": "v6.3.0", "version": "v6.4.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/process.git", "url": "https://github.com/symfony/process.git",
"reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628" "reference": "c4b1ef0bc80533d87a2e969806172f1c2a980241"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/8741e3ed7fe2e91ec099e02446fb86667a0f1628", "url": "https://api.github.com/repos/symfony/process/zipball/c4b1ef0bc80533d87a2e969806172f1c2a980241",
"reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628", "reference": "c4b1ef0bc80533d87a2e969806172f1c2a980241",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -938,7 +989,7 @@
"description": "Executes commands in sub-processes", "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/process/tree/v6.3.0" "source": "https://github.com/symfony/process/tree/v6.4.2"
}, },
"funding": [ "funding": [
{ {
@ -954,43 +1005,50 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2023-05-19T08:06:44+00:00" "time": "2023-12-22T16:42:54+00:00"
}, },
{ {
"name": "thecodingmachine/safe", "name": "thecodingmachine/safe",
"version": "v1.3.3", "version": "v2.5.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thecodingmachine/safe.git", "url": "https://github.com/thecodingmachine/safe.git",
"reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc" "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc", "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/3115ecd6b4391662b4931daac4eba6b07a2ac1f0",
"reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc", "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=7.2" "php": "^8.0"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^0.12", "phpstan/phpstan": "^1.5",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.2", "squizlabs/php_codesniffer": "^3.2",
"thecodingmachine/phpstan-strict-rules": "^0.12" "thecodingmachine/phpstan-strict-rules": "^1.0"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "0.1-dev" "dev-master": "2.2.x-dev"
} }
}, },
"autoload": { "autoload": {
"files": [ "files": [
"deprecated/apc.php", "deprecated/apc.php",
"deprecated/array.php",
"deprecated/datetime.php",
"deprecated/libevent.php", "deprecated/libevent.php",
"deprecated/misc.php",
"deprecated/password.php",
"deprecated/mssql.php", "deprecated/mssql.php",
"deprecated/stats.php", "deprecated/stats.php",
"deprecated/strings.php",
"lib/special_cases.php", "lib/special_cases.php",
"deprecated/mysqli.php",
"generated/apache.php", "generated/apache.php",
"generated/apcu.php", "generated/apcu.php",
"generated/array.php", "generated/array.php",
@ -1011,6 +1069,7 @@
"generated/fpm.php", "generated/fpm.php",
"generated/ftp.php", "generated/ftp.php",
"generated/funchand.php", "generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php", "generated/gmp.php",
"generated/gnupg.php", "generated/gnupg.php",
"generated/hash.php", "generated/hash.php",
@ -1020,7 +1079,6 @@
"generated/image.php", "generated/image.php",
"generated/imap.php", "generated/imap.php",
"generated/info.php", "generated/info.php",
"generated/ingres-ii.php",
"generated/inotify.php", "generated/inotify.php",
"generated/json.php", "generated/json.php",
"generated/ldap.php", "generated/ldap.php",
@ -1029,20 +1087,14 @@
"generated/mailparse.php", "generated/mailparse.php",
"generated/mbstring.php", "generated/mbstring.php",
"generated/misc.php", "generated/misc.php",
"generated/msql.php",
"generated/mysql.php", "generated/mysql.php",
"generated/mysqli.php",
"generated/mysqlndMs.php",
"generated/mysqlndQc.php",
"generated/network.php", "generated/network.php",
"generated/oci8.php", "generated/oci8.php",
"generated/opcache.php", "generated/opcache.php",
"generated/openssl.php", "generated/openssl.php",
"generated/outcontrol.php", "generated/outcontrol.php",
"generated/password.php",
"generated/pcntl.php", "generated/pcntl.php",
"generated/pcre.php", "generated/pcre.php",
"generated/pdf.php",
"generated/pgsql.php", "generated/pgsql.php",
"generated/posix.php", "generated/posix.php",
"generated/ps.php", "generated/ps.php",
@ -1053,7 +1105,6 @@
"generated/sem.php", "generated/sem.php",
"generated/session.php", "generated/session.php",
"generated/shmop.php", "generated/shmop.php",
"generated/simplexml.php",
"generated/sockets.php", "generated/sockets.php",
"generated/sodium.php", "generated/sodium.php",
"generated/solr.php", "generated/solr.php",
@ -1076,13 +1127,13 @@
"generated/zip.php", "generated/zip.php",
"generated/zlib.php" "generated/zlib.php"
], ],
"psr-4": { "classmap": [
"Safe\\": [ "lib/DateTime.php",
"lib/", "lib/DateTimeImmutable.php",
"deprecated/", "lib/Exceptions/",
"generated/" "deprecated/Exceptions/",
"generated/Exceptions/"
] ]
}
}, },
"notification-url": "https://packagist.org/downloads/", "notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
@ -1091,24 +1142,24 @@
"description": "PHP core functions that throw exceptions instead of returning FALSE on error", "description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": { "support": {
"issues": "https://github.com/thecodingmachine/safe/issues", "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": [ "packages-dev": [
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "1.10.21", "version": "1.10.50",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan.git", "url": "https://github.com/phpstan/phpstan.git",
"reference": "b2a30186be2e4d97dce754ae4e65eb0ec2f04eb5" "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b2a30186be2e4d97dce754ae4e65eb0ec2f04eb5", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/06a98513ac72c03e8366b5a0cb00750b487032e4",
"reference": "b2a30186be2e4d97dce754ae4e65eb0ec2f04eb5", "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1157,7 +1208,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2023-06-21T20:07:58+00:00" "time": "2023-12-13T10:59:42+00:00"
}, },
{ {
"name": "thecodingmachine/phpstan-safe-rule", "name": "thecodingmachine/phpstan-safe-rule",

View file

@ -295,6 +295,15 @@ Define webroot /standardebooks.org/web
RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1 RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1
RewriteRule ^/bulk-downloads/([^/\.]+)$ /bulk-downloads/collection.php?class=$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 # Specific config for /bulk-downloads
<DirectoryMatch "${webroot}/www/bulk-downloads"> <DirectoryMatch "${webroot}/www/bulk-downloads">
# Both directives are required # Both directives are required

View file

@ -277,6 +277,15 @@ Define webroot /standardebooks.org/web
RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1 RewriteRule ^/bulk-downloads/(.+\.zip)$ /bulk-downloads/download.php?path=$1
RewriteRule ^/bulk-downloads/([^/\.]+)$ /bulk-downloads/collection.php?class=$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 # Specific config for /bulk-downloads
<DirectoryMatch "${webroot}/www/bulk-downloads"> <DirectoryMatch "${webroot}/www/bulk-downloads">
# Both directives are required # Both directives are required

View file

@ -10,6 +10,9 @@ allow_url_fopen = false
allow_url_include = false allow_url_include = false
expose_php = Off expose_php = Off
post_max_size = 64M
upload_max_filesize = 32M
[Date] [Date]
date.timezone = Etc/UTC date.timezone = Etc/UTC

View file

@ -7,6 +7,9 @@ allow_url_fopen = false
allow_url_include = false allow_url_include = false
expose_php = Off expose_php = Off
post_max_size = 64M
upload_max_filesize = 32M
[Date] [Date]
date.timezone = Etc/UTC date.timezone = Etc/UTC

View file

@ -13,9 +13,6 @@ parameters:
# Ignore errors caused by type hints that should be union types. Union types are not yet supported in PHP. # 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.#' - '#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: level:
8 8
paths: paths:
@ -28,3 +25,8 @@ parameters:
- DONATION_ALERT_ON - DONATION_ALERT_ON
- DONATION_DRIVE_ON - DONATION_DRIVE_ON
- DONATION_DRIVE_COUNTER_ON - DONATION_DRIVE_COUNTER_ON
earlyTerminatingMethodCalls:
Template:
- Emit404
- Emit403
- RedirectToLogin

View file

@ -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;

10
config/sql/se/Artists.sql Normal file
View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -3,6 +3,8 @@ CREATE TABLE `Benefits` (
`CanAccessFeeds` tinyint(1) unsigned NOT NULL, `CanAccessFeeds` tinyint(1) unsigned NOT NULL,
`CanVote` tinyint(1) unsigned NOT NULL, `CanVote` tinyint(1) unsigned NOT NULL,
`CanBulkDownload` 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`), PRIMARY KEY (`UserId`),
KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`) KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -1,5 +1,5 @@
CREATE TABLE `FeedUserAgents` ( CREATE TABLE `FeedUserAgents` (
`UserAgentId` int(11) unsigned NOT NULL AUTO_INCREMENT, `UserAgentId` int(10) unsigned NOT NULL AUTO_INCREMENT,
`UserAgent` text NOT NULL, `UserAgent` text NOT NULL,
`Created` datetime NOT NULL, `Created` datetime NOT NULL,
PRIMARY KEY (`UserAgentId`) PRIMARY KEY (`UserAgentId`)

View file

@ -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;

View file

@ -1,6 +1,6 @@
CREATE TABLE `PollVotes` ( CREATE TABLE `PollVotes` (
`UserId` int(11) unsigned NOT NULL, `UserId` int(10) unsigned NOT NULL,
`PollItemId` int(11) unsigned NOT NULL, `PollItemId` int(10) unsigned NOT NULL,
`Created` datetime NOT NULL, `Created` datetime NOT NULL,
UNIQUE KEY `idxUnique` (`PollItemId`,`UserId`) UNIQUE KEY `idxUnique` (`PollItemId`,`UserId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -1,5 +1,5 @@
CREATE TABLE `Polls` ( CREATE TABLE `Polls` (
`PollId` int(11) unsigned NOT NULL AUTO_INCREMENT, `PollId` int(10) unsigned NOT NULL AUTO_INCREMENT,
`Created` datetime NOT NULL, `Created` datetime NOT NULL,
`Name` varchar(255) NOT NULL, `Name` varchar(255) NOT NULL,
`UrlName` varchar(255) NOT NULL, `UrlName` varchar(255) NOT NULL,

6
config/sql/se/Tags.sql Normal file
View file

@ -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;

153
lib/Artist.php Normal file
View file

@ -0,0 +1,153 @@
<?
use Safe\DateTime;
use function Safe\date;
/**
* @property string $UrlName
* @property array<string> $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<string>
*/
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]);
}
}

594
lib/Artwork.php Normal file
View file

@ -0,0 +1,594 @@
<?
use Safe\DateTime;
use function Safe\copy;
use function Safe\date;
use function Safe\exec;
use function Safe\filesize;
use function Safe\getimagesize;
use function Safe\ini_get;
use function Safe\preg_replace;
use function Safe\sprintf;
/**
* @property string $UrlName
* @property string $Url
* @property array<ArtworkTag> $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<ArtworkTag>
*/
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<mixed> $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<ArtworkTag> */
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<mixed> $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];
}
}

66
lib/ArtworkTag.php Normal file
View file

@ -0,0 +1,66 @@
<?
class ArtworkTag extends Tag{
// *******
// GETTERS
// *******
/**
* @return string
*/
protected function GetUrl(): string{
if($this->_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;
}
}
}

View file

@ -3,4 +3,6 @@ class Benefits{
public $CanAccessFeeds = false; public $CanAccessFeeds = false;
public $CanVote = false; public $CanVote = false;
public $CanBulkDownload = false; public $CanBulkDownload = false;
public $CanUploadArtwork = false;
public $CanReviewArtwork = false;
} }

View file

@ -24,6 +24,7 @@ const REPOS_PATH = SITE_ROOT . '/ebooks';
const TEMPLATES_PATH = SITE_ROOT . '/web/templates'; const TEMPLATES_PATH = SITE_ROOT . '/web/templates';
const MANUAL_PATH = WEB_ROOT . '/manual'; const MANUAL_PATH = WEB_ROOT . '/manual';
const EBOOKS_DIST_PATH = WEB_ROOT . '/ebooks/'; const EBOOKS_DIST_PATH = WEB_ROOT . '/ebooks/';
const COVER_ART_UPLOAD_PATH = '/images/cover-uploads/';
const DATABASE_DEFAULT_DATABASE = 'se'; const DATABASE_DEFAULT_DATABASE = 'se';
const DATABASE_DEFAULT_HOST = 'localhost'; const DATABASE_DEFAULT_HOST = 'localhost';
@ -34,6 +35,19 @@ const SORT_AUTHOR_ALPHA = 'author-alpha';
const SORT_READING_EASE = 'reading-ease'; const SORT_READING_EASE = 'reading-ease';
const SORT_LENGTH = 'length'; 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_HEIGHT = 72;
const CAPTCHA_IMAGE_WIDTH = 230; const CAPTCHA_IMAGE_WIDTH = 230;
const NO_REPLY_EMAIL_ADDRESS = 'admin@standardebooks.org'; 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 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 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 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_YEAR', intval(gmdate('Y')) - 96);
define('PD_STRING', 'January 1, ' . (PD_YEAR + 1)); 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); define('DONATION_ALERT_ON', DONATION_HOLIDAY_ALERT_ON || rand(1, 4) == 2);
// Controls the progress bar donation dialog // 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_START = 'December 11, 2023 00:00:00 America/New_York';
const DONATION_DRIVE_END = 'January 7, 2024 23:59:00 America/New_York'; const DONATION_DRIVE_END = 'January 7, 2024 23:59:00 America/New_York';

View file

@ -3,6 +3,7 @@
// These functions are broken out of Core.php to satisfy PHPStan // These functions are broken out of Core.php to satisfy PHPStan
use function Safe\ob_end_clean; use function Safe\ob_end_clean;
use function Safe\ob_start;
// Convenience alias of var_dump. // Convenience alias of var_dump.
function vd($var): void{ function vd($var): void{

View file

@ -1,6 +1,7 @@
<? <?
use Safe\DateTime; use Safe\DateTime;
use function Safe\preg_match; use function Safe\preg_match;
use function Safe\posix_getpwuid;
class DbConnection{ class DbConnection{
private $_link = null; private $_link = null;
@ -126,7 +127,7 @@ class DbConnection{
$done = true; $done = true;
} }
catch(\PDOException $ex){ catch(\PDOException $ex){
if($ex->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++; $deadlockRetries++;
usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds
@ -188,12 +189,17 @@ class DbConnection{
$object = new $class(); $object = new $class();
for($i = 0; $i < $handle->columnCount(); $i++){ for($i = 0; $i < $handle->columnCount(); $i++){
if($metadata[$i] === false){
continue;
}
if($row[$i] === null){ if($row[$i] === null){
$object->{$metadata[$i]['name']} = null; $object->{$metadata[$i]['name']} = null;
} }
else{ else{
switch($metadata[$i]['native_type']){ switch($metadata[$i]['native_type'] ?? null){
case 'DATETIME': case 'DATETIME':
case 'TIMESTAMP':
$object->{$metadata[$i]['name']} = new DateTime($row[$i], new DateTimeZone('UTC')); $object->{$metadata[$i]['name']} = new DateTime($row[$i], new DateTimeZone('UTC'));
break; break;
@ -228,7 +234,7 @@ class DbConnection{
catch(\PDOException $ex){ catch(\PDOException $ex){
// HY000 is thrown when there is no result set, e.g. for an update operation. // 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 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; throw $ex;
} }
} }

View file

@ -7,6 +7,7 @@ use function Safe\glob;
use function Safe\preg_match; use function Safe\preg_match;
use function Safe\preg_replace; use function Safe\preg_replace;
use function Safe\sprintf; use function Safe\sprintf;
use function Safe\shell_exec;
use function Safe\substr; use function Safe\substr;
class Ebook{ class Ebook{
@ -77,8 +78,7 @@ class Ebook{
$this->RepoFilesystemPath = preg_replace('/\.git$/ius', '', $this->RepoFilesystemPath); $this->RepoFilesystemPath = preg_replace('/\.git$/ius', '', $this->RepoFilesystemPath);
} }
catch(Exception){ catch(Exception){
// We may get an exception from preg_replace if the passed repo wwwFilesystemPath contains invalid UTF8 characters, // We may get an exception from preg_replace if the passed repo wwwFilesystemPath contains invalid UTF-8 characters, whichis a common injection attack vector
// which a common injection attack vector
throw new Exceptions\InvalidEbookException('Invalid repo filesystem path: ' . $this->RepoFilesystemPath); throw new Exceptions\InvalidEbookException('Invalid repo filesystem path: ' . $this->RepoFilesystemPath);
} }
} }
@ -164,7 +164,7 @@ class Ebook{
} }
// Fill in the short history of this repo. // 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){ foreach($historyEntries as $entry){
$array = explode(' ', $entry, 3); $array = explode(' ', $entry, 3);
@ -221,7 +221,7 @@ class Ebook{
// Get SE tags // Get SE tags
foreach($xml->xpath('/package/metadata/meta[@property="se:subject"]') ?: [] as $tag){ 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; $includeToc = sizeof($xml->xpath('/package/metadata/meta[@property="se:is-a-collection"]') ?: []) > 0;
@ -742,6 +742,10 @@ class Ebook{
return $string; return $string;
} }
/**
* @param array<SimpleXMLElement>|false|null $elements
*/
private function NullIfEmpty($elements): ?string{ private function NullIfEmpty($elements): ?string{
if($elements === false){ if($elements === false){
return null; return null;

21
lib/EbookTag.php Normal file
View file

@ -0,0 +1,21 @@
<?
class EbookTag extends Tag{
public function __construct(string $name){
$this->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;
}
}

View file

@ -14,7 +14,7 @@ class Email{
public $Subject = ''; public $Subject = '';
public $Body = ''; public $Body = '';
public $TextBody = ''; public $TextBody = '';
public $Attachments = array(); public $Attachments = [];
public $PostmarkStream = null; public $PostmarkStream = null;
public function __construct(bool $isNoReplyEmail = false){ public function __construct(bool $isNoReplyEmail = false){

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class ArtistNameRequiredException extends AppException{
protected $message = 'An artist name is required.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class ArtworkAlreadyExistsException extends AppException{
protected $message = 'An artwork with this name already exists.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class ArtworkNameRequiredException extends AppException{
protected $message = 'An artwork name is required.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class ArtworkNotFoundException extends AppException{
protected $message = 'We couldnt locate that artwork.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidArtistException extends AppException{
protected $message = 'We couldnt locate that artist.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidArtworkException extends AppException{
protected $message = 'Artwork is invalid.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidArtworkPageUrlException extends AppException{
protected $message = 'Invalid link to page with artwork.';
}

View file

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

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidCompletedYearException extends AppException{
protected $message = 'Invalid year of completion.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidCopyrightPageUrlException extends AppException{
protected $message = 'Invalid link to page with copyright details.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidDeathYearException extends AppException{
protected $message = 'Invalid year of death.';
}

View file

@ -0,0 +1,7 @@
<?php
namespace Exceptions;
class InvalidImageUploadException extends AppException{
protected $message = 'Uploaded image is invalid.';
}

View file

@ -0,0 +1,7 @@
<?php
namespace Exceptions;
class InvalidMimeTypeException extends AppException{
protected $message = 'Uploaded image must be a JPG, BMP, PNG, or TIFF file.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidMuseumUrlException extends AppException{
protected $message = 'Invalid link to an approved museum page.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidPublicationYearException extends AppException{
protected $message = 'Invalid year of publication.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidPublicationYearPageUrlException extends AppException{
protected $message = 'Invalid link to page with year of publication.';
}

View file

@ -2,5 +2,5 @@
namespace Exceptions; namespace Exceptions;
class InvalidRequestException extends AppException{ class InvalidRequestException extends AppException{
protected $message = 'Invalid request.'; protected $message = 'Invalid HTTP request.';
} }

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class MissingEbookException extends AppException{
protected $message = 'Artwork marked as “in use”, but the ebook couldnt be found in the filesystem.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class MissingPdProofException extends AppException{
protected $message = 'Missing proof of U.S. public domain status.';
}

View file

@ -0,0 +1,11 @@
<?
namespace Exceptions;
class StringTooLongException extends AppException{
public $Source;
public function __construct(string $source = ''){
$this->Source = $source;
parent::__construct('String too long: ' . $source);
}
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class TagsRequiredException extends AppException{
protected $message = 'At least one tag is required.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class TooManyTagsException extends AppException{
protected $message = 'Too many tags; the maximum is ' . COVER_ARTWORK_MAX_TAGS . '.';
}

View file

@ -1,5 +1,6 @@
<? <?
use Safe\DateTime; use Safe\DateTime;
use function Safe\exec;
use function Safe\file_get_contents; use function Safe\file_get_contents;
use function Safe\file_put_contents; use function Safe\file_put_contents;
use function Safe\tempnam; use function Safe\tempnam;

View file

@ -40,6 +40,12 @@ class Formatter{
return htmlspecialchars(trim($text ?? ''), ENT_QUOTES|ENT_XML1, 'utf-8'); return htmlspecialchars(trim($text ?? ''), ENT_QUOTES|ENT_XML1, 'utf-8');
} }
public static function EscapeMarkdown(?string $text): string{
$parsedown = new Parsedown();
$parsedown->setSafeMode(true);
return $parsedown->text($text);
}
public static function ToFileSize(?int $bytes): string{ public static function ToFileSize(?int $bytes): string{
// See https://stackoverflow.com/a/5501447 // See https://stackoverflow.com/a/5501447
$output = ''; $output = '';

View file

@ -1,5 +1,7 @@
<? <?
use function Safe\ini_get;
use function Safe\preg_match; use function Safe\preg_match;
use function Safe\substr;
class HttpInput{ class HttpInput{
public static function RequestMethod(): int{ public static function RequestMethod(): int{
@ -21,6 +23,29 @@ class HttpInput{
return HTTP_GET; return HTTP_GET;
} }
public static function GetMaxPostSize(): int{ // bytes
$post_max_size = ini_get('post_max_size');
$unit = substr($post_max_size, -1);
$size = (int) substr($post_max_size, 0, -1);
return match ($unit){
'g', 'G' => $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{ public static function RequestType(): int{
return preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT'] ?? '') ? WEB : REST; 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); 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 = []; $vars = [];
switch($set){ switch($set){

94
lib/Image.php Normal file
View file

@ -0,0 +1,94 @@
<?
use function Safe\exec;
use function Safe\imagecopyresampled;
use function Safe\imagecreatetruecolor;
use function Safe\imagejpeg;
use function Safe\getimagesize;
use function Safe\unlink;
class Image{
public $Path;
public $MimeType;
public function __construct(string $path){
$this->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);
}
}

47
lib/ImageMimeType.php Normal file
View file

@ -0,0 +1,47 @@
<?
use function Safe\mime_content_type;
use function Safe\imagecreatefromjpeg;
enum ImageMimeType: string{
case JPG = 'image/jpeg';
case BMP = 'image/bmp';
case PNG = 'image/png';
case TIFF = 'image/tiff';
public function GetFileExtension(): string{
return match($this){
self::JPG => '.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<string>
*/
public static function Values(): array{
return array_map(function(ImageMimeType $case){
return $case->value;
}, ImageMimeType::cases());
}
}

View file

@ -1,17 +1,17 @@
<? <?
use Safe\DateTime; use Safe\DateTime;
use function Safe\apcu_fetch; use function Safe\apcu_fetch;
use function Safe\exec;
use function Safe\filemtime; use function Safe\filemtime;
use function Safe\filesize; use function Safe\filesize;
use function Safe\glob; use function Safe\glob;
use function Safe\gmdate; use function Safe\gmdate;
use function Safe\ksort; use function Safe\ksort;
use function Safe\natsort;
use function Safe\preg_match; use function Safe\preg_match;
use function Safe\preg_replace; use function Safe\preg_replace;
use function Safe\shell_exec;
use function Safe\sleep; use function Safe\sleep;
use function Safe\sort; use function Safe\sort;
use function Safe\rsort;
use function Safe\usort; use function Safe\usort;
@ -159,6 +159,112 @@ class Library{
return self::GetFromApcu('tags'); return self::GetFromApcu('tags');
} }
/**
* Browsable Artwork can be displayed publically, e.g., at /artworks.
* Unverified and declined Artwork shouldn't be browsable.
* @return array<Artwork>
*/
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<Artwork>
*/
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<mixed> * @return array<mixed>
*/ */
@ -187,6 +293,10 @@ class Library{
} }
} }
if(!is_array($results)){
$results = [$results];
}
return $results; return $results;
} }
@ -211,7 +321,8 @@ class Library{
*/ */
public static function GetEbooksFromFilesystem(?string $webRoot = WEB_ROOT): array{ public static function GetEbooksFromFilesystem(?string $webRoot = WEB_ROOT): array{
$ebooks = []; $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){ foreach($contentFiles as $path){
if($path == '') if($path == '')
@ -435,6 +546,21 @@ class Library{
return $retval; 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{ public static function RebuildCache(): void{
// We check a lockfile because this can be a long-running command. // 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. // 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 = []; $authors = [];
$tagsByName = []; $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{ try{
$ebookWwwFilesystemPath = preg_replace('|/content\.opf|ius', '', $filename); $ebookWwwFilesystemPath = preg_replace('|/content\.opf|ius', '', $filename);
@ -562,4 +688,14 @@ class Library{
apcu_delete($lockVar); apcu_delete($lockVar);
} }
/**
* @return array<Artist>
*/
public static function GetAllArtists(): array{
return Db::Query('
SELECT *
from Artists
order by Name asc', [], 'Artist');
}
} }

18
lib/Museum.php Normal file
View file

@ -0,0 +1,18 @@
<?
class Museum extends PropertiesBase{
public $MuseumId;
public $Name;
public $Domain;
public static function GetByUrl(string $url): ?Museum{
$result = Db::Query('
SELECT *
from Museums
where ? like concat("%", Domain, "%")
limit 1;
', [$url], 'Museum');
return $result[0] ?? null;
}
}

View file

@ -3,10 +3,9 @@ use function Safe\substr;
abstract class PropertiesBase{ abstract class PropertiesBase{
/** /**
* @param mixed $var
* @return mixed * @return mixed
*/ */
public function __get($var){ public function __get(string $var){
$function = 'Get' . $var; $function = 'Get' . $var;
$privateVar = '_' . $var; $privateVar = '_' . $var;

View file

@ -1,12 +1,11 @@
<? <?
class Tag{
public $Name;
public $Url;
public $UrlName;
public function __construct(string $name){ /**
$this->Name = $name; * @property string $Url
$this->UrlName = Formatter::MakeUrlSafe($this->Name); */
$this->Url = '/subjects/' . $this->UrlName; class Tag extends PropertiesBase{
} public $TagId;
public $Name;
public $UrlName;
protected $_Url;
} }

View file

@ -1,5 +1,6 @@
<? <?
use function Safe\ob_end_clean; use function Safe\ob_end_clean;
use function Safe\ob_start;
class Template{ class Template{
/** /**
@ -34,6 +35,12 @@ class Template{
} }
} }
public static function Emit403(): void{
http_response_code(403);
include(WEB_ROOT . '/403.php');
exit();
}
public static function Emit404(): void{ public static function Emit404(): void{
http_response_code(404); http_response_code(404);
include(WEB_ROOT . '/404.php'); include(WEB_ROOT . '/404.php');

View file

@ -7,7 +7,7 @@ DESCRIPTION
Deploy a Standard Ebook source repository to the web. Deploy a Standard Ebook source repository to the web.
USAGE USAGE
deploy-ebook-to-www [-v,--verbose] [-g,--group GROUP] [--webroot WEBROOT] [--weburl WEBURL] [--no-images] [--no-build] [--no-epubcheck] [--no-recompose] [--no-feeds] [-l,--last-push-hash HASH] DIRECTORY [DIRECTORY...] deploy-ebook-to-www [-v,--verbose] [-g,--group GROUP] [--webroot WEBROOT] [--weburl WEBURL] [--no-images] [--no-build] [--no-epubcheck] [--no-recompose] [--no-feeds] [--no-bulk-downloads] [--upsert-to-cover-art-database] [-l,--last-push-hash HASH] DIRECTORY [DIRECTORY...]
DIRECTORY is a bare source repository. DIRECTORY is a bare source repository.
GROUP is a groupname. Defaults to "se". GROUP is a groupname. Defaults to "se".
WEBROOT is the path to your webroot. Defaults to "/standardebooks.org/web/www". WEBROOT is the path to your webroot. Defaults to "/standardebooks.org/web/www".
@ -39,6 +39,8 @@ USAGE
With --no-bulk-downloads, don't generate bulk download files. With --no-bulk-downloads, don't generate bulk download files.
With --upsert-to-cover-art-database, update/insert cover in the cover art database.
EOF EOF
exit exit
} }
@ -57,6 +59,7 @@ epubcheck="true"
recompose="true" recompose="true"
feeds="true" feeds="true"
bulkDownloads="true" bulkDownloads="true"
coverArtDatabase="false"
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
usage usage
@ -114,6 +117,10 @@ while [ $# -gt 0 ]; do
bulkDownloads="false" bulkDownloads="false"
shift 1 shift 1
;; ;;
--upsert-to-cover-art-database)
coverArtDatabase="true"
shift 1
;;
*) break ;; *) break ;;
esac esac
done done
@ -149,6 +156,10 @@ if ! [ -f "${scriptsDir}"/generate-bulk-downloads ]; then
die "\"${scriptsDir}\"/generate-bulk-downloads\" is not a file or could not be found." die "\"${scriptsDir}\"/generate-bulk-downloads\" is not a file or could not be found."
fi fi
if ! [ -f "${scriptsDir}"/upsert-to-cover-art-database ]; then
die "\"${scriptsDir}\"/upsert-to-cover-art-database\" is not a file or could not be found."
fi
mkdir -p "${webRoot}"/images/covers/ mkdir -p "${webRoot}"/images/covers/
for dir in "$@" for dir in "$@"
@ -387,6 +398,18 @@ do
mv "${imgWorkDir}/${urlSafeIdentifier}"*.{jpg,avif} "${webRoot}/images/covers/" mv "${imgWorkDir}/${urlSafeIdentifier}"*.{jpg,avif} "${webRoot}/images/covers/"
fi fi
if [ "${coverArtDatabase}" = "true" ]; then
if [ "${verbose}" = "true" ]; then
printf "Updating/inserting cover in the cover art database ... "
fi
"${scriptsDir}"/upsert-to-cover-art-database --repoDir "${repoDir}" --workDir "${workDir}" --ebookWwwFilesystemPath "${webDir}"
if [ "${verbose}" = "true" ]; then
printf "Done.\n"
fi
fi
# Delete the now-empty work dir (empty except for .git) # Delete the now-empty work dir (empty except for .git)
rm --preserve-root --recursive --force "${workDir}" "${imgWorkDir}" rm --preserve-root --recursive --force "${workDir}" "${imgWorkDir}"

View file

@ -0,0 +1,171 @@
#!/usr/bin/php
<?
require_once('/standardebooks.org/web/lib/Core.php');
$longopts = ['repoDir:', 'workDir:', 'ebookWwwFilesystemPath:', 'verbose'];
$options = getopt('v', $longopts);
$repoDir = $options['repoDir'] ?? false;
$workDir = $options['workDir'] ?? false;
$ebookWwwFilesystemPath = $options['ebookWwwFilesystemPath'] ?? false;
$verbose = false;
if(isset($options['v']) || isset($options['verbose'])){
$verbose = true;
}
/**
* Coverts SimpleXMLElement objects with inner tags like this:
* '<abbr>Mr.</abbr> 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 <dir> --workDir <dir> --ebookWwwFilesystemPath <path>\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);
}

View file

@ -0,0 +1,83 @@
<?
$artwork = $artwork ?? null;
if($artwork === null){
return;
}
?>
<h1><?= Formatter::ToPlainText($artwork->Name) ?></h1>
<a href="<?= $artwork->ImageUrl ?>">
<picture>
<source srcset="<?= $artwork->Thumb2xUrl ?> 2x, <?= $artwork->ThumbUrl ?> 1x" type="image/jpg"/>
<img src="<?= $artwork->ThumbUrl ?>" alt="" property="schema:image"/>
</picture>
</a>
<?= Template::ImageCopyrightNotice() ?>
<h2>Metadata</h2>
<table class="artwork-metadata">
<tr>
<td>Title</td>
<td><?= Formatter::ToPlainText($artwork->Name) ?></td>
</tr>
<tr>
<td>Artist</td>
<td>
<?= Formatter::ToPlainText($artwork->Artist->Name) ?><? if(sizeof($artwork->Artist->AlternateSpellings) > 0){ ?> (<abbr>AKA</abbr> <span class="author" typeof="schema:Person" property="schema:name"><?= implode('</span>, <span class="author" typeof="schema:Person" property="schema:name">', array_map('Formatter::ToPlainText', $artwork->Artist->AlternateSpellings)) ?></span>)<? } ?><? if($artwork->Artist->DeathYear !== null){ ?>, <abbr title="deceased">d.</abbr> <?= $artwork->Artist->DeathYear ?><? } ?>
</td>
</tr>
<tr>
<td>Year completed</td>
<td><? if($artwork->CompletedYear === null){ ?>(unknown)<? }else{ ?><?= $artwork->CompletedYear ?><? if($artwork->CompletedYearIsCirca){ ?> (circa)<? } ?><? } ?></td>
</tr>
<tr>
<td>File size</td>
<td><?= $artwork->ImageSize ?></td>
</tr>
<tr>
<td>Uploaded</td>
<td><?= $artwork->Created->format('F j, Y g:i a') ?></td>
</tr>
<tr>
<td>Status</td>
<td><?= Template::ArtworkStatus(['artwork' => $artwork]) ?></td>
</tr>
<tr>
<td>Tags</td>
<td><ul class="tags"><? foreach($artwork->Tags as $tag){ ?><li><a href="<?= $tag->Url ?>"><?= Formatter::ToPlainText($tag->Name) ?></a></li><? } ?></ul></td>
</tr>
</table>
<h2>U.S. public domain proof</h2>
<? if($artwork->MuseumUrl !== null){ ?>
<h3>Museum page</h3>
<p><a href="<?= Formatter::ToPlainText($artwork->MuseumUrl) ?>"><?= Formatter::ToPlainText($artwork->MuseumUrl) ?></a></p>
<? if($artwork->Museum !== null){ ?>
<figure class="corrected full">
<p>Approved museum: <?= Formatter::ToPlainText($artwork->Museum->Name) ?> <code>(<?= Formatter::ToPlainText($artwork->Museum->Domain) ?>)</code></p>
</figure>
<? }else{ ?>
<figure class="wrong full">
<p>Not an approved museum.</p>
</figure>
<? } ?>
<? } ?>
<? if($artwork->PublicationYear !== null){ ?>
<h3>Page scans</h3>
<ul>
<li>Year book was published: <? if($artwork->PublicationYear !== null){ ?><?= $artwork->PublicationYear ?><? }else{ ?><i>Not provided</i><? } ?></li>
<li>Was book published in the U.S.: <? if($artwork->IsPublishedInUs){ ?>Yes<? }else{ ?>No<? } ?></li>
<li>Page scan of book publication year: <? if($artwork->PublicationYearPageUrl !== null){ ?><a href="<?= Formatter::ToPlainText($artwork->PublicationYearPageUrl) ?>">Link</a><? }else{ ?><i>Not provided</i><? } ?></li>
<li>Page scan of rights statement page: <? if($artwork->CopyrightPageUrl !== null){ ?><a href="<?= Formatter::ToPlainText($artwork->CopyrightPageUrl) ?>">Link</a><? }else{ ?><i>Not provided</i><? } ?></li>
<li>Page scan of artwork: <? if($artwork->ArtworkPageUrl !== null){ ?><a href="<?= Formatter::ToPlainText($artwork->ArtworkPageUrl) ?>">Link</a><? }else{ ?><i>Not provided</i><? } ?></li>
</ul>
<? } ?>
<? if($artwork->Exception !== null){ ?>
<h3>Special public domain exception</h3>
<?= Formatter::EscapeMarkdown($artwork->Exception) ?>
<? } ?>

37
templates/ArtworkList.php Normal file
View file

@ -0,0 +1,37 @@
<?
$artworks = $artworks ?? [];
$useAdminUrl = $useAdminUrl ?? false;
?>
<ol class="artwork-list list">
<? foreach($artworks as $artwork){ ?>
<? if($useAdminUrl){ ?>
<? $url = $artwork->AdminUrl; ?>
<? }else{ ?>
<? $url = $artwork->Url; ?>
<? } ?>
<li class="<?= $artwork->Status ?>">
<div class="thumbnail-container">
<a href="<?= $url ?>">
<picture>
<source srcset="<?= $artwork->Thumb2xUrl ?> 2x, <?= $artwork->ThumbUrl ?> 1x" type="image/jpg"/>
<img src="<?= $artwork->ThumbUrl ?>" alt="" property="schema:image"/>
</picture>
</a>
</div>
<p><a href="<?= $url ?>" property="schema:name"><?= Formatter::ToPlainText($artwork->Name) ?></a></p>
<p>
<span class="author" typeof="schema:Person" property="schema:name"><?= Formatter::ToPlainText($artwork->Artist->Name) ?></span>
<? if(sizeof($artwork->Artist->AlternateSpellings) > 0){ ?>(<abbr>AKA</abbr> <span class="author" typeof="schema:Person" property="schema:name"><?= implode('</span>, <span class="author" typeof="schema:Person" property="schema:name">', array_map('Formatter::ToPlainText', $artwork->Artist->AlternateSpellings)) ?></span>)<? } ?>
</p>
<div>
<p>Year completed: <? if($artwork->CompletedYear === null){ ?>(unknown)<? }else{ ?><?= $artwork->CompletedYear ?><? if($artwork->CompletedYearIsCirca){ ?> (circa)<? } ?><? } ?></p>
<p>Status: <?= Template::ArtworkStatus(['artwork' => $artwork]) ?></p>
<? if(count($artwork->Tags) > 0){ ?>
<p>Tags:</p>
<ul class="tags"><? foreach($artwork->Tags as $tag){ ?><li><a href="<?= $tag->Url ?>"><?= Formatter::ToPlainText($tag->Name) ?></a></li><? } ?></ul>
<? } ?>
</div>
</li>
<? } ?>
</ol>

View file

@ -0,0 +1,33 @@
<form action="/artworks" method="get" rel="search">
<label>Status
<select name="status" size="1">
<option value="all">All</option>
<option value="<?= COVER_ARTWORK_STATUS_APPROVED ?>"<? if($status == COVER_ARTWORK_STATUS_APPROVED){ ?> selected="selected"<? } ?>>Approved</option>
<option value="<?= COVER_ARTWORK_STATUS_IN_USE ?>"<? if($status == COVER_ARTWORK_STATUS_IN_USE){ ?> selected="selected"<? } ?>>In use</option>
</select>
</label>
<label class="search">Keywords
<input type="search" name="query" value="<?= Formatter::ToPlainText($query ?? '') ?>"/>
</label>
<label>
<span>Sort</span>
<span>
<select name="sort">
<option value="<?= SORT_COVER_ARTWORK_CREATED_NEWEST ?>"<? if($sort == SORT_COVER_ARTWORK_CREATED_NEWEST){ ?> selected="selected"<? } ?>>Added date (new &#x2192; old)</option>
<option value="<?= SORT_COVER_ARTIST_ALPHA ?>"<? if($sort == SORT_COVER_ARTIST_ALPHA){ ?> selected="selected"<? } ?>>Artist name (a &#x2192; z)</option>
<option value="<?= SORT_COVER_ARTWORK_COMPLETED_NEWEST ?>"<? if($sort == SORT_COVER_ARTWORK_COMPLETED_NEWEST){ ?> selected="selected"<? } ?>>Completed date (new &#x2192; old)</option>
</select>
</span>
</label>
<label>
<span>Per page</span>
<span>
<select name="per-page">
<option value="50"<? if($perPage == 50){ ?> selected="selected"<? } ?>>50</option>
<option value="100"<? if($perPage == 100){ ?> selected="selected"<? } ?>>100</option>
<option value="200"<? if($perPage == 200){ ?> selected="selected"<? } ?>>200</option>
</select>
</span>
</label>
<button>Filter</button>
</form>

View file

@ -0,0 +1,10 @@
<?
$artwork = $artwork ?? null;
?>
<? if($artwork !== null){ ?>
<? if($artwork->Status === COVER_ARTWORK_STATUS_APPROVED){ ?>Approved<? } ?>
<? if($artwork->Status === COVER_ARTWORK_STATUS_DECLINED){ ?>Declined<? } ?>
<? if($artwork->Status === COVER_ARTWORK_STATUS_UNVERIFIED){ ?>Unverified<? } ?>
<? if($artwork->Status === COVER_ARTWORK_STATUS_IN_USE){ ?>In use<? if($artwork->Ebook !== null && $artwork->Ebook->Url !== null){ ?> by <a href="<?= $artwork->Ebook->Url ?>" property="schema:url"><span property="schema:name"><?= Formatter::ToPlainText($artwork->Ebook->Title) ?></span></a><? } ?><? } ?>
<? } ?>

View file

@ -3,7 +3,7 @@
$start = new DateTime(DONATION_DRIVE_START); $start = new DateTime(DONATION_DRIVE_START);
$end = new DateTime(DONATION_DRIVE_END); $end = new DateTime(DONATION_DRIVE_END);
$totalCurrent = 0; $totalCurrent = 0;
$baseTarget = 30; $baseTarget = 50;
$stretchCurrent = 0; $stretchCurrent = 0;
$stretchTarget = 20; $stretchTarget = 20;

View file

@ -4,11 +4,12 @@ $title = $title ?? '';
$highlight = $highlight ?? ''; $highlight = $highlight ?? '';
$description = $description ?? ''; $description = $description ?? '';
$manual = $manual ?? false; $manual = $manual ?? false;
$artwork = $artwork ?? false;
$colorScheme = $_COOKIE['color-scheme'] ?? 'auto'; $colorScheme = $_COOKIE['color-scheme'] ?? 'auto';
$isXslt = $isXslt ?? false; $isXslt = $isXslt ?? false;
$feedUrl = $feedUrl ?? null; $feedUrl = $feedUrl ?? null;
$feedTitle = $feedTitle ?? ''; $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, // As of Sep 2022, all versions of Safari have a bug where if the page is served as XHTML,
// then <picture> elements download all <source>s instead of the first supported match. // then <picture> elements download all <source>s instead of the first supported match.
@ -50,6 +51,9 @@ if(!$isXslt){
<link href="/css/manual-dark.css?version=<?= filemtime(WEB_ROOT . '/css/manual-dark.css') ?>" media="screen<? if($colorScheme == 'auto'){ ?> and (prefers-color-scheme: dark)<? } ?>" rel="stylesheet" type="text/css"/> <link href="/css/manual-dark.css?version=<?= filemtime(WEB_ROOT . '/css/manual-dark.css') ?>" media="screen<? if($colorScheme == 'auto'){ ?> and (prefers-color-scheme: dark)<? } ?>" rel="stylesheet" type="text/css"/>
<? } ?> <? } ?>
<? } ?> <? } ?>
<? if($artwork){ ?>
<link href="/css/artwork.css?version=<?= filemtime(WEB_ROOT . '/css/artwork.css') ?>" media="screen" rel="stylesheet" type="text/css"/>
<? } ?>
<link href="/apple-touch-icon-120x120.png" rel="apple-touch-icon" sizes="120x120"/> <link href="/apple-touch-icon-120x120.png" rel="apple-touch-icon" sizes="120x120"/>
<link href="/apple-touch-icon-152x152.png" rel="apple-touch-icon" sizes="152x152"/> <link href="/apple-touch-icon-152x152.png" rel="apple-touch-icon" sizes="152x152"/>
<link href="/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180"/> <link href="/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180"/>
@ -67,7 +71,7 @@ if(!$isXslt){
<? } ?> <? } ?>
<link rel="search" href="/ebooks" type="application/xhtml+xml; charset=utf-8"/> <link rel="search" href="/ebooks" type="application/xhtml+xml; charset=utf-8"/>
<link rel="search" href="/ebooks/opensearch" type="application/opensearchdescription+xml; charset=utf-8"/> <link rel="search" href="/ebooks/opensearch" type="application/opensearchdescription+xml; charset=utf-8"/>
<? if(!$is404){ ?> <? if(!$isErrorPage){ ?>
<meta content="#394451" name="theme-color"/> <meta content="#394451" name="theme-color"/>
<meta content="<? if($title != ''){ ?><?= Formatter::ToPlainText($title) ?><? }else{ ?>Standard Ebooks<? } ?>" property="og:title"/> <meta content="<? if($title != ''){ ?><?= Formatter::ToPlainText($title) ?><? }else{ ?>Standard Ebooks<? } ?>" property="og:title"/>
<meta content="<?= $ogType ?? 'website' ?>" property="og:type"/> <meta content="<?= $ogType ?? 'website' ?>" property="og:type"/>

View file

@ -0,0 +1 @@
<p>These images are thought to be free of copyright restrictions in the United States. They may still be under copyright in other countries. If youre 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 youre located in before accessing, downloading, or using them.</p>

17
www/403.php Normal file
View file

@ -0,0 +1,17 @@
<?= Template::Header(['title' => 'You Dont Have Permission to View This Page', 'highlight' => '', 'description' => 'You dont have permission to view this page.', 'isErrorPage' => true]) ?>
<main>
<section class="narrow has-hero">
<hgroup>
<h1>You Dont Have Permission to View This Page</h1>
<h2>This is 403 error.</h2>
</hgroup>
<picture data-caption="The Guard Room. David Teniers II, 1642">
<source srcset="/images/guard-room@2x.avif 2x, /images/guard-room.avif 1x" type="image/avif"/>
<source srcset="/images/guard-room@2x.jpg 2x, /images/guard-room.jpg 1x" type="image/jpg"/>
<img src="/images/guard-room@2x.jpg" alt="Baroque-era guards gather around a table in a room."/>
</picture>
<p>Your account doesnt have permission to view this page.</p>
<p>If you arrived here in error, <a href="/about#editor-in-chief">contact us</a> so that we can fix it.</p>
</section>
</main>
<?= Template::Footer() ?>

View file

@ -1,4 +1,4 @@
<?= Template::Header(['title' => 'We Couldnt Find That Document', 'highlight' => '', 'description' => 'We couldnt find that document.', 'is404' => true]) ?> <?= Template::Header(['title' => 'We Couldnt Find That Document', 'highlight' => '', 'description' => 'We couldnt find that document.', 'isErrorPage' => true]) ?>
<main> <main>
<section class="narrow has-hero"> <section class="narrow has-hero">
<hgroup> <hgroup>

View file

@ -0,0 +1,48 @@
<?
use function Safe\session_unset;
session_start();
$exception = $_SESSION['exception'] ?? null;
try{
$artwork = Artwork::Get(HttpInput::Int(GET, 'artworkid'));
// The artwork is already approved or in use, so redirect to its public page
if($artwork->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();
}
?><?= Template::Header(['title' => 'Review Artwork', 'artwork' => true, 'highlight' => '', 'description' => 'Unverified artwork.']) ?>
<main class="artworks">
<?= Template::Error(['exception' => $exception]) ?>
<section class="narrow">
<?= Template::ArtworkDetail(['artwork' => $artwork]) ?>
<? if($artwork->Status == COVER_ARTWORK_STATUS_DECLINED){ ?>
<h2>Status</h2>
<p>This artwork has been declined by a reviewer.</p>
<? } ?>
<? if($artwork->Status == COVER_ARTWORK_STATUS_UNVERIFIED){ ?>
<h2>Review</h2>
<p>Review the metadata and PD proof for this artwork submission. Approve to make it available for future producers.</p>
<form method="post" action="/admin/artworks/<?= $artwork->ArtworkId ?>">
<input type="hidden" name="_method" value="PATCH" />
<button name="status" value="<?= COVER_ARTWORK_STATUS_APPROVED ?>">Approve</button>
<button name="status" value="<?= COVER_ARTWORK_STATUS_DECLINED ?>" class="decline-button">Decline</button>
</form>
<? } ?>
</section>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,88 @@
<?
use function Safe\session_unset;
session_start();
$artwork = null;
$status = HttpInput::Str(SESSION, 'status', false);
$page = HttpInput::Int(GET, 'page') ?? 1;
$perPage = 10;
$hasNextPage = false;
$unverifiedArtworks = [];
try{
if($GLOBALS['User'] === null){
throw new Exceptions\LoginRequiredException();
}
if(!$GLOBALS['User']->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
}
?><?= Template::Header(['title' => 'Artwork Review Queue', 'artwork' => true, 'highlight' => '', 'description' => 'The queue of unverified artwork.']) ?>
<main class="artworks">
<section class="narrow">
<h1>Artwork Review Queue</h1>
<? if($status == COVER_ARTWORK_STATUS_APPROVED){ ?>
<p class="message success">
<? if($artwork !== null){ ?>
<i><a href="<?= $artwork->Url ?>" property="schema:name"><?= Formatter::ToPlainText($artwork->Name) ?></a></i> approved.
<? }else{ ?>
Artwork approved.
<? } ?>
</p>
<? } ?>
<? if($status == COVER_ARTWORK_STATUS_DECLINED){ ?>
<p class="message">
<? if($artwork !== null){ ?>
<i><?= Formatter::ToPlainText($artwork->Name) ?></i> declined.
<? }else{ ?>
Artwork declined.
<? } ?>
</p>
<? } ?>
<? if(sizeof($unverifiedArtworks) == 0){ ?>
<p>No artwork to review.</p>
<? }else{ ?>
<?= Template::ArtworkList(['artworks' => $unverifiedArtworks, 'useAdminUrl' => true]) ?>
<? } ?>
</section>
<nav>
<a<? if($page > 1){ ?> href="/admin/artworks/?page=<?= $page - 1 ?>" rel="prev"<? }else{ ?> aria-disabled="true"<? } ?>>Back</a>
<a<? if($hasNextPage){ ?> href="/admin/artworks/?page=<?= $page + 1 ?>" rel="next"<? }else{ ?> aria-disabled="true"<? } ?>>Next</a>
</nav>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,45 @@
<?
try{
session_start();
if(HttpInput::RequestMethod() != HTTP_PATCH){
throw new Exceptions\InvalidRequestException();
}
if($GLOBALS['User'] === null){
throw new Exceptions\LoginRequiredException();
}
if(!$GLOBALS['User']->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);
}

19
www/artworks/get.php Normal file
View file

@ -0,0 +1,19 @@
<?
try{
$artistUrlName = HttpInput::Str(GET, 'artist') ?? '';
$artworkUrlName = HttpInput::Str(GET, 'artwork') ?? '';
$artwork = Artwork::GetByUrlAndIsApproved($artistUrlName, $artworkUrlName);
}
catch(Exceptions\ArtworkNotFoundException){
Template::Emit404();
}
?><?= Template::Header(['title' => $artwork->Name, 'artwork' => true]) ?>
<main class="artworks">
<section class="narrow">
<?= Template::ArtworkDetail(['artwork' => $artwork]) ?>
</section>
</main>
<?= Template::Footer() ?>

88
www/artworks/index.php Normal file
View file

@ -0,0 +1,88 @@
<?
use function Safe\preg_replace;
$page = HttpInput::Int(GET, 'page') ?? 1;
$perPage = HttpInput::Int(GET, 'per-page') ?? COVER_ARTWORK_PER_PAGE;
$query = HttpInput::Str(GET, 'query', false) ?? '';
$status = HttpInput::Str(GET, 'status', false) ?? null;
$sort = HttpInput::Str(GET, 'sort', false);
$pages = 0;
$totalArtworkCount = 0;
$pageDescription = '';
$pageTitle = '';
$queryString = '';
if($page <= 0){
$page = 1;
}
if($perPage != COVER_ARTWORK_PER_PAGE && $perPage != 100 && $perPage != 200){
$perPage = COVER_ARTWORK_PER_PAGE;
}
// If we're passed string values that are the same as the defaults,
// set them to null so that we can have cleaner query strings in the navigation footer
if($sort !== null){
$sort = mb_strtolower($sort);
}
if($sort === 'created-newest'){
$sort = null;
}
$artworks = Library::FilterArtwork($query != '' ? $query : null, $status, $sort);
$pageTitle = 'Browse Artwork';
$pages = ceil(sizeof($artworks) / $perPage);
$totalArtworkCount = sizeof($artworks);
$artworks = array_slice($artworks, ($page - 1) * $perPage, $perPage);
if($page > 1){
$pageTitle .= ', page ' . $page;
}
$pageDescription = 'Page ' . $page . ' of artwork';
if($query != ''){
$queryString .= '&amp;query=' . urlencode($query);
}
if($status !== null){
$queryString .= '&amp;status=' . urlencode($status);
}
if($sort !== null){
$queryString .= '&amp;sort=' . urlencode($sort);
}
if($perPage !== COVER_ARTWORK_PER_PAGE){
$queryString .= '&amp;per-page=' . urlencode((string)$perPage);
}
$queryString = preg_replace('/^&amp;/ius', '', $queryString);
?><?= Template::Header(['title' => $pageTitle, 'artwork' => true, 'description' => $pageDescription]) ?>
<main class="artworks">
<section class="narrow">
<h1>Browse U.S. Public Domain Artwork</h1>
<?= Template::ArtworkSearchForm(['query' => $query, 'status' => $status, 'sort' => $sort, 'perPage' => $perPage]) ?>
<?= Template::ImageCopyrightNotice() ?>
<? if($totalArtworkCount == 0){ ?>
<p class="no-results">No artwork matched your filters. You can try different filters, or <a href="/artworks">browse all artwork</a>.</p>
<? }else{ ?>
<?= Template::ArtworkList(['artworks' => $artworks, 'useAdminUrl' => false]) ?>
<? } ?>
<? if($totalArtworkCount > 0){ ?>
<nav>
<a<? if($page > 1){ ?> href="/artworks?page=<?= $page - 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="prev"<? }else{ ?> aria-disabled="true"<? } ?>>Back</a>
<ol>
<? for($i = 1; $i < $pages + 1; $i++){ ?>
<li<? if($page == $i){ ?> class="highlighted"<? } ?>><a href="/artworks?page=<?= $i ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>"><?= $i ?></a></li>
<? } ?>
</ol>
<a<? if($page < ceil($totalArtworkCount / $perPage)){ ?> href="/artworks?page=<?= $page + 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="next"<? }else{ ?> aria-disabled="true"<? } ?>>Next</a>
</nav>
<? } ?>
</section>
</main>
<?= Template::Footer() ?>

243
www/artworks/new.php Normal file
View file

@ -0,0 +1,243 @@
<?
use function Safe\gmdate;
use function Safe\session_unset;
session_start();
$created = HttpInput::Bool(SESSION, 'artwork-created', false);
$exception = $_SESSION['exception'] ?? null;
/** @var Artwork $artwork */
$artwork = $_SESSION['artwork'] ?? null;
try{
if($GLOBALS['User'] === null){
throw new Exceptions\LoginRequiredException();
}
if(!$GLOBALS['User']->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
}
?>
<?= Template::Header(
[
'title' => 'Submit an Artwork',
'artwork' => true,
'highlight' => '',
'description' => 'Submit public domain artwork to the database for use as cover art.'
]
) ?>
<main>
<section class="narrow">
<h1>Submit an Artwork</h1>
<?= Template::Error(['exception' => $exception]) ?>
<? if($created){ ?>
<p class="message success">Artwork submitted!</p>
<? } ?>
<form method="post" action="/artworks" enctype="multipart/form-data">
<fieldset>
<legend>Artist details</legend>
<label>
<span>Name</span>
<span>For existing artists, leave the year of death blank.</span>
<datalist id="artist-names">
<? foreach(Library::GetAllArtists() as $existingArtist){ ?>
<option value="<?= Formatter::ToPlainText($existingArtist->Name) ?>"><?= Formatter::ToPlainText($existingArtist->Name) ?>, d. <? if($existingArtist->DeathYear !== null){ ?><?= $existingArtist->DeathYear ?><? }else{ ?>(unknown)<? } ?></option>
<? } ?>
</datalist>
<input
type="text"
name="artist-name"
list="artist-names"
required="required"
autocomplete="off"
value="<?= Formatter::ToPlainText($artwork->Artist->Name) ?>"
/>
</label>
<label>
<span>Year of death</span>
<span>If circa or unknown, enter the latest possible year.</span>
<input
type="number"
name="artist-year-of-death"
min="1"
max="<?= gmdate('Y') ?>"
value="<?= $artwork->Artist->DeathYear ?>"
/>
</label>
</fieldset>
<fieldset>
<legend>Artwork details</legend>
<label>
Name
<input type="text" name="artwork-name" required="required"
value="<?= Formatter::ToPlainText($artwork->Name) ?>"/>
</label>
<fieldset>
<label>
Year of completion
<input
type="number"
name="artwork-year"
min="1"
max="<?= gmdate('Y') ?>"
value="<?= $artwork->CompletedYear ?>"
/>
</label>
<label>
<input
type="checkbox"
name="artwork-year-is-circa"
<? if($artwork->CompletedYearIsCirca){ ?>checked="checked"<? } ?>
/> Year is circa
</label>
</fieldset>
<label>
<span>Tags</span>
<span>A list of comma-separated tags.</span>
<input
type="text"
name="artwork-tags"
required="required"
autocomplete="off"
value="<?= Formatter::ToPlainText($artwork->ImplodeTags()) ?>"
/>
</label>
<label>
<span>Image</span>
<span>jpg, bmp, png, and tiff are accepted.</span>
<input
type="file"
name="artwork-image"
required="required"
accept="<?= implode(',', ImageMimeType::Values()) ?>"
/>
</label>
</fieldset>
<fieldset id="pd-proof">
<legend>Proof of U.S. public domain status</legend>
<p>See the <a href="/manual/latest/10-art-and-images#10.3.3.7">US-PD clearance section of the SEMoS</a> for details on this section.</p>
<p>PD proof must take the form of <strong>either</strong>:</p>
<fieldset>
<label>
URL of the artwork at an <a href="/manual/latest/10-art-and-images#10.3.3.7.4">approved museum</a>
<input
type="url"
name="artwork-museum-url"
autocomplete="off"
value="<?= Formatter::ToPlainText($artwork->MuseumUrl) ?>"
/>
</label>
</fieldset>
<p><strong>or</strong> proof that the artwork was reproduced in a book published before <?= PD_STRING ?>, with <strong>all</strong> of the following:</p>
<fieldset>
<label>
Year of publication
<input
type="number"
name="artwork-publication-year"
min="1"
max="<?= gmdate('Y') ?>"
value="<?= $artwork->PublicationYear ?>"
/>
</label>
<label>
<span>URL of page with year of publication</span>
<span>Roman numerals are OK.</span>
<input
type="url"
name="artwork-publication-year-page-url"
autocomplete="off"
value="<?= Formatter::ToPlainText($artwork->PublicationYearPageUrl) ?>"
/>
</label>
<label>
<span>URL of page with rights statement</span>
<span>Might be same URL as above; non-English is OK; keywords in other languages include <i>droits</i> and <i>rechte vorbehalten</i>.</span>
<input
type="url"
name="artwork-copyright-page-url"
autocomplete="off"
value="<?= Formatter::ToPlainText($artwork->CopyrightPageUrl) ?>"
/>
</label>
<label>
<input
type="checkbox"
name="artwork-is-published-in-us"
value="true"
<? if($artwork->IsPublishedInUs){ ?> checked="checked"<? } ?> />
<span>This book was published in the U.S.</span>
<span>Yes, if a U.S. city appears anywhere near the publication year or rights statement.</span>
</label>
<label>
URL of page with artwork
<input
type="url"
name="artwork-artwork-page-url"
autocomplete="off"
value="<?= Formatter::ToPlainText($artwork->ArtworkPageUrl) ?>"
/>
</label>
</fieldset>
<p><strong>or</strong> a special reason for an exception:</p>
<fieldset>
<label>
<span>Exception reason</span>
<span>Markdown accepted.</span>
<textarea maxlength="1024" name="artwork-exception"><?= Formatter::ToPlainText($artwork->Exception) ?></textarea>
</label>
</fieldset>
</fieldset>
<? if($GLOBALS['User']->Benefits->CanReviewArtwork){ ?>
<fieldset>
<legend>Reviewer options</legend>
<label>
<input
type="checkbox"
name="artwork-status"
value="<?= COVER_ARTWORK_STATUS_APPROVED ?>"
<? if($artwork->Status == COVER_ARTWORK_STATUS_APPROVED){ ?> checked="checked"<? } ?> />
Approve this artwork immediately
</label>
</fieldset>
<? } ?>
<div class="footer">
<button>Submit</button>
</div>
</form>
</section>
</main>
<?= Template::Footer() ?>

74
www/artworks/post.php Normal file
View file

@ -0,0 +1,74 @@
<?
use Exceptions\InvalidRequestException;
use function Safe\ini_get;
use function Safe\substr;
try{
session_start();
if(HttpInput::RequestMethod() != HTTP_POST){
throw new InvalidRequestException('Only HTTP POST accepted.');
}
if(HttpInput::IsRequestTooLarge()){
throw new Exceptions\InvalidRequestException('File upload too large.');
}
if($GLOBALS['User'] === null){
throw new Exceptions\LoginRequiredException();
}
if(!$GLOBALS['User']->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');
}

496
www/css/artwork.css Normal file
View file

@ -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;
}

View file

@ -823,11 +823,16 @@ a.button:focus,
input[type="email"]:focus, input[type="email"]:focus,
input[type="text"]:focus, input[type="text"]:focus,
input[type="month"]:focus, input[type="month"]:focus,
input[type="number"]:focus,
input[type="url"]:focus,
input[type="search"]:focus, input[type="search"]:focus,
input[type="file"]:focus,
label.checkbox:focus-within, label.checkbox:focus-within,
label:has(input[type="checkbox"]):focus-within,
select:focus, select:focus,
button:focus, button:focus,
nav a[rel]:focus{ nav a[rel]:focus,
textarea:focus{
outline: 1px dashed var(--input-outline); outline: 1px dashed var(--input-outline);
} }
@ -1703,8 +1708,11 @@ label.search{
} }
select, select,
textarea,
input[type="text"], input[type="text"],
input[type="month"], input[type="month"],
input[type="number"],
input[type="url"],
input[type="email"], input[type="email"],
input[type="search"]{ input[type="search"]{
-webkit-appearance: none; -webkit-appearance: none;
@ -1736,6 +1744,42 @@ select{
display: block; 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]{ select[multiple]{
padding: 1rem; padding: 1rem;
} }
@ -1809,6 +1853,8 @@ a.button,
button, button,
input[type="text"], input[type="text"],
input[type="month"], input[type="month"],
input[type="number"],
input[type="url"],
input[type="email"], input[type="email"],
input[type="search"], input[type="search"],
select{ select{
@ -1826,10 +1872,16 @@ input[type="text"]:focus,
input[type="text"]:hover, input[type="text"]:hover,
input[type="month"]:focus, input[type="month"]:focus,
input[type="month"]:hover, 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"]:focus,
input[type="email"]:hover, input[type="email"]:hover,
input[type="search"]:focus, input[type="search"]:focus,
input[type="search"]:hover, input[type="search"]:hover,
textarea:focus,
textarea:hover,
select:focus, select:focus,
select:hover{ select:hover{
border-color: var(--input-outline); border-color: var(--input-outline);
@ -1839,6 +1891,8 @@ select:hover{
input[type="text"]:user-invalid, input[type="text"]:user-invalid,
input[type="month"]:user-invalid, input[type="month"]:user-invalid,
input[type="number"]:user-invalid,
input[type="url"]:user-invalid,
input[type="email"]:user-invalid, input[type="email"]:user-invalid,
input[type="search"]:user-invalid{ input[type="search"]:user-invalid{
border-color: #ff0000; border-color: #ff0000;
@ -1847,6 +1901,8 @@ input[type="search"]:user-invalid{
input[type="text"]:-moz-ui-invalid, input[type="text"]:-moz-ui-invalid,
input[type="month"]:-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="email"]:-moz-ui-invalid,
input[type="search"]:-moz-ui-invalid{ input[type="search"]:-moz-ui-invalid{
border-color: #ff0000; border-color: #ff0000;
@ -3535,12 +3591,15 @@ ul.feed p{
label.select:hover > span + span::after, label.select:hover > span + span::after,
label.email::before, label.email::before,
label.search::before, label.search::before,
textarea:hover,
nav li.highlighted a, nav li.highlighted a,
nav a[rel], nav a[rel],
a.button, a.button,
button, button,
input[type="text"], input[type="text"],
input[type="month"], input[type="month"],
input[type="number"],
input[type="url"],
input[type="email"], input[type="email"],
input[type="search"], input[type="search"],
select, select,
@ -3552,6 +3611,10 @@ ul.feed p{
input[type="text"]:hover, input[type="text"]:hover,
input[type="month"]:focus, input[type="month"]:focus,
input[type="month"]:hover, 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"]:focus,
input[type="email"]:hover, input[type="email"]:hover,
input[type="search"]:focus, input[type="search"]:focus,

View file

@ -58,6 +58,8 @@ article.ebook section .donation{
select, select,
input[type="text"], input[type="text"],
input[type="month"], input[type="month"],
input[type="number"],
input[type="url"],
input[type="email"], input[type="email"],
input[type="search"]{ input[type="search"]{
box-shadow: 1px 1px 0 rgba(0, 0, 0, .5) inset; box-shadow: 1px 1px 0 rgba(0, 0, 0, .5) inset;

View file

@ -145,13 +145,13 @@ catch(Exceptions\InvalidCollectionException){
<? } ?> <? } ?>
<? if(sizeof($ebooks) > 0 && $collection === null){ ?> <? if(sizeof($ebooks) > 0 && $collection === null){ ?>
<nav> <nav>
<a<? if($page > 1){ ?> href="/ebooks/?page=<?= $page - 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="prev"<? }else{ ?> aria-disabled="true"<? } ?>>Back</a> <a<? if($page > 1){ ?> href="/ebooks?page=<?= $page - 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="prev"<? }else{ ?> aria-disabled="true"<? } ?>>Back</a>
<ol> <ol>
<? for($i = 1; $i < $pages + 1; $i++){ ?> <? for($i = 1; $i < $pages + 1; $i++){ ?>
<li<? if($page == $i){ ?> class="highlighted"<? } ?>><a href="/ebooks/?page=<?= $i ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>"><?= $i ?></a></li> <li<? if($page == $i){ ?> class="highlighted"<? } ?>><a href="/ebooks?page=<?= $i ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>"><?= $i ?></a></li>
<? } ?> <? } ?>
</ol> </ol>
<a<? if($page < ceil($totalEbooks / $perPage)){ ?> href="/ebooks/?page=<?= $page + 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="next"<? }else{ ?> aria-disabled="true"<? } ?>>Next</a> <a<? if($page < ceil($totalEbooks / $perPage)){ ?> href="/ebooks?page=<?= $page + 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="next"<? }else{ ?> aria-disabled="true"<? } ?>>Next</a>
</nav> </nav>
<? } ?> <? } ?>

View file

@ -1,4 +1,6 @@
<? <?
use function Safe\exec;
$author = HttpInput::Str(GET, 'author', false); $author = HttpInput::Str(GET, 'author', false);
$collection = HttpInput::Str(GET, 'collection', false); $collection = HttpInput::Str(GET, 'collection', false);
$name = null; $name = null;

8
www/images/cover-uploads/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
# Ignore everything
*
# Except this file
!.gitignore
# This directory (/www/images/uploads) contains image uploads and thumbnails that
# should not be checked into git.

BIN
www/images/guard-room.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
www/images/guard-room.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View file

@ -1,9 +1,11 @@
<? <?
use function Safe\exec;
use function Safe\substr; use function Safe\substr;
use function Safe\file_get_contents; use function Safe\file_get_contents;
use function Safe\preg_replace; use function Safe\preg_replace;
use function Safe\json_decode; use function Safe\json_decode;
use function Safe\glob; use function Safe\glob;
use function Safe\shell_exec;
// This script makes various calls to external scripts using exec() (and when called via Apache, as the www-data user). // This script makes various calls to external scripts using exec() (and when called via Apache, as the www-data user).
// These scripts are allowed using the /etc/sudoers.d/www-data file. Only the specific scripts // These scripts are allowed using the /etc/sudoers.d/www-data file. Only the specific scripts
@ -26,7 +28,10 @@ try{
$hashAlgorithm = $splitHash[0]; $hashAlgorithm = $splitHash[0];
$hash = $splitHash[1]; $hash = $splitHash[1];
if(!hash_equals($hash, hash_hmac($hashAlgorithm, $post, get_cfg_var('se.secrets.github.se_vcs_bot.secret')))){ /** @var string $gitHubWebhookSecret */
$gitHubWebhookSecret = get_cfg_var('se.secrets.github.se_vcs_bot.secret');
if(!hash_equals($hash, hash_hmac($hashAlgorithm, $post, $gitHubWebhookSecret))){
throw new Exceptions\InvalidCredentialsException(); throw new Exceptions\InvalidCredentialsException();
} }
@ -77,7 +82,8 @@ try{
$log->Write('Processing ebook `' . $repoName . '` located at `' . $dir . '`.'); $log->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. // 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 == ''){ if($lastCommitSha1 == ''){
$log->Write('Error getting last local commit. Output: ' . $lastCommitSha1); $log->Write('Error getting last local commit. Output: ' . $lastCommitSha1);

View file

@ -9,6 +9,7 @@ use function Safe\substr;
$log = new Log(POSTMARK_WEBHOOK_LOG_FILE_PATH); $log = new Log(POSTMARK_WEBHOOK_LOG_FILE_PATH);
try{ try{
/** @var string $smtpUsername */
$smtpUsername = get_cfg_var('se.secrets.postmark.username'); $smtpUsername = get_cfg_var('se.secrets.postmark.username');
$log->Write('Received Postmark webhook.'); $log->Write('Received Postmark webhook.');

View file

@ -21,7 +21,10 @@ try{
$post = file_get_contents('php://input'); $post = file_get_contents('php://input');
// Validate the Zoho secret. // 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(); throw new Exceptions\InvalidCredentialsException();
} }