Author: Jim Winstead (jimwins) Date: 2024-08-23T15:45:39-07:00 Commit: https://github.com/php/web-news/commit/9cb47afc5cde13496c736ded39184f01af1625ca Raw diff: https://github.com/php/web-news/commit/9cb47afc5cde13496c736ded39184f01af1625ca.diff
Reformat with phpcbf and a some hand tweaking Changed paths: A lib/common.php A lib/config.php A lib/group-navbar.php M article.php M common.php M getpart.php M group.php M index.php M lib/Web/News/Nntp.php M lib/fMailbox.php Diff: diff --git a/article.php b/article.php index eb88ad8..578b030 100644 --- a/article.php +++ b/article.php @@ -2,217 +2,216 @@ require 'common.php'; -/* Prevents the poor mail server from suffering if it receives a message with many references */ -/* (References: <xxx> or In-Reply-To: <xxx>) */ -define('REFERENCES_LIMIT', 20); - if (isset($_GET['article'])) { - $article = (int)$_GET['article']; + $article = (int)$_GET['article']; } else { - error("No article specified"); + error("No article specified"); } if (isset($_GET['group'])) { - $group = preg_replace('@[^A-Za-z0-9.-]@', '', $_GET['group']); + $group = preg_replace('@[^A-Za-z0-9.-]@', '', $_GET['group']); } else { - $group = false; + $group = false; } try { - $nntpClient = new \Web\News\Nntp(NNTP_HOST); - $message = $nntpClient->readArticle($article, $group); - - if ($message === null) { - error('No article found'); - } - - $mail = fMailbox::parseMessage($message); - - $rawReferences = []; - if (!empty($mail['headers']['references'])) { - $rawReferences = $mail['headers']['references']; - } elseif (!empty($mail['headers']['in-reply-to'])) { - $rawReferences = $mail['headers']['in-reply-to']; - } - - $references = []; - foreach ($rawReferences as $ref) { - $matches = []; - if (preg_match_all('/\<(.*?)\>/', $ref, $matches)) { - foreach ($matches[0] as $match) { - $references[] = $match; - } - } - } - - $refsResolved = []; - - $refCount = 0; - foreach ($references as $messageId) { - if (!$messageId) { - continue; - } - if ($refCount >= REFERENCES_LIMIT) { - break; - } - $refsResolved[] = $nntpClient->xpath($messageId); - $refCount++; - } + $nntpClient = new \Web\News\Nntp($NNTP_HOST); + $message = $nntpClient->readArticle($article, $group); + + if ($message === null) { + error('No article found'); + } + + $mail = \Flourish\Mailbox::parseMessage($message); + + $rawReferences = []; + if (!empty($mail['headers']['references'])) { + $rawReferences = $mail['headers']['references']; + } elseif (!empty($mail['headers']['in-reply-to'])) { + $rawReferences = $mail['headers']['in-reply-to']; + } + + $references = []; + foreach ($rawReferences as $ref) { + $matches = []; + if (preg_match_all('/\<(.*?)\>/', $ref, $matches)) { + foreach ($matches[0] as $match) { + $references[] = $match; + } + } + } + + $refsResolved = []; + + $refCount = 0; + foreach ($references as $messageId) { + if (!$messageId) { + continue; + } + if ($refCount >= REFERENCES_LIMIT) { + break; + } + $refsResolved[] = $nntpClient->xpath($messageId); + $refCount++; + } } catch (Exception $e) { - error($e->getMessage()); + error($e->getMessage()); } head("{$group}: " . format_title($mail['headers']['subject'], 'utf-8')); echo '<nav class="secondary-nav">'; echo ' <ul class="breadcrumbs">'; echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/">PHP Mailing Lists</a></li>'; -echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/'.htmlspecialchars($group, ENT_QUOTES, "UTF-8").'">'.htmlspecialchars($group, ENT_QUOTES, "UTF-8").'</a></li>'; -echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/'.htmlspecialchars($group, ENT_QUOTES, "UTF-8").'/'.$article.'">'.format_title($mail['headers']['subject']).'</a></li>'; +echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/' . + htmlspecialchars($group, ENT_QUOTES, "UTF-8") . '">' . + htmlspecialchars($group, ENT_QUOTES, "UTF-8") . '</a></li>'; +echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/' . + htmlspecialchars($group, ENT_QUOTES, "UTF-8") . '/' . $article . '">' . + format_title($mail['headers']['subject']) . '</a></li>'; echo ' </ul>'; echo '</nav>'; echo '<section class="content">'; -start_article($mail, $refsResolved); + +echo '<h1>' . format_subject($mail['headers']['subject'], 'utf-8') . "</h1>\n"; + +echo " <blockquote>\n"; +echo ' <table class="standard">' . "\n"; +# from +echo ' <tr class="vcard">' . "\n"; +echo ' <td class="headerlabel">From:</td>' . "\n"; +echo ' <td class="headervalue">' . format_author($mail['headers']['from']['raw'], 'utf-8') . "</td>\n"; +# date +echo ' <td class="headerlabel">Date:</td>' . "\n"; +echo ' <td class="headervalue">' . format_date($mail['headers']['date']) . "</td>\n"; +echo " </tr>\n"; +# subject +echo ' <tr>' . "\n"; +echo ' <td class="headerlabel">Subject:</td>' . "\n"; +echo ' <td class="headervalue" colspan="3">' . format_subject($mail['headers']['subject'], 'utf-8') . "</td>\n"; +echo " </tr>\n"; +echo " <tr>\n"; +# references +if (!empty($refsResolved)) { + echo ' <td class="headerlabel">References:</td>' . "\n"; + echo ' <td class="headervalue" ' . (empty($mail['headers']['newsgroups']) ? 'colspan="3"' : null) . '>'; + foreach ($refsResolved as $k => $ref) { + echo "<a href=\"/" . urlencode($ref['group']) . '/' . urlencode($ref['articleId']) . "\">" . + ($k + 1) . "</a> "; + } + echo "</td>\n"; +} +# groups +if (!empty($mail['headers']['newsgroups'])) { + echo ' <td class="headerlabel">Groups:</td>' . "\n"; + echo ' <td class="headervalue" ' . (empty($refsResolved) ? 'colspan="3"' : null) . '>'; + $r = explode(",", rtrim($mail['headers']['newsgroups'])); + foreach ($r as $v) { + echo "<a href=\"/" . urlencode($v) . "\">" . htmlspecialchars($v) . "</a> "; + } + echo "</td>\n"; +} +echo " </tr>\n"; +echo " </table>\n"; +echo " </blockquote>\n"; +echo " <blockquote>\n"; +echo " <pre>\n"; $lines = preg_split("@(?<=\r\n|\n)@", $mail['text']); $insig = 0; foreach ($lines as $line) { - # fix lines that started with a period and got escaped - if (substr($line,0,2) == "..") { - $line = substr($line,1); - } - - # this is some amazingly simplistic code to color quotes/signatures - # differently, and turn links into real links. it actually appears - # to work fairly well, but could easily be made more sophistimicated. - /* NOQUOTES? Why? It creates invalid HTML: http:"x */ - $line = htmlentities($line,ENT_QUOTES,"utf-8"); - $line = preg_replace("/((mailto|https?|ftp|nntp|news):.+?)(>|\\s|\\)|\\.\\s|$)/","<a href=\"\\1\">\\1</a>\\3",$line); - if (!$insig && ($line == "-- \r\n" || $line == "--\r\n")) { - echo "<span class=\"signature\">"; - $insig = 1; - } - if (!$insig && substr($line,0,4) == ">") { - echo "<span class=\"quote\">$line</span>"; - } else { - echo $line; - } + # fix lines that started with a period and got escaped + if (substr($line, 0, 2) == "..") { + $line = substr($line, 1); + } + + # this is some amazingly simplistic code to color quotes/signatures + # differently, and turn links into real links. it actually appears + # to work fairly well, but could easily be made more sophistimicated. + /* NOQUOTES? Why? It creates invalid HTML: http:"x */ + $line = htmlentities($line, ENT_QUOTES, "utf-8"); + $line = preg_replace( + "/((mailto|https?|ftp|nntp|news):.+?)(>|\\s|\\)|\\.\\s|$)/", + "<a href=\"\\1\">\\1</a>\\3", + $line + ); + if (!$insig && ($line == "-- \r\n" || $line == "--\r\n")) { + echo "<span class=\"signature\">"; + $insig = 1; + } + if (!$insig && substr($line, 0, 4) == ">") { + echo "<span class=\"quote\">$line</span>"; + } else { + echo $line; + } } if ($insig) { - echo "</span>"; - $insig = 0; + echo "</span>"; + $insig = 0; } echo "<br><br>"; if (!empty($mail['attachment'])) { - foreach ($mail['attachment'] as $mimecount => $attachment) { - $mimetype = $attachment['mimetype']; - $name = $attachment['filename']; - - if ($mimetype == 'text/plain') { - echo htmlspecialchars($attachment['data']); - continue; - } - - if (!empty($attachment['description'])) { - $description = trim($attachment['description']) . " "; - } else { - $description = ''; - } - - $description .= $name; - $link_desc = "[$mimetype]"; - if (strlen($description)) { - $link_desc .= " " . $description; - } - - $dl_link = "/getpart.php?group=$group&article=$article&part=$mimecount"; - $link_desc = htmlspecialchars($link_desc,ENT_QUOTES,'UTF-8'); - - /* Attachment filename and mimetype might contain malicious characters */ - printf('Attachment: <a href="%s">%s</a><br />'."\n", - $dl_link, - htmlspecialchars($link_desc) - ); - } + foreach ($mail['attachment'] as $mimecount => $attachment) { + $mimetype = $attachment['mimetype']; + $name = $attachment['filename']; + + if ($mimetype == 'text/plain') { + echo htmlspecialchars($attachment['data']); + continue; + } + + if (!empty($attachment['description'])) { + $description = trim($attachment['description']) . " "; + } else { + $description = ''; + } + + $description .= $name; + $link_desc = "[$mimetype]"; + if (strlen($description)) { + $link_desc .= " " . $description; + } + + $dl_link = "/getpart.php?group=$group&article=$article&part=$mimecount"; + $link_desc = htmlspecialchars($link_desc, ENT_QUOTES, 'UTF-8'); + + /* Attachment filename and mimetype might contain malicious characters */ + printf( + 'Attachment: <a href="%s">%s</a><br />' . "\n", + $dl_link, + htmlspecialchars($link_desc) + ); + } } echo " </pre>\n"; echo " </blockquote>\n"; -function start_article($mail, $refsResolved) { - - echo '<h1>'.format_subject($mail['headers']['subject'], 'utf-8')."</h1>\n"; - - echo " <blockquote>\n"; - echo ' <table class="standard">' . "\n"; - # from - echo ' <tr class="vcard">' . "\n"; - echo ' <td class="headerlabel">From:</td>' . "\n"; - echo ' <td class="headervalue">' . format_author($mail['headers']['from']['raw'], 'utf-8')."</td>\n"; - # date - echo ' <td class="headerlabel">Date:</td>' . "\n"; - echo ' <td class="headervalue">' . format_date($mail['headers']['date'])."</td>\n"; - echo " </tr>\n"; - # subject - echo ' <tr>' . "\n"; - echo ' <td class="headerlabel">Subject:</td>' . "\n"; - echo ' <td class="headervalue" colspan="3">'.format_subject($mail['headers']['subject'], 'utf-8')."</td>\n"; - echo " </tr>\n"; - echo " <tr>\n"; - # references - if (!empty($refsResolved)) { - echo ' <td class="headerlabel">References:</td>' . "\n"; - echo ' <td class="headervalue" '.(empty($mail['headers']['newsgroups']) ? 'colspan="3"' : null).'>'; - foreach ($refsResolved as $k => $ref) { - echo "<a href=\"/". urlencode($ref['group']) . '/' . urlencode($ref['articleId']) ."\">".($k + 1)."</a> "; - } - echo "</td>\n"; - } - # groups - if (!empty($mail['headers']['newsgroups'])) { - echo ' <td class="headerlabel">Groups:</td>' . "\n"; - echo ' <td class="headervalue" '.(empty($refsResolved) ? 'colspan="3"' : null).'>'; - $r = explode(",", rtrim($mail['headers']['newsgroups'])); - foreach ($r as $v) { - echo "<a href=\"/".urlencode($v)."\">".htmlspecialchars($v)."</a> "; - } - echo "</td>\n"; - } - echo " </tr>\n"; - echo " </table>\n"; - echo " </blockquote>\n"; - echo " <blockquote>\n"; - echo " <pre>\n"; -} - // Does not check existence of next, so consider this the super duper fast [broken] version // Based off navbar() in group.php -function navbar($group, $current) { - - $group = htmlspecialchars($group, ENT_QUOTES, "UTF-8"); - - echo ' <table class="standard">' . "\n"; - echo ' <tr>' . "\n"; - echo ' <th class="nav">'; - - if ($current > 1) { - echo ' <a href="/' , $group , '/' , ($current-1) , '"><b>« <span>previous</span></b></a>'; - } else { - echo ' '; - } - - echo ' </th>' . "\n"; - echo ' <th class="align-center">' . "$group (#$current)</th>\n"; - echo ' <th class="nav align-right">'; - echo ' <a href="/' , $group , '/' , ($current+1) , '"><b><span>next</span> »</b></a>'; - echo ' </th>' . "\n"; - echo ' </tr>' . "\n"; - echo ' </table>' . "\n"; +$group = htmlspecialchars($group, ENT_QUOTES, "UTF-8"); +$current = $article; + +echo ' <table class="standard">' . "\n"; +echo ' <tr>' . "\n"; +echo ' <th class="nav">'; + +if ($current > 1) { + echo ' <a href="/' , $group , '/' , ($current - 1) , '"><b>« <span>previous</span></b></a>'; +} else { + echo ' '; } -navbar($group, $article); +echo ' </th>' . "\n"; +echo ' <th class="align-center">' . "$group (#$current)</th>\n"; +echo ' <th class="nav align-right">'; +echo ' <a href="/' , $group , '/' , ($current + 1) , '"><b><span>next</span> »</b></a>'; +echo ' </th>' . "\n"; +echo ' </tr>' . "\n"; +echo ' </table>' . "\n"; echo '</section>'; + foot(); diff --git a/common.php b/common.php index 28fe391..644fb75 100644 --- a/common.php +++ b/common.php @@ -1,172 +1,8 @@ <?php +# Why autoload when you can just load? + +require_once 'lib/common.php'; +require_once 'lib/config.php'; require_once 'lib/Web/News/Nntp.php'; require_once 'lib/fMailbox.php'; - -$NNTP_HOST = 'localhost'; -if (getenv('NNTP_HOST')) { - $NNTP_HOST = getenv('NNTP_HOST'); -} - -define('NNTP_HOST', $NNTP_HOST); - -function error($str) { - head("PHP news : error"); - echo "<section class=\"content\"><blockquote><strong>Error:</strong> ".to_utf8($str)."</blockquote></section>\n"; - foot(); - die(); -} - -function head($title="PHP Mailing Lists (PHP News)") { - header("Content-type: text/html; charset=utf-8"); - -?> -<!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title><?php echo htmlspecialchars($title); ?></title> - <link rel="stylesheet" href="/fonts/Fira/fira.css" type="text/css" /> - <link rel="stylesheet" href="/style.css" type="text/css" /> - <link rel="shortcut icon" href="/favicon.ico"> - </head> - <body> - <header class="header"> - <nav class="header-inner"> - <a href="/" class="header-brand"><img src="//php.net/images/logos/php-logo.svg" class="header-brand-img" alt="PHP" height="24" width="48"><span class="header-brand-text">lists</span></a><ul class="header-menu"> - <li class="header-menu-item"><a class="header-menu-item-link" href="https://php.net/downloads.php">Downloads</a></li> - <li class="header-menu-item"><a class="header-menu-item-link" href="https://php.net/docs.php">Documentation</a></li> - <li class="header-menu-item"><a class="header-menu-item-link" href="https://php.net/get-involved.php">Get Involved</a></li> - <li class="header-menu-item mod-active"><a class="header-menu-item-link" href="https://php.net/support.php">Help</a></li> - </ul> - <form class="search-form" action="https://php.net/search.php"> - <input class="search-input" value="" name="pattern" placeholder="Search"> - </form> - <div class="menu-icon" onclick="document.querySelector('.menu-mobile').classList.toggle('hide')">☰ MENU</div> - <ul class="menu-mobile hide"> - <li class="menu-mobile-item"><a class="menu-mobile-item-link" href="https://php.net/downloads.php">Downloads</a></li> - <li class="menu-mobile-item"><a class="menu-mobile-item-link" href="https://php.net/docs.php">Documentation</a></li> - <li class="menu-mobile-item"><a class="menu-mobile-item-link" href="https://php.net/get-involved.php">Get Involved</a></li> - <li class="menu-mobile-item mod-active"><a class="menu-mobile-item-link" href="https://php.net/support.php">Help</a></li> - </ul> - </nav> - </header> -<?php -} - -function foot() {?> - - <footer class="footer"> - <ul class="footer-nav"> - <li class="footer-nav-item"><a class="footer-nav-item-link" href="https://php.net/copyright.php">Copyright © 2001-<?php echo date('Y'); ?> The PHP Group</a></li> - <li class="footer-nav-item"><a class="footer-nav-item-link" href="https://php.net/my.php">My PHP.net</a></li> - <li class="footer-nav-item"><a class="footer-nav-item-link" href="https://php.net/contact.php">Contact</a></li> - <li class="footer-nav-item"><a class="footer-nav-item-link" href="https://php.net/sites.php">Other PHP.net sites</a></li> - <li class="footer-nav-item"><a class="footer-nav-item-link" href="https://php.net/mirrors.php">Mirror sites</a></li> - <li class="footer-nav-item"><a class="footer-nav-item-link" href="https://php.net/privacy.php">Privacy policy</a></li> - </ul> - </footer> - </body> -</html> -<?php -} - -function to_utf8($str, $charset = 'iso-8859-1') -{ - $n = iconv($charset , 'utf-8', $str); - if ($n === false) { - return $str; - } - return $n; -} - -function decode_header($charset,$encoding,$text) { - if (strtolower($encoding) == "b") { - $text = base64_decode($text); - } else { - $text = quoted_printable_decode($text); - } - return to_utf8($text, $charset); -} - -function recode_header($header, $basecharset) { - if (strpos($header, "=?") === false) { - return to_utf8($header, $basecharset); - } - return preg_replace_callback( - "/=\\?(.+?)\\?([qb])\\?(.+?)(\\?=|$)/i", - function ($m) { - return decode_header($m[1], $m[2], $m[3]); - }, - $header - ); -} - -/* Email spam protection (taken from php-bugs-web) */ -function spam_protect($txt) { - $translate = array('@' => ' at ', '.' => ' dot '); - - /* php.net addresses are not protected! */ - if (preg_match('/^(.+)@php\.net/i', $txt)) { - return $txt; - } else { - return strtr($txt, $translate); - } -} - - -# this turns some common forms of email addresses into mailto: links -function format_author($a, $charset = 'iso-8859-1') { - $a = recode_header($a, $charset); - if (preg_match("/^\s*(.+)\s+\\(\"?(.+?)\"?\\)\s*$/",$a,$ar)) { - return "<a href=\"mailto:".htmlspecialchars(urlencode(spam_protect($ar[1])), ENT_QUOTES, "UTF-8")."\" class=\"email fn n\">".str_replace(" ", " ", htmlspecialchars($ar[2], ENT_QUOTES, "UTF-8"))."</a>"; - } - if (preg_match("/^\s*\"?(.+?)\"?\s*<(.+)>\s*$/",$a,$ar)) { - return "<a href=\"mailto:".htmlspecialchars(urlencode(spam_protect($ar[2])), ENT_QUOTES, "UTF-8")."\" class=\"email fn n\">".str_replace(" ", " ", htmlspecialchars($ar[1], ENT_QUOTES, "UTF-8"))."</a>"; - } - if (strpos("@",$a) !== false) { - $a = spam_protect($a); - return "<a href=\"mailto:".htmlspecialchars(urlencode($a), ENT_QUOTES, "UTF-8")."\" class=\"email fn n\">".htmlspecialchars($a, ENT_QUOTES, "UTF-8")."</a>"; - } - return str_replace(" ", " ", htmlspecialchars($a, ENT_QUOTES, "UTF-8")); -} - -function format_subject($s, $charset = 'iso-8859-1') { - global $article; - $s = recode_header($s, $charset); - - if ((($pos = strpos($s, '[PHP')) !== false || ($pos = strpos($s, '[PEAR')) !== false)) { - if (($end_pos = strpos($s, ']', $pos)) !== false) { - $s = ltrim(substr_replace($s, '', $pos, $end_pos - $pos + 1)); - } - } - - // make this look better on the preview page.. - if (strlen($s) > 150 && !isset($article)) { - $s = substr($s, 0, 150) . "..."; - } else { - $s = wordwrap($s, 150); - } - return nl2br(htmlspecialchars($s, ENT_QUOTES, "UTF-8")); -} - - -function format_title($s, $charset = 'iso-8859-1') { - global $article; - $s = recode_header($s, $charset); - $s = preg_replace("/^(Re: *)?\[(PHP|PEAR)(-.*)?\] /i", "\\1", $s); - // make this look better on the preview page.. - if (strlen($s) > 150 && !isset($article)) { - $s = substr($s, 0, 150) . "..."; - } else { - $s = wordwrap($s, 150); - } - return htmlspecialchars($s, ENT_QUOTES, "UTF-8"); -} - -function format_date($d) { - $d = strtotime($d); - $d = gmdate('r', $d); - return str_replace(" ", " ", $d); -} diff --git a/getpart.php b/getpart.php index 986d04e..c14b616 100644 --- a/getpart.php +++ b/getpart.php @@ -3,57 +3,57 @@ require 'common.php'; if (isset($_GET['group'])) { - $group = preg_replace('@[^A-Za-z0-9.-]@', '', $_GET['group']); + $group = preg_replace('@[^A-Za-z0-9.-]@', '', $_GET['group']); } else { - $group = false; + $group = false; } if (isset($_GET['article'])) { - $article = (int)$_GET['article']; + $article = (int)$_GET['article']; } else { - error("No article specified"); + error("No article specified"); } if (isset($_GET['part'])) { - $part = $_GET['part']; + $part = $_GET['part']; } else { - error("No part specified"); + error("No part specified"); } try { - $nntpClient = new \Web\News\Nntp(NNTP_HOST); - $message = $nntpClient->readArticle($article, $group); + $nntpClient = new \Web\News\Nntp($NNTP_HOST); + $message = $nntpClient->readArticle($article, $group); - if ($message === null) { - error('No article found'); - } + if ($message === null) { + error('No article found'); + } - $mail = fMailbox::parseMessage($message); + $mail = \Flourish\Mailbox::parseMessage($message); } catch (Exception $e) { - error($e->getMessage()); + error($e->getMessage()); } if (!empty($mail['attachment'][$part])) { - $attachment = $mail['attachment'][$part]; + $attachment = $mail['attachment'][$part]; - /* Do not rely on user-provided content-deposition header, generate own one to */ - /* make the content downloadable, do NOT use inline, we can't trust the attachment*/ - /* Downside of this approach: images should be downloaded before use */ - /* this is safer though, and prevents doing evil things on php.net domain */ - $contentdisposition = 'attachment'; + /* Do not rely on user-provided content-deposition header, generate own one to */ + /* make the content downloadable, do NOT use inline, we can't trust the attachment*/ + /* Downside of this approach: images should be downloaded before use */ + /* this is safer though, and prevents doing evil things on php.net domain */ + $contentdisposition = 'attachment'; - if (!empty($attachment['filename'])) { - $contentdisposition .= '; filename="' . $attachment['filename'] . '"'; - } + if (!empty($attachment['filename'])) { + $contentdisposition .= '; filename="' . $attachment['filename'] . '"'; + } - header('Content-Type: ' . $attachment['mimetype']); - header('Content-Disposition: ' . $contentdisposition); + header('Content-Type: ' . $attachment['mimetype']); + header('Content-Disposition: ' . $contentdisposition); - if (isset($attachment['description'])) { - header('Content-Description: ' . $attachment['description']); - } + if (isset($attachment['description'])) { + header('Content-Description: ' . $attachment['description']); + } - echo $attachment['data']; + echo $attachment['data']; } else { - error('Part not found'); + error('Part not found'); } diff --git a/group.php b/group.php index fb9a380..91f101e 100644 --- a/group.php +++ b/group.php @@ -1,81 +1,85 @@ <?php require 'common.php'; +require 'lib/group-navbar.php'; if (isset($_GET['group'])) { - $group = preg_replace('@[^A-Za-z0-9.-]@', '', $_GET['group']); + $group = preg_replace('@[^A-Za-z0-9.-]@', '', $_GET['group']); } else { - error("Missing group"); + error("Missing group"); } if (isset($_GET['format'])) { - $format = $_GET['format']; + $format = $_GET['format']; } else { - // assume html - $format = 'html'; + // assume html + $format = 'html'; } if (isset($_GET['i'])) { - $i = (int)$_GET['i']; + $i = (int)$_GET['i']; } else { - $i = 0; + $i = 0; } try { - $nntpClient = new \Web\News\Nntp(NNTP_HOST); - $overview = $nntpClient->getArticlesOverview($group, $i); + $nntpClient = new \Web\News\Nntp($NNTP_HOST); + $overview = $nntpClient->getArticlesOverview($group, $i); } catch (Exception $e) { - error($e->getMessage()); + error($e->getMessage()); } $host = htmlspecialchars($_SERVER['HTTP_HOST'], ENT_QUOTES, "UTF-8"); -switch($format) { - case 'rss': - header("Content-type: text/xml"); - echo '<?xml version="1.0" encoding="utf-8"?>' . "\n";?> +switch ($format) { + case 'rss': + header("Content-type: text/xml"); + echo '<?xml version="1.0" encoding="utf-8"?>' . "\n";?> <rss version="2.0"> <channel> <title><?php echo $host; ?>: <?php echo $group?></title> <link>http://<?php echo $host; ?>/group.php?group=<?php echo $group?></link> <description></description> -<?php break; -case 'rdf': -header("Content-type: text/xml"); -echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"; -?> + <?php + break; + case 'rdf': + header("Content-type: text/xml"); + echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"; + ?> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://my.netscape.com/rdf/simple/0.9/"> <channel> <title><?php echo $host; ?>: <?php echo $group?></title> <link>http://<?php echo $host; ?>/group.php?group=<?php echo $group?></link> - <description><?php echo $group?> Newsgroup at <?php echo NNTP_HOST; ?></description> + <description><?php echo $group?> Newsgroup at <?php echo $NNTP_HOST; ?></description> <language>en-US</language> </channel> -<?php -break; -case 'html': -default: -head($group.' mailing list'); -echo '<nav class="secondary-nav">'; -echo ' <ul class="breadcrumbs">'; -echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/">PHP Mailing Lists</a></li>'; -echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/'.htmlspecialchars($group, ENT_QUOTES, "UTF-8").'">'.htmlspecialchars($group, ENT_QUOTES, "UTF-8").'</a></li>'; -echo ' </ul>'; -echo '</nav>'; -echo '<section class="content">'; -echo '<h1>'.htmlspecialchars($group, ENT_QUOTES, "UTF-8").'</h1>'; -navbar($group, $overview['group']['low'], $overview['group']['high'], $overview['group']['start']); -echo ' <div class="responsive-table">' . "\n"; -echo ' <table class="standard">' . "\n"; -echo ' <tr>' . "\n"; -echo ' <th>#</th>' . "\n"; -echo ' <th>subject</th>' . "\n"; -echo ' <th>author</th>' . "\n"; -echo ' <th>date</th>' . "\n"; -echo ' <th>lines</th>' . "\n"; -echo ' </tr>' . "\n"; -break; + <?php + break; + case 'html': + default: + head($group . ' mailing list'); + echo '<nav class="secondary-nav">'; + echo ' <ul class="breadcrumbs">'; + echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/">PHP Mailing Lists</a></li>'; + echo ' <li class="breadcrumbs-item"><a class="breadcrumbs-item-link" href="/', + htmlspecialchars($group, ENT_QUOTES, "UTF-8") . '">', + htmlspecialchars($group, ENT_QUOTES, "UTF-8") . '</a></li>'; + echo ' </ul>'; + echo '</nav>'; + echo '<section class="content">'; + echo '<h1>' . htmlspecialchars($group, ENT_QUOTES, "UTF-8") . '</h1>'; + navbar($group, $overview['group']['low'], $overview['group']['high'], $overview['group']['start']); + echo ' <div class="responsive-table">' . "\n"; + echo ' <table class="standard">' . "\n"; + echo ' <tr>' . "\n"; + echo ' <th>#</th>' . "\n"; + echo ' <th>subject</th>' . "\n"; + echo ' <th>author</th>' . "\n"; + echo ' <th>date</th>' . "\n"; + echo ' <th>lines</th>' . "\n"; + echo ' </tr>' . "\n"; + break; } # list of articles @@ -83,79 +87,57 @@ $charset = "utf-8"; foreach ($overview['articles'] as $articleNumber => $details) { - /* $date = date("H:i:s M/d/y", strtotime($odate)); */ - $date822 = date("r", strtotime($details['date'])); + /* $date = date("H:i:s M/d/y", strtotime($odate)); */ + $date822 = date("r", strtotime($details['date'])); - switch($format) { - case 'rss': - echo " <item>\n"; - echo " <link>http://$host/$group/$articleNumber</link>\n"; - echo " <title>", format_subject($details['subject'], $charset), "</title>\n"; - echo " <description>", htmlspecialchars(format_author($details['author'], $charset), ENT_QUOTES, "UTF-8"), "</description>\n"; - echo " <pubDate>$date822</pubDate>\n"; - echo " </item>\n"; - break; - case 'rdf': - echo " <item>\n"; - echo " <title>", format_subject($details['subject'], $charset), "</title>\n"; - echo " <link>http://$host/$group/$articleNumber</link>\n"; - echo " <description>", htmlspecialchars(format_author($details['author'], $charset), ENT_QUOTES, "UTF-8"), "</description>\n"; - echo " <pubDate>$date822</pubDate>\n"; - echo " </item>\n"; - break; - case 'html': - default: - echo " <tr>\n"; - echo " <td><a href=\"/$group/$articleNumber\">$articleNumber</a></td>\n"; - echo " <td><a href=\"/$group/$articleNumber\">"; - echo format_subject($details['subject'], $charset); - echo "</a></td>\n"; - echo " <td class=\"vcard\">".format_author($details['author'], $charset)."</td>\n"; - echo " <td class=\"align-center\"><span class='monospace mod-small'>" . format_date($details['date']) . "</span></td>\n"; - echo " <td class=\"align-right\">{$details['lines']}</td>\n"; - echo " </tr>\n"; - } + switch ($format) { + case 'rss': + echo " <item>\n"; + echo " <link>http://$host/$group/$articleNumber</link>\n"; + echo " <title>", format_subject($details['subject'], $charset), "</title>\n"; + echo " <description>", + htmlspecialchars(format_author($details['author'], $charset), ENT_QUOTES, "UTF-8"), + "</description>\n"; + echo " <pubDate>$date822</pubDate>\n"; + echo " </item>\n"; + break; + case 'rdf': + echo " <item>\n"; + echo " <title>", format_subject($details['subject'], $charset), "</title>\n"; + echo " <link>http://$host/$group/$articleNumber</link>\n"; + echo " <description>", + htmlspecialchars(format_author($details['author'], $charset), ENT_QUOTES, "UTF-8"), + "</description>\n"; + echo " <pubDate>$date822</pubDate>\n"; + echo " </item>\n"; + break; + case 'html': + default: + echo " <tr>\n"; + echo " <td><a href=\"/$group/$articleNumber\">$articleNumber</a></td>\n"; + echo " <td><a href=\"/$group/$articleNumber\">"; + echo format_subject($details['subject'], $charset); + echo "</a></td>\n"; + echo " <td class=\"vcard\">" . format_author($details['author'], $charset) . "</td>\n"; + echo " <td class=\"align-center\"><span class='monospace mod-small'>" . + format_date($details['date']) . "</span></td>\n"; + echo " <td class=\"align-right\">{$details['lines']}</td>\n"; + echo " </tr>\n"; + } } switch ($format) { - case 'rss': - echo " </channel>\n</rss>\n"; - break; - case 'rdf': - echo "</rdf:RDF>\n"; - break; - case 'html': - default: - echo " </table>\n"; - echo " </div>\n"; - navbar($group, $overview['group']['low'], $overview['group']['high'], $overview['group']['start']); - echo "</section>"; - foot(); -} - -function navbar($g, $f, $l, $i) { - echo ' <table class="standard">' . "\n"; - echo ' <tr>' . "\n"; - echo ' <th class="nav">'; - if ($i > $f) { - $p = max($i-20,$f); - echo "<a href=\"/" . htmlspecialchars($g, ENT_QUOTES, "UTF-8") . "/start/$p\"><b>« <span>previous</span></b></a>"; - } else { - echo " "; - } - echo '</th>' . "\n"; - $j = min($i + 20, $l); - $c = $l - $f + 1; - echo ' <th class="align-center">'.htmlspecialchars($g, ENT_QUOTES, "UTF-8")." ($i-$j of $c)</th>\n"; - echo ' <th class="nav align-right">'; - if ($i+20 <= $l) { - $n = min($i + 20, $l - 19); - echo "<a href=\"/" . htmlspecialchars($g, ENT_QUOTES, "UTF-8") . "/start/$n\"><b><span>next</span> »</b></a>"; - } - else { - echo " "; - } - echo '</th>' . "\n"; - echo ' </tr>' . "\n"; - echo ' </table>' . "\n"; + case 'rss': + echo " </channel>\n</rss>\n"; + break; + case 'rdf': + echo "</rdf:RDF>\n"; + break; + case 'html': + default: + echo " </table>\n"; + echo " </div>\n"; + navbar($group, $overview['group']['low'], $overview['group']['high'], $overview['group']['start']); + echo "</section>"; + foot(); } diff --git a/index.php b/index.php index 021ce80..64a9541 100644 --- a/index.php +++ b/index.php @@ -3,15 +3,15 @@ require 'common.php'; try { - $nntpClient = new \Web\News\Nntp(NNTP_HOST); - $groups = $nntpClient->listGroups(); - /* Reorder so it's moderated, active, and inactive */ - $order = [ 'm' => 1, 'y' => 2, 'n' => 3 ]; - uasort($groups, function ($a, $b) use ($order) { - return $order[$a['status']] <=> $order[$b['status']]; - }); + $nntpClient = new \Web\News\Nntp($NNTP_HOST); + $groups = $nntpClient->listGroups(); + /* Reorder so it's moderated, active, and inactive */ + $order = [ 'm' => 1, 'y' => 2, 'n' => 3 ]; + uasort($groups, function ($a, $b) use ($order) { + return $order[$a['status']] <=> $order[$b['status']]; + }); } catch (Exception $e) { - error($e->getMessage()); + error($e->getMessage()); } head(); @@ -48,26 +48,26 @@ <?php $last_status = 'm'; foreach ($groups as $group => $details) { - if ($details['status'] != $last_status) { - $last_status = $details['status']; - echo '<tr><th colspan="4">', - $last_status == 'y' ? 'Discussion Lists' : 'Inactive Lists', - "</th></tr>\n"; - } - echo " <tr>\n"; - echo " <td><a class=\"active{$details['status']}\" href=\"/$group\">$group</a></td>\n"; - echo " <td class=\"align-right\">", $details['high']-$details['low']+1, "</td>\n"; - echo " <td>"; - if ($details['status'] != 'n') { - echo "<a href=\"group.php?group=$group&format=rss\">rss</a>"; - } - echo "</td>\n"; - echo " <td>"; - if ($details['status'] != 'n') { - echo "<a href=\"group.php?group=$group&format=rdf\">rdf</a>"; - } - echo "</td>\n"; - echo " </tr>\n"; + if ($details['status'] != $last_status) { + $last_status = $details['status']; + echo '<tr><th colspan="4">', + $last_status == 'y' ? 'Discussion Lists' : 'Inactive Lists', + "</th></tr>\n"; + } + echo " <tr>\n"; + echo " <td><a class=\"active{$details['status']}\" href=\"/$group\">$group</a></td>\n"; + echo " <td class=\"align-right\">", $details['high'] - $details['low'] + 1, "</td>\n"; + echo " <td>"; + if ($details['status'] != 'n') { + echo "<a href=\"group.php?group=$group&format=rss\">rss</a>"; + } + echo "</td>\n"; + echo " <td>"; + if ($details['status'] != 'n') { + echo "<a href=\"group.php?group=$group&format=rdf\">rdf</a>"; + } + echo "</td>\n"; + echo " </tr>\n"; } ?> </table> diff --git a/lib/Web/News/Nntp.php b/lib/Web/News/Nntp.php index 837c022..10d7c55 100644 --- a/lib/Web/News/Nntp.php +++ b/lib/Web/News/Nntp.php @@ -1,4 +1,5 @@ <?php + namespace Web\News; /** @@ -6,229 +7,229 @@ */ class Nntp { - /** - * @var resource - */ - protected $connection; - - /** - * Constructs an Nntp object - * - * @param string $hostname - * @param int $port - */ - public function __construct($hostname, $port = 119) - { - $errno = $errstr = null; - $this->connection = @fsockopen($hostname, $port, $errno, $errstr, 30); - - if (!$this->connection) { - throw new \RuntimeException( - "Unable to connect to {$hostname} on port {$port}: {$errstr}" - ); - } - - $hello = fgets($this->connection); - $responseCode = substr($hello, 0, 3); - - switch ($responseCode) { - case 400: - case 502: - throw new \RuntimeException('Service unavailable'); - break; - case 200: - case 201: - default: - // Successful connection - break; - } - } - - /** - * Closes the NNTP connection when the object is destroyed - */ - public function __destruct() - { - $this->sendCommand('QUIT', 205); - fclose($this->connection); - $this->connection = null; - } - - /** - * Sends the LIST command to the server and returns an array of newsgroups - * - * @return array - */ - public function listGroups() - { - $list = []; - $response = $this->sendCommand('LIST', 215); - - if ($response !== false) { - while ($line = fgets($this->connection)) { - if ($line == ".\r\n") { - break; - } - - $line = rtrim($line); - list($group, $high, $low, $status) = explode(' ', $line); - - $list[$group] = [ - 'high' => $high, - 'low' => $low, - 'status' => $status, - ]; - } - } - - return $list; - } - - /** - * Sets the active group at the server and returns details about the group - * - * @param string $group Name of the group to set as the active group - * @return array - * @throws \RuntimeException - */ - public function selectGroup($group) - { - $response = $this->sendCommand("GROUP {$group}", 211); - - if ($response !== false) { - list($number, $low, $high, $group) = explode(' ', $response); - - return [ - 'group' => $group, - 'articlesCount' => $number, - 'low' => $low, - 'high' => $high, - ]; - } - - throw new \RuntimeException('Failed to get info on group'); - } - - /** - * Returns an overview of the selected articles from the specified group - * - * @param string $group The name of the group to select - * @param int $start The number of the article to start from - * @param int $pageSize The number of articles to return - * @return array - */ - public function getArticlesOverview($group, $start, $pageSize = 20) - { - $groupDetails = $this->selectGroup($group); - - $pageSize = $pageSize - 1; - $high = $groupDetails['high']; - $low = $groupDetails['low']; - - if (!$start || $start > $high - $pageSize || $start < $low) { - $start = $high - $low > $pageSize ? $high - $pageSize : $low; - } - - $end = min($high, $start + $pageSize); - - $overview = [ - 'group' => $groupDetails + ['start' => $start], - 'articles' => [], - ]; - - $response = $this->sendCommand("XOVER {$start}-{$end}", 224); - - while ($line = fgets($this->connection)) { - if ($line == ".\r\n") { - break; - } - - $line = rtrim($line); - list($n, $subject, $author, $date, $messageId, $references, $lines, $extra) = explode("\t", $line, 9); - - $overview['articles'][$n] = [ - 'subject' => $subject, - 'author' => $author, - 'date' => $date, - 'messageId' => $messageId, - 'references' => $references, - 'lines' => $lines, - 'extra' => $extra, - ]; - } - - return $overview; - } - - /** - * Returns the full content of the specified article (headers and body) - * - * @param int $articleId - * @param string|null $group - * @return string - */ - public function readArticle($articleId, $group = null) - { - if ($group) { - $groupDetails = $this->selectGroup($group); - } - - $article = ''; - - try { - $response = $this->sendCommand("ARTICLE {$articleId}", 220); - } catch (\RuntimeException $e) { - return null; - } - - while ($line = fgets($this->connection)) { - if ($line == ".\r\n") { - break; - } - - $article .= $line; - } - - return $article; - } - - /** - * Performs a lookup on the $messageId to find its group and article ID - * - * @param string $messageId - * @return array - */ - public function xpath($messageId) - { - $response = $this->sendCommand("XPATH {$messageId}", 223); - list($group, $articleId) = explode('/', $response); - - return [ - 'messageId' => $messageId, - 'group' => $group, - 'articleId' => $articleId, - ]; - } - - /** - * Sends a command to the server and checks the expected response code - * - * @param string $command - * @param int $expected The successful response code expected - * @return string - */ - protected function sendCommand($command, $expected) - { - fwrite($this->connection, "$command\r\n"); - $result = fgets($this->connection); - list($code, $response) = explode(' ', $result, 2); - - if ($code == $expected) { - return rtrim($response); - } - - throw new \RuntimeException( - "Expected response code of {$expected} but received {$code} for command `{$command}'" - ); - } + /** + * @var resource + */ + protected $connection; + + /** + * Constructs an Nntp object + * + * @param string $hostname + * @param int $port + */ + public function __construct($hostname, $port = 119) + { + $errno = $errstr = null; + $this->connection = @fsockopen($hostname, $port, $errno, $errstr, 30); + + if (!$this->connection) { + throw new \RuntimeException( + "Unable to connect to {$hostname} on port {$port}: {$errstr}" + ); + } + + $hello = fgets($this->connection); + $responseCode = substr($hello, 0, 3); + + switch ($responseCode) { + case 400: + case 502: + throw new \RuntimeException('Service unavailable'); + break; + case 200: + case 201: + default: + // Successful connection + break; + } + } + + /** + * Closes the NNTP connection when the object is destroyed + */ + public function __destruct() + { + $this->sendCommand('QUIT', 205); + fclose($this->connection); + $this->connection = null; + } + + /** + * Sends the LIST command to the server and returns an array of newsgroups + * + * @return array + */ + public function listGroups() + { + $list = []; + $response = $this->sendCommand('LIST', 215); + + if ($response !== false) { + while ($line = fgets($this->connection)) { + if ($line == ".\r\n") { + break; + } + + $line = rtrim($line); + list($group, $high, $low, $status) = explode(' ', $line); + + $list[$group] = [ + 'high' => $high, + 'low' => $low, + 'status' => $status, + ]; + } + } + + return $list; + } + + /** + * Sets the active group at the server and returns details about the group + * + * @param string $group Name of the group to set as the active group + * @return array + * @throws \RuntimeException + */ + public function selectGroup($group) + { + $response = $this->sendCommand("GROUP {$group}", 211); + + if ($response !== false) { + list($number, $low, $high, $group) = explode(' ', $response); + + return [ + 'group' => $group, + 'articlesCount' => $number, + 'low' => $low, + 'high' => $high, + ]; + } + + throw new \RuntimeException('Failed to get info on group'); + } + + /** + * Returns an overview of the selected articles from the specified group + * + * @param string $group The name of the group to select + * @param int $start The number of the article to start from + * @param int $pageSize The number of articles to return + * @return array + */ + public function getArticlesOverview($group, $start, $pageSize = 20) + { + $groupDetails = $this->selectGroup($group); + + $pageSize = $pageSize - 1; + $high = $groupDetails['high']; + $low = $groupDetails['low']; + + if (!$start || $start > $high - $pageSize || $start < $low) { + $start = $high - $low > $pageSize ? $high - $pageSize : $low; + } + + $end = min($high, $start + $pageSize); + + $overview = [ + 'group' => $groupDetails + ['start' => $start], + 'articles' => [], + ]; + + $response = $this->sendCommand("XOVER {$start}-{$end}", 224); + + while ($line = fgets($this->connection)) { + if ($line == ".\r\n") { + break; + } + + $line = rtrim($line); + list($n, $subject, $author, $date, $messageId, $references, $lines, $extra) = explode("\t", $line, 9); + + $overview['articles'][$n] = [ + 'subject' => $subject, + 'author' => $author, + 'date' => $date, + 'messageId' => $messageId, + 'references' => $references, + 'lines' => $lines, + 'extra' => $extra, + ]; + } + + return $overview; + } + + /** + * Returns the full content of the specified article (headers and body) + * + * @param int $articleId + * @param string|null $group + * @return string + */ + public function readArticle($articleId, $group = null) + { + if ($group) { + $groupDetails = $this->selectGroup($group); + } + + $article = ''; + + try { + $response = $this->sendCommand("ARTICLE {$articleId}", 220); + } catch (\RuntimeException $e) { + return null; + } + + while ($line = fgets($this->connection)) { + if ($line == ".\r\n") { + break; + } + + $article .= $line; + } + + return $article; + } + + /** + * Performs a lookup on the $messageId to find its group and article ID + * + * @param string $messageId + * @return array + */ + public function xpath($messageId) + { + $response = $this->sendCommand("XPATH {$messageId}", 223); + list($group, $articleId) = explode('/', $response); + + return [ + 'messageId' => $messageId, + 'group' => $group, + 'articleId' => $articleId, + ]; + } + + /** + * Sends a command to the server and checks the expected response code + * + * @param string $command + * @param int $expected The successful response code expected + * @return string + */ + protected function sendCommand($command, $expected) + { + fwrite($this->connection, "$command\r\n"); + $result = fgets($this->connection); + list($code, $response) = explode(' ', $result, 2); + + if ($code == $expected) { + return rtrim($response); + } + + throw new \RuntimeException( + "Expected response code of {$expected} but received {$code} for command `{$command}'" + ); + } } diff --git a/lib/common.php b/lib/common.php new file mode 100644 index 0000000..149ab12 --- /dev/null +++ b/lib/common.php @@ -0,0 +1,219 @@ +<?php + +/* Prevents the poor mail server from suffering if it receives a message with many references */ +/* (References: <xxx> or In-Reply-To: <xxx>) */ +define('REFERENCES_LIMIT', 20); + +function error($str) +{ + head("PHP news : error"); + echo "<section class=\"content\"><blockquote><strong>Error:</strong> ", + to_utf8($str), "</blockquote></section>\n"; + foot(); + die(); +} + +function head($title = "PHP Mailing Lists (PHP News)") +{ + header("Content-type: text/html; charset=utf-8"); + + ?> +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title><?php echo htmlspecialchars($title); ?></title> + <link rel="stylesheet" href="/fonts/Fira/fira.css" type="text/css" /> + <link rel="stylesheet" href="/style.css" type="text/css" /> + <link rel="shortcut icon" href="/favicon.ico"> + </head> + <body> + <header class="header"> + <nav class="header-inner"> + <a href="/" class="header-brand"> + <img src="//php.net/images/logos/php-logo.svg" class="header-brand-img" alt="PHP" height="24" width="48"> + <span class="header-brand-text">lists</span> + </a> + <ul class="header-menu"> + <li class="header-menu-item"> + <a class="header-menu-item-link" href="https://php.net/downloads.php">Downloads</a> + </li> + <li class="header-menu-item"> + <a class="header-menu-item-link" href="https://php.net/docs.php">Documentation</a> + </li> + <li class="header-menu-item"> + <a class="header-menu-item-link" href="https://php.net/get-involved.php">Get Involved</a> + </li> + <li class="header-menu-item mod-active"> + <a class="header-menu-item-link" href="https://php.net/support.php">Help</a> + </li> + </ul> + <form class="search-form" action="https://php.net/search.php"> + <input class="search-input" value="" name="pattern" placeholder="Search"> + </form> + <div class="menu-icon" onclick="document.querySelector('.menu-mobile').classList.toggle('hide')">☰ MENU</div> + <ul class="menu-mobile hide"> + <li class="menu-mobile-item"> + <a class="menu-mobile-item-link" href="https://php.net/downloads.php">Downloads</a> + </li> + <li class="menu-mobile-item"> + <a class="menu-mobile-item-link" href="https://php.net/docs.php">Documentation</a> + </li> + <li class="menu-mobile-item"> + <a class="menu-mobile-item-link" href="https://php.net/get-involved.php">Get Involved</a> + </li> + <li class="menu-mobile-item mod-active"> + <a class="menu-mobile-item-link" href="https://php.net/support.php">Help</a> + </li> + </ul> + </nav> + </header> + <?php +} + +function foot() +{ + ?> + + <footer class="footer"> + <ul class="footer-nav"> + <li class="footer-nav-item"> + <a class="footer-nav-item-link" href="https://php.net/copyright.php"> + Copyright ©2001-<?php echo date('Y'); ?> The PHP Group + </a> + </li> + <li class="footer-nav-item"> + <a class="footer-nav-item-link" href="https://php.net/my.php">My PHP.net</a> + </li> + <li class="footer-nav-item"> + <a class="footer-nav-item-link" href="https://php.net/contact.php">Contact</a> + </li> + <li class="footer-nav-item"> + <a class="footer-nav-item-link" href="https://php.net/sites.php">Other PHP.net sites</a> + </li> + <li class="footer-nav-item"> + <a class="footer-nav-item-link" href="https://php.net/mirrors.php">Mirror sites</a> + </li> + <li class="footer-nav-item"> + <a class="footer-nav-item-link" href="https://php.net/privacy.php">Privacy policy</a> + </li> + </ul> + </footer> + </body> +</html> + <?php +} + +function to_utf8($str, $charset = 'iso-8859-1') +{ + $n = iconv($charset, 'utf-8', $str); + if ($n === false) { + return $str; + } + return $n; +} + +function decode_header($charset, $encoding, $text) +{ + if (strtolower($encoding) == "b") { + $text = base64_decode($text); + } else { + $text = quoted_printable_decode($text); + } + return to_utf8($text, $charset); +} + +function recode_header($header, $basecharset) +{ + if (strpos($header, "=?") === false) { + return to_utf8($header, $basecharset); + } + return preg_replace_callback( + "/=\\?(.+?)\\?([qb])\\?(.+?)(\\?=|$)/i", + function ($m) { + return decode_header($m[1], $m[2], $m[3]); + }, + $header + ); +} + +/* Email spam protection (taken from php-bugs-web) */ +function spam_protect($txt) +{ + $translate = array('@' => ' at ', '.' => ' dot '); + + /* php.net addresses are not protected! */ + if (preg_match('/^(.+)@php\.net/i', $txt)) { + return $txt; + } else { + return strtr($txt, $translate); + } +} + + +# this turns some common forms of email addresses into mailto: links +function format_author($a, $charset = 'iso-8859-1') +{ + $a = recode_header($a, $charset); + if (preg_match("/^\s*(.+)\s+\\(\"?(.+?)\"?\\)\s*$/", $a, $ar)) { + return "<a href=\"mailto:" . + htmlspecialchars(urlencode(spam_protect($ar[1])), ENT_QUOTES, "UTF-8") . + "\" class=\"email fn n\">" . + str_replace(" ", " ", htmlspecialchars($ar[2], ENT_QUOTES, "UTF-8")) . "</a>"; + } + if (preg_match("/^\s*\"?(.+?)\"?\s*<(.+)>\s*$/", $a, $ar)) { + return "<a href=\"mailto:" . + htmlspecialchars(urlencode(spam_protect($ar[2])), ENT_QUOTES, "UTF-8") . + "\" class=\"email fn n\">" . + str_replace(" ", " ", htmlspecialchars($ar[1], ENT_QUOTES, "UTF-8")) . "</a>"; + } + if (strpos("@", $a) !== false) { + $a = spam_protect($a); + return "<a href=\"mailto:" . htmlspecialchars(urlencode($a), ENT_QUOTES, "UTF-8") . + "\" class=\"email fn n\">" . htmlspecialchars($a, ENT_QUOTES, "UTF-8") . "</a>"; + } + return str_replace(" ", " ", htmlspecialchars($a, ENT_QUOTES, "UTF-8")); +} + +function format_subject($s, $charset = 'iso-8859-1') +{ + global $article; + $s = recode_header($s, $charset); + + if ((($pos = strpos($s, '[PHP')) !== false || ($pos = strpos($s, '[PEAR')) !== false)) { + if (($end_pos = strpos($s, ']', $pos)) !== false) { + $s = ltrim(substr_replace($s, '', $pos, $end_pos - $pos + 1)); + } + } + + // make this look better on the preview page.. + if (strlen($s) > 150 && !isset($article)) { + $s = substr($s, 0, 150) . "..."; + } else { + $s = wordwrap($s, 150); + } + return nl2br(htmlspecialchars($s, ENT_QUOTES, "UTF-8")); +} + + +function format_title($s, $charset = 'iso-8859-1') +{ + global $article; + $s = recode_header($s, $charset); + $s = preg_replace("/^(Re: *)?\[(PHP|PEAR)(-.*)?\] /i", "\\1", $s); + // make this look better on the preview page.. + if (strlen($s) > 150 && !isset($article)) { + $s = substr($s, 0, 150) . "..."; + } else { + $s = wordwrap($s, 150); + } + return htmlspecialchars($s, ENT_QUOTES, "UTF-8"); +} + +function format_date($d) +{ + $d = strtotime($d); + $d = gmdate('r', $d); + return str_replace(" ", " ", $d); +} diff --git a/lib/config.php b/lib/config.php new file mode 100644 index 0000000..71a3835 --- /dev/null +++ b/lib/config.php @@ -0,0 +1,6 @@ +<?php + +$NNTP_HOST = 'localhost'; +if (getenv('NNTP_HOST')) { + $NNTP_HOST = getenv('NNTP_HOST'); +} diff --git a/lib/fMailbox.php b/lib/fMailbox.php index 04f05af..78a5169 100644 --- a/lib/fMailbox.php +++ b/lib/fMailbox.php @@ -1,4 +1,7 @@ <?php + +namespace Flourish; + /** * This is a heavily-trimmed version of Will Bond's Flourish library fMailbox * class. It is based on the version of the file located here: @@ -36,528 +39,519 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -class fMailbox +class Mailbox { - /** - * Takes a date, removes comments and cleans up some common formatting inconsistencies - * - * @param string $date The date to clean - * @return string The cleaned date - */ - private static function cleanDate($date) - { - $date = preg_replace('#\([^)]+\)#', ' ', trim($date)); - $date = preg_replace('#\s+#', ' ', $date); - $date = preg_replace('#(\d+)-([a-z]+)-(\d{4})#i', '\1 \2 \3', $date); - $date = preg_replace('#^[a-z]+\s*,\s*#i', '', trim($date)); - return trim($date); - } - - /** - * Decodes encoded-word headers of any encoding into raw UTF-8 - * - * @param string $text The header value to decode - * @return string The decoded UTF-8 - */ - private static function decodeHeader($text) - { - $parts = preg_split('#(=\?[^\?]+\?[QB]\?[^\?]+\?=)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE); - - $part_with_encoding = array(); - $output = ''; - foreach ($parts as $part) { - if ($part === '') { - continue; - } - - if (preg_match_all('#=\?([^\?]+)\?([QB])\?([^\?]+)\?=#i', $part, $matches, PREG_SET_ORDER)) { - foreach ($matches as $match) { - if (strtoupper($match[2]) == 'Q') { - $part_string = rawurldecode(strtr( - $match[3], - array( - '=' => '%', - '_' => ' ' - ) - )); - } else { - $part_string = base64_decode($match[3]); - } - $lower_encoding = strtolower($match[1]); - $last_key = count($part_with_encoding) - 1; - if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == $lower_encoding) { - $part_with_encoding[$last_key]['string'] .= $part_string; - } else { - $part_with_encoding[] = array('encoding' => $lower_encoding, 'string' => $part_string); - } - } - - } else { - $last_key = count($part_with_encoding) - 1; - if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == 'iso-8859-1') { - $part_with_encoding[$last_key]['string'] .= $part; - } else { - $part_with_encoding[] = array('encoding' => 'iso-8859-1', 'string' => $part); - } - } - } - - foreach ($part_with_encoding as $part) { - $output .= self::iconv($part['encoding'], 'UTF-8', $part['string']); - } - - return $output; - } - - /** - * Handles an individual part of a multipart message - * - * @param array $info An array of information about the message - * @param array $structure An array describing the structure of the message - * @return array The modified $info array - */ - private static function handlePart($info, $structure) - { - if ($structure['type'] == 'multipart') { - foreach ($structure['parts'] as $part) { - $info = self::handlePart($info, $part); - } - return $info; - } - - if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-mime', 'x-pkcs7-mime'))) { - $to = null; - if (isset($info['headers']['to'][0])) { - $to = $info['headers']['to'][0]['mailbox']; - if (!empty($info['headers']['to'][0]['host'])) { - $to .= '@' . $info['headers']['to'][0]['host']; - } - } - } - - if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-signature', 'x-pkcs7-signature'))) { - $from = null; - if (isset($info['headers']['from'])) { - $from = $info['headers']['from']['mailbox']; - if (!empty($info['headers']['from']['host'])) { - $from .= '@' . $info['headers']['from']['host']; - } - } - } - - $data = $structure['data']; - - if ($structure['encoding'] == 'base64') { - $content = ''; - foreach (explode("\r\n", $data) as $line) { - $content .= base64_decode($line); - } - } elseif ($structure['encoding'] == 'quoted-printable') { - $content = quoted_printable_decode($data); - } else { - $content = $data; - } - - if ($structure['type'] == 'text') { - $charset = 'iso-8859-1'; - foreach ($structure['type_fields'] as $field => $value) { - if (strtolower($field) == 'charset') { - $charset = $value; - break; - } - } - $content = self::iconv($charset, 'UTF-8', $content); - if ($structure['subtype'] == 'html') { - $content = preg_replace('#(content=(["\'])text/html\s*;\s*charset=(["\']?))' . preg_quote($charset, '#') . '(\3\2)#i', '\1utf-8\4', $content); - } - } - - // This indicates a content-id which is used for multipart/related - if ($structure['content_id']) { - if (!isset($info['related'])) { - $info['related'] = array(); - } - $cid = $structure['content_id'][0] == '<' ? substr($structure['content_id'], 1, -1) : $structure['content_id']; - $info['related']['cid:' . $cid] = array( - 'mimetype' => $structure['type'] . '/' . $structure['subtype'], - 'data' => $content - ); - return $info; - } - - - $has_disposition = !empty($structure['disposition']); - $is_text = $structure['type'] == 'text' && $structure['subtype'] == 'plain'; - $is_html = $structure['type'] == 'text' && $structure['subtype'] == 'html'; - - // If the part doesn't have a disposition and is not the default text or html, set the disposition to inline - if (!$has_disposition && ((!$is_text || !empty($info['text'])) && (!$is_html || !empty($info['html'])))) { - $is_web_image = $structure['type'] == 'image' && in_array($structure['subtype'], array('gif', 'png', 'jpeg', 'pjpeg')); - $structure['disposition'] = $is_text || $is_html || $is_web_image ? 'inline' : 'attachment'; - $structure['disposition_fields'] = array(); - $has_disposition = true; - } - - - // Attachments or inline content - if ($has_disposition) { - - $filename = ''; - foreach ($structure['disposition_fields'] as $field => $value) { - if (strtolower($field) == 'filename') { - $filename = $value; - break; - } - } - foreach ($structure['type_fields'] as $field => $value) { - if (strtolower($field) == 'name') { - $filename = $value; - break; - } - } - - // This automatically handles primary content that has a content-disposition header on it - if ($structure['disposition'] == 'inline' && $filename === '') { - if ($is_text && !isset($info['text'])) { - $info['text'] = $content; - return $info; - } - if ($is_html && !isset($info['html'])) { - $info['html'] = $content; - return $info; - } - } - - if (!isset($info[$structure['disposition']])) { - $info[$structure['disposition']] = array(); - } - - $info[$structure['disposition']][] = array( - 'filename' => $filename, - 'mimetype' => $structure['type'] . '/' . $structure['subtype'], - 'data' => $content, - 'description' => $structure['description'], - ); - return $info; - } - - if ($is_text) { - $info['text'] = $content; - return $info; - } - - if ($is_html) { - $info['html'] = $content; - return $info; - } - } - - /** - * This works around a bug in MAMP 1.9.4+ and PHP 5.3 where iconv() - * does not seem to properly assign the return value to a variable, but - * does work when returning the value. - * - * @param string $in_charset The incoming character encoding - * @param string $out_charset The outgoing character encoding - * @param string $string The string to convert - * @return string The converted string - */ - private static function iconv($in_charset, $out_charset, $string) - { - return iconv($in_charset, $out_charset, $string); - } - - /** - * Parses a string representation of an email into the persona, mailbox and host parts - * - * @param string $string The email string to parse - * @return array An associative array with the key `mailbox`, and possibly `host` and `personal` - */ - private static function parseEmail($string) - { - $email_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+")(?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))*)@((?:[a-z0-9\\-]+\.)+[a-z]{2,}|\[(?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])\])'; - $name_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*)(?:\.?[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*))*)'; - - if (preg_match('~^[ \t]*' . $name_regex . '[ \t]*<[ \t]*' . $email_regex . '[ \t]*>[ \t]*$~ixD', $string, $match)) { - $match[1] = trim($match[1]); - if ($match[1][0] == '"' && substr($match[1], -1) == '"') { - $match[1] = substr($match[1], 1, -1); - } - return array( - 'personal' => self::decodeHeader($match[1]), - 'mailbox' => self::decodeHeader($match[2]), - 'host' => self::decodeHeader($match[3]), - 'raw' => $string, - ); - - } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*$~ixD', $string, $match)) { - return array( - 'mailbox' => self::decodeHeader($match[1]), - 'host' => self::decodeHeader($match[2]), - 'raw' => $string, - ); - - // This handles the outdated practice of including the personal - // part of the email in a comment after the email address - } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*\(([^)]+)\)[ \t]*$~ixD', $string, $match)) { - $match[3] = trim($match[1]); - if ($match[3][0] == '"' && substr($match[3], -1) == '"') { - $match[3] = substr($match[3], 1, -1); - } - - return array( - 'personal' => self::decodeHeader($match[3]), - 'mailbox' => self::decodeHeader($match[1]), - 'host' => self::decodeHeader($match[2]), - 'raw' => $string, - ); - } - - if (strpos($string, '@') !== false) { - list ($mailbox, $host) = explode('@', $string, 2); - return array( - 'mailbox' => self::decodeHeader($mailbox), - 'host' => self::decodeHeader($host), - 'raw' => $string, - ); - } - - return array( - 'mailbox' => self::decodeHeader($string), - 'host' => '', - 'raw' => $string, - ); - } - - /** - * Parses full email headers into an associative array - * - * @param string $headers The header to parse - * @param string $filter Remove any headers that match this - * @return array The parsed headers - */ - private static function parseHeaders($headers, $filter = null) - { - $headers = trim($headers); - if (!strlen($headers)) { - return array(); - } - $header_lines = preg_split("#\r\n(?!\s)#", $headers); - - $single_email_fields = array('from', 'sender', 'reply-to'); - $multi_email_fields = array('to', 'cc'); - $additional_info_fields = array('content-type', 'content-disposition'); - - $parsed_headers = array(); - foreach ($header_lines as $header_line) { - $header_line = preg_replace("#\r\n\s+#", ' ', $header_line); - $header_line = trim($header_line); - - list ($header, $value) = preg_split('#:\s*#', $header_line, 2); - $header = strtolower($header); - - if ($filter !== null && strpos($header, $filter) !== false) { - continue; - } - - $is_single_email = in_array($header, $single_email_fields); - $is_multi_email = in_array($header, $multi_email_fields); - $is_additional_info_field = in_array($header, $additional_info_fields); - - if ($is_additional_info_field) { - $pieces = preg_split('#;\s*#', $value, 2); - $value = $pieces[0]; - - $parsed_headers[$header] = array('value' => self::decodeHeader($value)); - - $fields = array(); - if (!empty($pieces[1])) { - preg_match_all('#(\w+)=("([^"]+)"|([^\s;]+))(?=;|$)#', $pieces[1], $matches, PREG_SET_ORDER); - foreach ($matches as $match) { - $fields[strtolower($match[1])] = self::decodeHeader(!empty($match[4]) ? $match[4] : $match[3]); - } - } - $parsed_headers[$header]['fields'] = $fields; - - } elseif ($is_single_email) { - $parsed_headers[$header] = self::parseEmail($value); - - } elseif ($is_multi_email) { - $strings = array(); - - preg_match_all('#"[^"]+?"#', $value, $matches, PREG_SET_ORDER); - foreach ($matches as $i => $match) { - $strings[] = $match[0]; - $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1); - } - preg_match_all('#\([^)]+?\)#', $value, $matches, PREG_SET_ORDER); - foreach ($matches as $i => $match) { - $strings[] = $match[0]; - $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1); - } - - $emails = explode(',', $value); - array_map('trim', $emails); - foreach ($strings as $i => $string) { - $emails = preg_replace( - '#:string' . ($i+1) . '\b#', - strtr($string, array('\\' => '\\\\', '$' => '\\$')), - $emails, - 1 - ); - } - - $parsed_headers[$header] = array(); - foreach ($emails as $email) { - $parsed_headers[$header][] = self::parseEmail($email); - } - - } elseif ($header == 'references') { - $parsed_headers[$header] = array_map(array('fMailbox', 'decodeHeader'), preg_split('#(?<=>)\s+(?=<)#', $value)); - - } elseif ($header == 'received') { - if (!isset($parsed_headers[$header])) { - $parsed_headers[$header] = array(); - } - $parsed_headers[$header][] = preg_replace('#\s+#', ' ', self::decodeHeader($value)); - - } else { - $parsed_headers[$header] = self::decodeHeader($value); - } - } - - return $parsed_headers; - } - - /** - * Parses a MIME message into an associative array of information - * - * The output includes the following keys: - * - * - `'received'`: The date the message was received by the server - * - `'headers'`: An associative array of mail headers, the keys are the header names, in lowercase - * - * And one or more of the following: - * - * - `'text'`: The plaintext body - * - `'html'`: The HTML body - * - `'attachment'`: An array of attachments, each containing: - * - `'filename'`: The name of the file - * - `'mimetype'`: The mimetype of the file - * - `'data'`: The raw contents of the file - * - `'inline'`: An array of inline files, each containing: - * - `'filename'`: The name of the file - * - `'mimetype'`: The mimetype of the file - * - `'data'`: The raw contents of the file - * - `'related'`: An associative array of related files, such as embedded images, with the key `'cid:{content-id}'` and an array value containing: - * - `'mimetype'`: The mimetype of the file - * - `'data'`: The raw contents of the file - * - `'verified'`: If the message contents were verified via an S/MIME certificate - if not verified the smime.p7s will be listed as an attachment - * - `'decrypted'`: If the message contents were decrypted via an S/MIME private key - if not decrypted the smime.p7m will be listed as an attachment - * - * All values in `headers`, `text` and `body` will have been decoded to - * UTF-8. Files in the `attachment`, `inline` and `related` array will all - * retain their original encodings. - * - * @param string $message The full source of the email message - * @param boolean $convert_newlines If `\r\n` should be converted to `\n` in the `text` and `html` parts the message - * @return array The parsed email message - see method description for details - */ - public static function parseMessage($message, $convert_newlines = false) - { - $info = array(); - list ($headers, $body) = explode("\r\n\r\n", $message, 2); - $parsed_headers = self::parseHeaders($headers); - $info['received'] = self::cleanDate(preg_replace('#^.*;\s*([^;]+)$#', '\1', $parsed_headers['received'][0])); - $info['headers'] = array(); - foreach ($parsed_headers as $header => $value) { - if (substr($header, 0, 8) == 'content-') { - continue; - } - $info['headers'][$header] = $value; - } - $info['raw_headers'] = $headers; - $info['raw_message'] = $message; - - $info = self::handlePart($info, self::parseStructure($body, $parsed_headers)); - unset($info['raw_message']); - unset($info['raw_headers']); - - if ($convert_newlines) { - if (isset($info['text'])) { - $info['text'] = str_replace("\r\n", "\n", $info['text']); - } - if (isset($info['html'])) { - $info['html'] = str_replace("\r\n", "\n", $info['html']); - } - } - - if (isset($info['text'])) { - $info['text'] = preg_replace('#\r?\n$#D', '', $info['text']); - } - if (isset($info['html'])) { - $info['html'] = preg_replace('#\r?\n$#D', '', $info['html']); - } - - return $info; - } - - /** - * Takes the raw contents of a MIME message and creates an array that - * describes the structure of the message - * - * @param string $data The contents to get the structure of - * @param string $headers The parsed headers for the message - if not present they will be extracted from the `$data` - * @return array The multi-dimensional, associative array containing the message structure - */ - private static function parseStructure($data, $headers = null) - { - if (!$headers) { - list ($headers, $data) = preg_split("#^\r\n|\r\n\r\n#", $data, 2); - $headers = self::parseHeaders($headers); - } - - if (!isset($headers['content-type'])) { - $headers['content-type'] = array( - 'value' => 'text/plain', - 'fields' => array() - ); - } - - list ($type, $subtype) = explode('/', strtolower($headers['content-type']['value']), 2); - - if ($type == 'multipart') { - $structure = array( - 'type' => $type, - 'subtype' => $subtype, - 'parts' => array() - ); - $boundary = $headers['content-type']['fields']['boundary']; - $start_pos = strpos($data, '--' . $boundary) + strlen($boundary) + 4; - $end_pos = strrpos($data, '--' . $boundary . '--') - 2; - $sub_contents = explode("\r\n--" . $boundary . "\r\n", substr( - $data, - $start_pos, - $end_pos - $start_pos - )); - foreach ($sub_contents as $sub_content) { - $structure['parts'][] = self::parseStructure($sub_content); - } - - } else { - $structure = array( - 'type' => $type, - 'type_fields' => !empty($headers['content-type']['fields']) ? $headers['content-type']['fields'] : array(), - 'subtype' => $subtype, - 'content_id' => isset($headers['content-id']) ? $headers['content-id'] : null, - 'encoding' => isset($headers['content-transfer-encoding']) ? strtolower($headers['content-transfer-encoding']) : '8bit', - 'disposition' => isset($headers['content-disposition']) ? strtolower($headers['content-disposition']['value']) : null, - 'disposition_fields' => isset($headers['content-disposition']) ? $headers['content-disposition']['fields'] : array(), - 'description' => isset($headers['content-description']) ? $headers['content-description'] : null, - 'data' => $data - ); - } - - return $structure; - } + /** + * Takes a date, removes comments and cleans up some common formatting inconsistencies + * + * @param string $date The date to clean + * @return string The cleaned date + */ + private static function cleanDate($date) + { + $date = preg_replace('#\([^)]+\)#', ' ', trim($date)); + $date = preg_replace('#\s+#', ' ', $date); + $date = preg_replace('#(\d+)-([a-z]+)-(\d{4})#i', '\1 \2 \3', $date); + $date = preg_replace('#^[a-z]+\s*,\s*#i', '', trim($date)); + return trim($date); + } + + /** + * Decodes encoded-word headers of any encoding into raw UTF-8 + * + * @param string $text The header value to decode + * @return string The decoded UTF-8 + */ + private static function decodeHeader($text) + { + $parts = preg_split('#(=\?[^\?]+\?[QB]\?[^\?]+\?=)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE); + + $part_with_encoding = array(); + $output = ''; + foreach ($parts as $part) { + if ($part === '') { + continue; + } + + if (preg_match_all('#=\?([^\?]+)\?([QB])\?([^\?]+)\?=#i', $part, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + if (strtoupper($match[2]) == 'Q') { + $part_string = rawurldecode(strtr( + $match[3], + array( + '=' => '%', + '_' => ' ' + ) + )); + } else { + $part_string = base64_decode($match[3]); + } + $lower_encoding = strtolower($match[1]); + $last_key = count($part_with_encoding) - 1; + if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == $lower_encoding) { + $part_with_encoding[$last_key]['string'] .= $part_string; + } else { + $part_with_encoding[] = array('encoding' => $lower_encoding, 'string' => $part_string); + } + } + } else { + $last_key = count($part_with_encoding) - 1; + if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == 'iso-8859-1') { + $part_with_encoding[$last_key]['string'] .= $part; + } else { + $part_with_encoding[] = array('encoding' => 'iso-8859-1', 'string' => $part); + } + } + } + + foreach ($part_with_encoding as $part) { + $output .= self::iconv($part['encoding'], 'UTF-8', $part['string']); + } + + return $output; + } + + /** + * Handles an individual part of a multipart message + * + * @param array $info An array of information about the message + * @param array $structure An array describing the structure of the message + * @return array The modified $info array + */ + private static function handlePart($info, $structure) + { + if ($structure['type'] == 'multipart') { + foreach ($structure['parts'] as $part) { + $info = self::handlePart($info, $part); + } + return $info; + } + + if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-mime', 'x-pkcs7-mime'))) { + $to = null; + if (isset($info['headers']['to'][0])) { + $to = $info['headers']['to'][0]['mailbox']; + if (!empty($info['headers']['to'][0]['host'])) { + $to .= '@' . $info['headers']['to'][0]['host']; + } + } + } + + if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-signature', 'x-pkcs7-signature'))) { + $from = null; + if (isset($info['headers']['from'])) { + $from = $info['headers']['from']['mailbox']; + if (!empty($info['headers']['from']['host'])) { + $from .= '@' . $info['headers']['from']['host']; + } + } + } + + $data = $structure['data']; + + if ($structure['encoding'] == 'base64') { + $content = ''; + foreach (explode("\r\n", $data) as $line) { + $content .= base64_decode($line); + } + } elseif ($structure['encoding'] == 'quoted-printable') { + $content = quoted_printable_decode($data); + } else { + $content = $data; + } + + if ($structure['type'] == 'text') { + $charset = 'iso-8859-1'; + foreach ($structure['type_fields'] as $field => $value) { + if (strtolower($field) == 'charset') { + $charset = $value; + break; + } + } + $content = self::iconv($charset, 'UTF-8', $content); + if ($structure['subtype'] == 'html') { + $content = preg_replace('#(content=(["\'])text/html\s*;\s*charset=(["\']?))' . preg_quote($charset, '#') . '(\3\2)#i', '\1utf-8\4', $content); + } + } + + // This indicates a content-id which is used for multipart/related + if ($structure['content_id']) { + if (!isset($info['related'])) { + $info['related'] = array(); + } + $cid = $structure['content_id'][0] == '<' ? substr($structure['content_id'], 1, -1) : $structure['content_id']; + $info['related']['cid:' . $cid] = array( + 'mimetype' => $structure['type'] . '/' . $structure['subtype'], + 'data' => $content + ); + return $info; + } + + + $has_disposition = !empty($structure['disposition']); + $is_text = $structure['type'] == 'text' && $structure['subtype'] == 'plain'; + $is_html = $structure['type'] == 'text' && $structure['subtype'] == 'html'; + + // If the part doesn't have a disposition and is not the default text or html, set the disposition to inline + if (!$has_disposition && ((!$is_text || !empty($info['text'])) && (!$is_html || !empty($info['html'])))) { + $is_web_image = $structure['type'] == 'image' && in_array($structure['subtype'], array('gif', 'png', 'jpeg', 'pjpeg')); + $structure['disposition'] = $is_text || $is_html || $is_web_image ? 'inline' : 'attachment'; + $structure['disposition_fields'] = array(); + $has_disposition = true; + } + + + // Attachments or inline content + if ($has_disposition) { + $filename = ''; + foreach ($structure['disposition_fields'] as $field => $value) { + if (strtolower($field) == 'filename') { + $filename = $value; + break; + } + } + foreach ($structure['type_fields'] as $field => $value) { + if (strtolower($field) == 'name') { + $filename = $value; + break; + } + } + + // This automatically handles primary content that has a content-disposition header on it + if ($structure['disposition'] == 'inline' && $filename === '') { + if ($is_text && !isset($info['text'])) { + $info['text'] = $content; + return $info; + } + if ($is_html && !isset($info['html'])) { + $info['html'] = $content; + return $info; + } + } + + if (!isset($info[$structure['disposition']])) { + $info[$structure['disposition']] = array(); + } + + $info[$structure['disposition']][] = array( + 'filename' => $filename, + 'mimetype' => $structure['type'] . '/' . $structure['subtype'], + 'data' => $content, + 'description' => $structure['description'], + ); + return $info; + } + + if ($is_text) { + $info['text'] = $content; + return $info; + } + + if ($is_html) { + $info['html'] = $content; + return $info; + } + } + + /** + * This works around a bug in MAMP 1.9.4+ and PHP 5.3 where iconv() + * does not seem to properly assign the return value to a variable, but + * does work when returning the value. + * + * @param string $in_charset The incoming character encoding + * @param string $out_charset The outgoing character encoding + * @param string $string The string to convert + * @return string The converted string + */ + private static function iconv($in_charset, $out_charset, $string) + { + return iconv($in_charset, $out_charset, $string); + } + + /** + * Parses a string representation of an email into the persona, mailbox and host parts + * + * @param string $string The email string to parse + * @return array An associative array with the key `mailbox`, and possibly `host` and `personal` + */ + private static function parseEmail($string) + { + $email_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+")(?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))*)@((?:[a-z0-9\\-]+\.)+[a-z]{2,}|\[(?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])\])'; + $name_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*)(?:\.?[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*))*)'; + + if (preg_match('~^[ \t]*' . $name_regex . '[ \t]*<[ \t]*' . $email_regex . '[ \t]*>[ \t]*$~ixD', $string, $match)) { + $match[1] = trim($match[1]); + if ($match[1][0] == '"' && substr($match[1], -1) == '"') { + $match[1] = substr($match[1], 1, -1); + } + return array( + 'personal' => self::decodeHeader($match[1]), + 'mailbox' => self::decodeHeader($match[2]), + 'host' => self::decodeHeader($match[3]), + 'raw' => $string, + ); + } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*$~ixD', $string, $match)) { + return array( + 'mailbox' => self::decodeHeader($match[1]), + 'host' => self::decodeHeader($match[2]), + 'raw' => $string, + ); + + // This handles the outdated practice of including the personal + // part of the email in a comment after the email address + } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*\(([^)]+)\)[ \t]*$~ixD', $string, $match)) { + $match[3] = trim($match[1]); + if ($match[3][0] == '"' && substr($match[3], -1) == '"') { + $match[3] = substr($match[3], 1, -1); + } + + return array( + 'personal' => self::decodeHeader($match[3]), + 'mailbox' => self::decodeHeader($match[1]), + 'host' => self::decodeHeader($match[2]), + 'raw' => $string, + ); + } + + if (strpos($string, '@') !== false) { + list ($mailbox, $host) = explode('@', $string, 2); + return array( + 'mailbox' => self::decodeHeader($mailbox), + 'host' => self::decodeHeader($host), + 'raw' => $string, + ); + } + + return array( + 'mailbox' => self::decodeHeader($string), + 'host' => '', + 'raw' => $string, + ); + } + + /** + * Parses full email headers into an associative array + * + * @param string $headers The header to parse + * @param string $filter Remove any headers that match this + * @return array The parsed headers + */ + private static function parseHeaders($headers, $filter = null) + { + $headers = trim($headers); + if (!strlen($headers)) { + return array(); + } + $header_lines = preg_split("#\r\n(?!\s)#", $headers); + + $single_email_fields = array('from', 'sender', 'reply-to'); + $multi_email_fields = array('to', 'cc'); + $additional_info_fields = array('content-type', 'content-disposition'); + + $parsed_headers = array(); + foreach ($header_lines as $header_line) { + $header_line = preg_replace("#\r\n\s+#", ' ', $header_line); + $header_line = trim($header_line); + + list ($header, $value) = preg_split('#:\s*#', $header_line, 2); + $header = strtolower($header); + + if ($filter !== null && strpos($header, $filter) !== false) { + continue; + } + + $is_single_email = in_array($header, $single_email_fields); + $is_multi_email = in_array($header, $multi_email_fields); + $is_additional_info_field = in_array($header, $additional_info_fields); + + if ($is_additional_info_field) { + $pieces = preg_split('#;\s*#', $value, 2); + $value = $pieces[0]; + + $parsed_headers[$header] = array('value' => self::decodeHeader($value)); + + $fields = array(); + if (!empty($pieces[1])) { + preg_match_all('#(\w+)=("([^"]+)"|([^\s;]+))(?=;|$)#', $pieces[1], $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + $fields[strtolower($match[1])] = self::decodeHeader(!empty($match[4]) ? $match[4] : $match[3]); + } + } + $parsed_headers[$header]['fields'] = $fields; + } elseif ($is_single_email) { + $parsed_headers[$header] = self::parseEmail($value); + } elseif ($is_multi_email) { + $strings = array(); + + preg_match_all('#"[^"]+?"#', $value, $matches, PREG_SET_ORDER); + foreach ($matches as $i => $match) { + $strings[] = $match[0]; + $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1); + } + preg_match_all('#\([^)]+?\)#', $value, $matches, PREG_SET_ORDER); + foreach ($matches as $i => $match) { + $strings[] = $match[0]; + $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1); + } + + $emails = explode(',', $value); + array_map('trim', $emails); + foreach ($strings as $i => $string) { + $emails = preg_replace( + '#:string' . ($i + 1) . '\b#', + strtr($string, array('\\' => '\\\\', '$' => '\\$')), + $emails, + 1 + ); + } + + $parsed_headers[$header] = array(); + foreach ($emails as $email) { + $parsed_headers[$header][] = self::parseEmail($email); + } + } elseif ($header == 'references') { + $parsed_headers[$header] = array_map(array('Flourish\\Mailbox', 'decodeHeader'), preg_split('#(?<=>)\s+(?=<)#', $value)); + } elseif ($header == 'received') { + if (!isset($parsed_headers[$header])) { + $parsed_headers[$header] = array(); + } + $parsed_headers[$header][] = preg_replace('#\s+#', ' ', self::decodeHeader($value)); + } else { + $parsed_headers[$header] = self::decodeHeader($value); + } + } + + return $parsed_headers; + } + + /** + * Parses a MIME message into an associative array of information + * + * The output includes the following keys: + * + * - `'received'`: The date the message was received by the server + * - `'headers'`: An associative array of mail headers, the keys are the header names, in lowercase + * + * And one or more of the following: + * + * - `'text'`: The plaintext body + * - `'html'`: The HTML body + * - `'attachment'`: An array of attachments, each containing: + * - `'filename'`: The name of the file + * - `'mimetype'`: The mimetype of the file + * - `'data'`: The raw contents of the file + * - `'inline'`: An array of inline files, each containing: + * - `'filename'`: The name of the file + * - `'mimetype'`: The mimetype of the file + * - `'data'`: The raw contents of the file + * - `'related'`: An associative array of related files, such as embedded images, with the key `'cid:{content-id}'` and an array value containing: + * - `'mimetype'`: The mimetype of the file + * - `'data'`: The raw contents of the file + * - `'verified'`: If the message contents were verified via an S/MIME certificate - if not verified the smime.p7s will be listed as an attachment + * - `'decrypted'`: If the message contents were decrypted via an S/MIME private key - if not decrypted the smime.p7m will be listed as an attachment + * + * All values in `headers`, `text` and `body` will have been decoded to + * UTF-8. Files in the `attachment`, `inline` and `related` array will all + * retain their original encodings. + * + * @param string $message The full source of the email message + * @param boolean $convert_newlines If `\r\n` should be converted to `\n` in the `text` and `html` parts the message + * @return array The parsed email message - see method description for details + */ + public static function parseMessage($message, $convert_newlines = false) + { + $info = array(); + list ($headers, $body) = explode("\r\n\r\n", $message, 2); + $parsed_headers = self::parseHeaders($headers); + $info['received'] = self::cleanDate(preg_replace('#^.*;\s*([^;]+)$#', '\1', $parsed_headers['received'][0])); + $info['headers'] = array(); + foreach ($parsed_headers as $header => $value) { + if (substr($header, 0, 8) == 'content-') { + continue; + } + $info['headers'][$header] = $value; + } + $info['raw_headers'] = $headers; + $info['raw_message'] = $message; + + $info = self::handlePart($info, self::parseStructure($body, $parsed_headers)); + unset($info['raw_message']); + unset($info['raw_headers']); + + if ($convert_newlines) { + if (isset($info['text'])) { + $info['text'] = str_replace("\r\n", "\n", $info['text']); + } + if (isset($info['html'])) { + $info['html'] = str_replace("\r\n", "\n", $info['html']); + } + } + + if (isset($info['text'])) { + $info['text'] = preg_replace('#\r?\n$#D', '', $info['text']); + } + if (isset($info['html'])) { + $info['html'] = preg_replace('#\r?\n$#D', '', $info['html']); + } + + return $info; + } + + /** + * Takes the raw contents of a MIME message and creates an array that + * describes the structure of the message + * + * @param string $data The contents to get the structure of + * @param string $headers The parsed headers for the message - if not present they will be extracted from the `$data` + * @return array The multi-dimensional, associative array containing the message structure + */ + private static function parseStructure($data, $headers = null) + { + if (!$headers) { + list ($headers, $data) = preg_split("#^\r\n|\r\n\r\n#", $data, 2); + $headers = self::parseHeaders($headers); + } + + if (!isset($headers['content-type'])) { + $headers['content-type'] = array( + 'value' => 'text/plain', + 'fields' => array() + ); + } + + list ($type, $subtype) = explode('/', strtolower($headers['content-type']['value']), 2); + + if ($type == 'multipart') { + $structure = array( + 'type' => $type, + 'subtype' => $subtype, + 'parts' => array() + ); + $boundary = $headers['content-type']['fields']['boundary']; + $start_pos = strpos($data, '--' . $boundary) + strlen($boundary) + 4; + $end_pos = strrpos($data, '--' . $boundary . '--') - 2; + $sub_contents = explode("\r\n--" . $boundary . "\r\n", substr( + $data, + $start_pos, + $end_pos - $start_pos + )); + foreach ($sub_contents as $sub_content) { + $structure['parts'][] = self::parseStructure($sub_content); + } + } else { + $structure = array( + 'type' => $type, + 'type_fields' => !empty($headers['content-type']['fields']) ? $headers['content-type']['fields'] : array(), + 'subtype' => $subtype, + 'content_id' => isset($headers['content-id']) ? $headers['content-id'] : null, + 'encoding' => isset($headers['content-transfer-encoding']) ? strtolower($headers['content-transfer-encoding']) : '8bit', + 'disposition' => isset($headers['content-disposition']) ? strtolower($headers['content-disposition']['value']) : null, + 'disposition_fields' => isset($headers['content-disposition']) ? $headers['content-disposition']['fields'] : array(), + 'description' => isset($headers['content-description']) ? $headers['content-description'] : null, + 'data' => $data + ); + } + + return $structure; + } } diff --git a/lib/group-navbar.php b/lib/group-navbar.php new file mode 100644 index 0000000..e8d92bc --- /dev/null +++ b/lib/group-navbar.php @@ -0,0 +1,30 @@ +<?php + +function navbar($g, $f, $l, $i) +{ + echo ' <table class="standard">' . "\n"; + echo ' <tr>' . "\n"; + echo ' <th class="nav">'; + if ($i > $f) { + $p = max($i - 20, $f); + echo "<a href=\"/" . htmlspecialchars($g, ENT_QUOTES, "UTF-8") . "/start/$p\">", + "<b>« <span>previous</span></b></a>"; + } else { + echo " "; + } + echo '</th>' . "\n"; + $j = min($i + 20, $l); + $c = $l - $f + 1; + echo ' <th class="align-center">' . htmlspecialchars($g, ENT_QUOTES, "UTF-8") . " ($i-$j of $c)</th>\n"; + echo ' <th class="nav align-right">'; + if ($i + 20 <= $l) { + $n = min($i + 20, $l - 19); + echo "<a href=\"/", htmlspecialchars($g, ENT_QUOTES, "UTF-8") . "/start/$n\">", + "<b><span>next</span> »</b></a>"; + } else { + echo " "; + } + echo '</th>' . "\n"; + echo ' </tr>' . "\n"; + echo ' </table>' . "\n"; +}