diff --git a/.env.example b/.env.example index 714341a..de12362 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,10 @@ DB_USER=xxx DB_PASS= DB_NAME=xxx -# Address of the site. The pages send plain HTTP requests to this host -# over HTTPS. If it is empty, the pages refuse plain HTTP. +# Address of the site. The RSS feed links, the notification email and the +# cookie domain use it, and the pages send plain HTTP requests to this +# host over HTTPS. The email addresses are editor@ and webmaster@ this +# host. If it is empty, the pages refuse plain HTTP. SITE_URL=https://xxx.org.za # Google reCAPTCHA v2 keys (https://www.google.com/recaptcha/admin). diff --git a/about.php b/about.php index 05972d0..7d5932b 100644 --- a/about.php +++ b/about.php @@ -75,7 +75,7 @@ the centre x a multiplication sign. Simply put, ten by

Something is broken

-If you find anything which seems to be broken, please email us +If you find anything which seems to be broken, please email us and tell us.

diff --git a/config.lib.php b/config.lib.php index 6b01fe1..d7b582b 100644 --- a/config.lib.php +++ b/config.lib.php @@ -41,20 +41,50 @@ function config($key, $default = NULL) { } /* - * Send a plain HTTP request to the same page over HTTPS, and stop. The - * host comes from SITE_URL, because the browser controls the Host header. + * The address of the site comes from SITE_URL, never from the Host + * header, which the browser controls. site_url() gives the scheme, host + * and port (for example "https://xxx.org.za"), and site_host() gives the + * host. Both are empty if SITE_URL has no host. + */ +function site_url_parts() { + $site = parse_url(trim((string)config('SITE_URL', ''))); + return is_array($site) && !empty($site['host']) ? $site : array(); +} + +function site_host() { + return site_url_parts()['host'] ?? ''; +} + +function site_url($scheme = NULL) { + $site = site_url_parts(); + if (!$site) + return ''; + $port = isset($site['port']) ? ':' . $site['port'] : ''; + return ($scheme ?? $site['scheme'] ?? 'https') . '://' . $site['host'] . $port; +} + +// An address at the site's host, such as "editor@xxx.org.za" +function site_email($name) { + return $name . '@' . site_host(); +} + +// The cookie domain: the site's host and its subdomains +function cookie_domain() { + return site_host() === '' ? '' : '.' . site_host(); +} + +/* + * Send a plain HTTP request to the same page over HTTPS, and stop. * Without SITE_URL, the page refuses plain HTTP. */ function require_https() { if (isset($_SERVER['HTTPS'])) return; - $site = parse_url((string)config('SITE_URL', '')); - if (empty($site['host'])) { + if (site_url() === '') { http_response_code(403); echo "This page needs HTTPS.\n"; } else { - $port = isset($site['port']) ? ':' . $site['port'] : ''; - header('Location: https://' . $site['host'] . $port . $_SERVER['REQUEST_URI']); + header('Location: ' . site_url('https') . $_SERVER['REQUEST_URI']); } exit; } diff --git a/display.php b/display.php index beffd0f..a07de23 100644 --- a/display.php +++ b/display.php @@ -5,7 +5,7 @@ require_once('./get_data.lib.php'); $queue = get_queue(); if ($queue) session_cache_limiter('nocache'); else session_cache_limiter('none'); -session_set_cookie_params(array('lifetime' => 0, 'path' => '/', 'domain' => '.xxx.org.za', 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); +session_set_cookie_params(array('lifetime' => 0, 'path' => '/', 'domain' => cookie_domain(), 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); session_start(); if (isset($_SESSION['editor'])) { $is_admin = TRUE; diff --git a/feed.php b/feed.php index 914ba36..564c60c 100644 --- a/feed.php +++ b/feed.php @@ -10,6 +10,8 @@ $show = get_show_num(); $table = "xxx"; header("Content-Type: application/rss+xml"); +$site = xml_text(site_url()); + // Set up a RSS "header" $query = "SELECT MAX(`date`) FROM $table;"; @@ -21,19 +23,19 @@ echo ''."\n"; echo ''."\n"; echo ''."\n"; echo 'x.x.x'."\n"; - echo 'http://xxx.org.za/'."\n"; - echo 'Most recent articles from xxx.org.za'."\n"; + echo ''.$site.'/'."\n"; + echo 'Most recent articles from '.xml_text(site_host()).''."\n"; echo 'en-za'."\n"; echo ''.$now.''."\n"; echo ''.date("D, d M Y H:i:s O", strtotime($most_recent[0])).''."\n"; - echo 'editor@xxx.org.za (Editors)'."\n"; - echo 'webmaster@xxx.org.za (Webmasters)'."\n"; - echo ''."\n"; + echo ''.hidden_email(site_email('editor')).' (Editors)'."\n"; + echo ''.hidden_email(site_email('webmaster')).' (Webmasters)'."\n"; + echo ''."\n"; echo ''."\n"; echo 'Front Page'."\n"; - echo 'http://xxx.org.za/'."\n"; + echo ''.$site.'/'."\n"; echo ''.date("D, d M Y H:i:s O", strtotime($most_recent[0])).''."\n"; - echo 'http://xxx.org.za/'."\n"; + echo ''.$site.'/'."\n"; echo ''."\n"; /* @@ -52,14 +54,14 @@ foreach ($result as $entry) { $id = (int)$entry['id']; echo ''."\n"; echo ''.xml_text($entry['title']).''."\n"; - echo 'http://xxx.org.za/article?id='.$id.''."\n"; + echo ''.$site.'/article?id='.$id.''."\n"; echo ''.date("D, d M Y H:i:s O", strtotime($entry['date'])).''."\n"; if ($entry['img']) echo 'Image: '.xml_text($entry['title']).''."\n"; else echo ''.xml_text(strip_tags($entry['story'])).''."\n"; echo ''.xml_text($entry['author']).''."\n"; - echo 'http://xxx.org.za/article?id='.$id.''."\n"; + echo ''.$site.'/article?id='.$id.''."\n"; echo ''."\n"; } diff --git a/html.lib.php b/html.lib.php index c8b0669..92bc0bb 100644 --- a/html.lib.php +++ b/html.lib.php @@ -8,4 +8,9 @@ function h($string) { function form_value($string) { return htmlspecialchars((string)$string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } + +// Text as numeric character references, which hides an email address from simple harvesters +function hidden_email($address) { + return preg_replace_callback('/./s', function ($char) { return '&#' . ord($char[0]) . ';'; }, (string)$address); +} ?> diff --git a/publish/post.lib.php b/publish/post.lib.php index 99b2c73..d7fa539 100644 --- a/publish/post.lib.php +++ b/publish/post.lib.php @@ -261,9 +261,9 @@ function sanitise($story) { function send_email($title, $author, $story) { include_once('../lib/convert_entities.inc.php'); - $to = 'editor@xxx.org.za'; + $to = site_email('editor'); $subject = "New submission: $title"; - $headers = 'From: x.x.x Queue '."\r\n"; + $headers = 'From: x.x.x Queue <'.site_email('editor').'>'."\r\n"; $message = "Please review the queue. $author has sent the following new submission:\n\n"; if ($story) { // Turn line and paragraph breaks into new lines before the tags are removed, @@ -275,6 +275,6 @@ function send_email($title, $author, $story) { } else $message .= ""; - $message .= "\n\nhttp://xxx.org.za/publish/edit"; + $message .= "\n\n" . site_url() . "/publish/edit"; mb_send_mail($to, $subject, $message, $headers); } diff --git a/session.lib.php b/session.lib.php index 5a58625..3448949 100644 --- a/session.lib.php +++ b/session.lib.php @@ -7,7 +7,7 @@ $is_admin = FALSE; if (isset($_SERVER['HTTPS'])) { session_cache_limiter('private'); - session_set_cookie_params(array('lifetime' => 0, 'path' => '/', 'domain' => '.xxx.org.za', 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); + session_set_cookie_params(array('lifetime' => 0, 'path' => '/', 'domain' => cookie_domain(), 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); session_start(); if (isset($_SESSION['editor'])) { @@ -83,7 +83,7 @@ function create_persistent_login($username) { open_db('close'); // Create cookie with key - setcookie('PersistentAuth', "$username|$key", array('expires' => $validity, 'path' => '/', 'domain' => '.xxx.org.za', 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); + setcookie('PersistentAuth', "$username|$key", array('expires' => $validity, 'path' => '/', 'domain' => cookie_domain(), 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); return true; } @@ -110,7 +110,7 @@ function delete_persistent_login() { open_db('close'); // Delete cookie - setcookie("PersistentAuth", '', array('expires' => strtotime('-1 year'), 'path' => '/', 'domain' => '.xxx.org.za', 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); + setcookie("PersistentAuth", '', array('expires' => strtotime('-1 year'), 'path' => '/', 'domain' => cookie_domain(), 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); return true; } diff --git a/tests/php/Pages/FeedAndImageTest.php b/tests/php/Pages/FeedAndImageTest.php index 31cd237..5150726 100644 --- a/tests/php/Pages/FeedAndImageTest.php +++ b/tests/php/Pages/FeedAndImageTest.php @@ -31,7 +31,11 @@ class FeedAndImageTest extends PageTestCase { // The front page, then the visible stories, newest first $this->assertSame(array('Front Page', 'Tom & "Jerry"', 'Picture', 'Not an image'), array_map('strval', iterator_to_array($xml->xpath('/rss/channel/item/title'), FALSE))); - $this->assertSame('http://xxx.org.za/article?id=1', (string)$items[1]->link); + // The addresses come from SITE_URL + $this->assertSame('https://site.test/', (string)$xml->channel->link); + $this->assertSame('https://site.test/article?id=1', (string)$items[1]->link); + $this->assertSame('editor@site.test (Editors)', (string)$xml->channel->managingEditor); + $this->assertStringContainsString('edi', $feed['body']); $this->assertSame('Wait… a bold move & more.', (string)$items[1]->description); $this->assertSame('Ann ', (string)$items[1]->children('http://purl.org/dc/elements/1.1/')->creator); $this->assertSame('Image: Picture', (string)$items[2]->description); diff --git a/tests/php/Pages/FrontPagesTest.php b/tests/php/Pages/FrontPagesTest.php index d3fa0e0..f7744ef 100644 --- a/tests/php/Pages/FrontPagesTest.php +++ b/tests/php/Pages/FrontPagesTest.php @@ -32,7 +32,7 @@ class FrontPagesTest extends PageTestCase { public function testTheSessionCookieIsSecure(): void { $cookie = $this->header($this->get('/'), 'Set-Cookie'); $this->assertMatchesRegularExpression('/^PHPSESSID=[0-9a-z,-]+;/', $cookie); - foreach (array('path=/', 'domain=.xxx.org.za', 'secure', 'HttpOnly', 'SameSite=Lax') as $flag) + foreach (array('path=/', 'domain=.site.test', 'secure', 'HttpOnly', 'SameSite=Lax') as $flag) $this->assertStringContainsString("; $flag", $cookie); } @@ -89,6 +89,8 @@ class FrontPagesTest extends PageTestCase { $page = $this->get('/about'); $this->assertSame(200, $page['status']); $this->assertStringContainsString('', $page['body']); + // The webmaster's address, at the host of SITE_URL, hidden from harvesters + $this->assertStringContainsString('email us', $page['body']); } public function testTheTestsAreNotServed(): void { diff --git a/tests/php/Unit/EmailTest.php b/tests/php/Unit/EmailTest.php index e56332e..d5cdd8d 100644 --- a/tests/php/Unit/EmailTest.php +++ b/tests/php/Unit/EmailTest.php @@ -16,8 +16,8 @@ class EmailTest extends TestCase { public function testGoesToTheEditorsFromTheQueue(): void { $mail = $this->send('A title', 'Ann', '

Hello.

'); - $this->assertSame('editor@xxx.org.za', $mail['headers']['to']); - $this->assertSame('x.x.x Queue ', $mail['headers']['from']); + $this->assertSame('editor@site.test', $mail['headers']['to']); + $this->assertSame('x.x.x Queue ', $mail['headers']['from']); $this->assertSame('New submission: A title', $mail['subject']); } @@ -29,7 +29,7 @@ class EmailTest extends TestCase { $mail = $this->send('T', 'Ann', "

First & bold.

\n

Line one
line two

<b> is text “quoted”

"); $this->assertSame("Please review the queue. Ann has sent the following new submission:\n\n" . "First & bold.\n\nLine one\nline two\n\n is text “quoted”" - . "\n\nhttp://xxx.org.za/publish/edit", trim($mail['body'])); + . "\n\nhttps://site.test/publish/edit", trim($mail['body'])); } public function testLongLinesAreWrapped(): void {