├── .gitignore ├── robots.txt ├── lib ├── config.php ├── group-navbar.php ├── ThreadTree.php ├── Web │ └── News │ │ └── Nntp.php ├── common.php └── fMailbox.php ├── common.php ├── .git-blame-ignore-revs ├── phpcs.xml ├── composer.json ├── .router.php ├── getpart.php ├── index.php ├── subscribe.php ├── README.md ├── group.php ├── style.css └── article.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | 4 | User-agent: bingbot 5 | Crawl-delay: 5 6 | Disallow: 7 | -------------------------------------------------------------------------------- /lib/config.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | article.php 11 | common.php 12 | getpart.php 13 | group.php 14 | index.php 15 | subscribe.php 16 | lib 17 | 18 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php/web-news", 3 | "description": "Web end to the PHP mailing lists using the NNTP server", 4 | "license": "proprietary", 5 | "type": "project", 6 | "homepage": "https://www.php.net/", 7 | "support": { 8 | "source": "https://github.com/php/web-news" 9 | }, 10 | "require": { 11 | "php": "~8.1.0" 12 | }, 13 | "require-dev": { 14 | "squizlabs/php_codesniffer": "^3.0" 15 | }, 16 | "config": { 17 | "platform": { 18 | "php": "8.1.0" 19 | }, 20 | "sort-packages": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.router.php: -------------------------------------------------------------------------------- 1 | ' . "\n"; 6 | echo ' ' . "\n"; 7 | echo ' '; 8 | if ($i > $f) { 9 | $p = max($i - 20, $f); 10 | echo "", 11 | "« previous"; 12 | } else { 13 | echo " "; 14 | } 15 | echo '' . "\n"; 16 | $j = min($i + 20, $l); 17 | $c = $l - $f + 1; 18 | echo ' ' . htmlspecialchars($g, ENT_QUOTES, "UTF-8") . " ($i-$j of $c)\n"; 19 | echo ' '; 20 | if ($i + 20 <= $l) { 21 | $n = min($i + 20, $l - 19); 22 | echo "", 23 | "next »"; 24 | } else { 25 | echo " "; 26 | } 27 | echo '' . "\n"; 28 | echo ' ' . "\n"; 29 | echo ' ' . "\n"; 30 | } 31 | -------------------------------------------------------------------------------- /getpart.php: -------------------------------------------------------------------------------- 1 | readArticle($article, $group); 26 | 27 | if ($message === null) { 28 | error('No article found'); 29 | } 30 | 31 | $mail = \Flourish\Mailbox::parseMessage($message); 32 | } catch (Exception $e) { 33 | error($e->getMessage()); 34 | } 35 | 36 | if (!empty($mail['attachment'][$part])) { 37 | $attachment = $mail['attachment'][$part]; 38 | 39 | /* Do not rely on user-provided content-deposition header, generate own one to */ 40 | /* make the content downloadable, do NOT use inline, we can't trust the attachment*/ 41 | /* Downside of this approach: images should be downloaded before use */ 42 | /* this is safer though, and prevents doing evil things on php.net domain */ 43 | $contentdisposition = 'attachment'; 44 | 45 | if (!empty($attachment['filename'])) { 46 | $contentdisposition .= '; filename="' . $attachment['filename'] . '"'; 47 | } 48 | 49 | header('Content-Type: ' . $attachment['mimetype']); 50 | header('Content-Disposition: ' . $contentdisposition); 51 | 52 | if (isset($attachment['description'])) { 53 | header('Content-Description: ' . $attachment['description']); 54 | } 55 | 56 | echo $attachment['data']; 57 | } else { 58 | error('Part not found'); 59 | } 60 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | listGroups(); 8 | $descriptions = $nntpClient->listGroupDescriptions(); 9 | /* Reorder so it's moderated, active, and inactive */ 10 | $order = [ 'm' => 1, 'y' => 2, 'n' => 3 ]; 11 | uasort($groups, function ($a, $b) use ($order) { 12 | return $order[$a['status']] <=> $order[$b['status']]; 13 | }); 14 | } catch (Exception $e) { 15 | error($e->getMessage()); 16 | } 17 | 18 | head(); 19 | 20 | $DISPLAY_NNTP_HOST = htmlspecialchars(($NNTP_HOST == 'localhost') ? 'news.php.net' : $NNTP_HOST); 21 | ?> 22 | 23 | 28 | 29 |
30 | 31 |
32 |

PHP Mailing Lists

33 |

34 | The PHP project collaborates across a number of mailing lists. The archives 35 | are available through this site and via NNTP at 36 | . 37 |

38 |

39 | Instructions for subscribing to active lists by email can be found on the page 40 | for each list (just follow the links below). Participation on each list is governed 41 | by the 42 | mailing list rules. 43 |

44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | $details) { 58 | if ($details['status'] != $last_status) { 59 | $last_status = $details['status']; 60 | echo '\n"; 63 | } 64 | echo " \n"; 65 | echo " \n"; 66 | echo " \n"; 67 | echo " \n"; 68 | echo " \n"; 73 | echo " \n"; 74 | } 75 | ?> 76 |
NameDescriptionMessagesRSS
Moderated Lists
', 61 | $last_status == 'y' ? 'Discussion Lists' : 'Inactive Lists', 62 | "
$group", htmlspecialchars($descriptions[$group]), "", $details['high'] - $details['low'] + 1, ""; 69 | if ($details['status'] != 'n') { 70 | echo "RSS"; 71 | } 72 | echo "
77 |
78 | '; 6 | 7 | // No error found yet 8 | $error = ""; 9 | 10 | // Check email address 11 | if ( 12 | empty($_POST['email']) || 13 | $_POST['email'] == 'user@example.com' || 14 | $_POST['email'] == 'fake@from.net' || 15 | !is_emailable_address($_POST['email']) 16 | ) { 17 | $error = "You forgot to specify an email address to be added to the list, or specified an invalid address." . 18 | "
Please go back and try again."; 19 | 20 | // Check if any mailing list was selected 21 | } elseif (empty($_POST['group'])) { 22 | $error = "You need to select a group subscribe to." . 23 | "
Please go back and try again."; 24 | 25 | // Check if type of subscription makes sense 26 | } elseif (!in_array($_POST['type'], [ '', 'digest', 'nomail' ])) { 27 | $error = "The subscription type you specified is not valid." . 28 | "
Please go back and try again."; 29 | 30 | // Seems to be a valid email address 31 | } else { 32 | $remote_addr = i2c_realip(); 33 | $maillist = get_list_address($_POST['group']); 34 | if ($_POST['type'] != '') { 35 | $maillist .= '-' . $_POST['type']; 36 | } 37 | 38 | if ($maillist) { 39 | // Get in contact with main server to subscribe the user 40 | $result = posttohost( 41 | "https://main.internal.php.net/entry/subscribe.php", 42 | [ 43 | "request" => 'subscribe', 44 | "email" => $_POST['email'], 45 | "maillist" => $maillist, 46 | "remoteip" => $remote_addr, 47 | "referer" 48 | => 'http' . (@$_SERVER['HTTPS'] ? 's' : '') . '://' . $_SERVER['SERVER_NAME'] . 49 | '/' . $_POST['group'], 50 | ], 51 | ); 52 | 53 | // Provide error if unable to subscribe 54 | if ($result) { 55 | $error = "We were unable to subscribe you due to some technical problems.
" . 56 | "Please try again later."; 57 | } 58 | } else { 59 | $error = "That's not a group that we can handle.
" . 60 | "Please try again later."; 61 | } 62 | } 63 | 64 | // Give error information or success report 65 | if (!empty($error)) { 66 | echo "

$error

"; 67 | } else { 68 | ?> 69 |

70 | A request has been entered into the mailing list processing queue. 71 | You should receive an email at shortly 72 | describing how to complete your request. 73 |

74 | '; 77 | foot(); 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP.net News Server Web Interface 2 | 3 | You may run this project using PHP's [built-in web server][webserver] 4 | for local development. 5 | 6 | ``` 7 | git clone https://github.com/php/web-news.git 8 | cd web-news/ 9 | NNTP_HOST=news.php.net php -S localhost:8080 .router.php 10 | ``` 11 | 12 | ----- 13 | 14 | this is all very ugly. just proof-of-concept, really. 15 | 16 | the biggest thing to do would be to do something smart with 17 | mime-encoded messages. but keeping the current property of not 18 | slurping the whole damn message into memory just to do so. 19 | 20 | another thing to do would be to support posting. to avoid 21 | completely anonymous posting, this could require confirming the 22 | email address before allowing posts. to do this without actually 23 | having to maintain a database of users, we could send an email 24 | containing md5(md5("email:timestamp").$secret) (where $secret is 25 | some value that is kept secret. duh.) and then let the user "log 26 | in" by supplying their email address and this code, and storing 27 | that in a cookie. depends on a secret for 'security', but like i 28 | said, it avoids having to maintain any sort of state on the server 29 | side. blocking email addresses for posting will be easy enough 30 | if anyone ever abuses the system. 31 | 32 | should also probably protect email addresses from harvesters. 33 | then again, anyone who wanted to harvest email addresses could just 34 | crawl the nntp server directly. or they can crawl any of the other 35 | mail archives that don't protect the addresses. 36 | 37 | keeping track of a .newsrc-like state for users would be cool, 38 | too. too bad there's no Set::IntSpan for php. 39 | 40 | perhaps chasing up the references: chain to display the 41 | thread when displaying an article would be interesting. i 42 | have a feeling that building some sort of index is going 43 | to be desirable at some point. should use jwz's threading 44 | algorithm. http://www.jwz.org/doc/threading.html 45 | 46 | ftp://ftp.isi.edu/in-notes/rfc2047.txt explains how to decode encoded 47 | header fields. handling utf-8 and iso-8859-1 should be pretty easy. 48 | could use the gnu recode functions to do this in a general way, 49 | i think. 50 | 51 | oh, and this uses direct socket functions instead of the php imap 52 | extension because nntp is a drop-dead-easy protocol, and i'm allergic 53 | to the c-client code. 54 | 55 | --- 56 | SC.2004.09.03: 57 | Here are the appropriate Rewrite rules for apache: 58 | 59 | RewriteEngine on 60 | RewriteRule ^/(php.+)/start/([0-9]+) /group.php?group=$1&i=$2 [L] 61 | RewriteRule ^/(php.+)/([0-9]+) /article.php?group=$1&article=$2 [L] 62 | RewriteRule ^/(php[^/]+)(/)?$ /group.php?group=$1 [L] 63 | 64 | 65 | [webserver]: http://php.net/manual/en/features.commandline.webserver.php 66 | -------------------------------------------------------------------------------- /lib/ThreadTree.php: -------------------------------------------------------------------------------- 1 | articles = $articles; 16 | 17 | /* 18 | * We need to build a tree of the articles. We know they are in article 19 | * number order, we assume that this means that parents come before 20 | * children. There may end up being some posts that appear unattached 21 | * and we just assume they are replies to the root of the tree. 22 | */ 23 | foreach ($this->articles as $articleNumber => $details) { 24 | $messageId = $details['messageId']; 25 | 26 | if (!isset($this->root)) { 27 | $this->root = $messageId; 28 | } 29 | 30 | $this->articleNumbers[$messageId] = $articleNumber; 31 | 32 | if ($details['references']) { 33 | /* Parent is the last reference. */ 34 | if (preg_match('/.*(<.+?>)$/', $details['references'], $matches)) { 35 | $parent = $matches[1]; 36 | $this->tree[$parent][] = $messageId; 37 | if (!array_key_exists($parent, $this->articleNumbers)) { 38 | $this->extraRootChildren[] = $parent; 39 | } 40 | } 41 | } else { 42 | if ($this->root && $this->root != $messageId) { 43 | $this->extraRootChildren[] = $messageId; 44 | } 45 | } 46 | } 47 | } 48 | 49 | public function count() 50 | { 51 | return count($this->articleNumbers); 52 | } 53 | 54 | protected function printArticleAndChildren($messageId, $group, $charset, $depth = 0) 55 | { 56 | if (array_key_exists($messageId, $this->articleNumbers)) { 57 | $articleNumber = $this->articleNumbers[$messageId]; 58 | 59 | # for debugging that we've actually handled all articles 60 | #unset($this->articleNumbers[$messageId]); 61 | 62 | $details = $this->articles[$articleNumber]; 63 | 64 | echo " \n"; 65 | echo " $articleNumber\n"; 66 | echo " "; 67 | echo str_repeat("   ", $depth ?? 0); 68 | echo ""; 69 | echo format_subject($details['subject'], $charset); 70 | echo "\n"; 71 | echo " " . format_author($details['author'], $charset) . "\n"; 72 | echo " " . 73 | format_date($details['date']) . "\n"; 74 | echo " \n"; 75 | } 76 | 77 | // bail out if things are too deep 78 | if ($depth > 40) { 79 | error_log("Tree was too deep, didn't print children of {$messageId})"); 80 | return; 81 | } 82 | 83 | if (array_key_exists($messageId, $this->tree)) { 84 | foreach ($this->tree[$messageId] as $child) { 85 | $this->printArticleAndChildren($child, $group, $charset, $depth + 1); 86 | } 87 | } 88 | } 89 | 90 | public function printRows($group, $charset = 'utf8') 91 | { 92 | $this->printArticleAndChildren($this->root, $group, $charset); 93 | foreach ($this->extraRootChildren as $root) { 94 | $this->printArticleAndChildren($root, $group, $charset, 1); 95 | } 96 | } 97 | 98 | public function printFullThread( 99 | $group, 100 | $includingArticleNumber, 101 | $charset = null 102 | ) { 103 | echo "
"; 121 | } 122 | 123 | public function printThread( 124 | $group, 125 | $messageId = null, 126 | $activeArticleNumber = null, 127 | $depth = 0, 128 | $subject = "", 129 | $charset = 'utf8' 130 | ) { 131 | if ($depth > 40) { 132 | echo "
  • Too deep!
  • "; 133 | return; 134 | } 135 | 136 | if (array_key_exists($messageId, $this->articleNumbers)) { 137 | $articleNumber = $this->articleNumbers[$messageId]; 138 | 139 | # for debugging that we've actually handled all articles 140 | #unset($this->articleNumbers[$messageId]); 141 | 142 | $details = $this->articles[$articleNumber]; 143 | 144 | echo '
  • '; 145 | 146 | $details = $this->articles[$articleNumber]; 147 | 148 | if ($articleNumber != $activeArticleNumber) { 149 | echo ""; 150 | } else { 151 | echo ""; 152 | } 153 | echo 154 | '', 155 | format_author($details['author'], $charset, nameOnly: true), 156 | '', 157 | '', 158 | '', 161 | ''; 162 | 163 | $newSubject = format_subject($details['subject'], $charset, trimRe: true); 164 | if ($messageId != $this->root && $newSubject != $subject) { 165 | echo ''; 166 | echo format_subject($details['subject'], $charset); 167 | echo ''; 168 | } 169 | 170 | if ($articleNumber != $activeArticleNumber) { 171 | echo ""; 172 | } else { 173 | echo ""; 174 | } 175 | 176 | if (array_key_exists($messageId, $this->tree)) { 177 | echo ''; 189 | } 190 | 191 | echo "
  • "; 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /group.php: -------------------------------------------------------------------------------- 1 | getArticlesOverview($group, $i); 28 | } catch (Exception $e) { 29 | error($e->getMessage()); 30 | } 31 | 32 | $host = htmlspecialchars($_SERVER['HTTP_HOST'], ENT_QUOTES, "UTF-8"); 33 | switch ($format) { 34 | case 'rss': 35 | header("Content-type: text/xml"); 36 | echo '' . "\n";?> 37 | 38 | 39 | <?php echo $host; ?>: <?php echo $group?> 40 | http:///group.php?group= 41 | 42 | ' . "\n"; 47 | ?> 48 | 51 | 52 | <?php echo $host; ?>: <?php echo $group?> 53 | http:///group.php?group= 54 | Newsgroup at 55 | en-US 56 | 57 | '; 63 | echo ' '; 69 | echo ''; 70 | echo '
    '; 71 | echo '

    ' . htmlspecialchars($group, ENT_QUOTES, "UTF-8") . '

    '; 72 | if ($i == 0) { 73 | /* Special header of info for the main page for a group */ 74 | $groups = $nntpClient->listGroups($group); 75 | 76 | if ($groups[$group]['status'] == 'n') { 77 | ?> 78 |

    79 | Warning: This list is closed to new 80 | posts and subscribers. 81 |

    82 | 86 |

    87 | Note: This list is only for 88 | announcements, so only approved posters can send messages. 89 |

    90 | 94 |
    95 | 96 |
    97 | 98 | 99 |
    100 |
    101 | 105 | 109 | 113 |
    114 |
    115 | 118 |
    119 |
    120 |

    121 | You can also subscribe to this list by sending a blank email to 122 | 123 | 124 | 125 |

    126 | ' . "\n"; 131 | echo ' ' . "\n"; 132 | echo ' ' . "\n"; 133 | echo ' ' . "\n"; 134 | echo ' ' . "\n"; 135 | echo ' ' . "\n"; 136 | echo ' ' . "\n"; 137 | echo ' ' . "\n"; 138 | echo ' ' . "\n"; 139 | break; 140 | } 141 | 142 | # list of articles 143 | # TODO: somehow determine the correct charset 144 | $charset = "utf-8"; 145 | 146 | foreach ($overview['articles'] as $articleNumber => $details) { 147 | /* $date = date("H:i:s M/d/y", strtotime($odate)); */ 148 | $date822 = date("r", strtotime($details['date'])); 149 | 150 | switch ($format) { 151 | case 'rss': 152 | echo " \n"; 153 | echo " http://$host/$group/$articleNumber\n"; 154 | echo " ", format_subject($details['subject'], $charset), "\n"; 155 | echo " ", 156 | htmlspecialchars(format_author($details['author'], $charset), ENT_QUOTES, "UTF-8"), 157 | "\n"; 158 | echo " $date822\n"; 159 | echo " \n"; 160 | break; 161 | case 'rdf': 162 | echo " \n"; 163 | echo " ", format_subject($details['subject'], $charset), "\n"; 164 | echo " http://$host/$group/$articleNumber\n"; 165 | echo " ", 166 | htmlspecialchars(format_author($details['author'], $charset), ENT_QUOTES, "UTF-8"), 167 | "\n"; 168 | echo " $date822\n"; 169 | echo " \n"; 170 | break; 171 | case 'html': 172 | default: 173 | echo " \n"; 174 | echo " \n"; 175 | echo " \n"; 178 | echo " \n"; 179 | echo " \n"; 181 | echo " \n"; 182 | echo " \n"; 183 | } 184 | } 185 | 186 | switch ($format) { 187 | case 'rss': 188 | echo " \n\n"; 189 | break; 190 | case 'rdf': 191 | echo "\n"; 192 | break; 193 | case 'html': 194 | default: 195 | echo "
    #subjectauthordatelines
    $articleNumber"; 176 | echo format_subject($details['subject'], $charset); 177 | echo "" . format_author($details['author'], $charset) . "" . 180 | format_date($details['date']) . "{$details['lines']}
    \n"; 196 | echo " \n"; 197 | navbar($group, $overview['group']['low'], $overview['group']['high'], $overview['group']['start']); 198 | echo "
    "; 199 | foot(); 200 | } 201 | -------------------------------------------------------------------------------- /lib/Web/News/Nntp.php: -------------------------------------------------------------------------------- 1 | connection = @fsockopen($hostname, $port, $errno, $errstr, 30); 25 | 26 | if (!$this->connection) { 27 | throw new \RuntimeException( 28 | "Unable to connect to {$hostname} on port {$port}: {$errstr}" 29 | ); 30 | } 31 | 32 | $hello = fgets($this->connection); 33 | $responseCode = substr($hello, 0, 3); 34 | 35 | switch ($responseCode) { 36 | case 400: 37 | case 502: 38 | throw new \RuntimeException('Service unavailable'); 39 | break; 40 | case 200: 41 | case 201: 42 | default: 43 | // Successful connection 44 | break; 45 | } 46 | } 47 | 48 | /** 49 | * Closes the NNTP connection when the object is destroyed 50 | */ 51 | public function __destruct() 52 | { 53 | $this->sendCommand('QUIT', 205); 54 | fclose($this->connection); 55 | $this->connection = null; 56 | } 57 | 58 | /** 59 | * Sends the LIST command to the server and returns an array of newsgroups 60 | * 61 | * @return array 62 | */ 63 | public function listGroups() 64 | { 65 | $list = []; 66 | $response = $this->sendCommand('LIST', 215); 67 | 68 | if ($response !== false) { 69 | while ($line = fgets($this->connection)) { 70 | if ($line == ".\r\n") { 71 | break; 72 | } 73 | 74 | $line = rtrim($line); 75 | list($group, $high, $low, $status) = explode(' ', $line); 76 | 77 | $list[$group] = [ 78 | 'high' => $high, 79 | 'low' => $low, 80 | 'status' => $status, 81 | ]; 82 | } 83 | } 84 | 85 | return $list; 86 | } 87 | 88 | /** 89 | * Sends the LIST NEWSGROUPS command to the server and returns an array of 90 | * groups descriptions 91 | * 92 | * @return array 93 | */ 94 | public function listGroupDescriptions() 95 | { 96 | $list = []; 97 | 98 | $response = $this->sendCommand('LIST NEWSGROUPS', 215); 99 | 100 | if ($response !== false) { 101 | while ($line = fgets($this->connection)) { 102 | if ($line == ".\r\n") { 103 | break; 104 | } 105 | 106 | $line = rtrim($line); 107 | list($group, $description) = explode(' ', $line, 2); 108 | 109 | $list[$group] = $description; 110 | } 111 | } 112 | 113 | return $list; 114 | } 115 | 116 | /** 117 | * Sets the active group at the server and returns details about the group 118 | * 119 | * @param string $group Name of the group to set as the active group 120 | * @return array 121 | * @throws \RuntimeException 122 | */ 123 | public function selectGroup($group) 124 | { 125 | $response = $this->sendCommand("GROUP {$group}", 211); 126 | 127 | if ($response !== false) { 128 | list($number, $low, $high, $group) = explode(' ', $response); 129 | 130 | return [ 131 | 'group' => $group, 132 | 'articlesCount' => $number, 133 | 'low' => $low, 134 | 'high' => $high, 135 | ]; 136 | } 137 | 138 | throw new \RuntimeException('Failed to get info on group'); 139 | } 140 | 141 | /** 142 | * Returns an overview of the selected articles from the specified group 143 | * 144 | * @param string $group The name of the group to select 145 | * @param int $start The number of the article to start from 146 | * @param int $pageSize The number of articles to return 147 | * @return array 148 | */ 149 | public function getArticlesOverview($group, $start, $pageSize = 20) 150 | { 151 | $groupDetails = $this->selectGroup($group); 152 | 153 | $pageSize = $pageSize - 1; 154 | $high = $groupDetails['high']; 155 | $low = $groupDetails['low']; 156 | 157 | if (!$start || $start > $high - $pageSize || $start < $low) { 158 | $start = $high - $low > $pageSize ? $high - $pageSize : $low; 159 | } 160 | 161 | $end = min($high, $start + $pageSize); 162 | 163 | $overview = [ 164 | 'group' => $groupDetails + ['start' => $start], 165 | 'articles' => [], 166 | ]; 167 | 168 | $response = $this->sendCommand("XOVER {$start}-{$end}", 224); 169 | 170 | while ($line = fgets($this->connection)) { 171 | if ($line == ".\r\n") { 172 | break; 173 | } 174 | 175 | $line = rtrim($line); 176 | list($n, $subject, $author, $date, $messageId, $references, $lines, $extra) = explode("\t", $line, 9); 177 | 178 | $overview['articles'][$n] = [ 179 | 'subject' => $subject, 180 | 'author' => $author, 181 | 'date' => $date, 182 | 'messageId' => $messageId, 183 | 'references' => $references, 184 | 'lines' => $lines, 185 | 'extra' => $extra, 186 | ]; 187 | } 188 | 189 | return $overview; 190 | } 191 | 192 | /** 193 | * Returns an overview of the articles in the same thread as the specified 194 | * message 195 | * 196 | * @param string $group The name of the group to select 197 | * @param int $article The number of an article in the thread 198 | * @return array 199 | */ 200 | public function getThreadOverview($group, $article) 201 | { 202 | $groupDetails = $this->selectGroup($group); 203 | 204 | $overview = [ 205 | 'group' => $groupDetails, 206 | 'articles' => [], 207 | ]; 208 | 209 | $response = $this->sendCommand("XTHREAD {$article}", 224); 210 | 211 | while ($line = fgets($this->connection)) { 212 | if ($line == ".\r\n") { 213 | break; 214 | } 215 | 216 | $line = rtrim($line); 217 | list($n, $subject, $author, $date, $messageId, $references, $lines, $extra) = explode("\t", $line, 9); 218 | 219 | $overview['articles'][$n] = [ 220 | 'subject' => $subject, 221 | 'author' => $author, 222 | 'date' => $date, 223 | 'messageId' => $messageId, 224 | 'references' => $references, 225 | 'lines' => $lines, 226 | 'extra' => $extra, 227 | ]; 228 | 229 | } 230 | 231 | return $overview; 232 | } 233 | 234 | /** 235 | * Returns the full content of the specified article (headers and body) 236 | * 237 | * @param int $articleId 238 | * @param string|null $group 239 | * @return string 240 | */ 241 | public function readArticle($articleId, $group = null) 242 | { 243 | if ($group) { 244 | $groupDetails = $this->selectGroup($group); 245 | } 246 | 247 | $article = ''; 248 | 249 | try { 250 | $response = $this->sendCommand("ARTICLE {$articleId}", 220); 251 | } catch (\RuntimeException $e) { 252 | return null; 253 | } 254 | 255 | while ($line = fgets($this->connection)) { 256 | if ($line == ".\r\n") { 257 | break; 258 | } 259 | 260 | $article .= $line; 261 | } 262 | 263 | return $article; 264 | } 265 | 266 | /** 267 | * Performs a lookup on the $messageId to find its group and article ID 268 | * 269 | * @param string $messageId 270 | * @return array 271 | */ 272 | public function xpath($messageId) 273 | { 274 | $response = $this->sendCommand("XPATH {$messageId}", 223); 275 | list($group, $articleId) = explode('/', $response); 276 | 277 | return [ 278 | 'messageId' => $messageId, 279 | 'group' => $group, 280 | 'articleId' => $articleId, 281 | ]; 282 | } 283 | 284 | /** 285 | * Sends a command to the server and checks the expected response code 286 | * 287 | * @param string $command 288 | * @param int $expected The successful response code expected 289 | * @return string 290 | */ 291 | protected function sendCommand($command, $expected) 292 | { 293 | fwrite($this->connection, "$command\r\n"); 294 | $result = fgets($this->connection); 295 | list($code, $response) = explode(' ', $result, 2); 296 | 297 | if ($code == $expected) { 298 | return rtrim($response); 299 | } 300 | 301 | throw new \RuntimeException( 302 | "Expected response code of {$expected} but received {$code} for command `{$command}'" 303 | ); 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | img { 2 | border: 0; 3 | } 4 | 5 | .monospace { 6 | font-family: "Fira Mono", monospace; 7 | } 8 | 9 | .monospace.mod-small { 10 | font-size: 14px; 11 | } 12 | 13 | th { 14 | background: #CCC; 15 | font-weight: bold; 16 | text-align: center; 17 | } 18 | 19 | table.stripped tr:nth-child(even) td { 20 | background: #EEE; 21 | } 22 | 23 | table.stripped tr:nth-child(odd) td { 24 | background: #DDD; 25 | } 26 | 27 | .headerlabel { 28 | background: #CCC; 29 | font-weight: bold; 30 | text-align: right; 31 | } 32 | 33 | .headervalue { 34 | background: #EEE; 35 | } 36 | 37 | a.top { 38 | text-decoration: none; 39 | } 40 | 41 | table.header { 42 | background: #99C; 43 | border-bottom: 17px solid #669; 44 | } 45 | 46 | .nav { 47 | width: 20%; 48 | } 49 | 50 | * { 51 | -webkit-box-sizing: border-box; 52 | -moz-box-sizing: border-box; 53 | box-sizing: border-box; 54 | } 55 | 56 | html { 57 | background: #333 url('//php.net/images/bg-texture-00.svg'); 58 | color: #CCC; 59 | } 60 | 61 | body { 62 | margin: 3.25rem 0 0; 63 | font-family: "Fira Sans", "Source Sans Pro", Helvetica, Arial, sans-serif; 64 | font-weight: 400; 65 | font-size: 1rem; 66 | color: #333; 67 | } 68 | 69 | .header { 70 | background: #8892BF; 71 | box-shadow: 0 .25em .25em rgba(0, 0, 0, .1); 72 | border-bottom: .25rem solid #4F5B93; 73 | color: #FFF; 74 | position: fixed; 75 | top: 0; 76 | right: 0; 77 | left: 0; 78 | z-index: 1; 79 | margin-bottom: 0; 80 | } 81 | 82 | .header-inner { 83 | max-width: 1440px; 84 | padding: 0 .75rem; 85 | margin: auto; 86 | position: relative; 87 | } 88 | 89 | .header-brand { 90 | display: inline-block; 91 | max-height: 48px; 92 | padding: .75rem .75rem .75rem .75rem; 93 | margin-right: 1.5rem; 94 | text-decoration: none; 95 | line-height: 24px; 96 | } 97 | 98 | .header-brand-img { 99 | opacity: .75; 100 | } 101 | 102 | .header-brand-text { 103 | color: #E2E4EF; 104 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 105 | text-decoration: none; 106 | line-height: 24px; 107 | vertical-align: top; 108 | } 109 | 110 | .header-menu { 111 | padding: 0; 112 | list-style: none; 113 | position: relative; 114 | left: 0; 115 | display: inline-block; 116 | margin: 0 10px 0 0; 117 | vertical-align: top; 118 | } 119 | 120 | .header-menu-item { 121 | float: left; 122 | } 123 | 124 | .header-menu-item.mod-active { 125 | color: #fff; 126 | background-color: #4F5B93; 127 | box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.125); 128 | } 129 | 130 | a.header-menu-item-link { 131 | display: inline-block; 132 | text-decoration: none; 133 | float: none; 134 | padding: .75rem; 135 | color: #E2E4EF; 136 | border: 0; 137 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 138 | line-height: 24px; 139 | } 140 | 141 | a.header-menu-item-link:hover { 142 | color: #fff; 143 | } 144 | 145 | .menu-mobile { 146 | background: #4F5B93; 147 | padding: 0; 148 | margin: 0; 149 | list-style: none; 150 | } 151 | 152 | .menu-mobile-item { 153 | text-align: right; 154 | display: inline-block; 155 | } 156 | 157 | a.menu-mobile-item-link { 158 | display: inline-block; 159 | text-decoration: none; 160 | float: none; 161 | padding: .75rem; 162 | color: #E2E4EF; 163 | border: 0; 164 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 165 | line-height: 24px; 166 | } 167 | 168 | .search-form { 169 | float: right; 170 | width: 25%; 171 | position: relative; 172 | margin-top: .625rem; 173 | margin-bottom: 0; 174 | } 175 | 176 | .search-input { 177 | width: 100%; 178 | border: 0; 179 | border-radius: 2px; 180 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, .2); 181 | line-height: 24px; 182 | height: 24px; 183 | padding: 2px 8px; 184 | font-size: 16px; 185 | } 186 | 187 | .secondary-nav { 188 | max-width: 1440px; 189 | margin: auto; 190 | padding: .75rem 1.5rem; 191 | font-size: 14px; 192 | } 193 | 194 | .breadcrumbs { 195 | color: #999; 196 | list-style: none; 197 | margin: 0; 198 | padding: 0; 199 | } 200 | 201 | .breadcrumbs-item { 202 | display: inline-block; 203 | } 204 | 205 | .breadcrumbs-item + .breadcrumbs-item::before { 206 | padding: 0 .5rem 0; 207 | content: "\203A"; 208 | } 209 | 210 | a.breadcrumbs-item-link { 211 | color: #ccc; 212 | text-decoration: none; 213 | line-height: 24px; 214 | } 215 | 216 | .content { 217 | background: #F2F2F2; 218 | max-width: 1440px; 219 | margin: auto; 220 | padding: 1.5rem; 221 | } 222 | 223 | .content a { 224 | color: #369; 225 | } 226 | 227 | .content a:hover { 228 | color: #AE508D; 229 | border-color: #AE508D; 230 | outline: 0; 231 | } 232 | 233 | .content a:visited { 234 | color: #8895A2; 235 | } 236 | 237 | h1 { 238 | font-weight: 500; 239 | color: #333; 240 | font-size: 1.75rem; 241 | line-height: 3rem; 242 | margin: 0 0 1.5rem; 243 | overflow: hidden; 244 | text-rendering: optimizeLegibility; 245 | } 246 | 247 | h1:after { 248 | display: table; 249 | width: 100%; 250 | content: " "; 251 | margin-top: -1px; 252 | border-bottom: 1px dotted; 253 | } 254 | 255 | pre { 256 | font-family: "Fira Mono", monospace; 257 | font-size: 14px; 258 | white-space: pre-wrap; 259 | word-wrap: break-word; 260 | } 261 | 262 | .footer { 263 | color: #F2F2F2; 264 | max-width: 1440px; 265 | margin: auto; 266 | padding: 1.5rem; 267 | line-height: 3rem; 268 | } 269 | 270 | .footer-nav { 271 | margin: 0; 272 | padding: 0; 273 | } 274 | 275 | .footer-nav-item { 276 | display: inline-block; 277 | margin: 0 0.75rem; 278 | } 279 | 280 | a.footer-nav-item-link { 281 | color: #ccc; 282 | text-decoration: none; 283 | } 284 | 285 | a.footer-nav-item-link:hover { 286 | color: #AE508D; 287 | border-color: #AE508D; 288 | outline: 0; 289 | } 290 | 291 | /* Standard Tables */ 292 | table.standard { 293 | border-collapse: collapse; 294 | border: 1px solid #d9d9d9; 295 | width: 100%; 296 | border-spacing: 2px; 297 | } 298 | 299 | table.standard td, 300 | table.standard th { 301 | border: 1px solid #d9d9d9; 302 | padding: 2px; 303 | } 304 | 305 | table.standard tr:nth-child(even) td { 306 | background-color: #E6E6E6; 307 | } 308 | 309 | table.standard th { 310 | font-size: 1.125rem; 311 | padding: 20px 10px 5px 10px; 312 | color: #666; 313 | font-weight: normal; 314 | } 315 | 316 | table.standard td { 317 | padding: 5px 10px; 318 | vertical-align: middle; 319 | } 320 | 321 | table.standard tr:nth-child(even) td.subr, 322 | table.standard tr:nth-child(even) th.subr, 323 | table.standard tr td.subr, 324 | table.standard tr th.subr, 325 | table.standard tr:nth-child(even) td.sub, 326 | table.standard tr:nth-child(even) th.sub, 327 | table.standard tr td.sub, 328 | table.standard tr th.sub { 329 | background: #E6E6E6; 330 | } 331 | 332 | table.standard td.subr, 333 | table.standard th.subr { 334 | text-align: right; 335 | } 336 | 337 | .responsive-table { 338 | overflow-x: scroll; 339 | } 340 | 341 | .responsive-table table { 342 | word-break: normal; 343 | } 344 | 345 | .align-right { 346 | text-align: right; 347 | } 348 | 349 | .align-center { 350 | text-align: center; 351 | } 352 | 353 | .menu-icon { 354 | display: none; 355 | background: #4F5B93; 356 | padding: 14px 24px 10px; 357 | line-height: 24px; 358 | float: right; 359 | color: #fff; 360 | cursor: pointer; 361 | } 362 | 363 | .hide { 364 | display: none; 365 | } 366 | 367 | /* Shading for quotes */ 368 | .quote1 { color: #006486; } 369 | .quote2 { color: #900; } 370 | .quote3 { color: #a36008; } 371 | .quote0 { color: #909; } 372 | 373 | pre.flowed { 374 | max-width: 100ch; 375 | font-family: "Fira Sans", "Source Sans Pro", Helvetica, Arial, sans-serif; 376 | font-size: 16px; 377 | } 378 | pre.flowed code, pre.flowed pre { 379 | font-weight: 700; 380 | color: #369; 381 | } 382 | pre.flowed pre { 383 | background: rgba(0,0,0,0.05); 384 | border: 1px solid rgba(0,0,0,0.2); 385 | padding: 0.5rem; 386 | margin: 0; 387 | } 388 | 389 | div.quote { 390 | border-left: 2px solid #777; 391 | padding: 0; 392 | padding-left: 1em; 393 | margin: 0; 394 | } 395 | 396 | /* Highlight of diffs in commit messages */ 397 | .added { color: #000099; } 398 | .removed { color: #990000; } 399 | 400 | /* Dim signatures */ 401 | .signature { font-size: smaller; color: gray; } 402 | .signature a:link { color: #9999ff; } 403 | .signature a:hover { color: #ff9999; } 404 | .signature a:active { color: #ff9999; } 405 | .signature a:visited { color: #ff66ff; } 406 | 407 | /* Subscription form */ 408 | form.subscription-form { 409 | display: flex; 410 | gap: 1em; 411 | } 412 | 413 | /* Thread tree, based on: https://www.cssscript.com/tree-view-unlimited-nesting/ */ 414 | .list-tree { 415 | --tree-clr: #075985; 416 | --tree-font-size: 1rem; 417 | --tree-item-height: 1.5; 418 | --tree-offset: 0.5rem; 419 | --tree-indent: 0.5rem; 420 | --tree-thickness: 1px; 421 | --tree-style: solid; 422 | } 423 | .list-tree ul{ 424 | display: grid; 425 | list-style: none; 426 | font-size: var(--tree-font-size); 427 | padding-inline-start: var(--tree-indent); 428 | max-width: 50em; 429 | } 430 | .list-tree li{ 431 | line-height: var(--tree-item-height); 432 | padding-inline-start: var(--tree-offset); 433 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 434 | position: relative; 435 | text-indent: .5rem; 436 | 437 | &:last-child { 438 | border-color: transparent; /* hide (not remove!) border on last li element*/ 439 | } 440 | 441 | & a, & b { 442 | display: grid; 443 | grid-template-columns: 1fr auto; 444 | align-item: start; 445 | & span.author { 446 | grid-column: 1 / 1; 447 | white-space: normal; 448 | } 449 | & span.date { 450 | grid-column: 2 / 2; 451 | white-space: nowrap; 452 | font-variant-numeric: tabular-nums; 453 | } 454 | & span.subject { 455 | grid-column: 1 / 2; 456 | white-space: normal; 457 | } 458 | } 459 | &::before{ 460 | content: ''; 461 | position: absolute; 462 | top: calc(var(--tree-font-size) / 2 + var(--tree-item-height) / 2 * -1 * var(--tree-font-size) + var(--tree-thickness)); 463 | left: calc(var(--tree-thickness) * -1); 464 | width: calc(var(--tree-offset) + var(--tree-thickness) * 2); 465 | height: calc(var(--tree-item-height) * var(--tree-font-size) - var(--tree-font-size) / 2); 466 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 467 | border-bottom: var(--tree-thickness) var(--tree-style) var(--tree-clr); 468 | } 469 | &::after{ 470 | content: ''; 471 | position: absolute; 472 | width: 6px; 473 | height: 6px; 474 | border-radius: 50%; 475 | background-color: var(--tree-clr); 476 | top: calc(var(--tree-item-height) / 2 * 1rem); 477 | left: var(--tree-offset) ; 478 | translate: calc(var(--tree-thickness) * -1) calc(var(--tree-thickness) * -1); 479 | } 480 | & li li{ 481 | /* 482 | change line color etc. 483 | --tree-clr: rgb(175, 208, 84); 484 | */ 485 | } 486 | } 487 | 488 | @media screen and (max-width: 760px) { 489 | .welcome { 490 | display: none; 491 | } 492 | 493 | .search-form { 494 | display: none; 495 | } 496 | 497 | blockquote { 498 | margin: 16px; 499 | } 500 | } 501 | 502 | @media screen and (max-width: 580px) { 503 | th.nav a { 504 | font-size: 2rem; 505 | text-decoration: none; 506 | } 507 | 508 | th.nav span { 509 | display: none; 510 | } 511 | 512 | .menu-icon { 513 | display: block; 514 | } 515 | 516 | .header-menu { 517 | display: none; 518 | } 519 | } 520 | 521 | @media screen and (max-width: 420px) { 522 | .content { 523 | padding: 0.5rem; 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /lib/common.php: -------------------------------------------------------------------------------- 1 | or In-Reply-To: ) */ 5 | define('REFERENCES_LIMIT', 20); 6 | 7 | function error($str) 8 | { 9 | head("PHP news : error"); 10 | echo "
    Error: ", 11 | to_utf8($str), "
    \n"; 12 | foot(); 13 | die(); 14 | } 15 | 16 | /* Borrowed from web-php repo. */ 17 | function clean($var) 18 | { 19 | return htmlspecialchars($var, \ENT_QUOTES); 20 | } 21 | 22 | // Try to check that this email address is valid 23 | function is_emailable_address($email) 24 | { 25 | $email = filter_var($email, FILTER_VALIDATE_EMAIL); 26 | // No email, no validation 27 | if (!$email) { 28 | return false; 29 | } 30 | 31 | $host = substr($email, strrpos($email, '@') + 1); 32 | // addresses from our mailing-list servers 33 | $host_regex = "!(lists\.php\.net|chek[^.*]\.com)!i"; 34 | if (preg_match($host_regex, $host)) { 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | // Returns the real IP address of the user 42 | function i2c_realip() 43 | { 44 | // No IP found (will be overwritten by for 45 | // if any IP is found behind a firewall) 46 | $ip = false; 47 | 48 | // If HTTP_CLIENT_IP is set, then give it priority 49 | if (!empty($_SERVER["HTTP_CLIENT_IP"])) { 50 | $ip = $_SERVER["HTTP_CLIENT_IP"]; 51 | } 52 | 53 | // User is behind a proxy and check that we discard RFC1918 IP addresses 54 | // if they are behind a proxy then only figure out which IP belongs to the 55 | // user. Might not need any more hackin if there is a squid reverse proxy 56 | // infront of apache. 57 | if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { 58 | // Put the IP's into an array which we shall work with shortly. 59 | $ips = explode(", ", $_SERVER['HTTP_X_FORWARDED_FOR']); 60 | if ($ip) { 61 | array_unshift($ips, $ip); 62 | $ip = false; 63 | } 64 | 65 | for ($i = 0; $i < count($ips); $i++) { 66 | // Skip RFC 1918 IP's 10.0.0.0/8, 172.16.0.0/12 and 67 | // 192.168.0.0/16 68 | // Also skip RFC 6598 IP's 69 | $regex = '/^(?:10|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])|172\.(?:1[6-9]|2\d|3[01])|192\.168)\./'; 70 | if (!preg_match($regex, $ips[$i]) && ip2long($ips[$i])) { 71 | $ip = $ips[$i]; 72 | break; 73 | } 74 | } 75 | } 76 | 77 | // Return with the found IP or the remote address 78 | return $ip ?: $_SERVER['REMOTE_ADDR']; 79 | } 80 | 81 | /* 82 | This code is used to post data to the central server which 83 | can store the data in database and/or mail notices or requests 84 | to PHP.net stuff and servers 85 | */ 86 | function posttohost($url, $data) 87 | { 88 | $data = http_build_query($data); 89 | 90 | $opts = [ 91 | 'method' => 'POST', 92 | 'header' => 'Content-type: application/x-www-form-urlencoded', 93 | 'content' => $data, 94 | ]; 95 | 96 | $ctx = stream_context_create(['http' => $opts]); 97 | 98 | return file_get_contents($url, false, $ctx); 99 | } 100 | 101 | function head($title = "PHP Mailing Lists (PHP News)") 102 | { 103 | header("Content-type: text/html; charset=utf-8"); 104 | 105 | ?> 106 | 107 | 108 | 109 | 110 | 111 | <?php echo htmlspecialchars($title); ?> 112 | 113 | 114 | 115 | 116 | 117 |
    118 | 156 |
    157 | 163 | 164 | 188 | 189 | 190 | ' at ', '.' => ' dot '); 230 | 231 | /* php.net addresses are not protected! */ 232 | if (preg_match('/^(.+)@php\.net/i', $txt)) { 233 | return $txt; 234 | } else { 235 | return strtr($txt, $translate); 236 | } 237 | } 238 | 239 | 240 | # this turns some common forms of email addresses into mailto: links 241 | function format_author($a, $charset = 'iso-8859-1', $nameOnly = false) 242 | { 243 | $a = recode_header($a, $charset); 244 | if (preg_match("/^\s*(.+)\s+\\(\"?(.+?)\"?\\)\s*$/", $a, $ar)) { 245 | $email= spam_protect($ar[1]); 246 | $name = $ar[2]; 247 | } 248 | elseif (preg_match("/^\s*\"?(.+?)\"?\s*<(.+)>\s*$/", $a, $ar)) { 249 | $email = spam_protect($ar[2]); 250 | $name = $ar[1]; 251 | } 252 | elseif (strpos("@", $a) !== false) { 253 | $email = $name = spam_protect($a); 254 | } else { 255 | $email = $name = $a; 256 | } 257 | if ($nameOnly) { 258 | return str_replace(" ", " ", htmlspecialchars($name, ENT_QUOTES, "UTF-8")); 259 | } else { 260 | return "" . 263 | str_replace(" ", " ", $name) . ""; 264 | } 265 | } 266 | 267 | function format_subject($s, $charset = 'iso-8859-1', $trimRe = false) 268 | { 269 | global $article; 270 | $s = recode_header($s, $charset); 271 | 272 | /* Trim most of the prefixes we add for lists */ 273 | $s = preg_replace('/^(Re:\s*)?(\s*\[(DOC|PEAR|PECL|PHP|ANNOUNCE|GIT-PULLS|STANDARDS|php-standards)(-.+?)?]\s*(Re:\s*)?)+/', $trimRe ? '' : '\1\5', $s); 274 | 275 | // make this look better on the preview page.. 276 | if (strlen($s) > 150 && !isset($article)) { 277 | $s = substr($s, 0, 150) . "..."; 278 | } else { 279 | $s = wordwrap($s, 150); 280 | } 281 | return nl2br(htmlspecialchars($s, ENT_QUOTES, "UTF-8")); 282 | } 283 | 284 | 285 | function format_title($s, $charset = 'iso-8859-1', $trimRe = false) 286 | { 287 | global $article; 288 | $s = recode_header($s, $charset); 289 | $s = preg_replace("/^(Re:\s*)?\[(PHP|PEAR)(-.*?)?\]\s/i", $trimRe ? "" : "\\1", $s); 290 | // make this look better on the preview page.. 291 | if (strlen($s) > 150 && !isset($article)) { 292 | $s = substr($s, 0, 150) . "..."; 293 | } else { 294 | $s = wordwrap($s, 150); 295 | } 296 | return htmlspecialchars($s, ENT_QUOTES, "UTF-8"); 297 | } 298 | 299 | function format_date($d, $format = 'r') 300 | { 301 | $d = strtotime($d); 302 | $d = gmdate($format, $d); 303 | return str_replace(" ", " ", $d); 304 | } 305 | 306 | /* 307 | * Translate a group name to the email address for the list. It's almost 308 | * easy but then we have a bunch of special cases. 309 | */ 310 | function get_list_address($group) 311 | { 312 | $address = str_replace('.', '-', $group); // php.internals -> php-internals 313 | $address = str_replace('php-doc-', 'doc-', $address); // php-doc-fr -> doc-fr 314 | $address = str_replace('php-pear-', 'pear-', $address); // php-pear-dev -> pear-dev 315 | $address = str_replace('php-pecl-', 'pecl-', $address); // php-pecl-dev -> pecl-dev 316 | $address = str_replace('php-standards', 'standards-', $address); // php-standards-cvs -> standards-cvs 317 | 318 | $special = [ 319 | 'doc-chm' => 'php-doc-chm', # revert earlier removal of php- 320 | 'php-internals' => 'internals', 321 | 'php-internals-win' => 'internals-win', 322 | 'php-doc' => 'phpdoc', 323 | 'php-general-bg' => 'general-bg', 324 | 'php-general-es' => 'general-es', 325 | 'php-git-pulls' => 'git-pulls', 326 | 'php-pres' => 'pres', 327 | 'php-pdo' => 'pdo', 328 | ]; 329 | 330 | if (array_key_exists($address, $special)) { 331 | $address = $special[$address]; 332 | } 333 | 334 | return $address; 335 | } 336 | 337 | function get_subscribe_address($group, $mode = '') 338 | { 339 | return get_list_address($group) . '+subscribe' . ($mode ? '-' . $mode : '') . '@lists.php.net'; 340 | } 341 | -------------------------------------------------------------------------------- /article.php: -------------------------------------------------------------------------------- 1 | readArticle($article, $group); 21 | 22 | if ($message === null) { 23 | error('No article found'); 24 | } 25 | 26 | $mail = \Flourish\Mailbox::parseMessage($message); 27 | 28 | $rawReferences = []; 29 | if (!empty($mail['headers']['references'])) { 30 | $rawReferences = $mail['headers']['references']; 31 | } elseif (!empty($mail['headers']['in-reply-to'])) { 32 | $rawReferences = $mail['headers']['in-reply-to']; 33 | } 34 | 35 | $references = []; 36 | foreach ($rawReferences as $ref) { 37 | $matches = []; 38 | if (preg_match_all('/\<(.*?)\>/', $ref, $matches)) { 39 | foreach ($matches[0] as $match) { 40 | $references[] = $match; 41 | } 42 | } 43 | } 44 | 45 | $refsResolved = []; 46 | 47 | $refCount = 0; 48 | foreach ($references as $messageId) { 49 | if (!$messageId) { 50 | continue; 51 | } 52 | if ($refCount >= REFERENCES_LIMIT) { 53 | break; 54 | } 55 | $refsResolved[] = $nntpClient->xpath($messageId); 56 | $refCount++; 57 | } 58 | } catch (Exception $e) { 59 | error($e->getMessage()); 60 | } 61 | 62 | head("{$group}: " . format_title($mail['headers']['subject'], 'utf-8')); 63 | echo ''; 74 | echo '
    '; 75 | 76 | echo '

    ' . format_subject($mail['headers']['subject'], 'utf-8') . "

    \n"; 77 | 78 | echo "
    \n"; 79 | echo ' ' . "\n"; 80 | # from 81 | echo ' ' . "\n"; 82 | echo ' ' . "\n"; 83 | echo ' \n"; 84 | # date 85 | echo ' ' . "\n"; 86 | echo ' \n"; 87 | echo " \n"; 88 | # subject 89 | echo ' ' . "\n"; 90 | echo ' ' . "\n"; 91 | echo ' \n"; 92 | echo " \n"; 93 | echo " \n"; 94 | # references 95 | if (!empty($refsResolved)) { 96 | echo ' ' . "\n"; 97 | echo ' \n"; 103 | } 104 | # groups 105 | if (!empty($mail['headers']['newsgroups'])) { 106 | echo ' ' . "\n"; 107 | echo ' \n"; 113 | } 114 | echo " \n"; 115 | # email to request archived copy 116 | $request_address = get_list_address($group) . '+get-' . $article . '@lists.php.net'; 117 | echo ' ' . "\n"; 118 | echo ' ' . "\n"; 119 | echo ' \n"; 120 | echo " \n"; 121 | echo " \n"; 122 | echo "
    From:' . format_author($mail['headers']['from']['raw'], 'utf-8') . "Date:' . format_date($mail['headers']['date']) . "
    Subject:' . format_subject($mail['headers']['subject'], 'utf-8') . "
    References:'; 98 | foreach ($refsResolved as $k => $ref) { 99 | echo "" . 100 | ($k + 1) . " "; 101 | } 102 | echo "Groups:'; 108 | $r = explode(",", rtrim($mail['headers']['newsgroups'])); 109 | foreach ($r as $v) { 110 | echo "" . htmlspecialchars($v) . " "; 111 | } 112 | echo "
    Request:Send a blank email to ' . clean($request_address) . " to get a copy of this message
    \n"; 123 | echo "
    \n"; 124 | echo "
    \n"; 125 | $class = $mail['flowed'] ? ' class="flowed"' : ''; 126 | echo " \n"; 127 | 128 | /* 129 | * If there was no text part of the message, see what we can do about creating 130 | * one from a text/html part, or just inject a note that there was no text to 131 | * avoid further errors. 132 | */ 133 | if (!array_key_exists('text', $mail)) { 134 | if (array_key_exists('html', $mail)) { 135 | /* 136 | * This just aggressively strips out all tags. For the examples at 137 | * hand, this looked okay-ish. Better than nothing, at least, and 138 | * should be totally safe because all of the text get re-encoded 139 | * later. 140 | */ 141 | // This makes HTML from Fastmail retain paragraph breaks 142 | $text = preg_replace('#

    #', "\n\n", $mail['html']); 143 | // And this avoids extra linebreaks from another example (Android?) 144 | $text = preg_replace("#\n
    \n#", "\n", $text); 145 | $mail['text'] = html_entity_decode(strip_tags($text), encoding: 'UTF-8'); 146 | } else { 147 | $mail['text'] = "> There was no text content in this message."; 148 | } 149 | } 150 | 151 | $lines = preg_split("@(?<=\r\n|\n)@", $mail['text']); 152 | $insig = $is_commit = $is_diff = 0; 153 | $level = 0; 154 | $in_flow = $was_flowed = false; 155 | $in_code_block = false; 156 | 157 | foreach ($lines as $line) { 158 | # Trim end of line 159 | $line = preg_replace('/\r?\n$/', '', $line); 160 | 161 | # fix lines that started with a period and got escaped 162 | if (str_starts_with($line, "..")) { 163 | $line = substr($line, 1); 164 | } 165 | 166 | # Notice commit messages so we can highlight the diffs 167 | if (str_starts_with($line, 'Commit: https://github.com/php')) { 168 | $is_commit = 1; 169 | } 170 | 171 | # We don't use htmlentities() because it seems like overkill and that 172 | # makes all of the later substitutions more complicated. 173 | $line = htmlspecialchars($line, ENT_QUOTES|ENT_SUBSTITUTE|ENT_HTML5, "utf-8"); 174 | 175 | # Turn bare links, not within [] or (), to HTML links 176 | $line = preg_replace( 177 | "/(^|[^[(])((mailto|https?|ftp|nntp|news):.+?)(>|\\s|\\)|\\.\\s|$)/", 178 | "\\1\\2\\4", 179 | $line 180 | ); 181 | 182 | # Turn Markdown links to HTML links 183 | $line = preg_replace( 184 | "/\[((mailto|https?|ftp|nntp|news):.+?)\]\((.+?)\)/", 185 | "\\3", 186 | $line 187 | ); 188 | 189 | # Highlight inline code 190 | $line = preg_replace( 191 | "/`([^`]+?)`/", 192 | "\\1", 193 | $line 194 | ); 195 | 196 | # Begin signature when we see the tell-tale '-- ' line 197 | if (!$insig && $line == "-- ") { 198 | echo ""; 199 | $insig = 1; 200 | } 201 | 202 | # In commit messages, highlight lines that start with + or - 203 | if (!$insig && $is_commit && preg_match('/^[-+]/', $line, $m)) { 204 | $is_diff = 1; 205 | echo ''; 206 | } 207 | 208 | # This gets a little tricker -- "flowed" messages basically have long 209 | # quoted lines broken up, so we can put quoted blocks in levels of
    210 | # blocks instead of highlighting them per-line 211 | 212 | if (!$insig && $mail['flowed']) { 213 | $flowed = false; 214 | $new_level = 0; 215 | 216 | if (preg_match('/^((\s*>)+)(.*)/', $line, $m)) { 217 | $new_level = substr_count($m[1], $m[2]); 218 | $line = $m[3]; 219 | } 220 | 221 | # Trim leading space (a format=flowed thing) 222 | if (str_starts_with($line, ' ')) { 223 | $line = substr($line, 1); 224 | } 225 | 226 | # A "flowed" line ends with a space. We also remove it if DelSp = "Yes". 227 | if (str_ends_with($line, ' ')) { 228 | $flowed = true; 229 | if ($mail['delsp']) { 230 | $line = substr($line, 0, -1); 231 | } 232 | } 233 | 234 | # If this line had more quoting, go ahead and open to that level 235 | if ($new_level && $new_level > $level) { 236 | foreach (range($level + 1, $new_level) as $this_level) { 237 | echo "
    "; 238 | } 239 | $level = $new_level; 240 | $in_flow = true; 241 | } 242 | # Otherwise if we are in a flow, but this line's level is lower (but 243 | # not 0), we need to close up the higher levels 244 | elseif ($in_flow && $new_level && $new_level < $level) { 245 | echo str_repeat('
    ', $level - $new_level); 246 | $level = $new_level; 247 | } 248 | 249 | # Handle indented code blocks 250 | if (preg_match('/( |\xC2\xA0){4}/', $line)) { 251 | if (!$in_code_block) { 252 | echo '
    ';
    253 |                 $in_code_block = true;
    254 |             }
    255 |         } elseif (!$flowed && !$was_flowed) {
    256 |             if ($in_code_block && is_bool($in_code_block)) {
    257 |                 echo '
    '; 258 | $in_code_block = false; 259 | } 260 | } 261 | 262 | # Handle ``` delimited code blocks 263 | if (preg_match('/^```(\w+)?$/', $line, $m)) { 264 | if ($in_code_block) { 265 | echo ''; 266 | $in_code_block = false; 267 | continue; 268 | } else { 269 | $language = $m[1] ?? 'php'; 270 | echo "
    ";
    271 |                 $in_code_block = $language;
    272 |                 continue;
    273 |             }
    274 |         }
    275 | 
    276 |         # Hey, it's the actual line of text!
    277 |         echo $line;
    278 | 
    279 |         # If the line is fixed, we close a flow or just add a newline
    280 |         if (!$flowed) {
    281 |             if ($level != $new_level) {
    282 |                 # Close out code block if we were in one
    283 |                 if ($in_code_block) {
    284 |                     echo '
    '; 285 | $in_code_block = false; 286 | } 287 | echo str_repeat("
    ", $level) . "\n"; 288 | $level = 0; 289 | $in_flow = false; 290 | } else { 291 | echo "\n"; 292 | } 293 | } 294 | 295 | $was_flowed = $flowed; 296 | } 297 | # Otherwise we're in a signature or not flowed 298 | else { 299 | if (!$insig && preg_match('/^((\s*\w*?> ?)+)/', $line, $m)) { 300 | $level = substr_count($m[1], '>') % 4; 301 | echo "", wordwrap($line, 100, "\n" . $m[1]), ""; 302 | } else { 303 | echo wordwrap($line, 100); 304 | } 305 | echo "\n"; 306 | } 307 | 308 | # If this line was a diff, close out the 309 | if ($is_diff) { 310 | $is_diff = 0; 311 | echo ''; 312 | } 313 | } 314 | 315 | if ($in_code_block) { 316 | echo ''; 317 | } 318 | if ($insig) { 319 | echo "
    "; 320 | $insig = 0; 321 | } 322 | if ($mail['flowed'] && $level) { 323 | echo str_repeat('', $level); 324 | } 325 | 326 | echo "

    "; 327 | 328 | if (!empty($mail['attachment'])) { 329 | foreach ($mail['attachment'] as $mimecount => $attachment) { 330 | $mimetype = $attachment['mimetype']; 331 | $name = $attachment['filename']; 332 | 333 | if ($mimetype == 'text/plain') { 334 | echo htmlspecialchars($attachment['data']); 335 | continue; 336 | } 337 | 338 | if (!empty($attachment['description'])) { 339 | $description = trim($attachment['description']) . " "; 340 | } else { 341 | $description = ''; 342 | } 343 | 344 | $description .= $name; 345 | $link_desc = "[$mimetype]"; 346 | if (strlen($description)) { 347 | $link_desc .= " " . $description; 348 | } 349 | 350 | $dl_link = "/getpart.php?group=$group&article=$article&part=$mimecount"; 351 | $link_desc = htmlspecialchars($link_desc, ENT_QUOTES, 'UTF-8'); 352 | 353 | /* Attachment filename and mimetype might contain malicious characters */ 354 | printf( 355 | 'Attachment: %s
    ' . "\n", 356 | $dl_link, 357 | htmlspecialchars($link_desc) 358 | ); 359 | } 360 | } 361 | 362 | echo " \n"; 363 | echo "
    \n"; 364 | 365 | try { 366 | $overview = $nntpClient->getThreadOverview($group, $article); 367 | 368 | $threads = new \PhpWeb\ThreadTree($overview['articles']); 369 | ?> 370 |
    371 |

    372 | Thread (count(), $count > 1 ? 's' : '') ?>) 373 |

    374 | printFullThread($group, $article, charset: 'utf8'); ?> 375 |
    376 | ' . "\n"; 387 | echo ' ' . "\n"; 388 | echo ' '; 389 | 390 | if ($current > 1) { 391 | echo ' « previous'; 392 | } else { 393 | echo ' '; 394 | } 395 | 396 | echo ' ' . "\n"; 397 | echo ' ' . "$group (#$current)\n"; 398 | echo ' '; 399 | echo ' next »'; 400 | echo ' ' . "\n"; 401 | echo ' ' . "\n"; 402 | echo ' ' . "\n"; 403 | echo '
    '; 404 | 405 | foot(); 406 | -------------------------------------------------------------------------------- /lib/fMailbox.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * This class parses mail messages retreived from the NNTP server. 11 | * 12 | * All headers, text and html content returned by this class are encoded in 13 | * UTF-8. Please see http://flourishlib.com/docs/UTF-8 for more information. 14 | * 15 | * @copyright Copyright (c) 2010-2012 Will Bond 16 | * @author Will Bond [wb] 17 | * @license http://flourishlib.com/license 18 | * 19 | * @package Flourish 20 | * @link http://flourishlib.com/fMailbox 21 | * 22 | * Copyright (c) 2010-2012 Will Bond 23 | * 24 | * Permission is hereby granted, free of charge, to any person obtaining a copy 25 | * of this software and associated documentation files (the "Software"), to deal 26 | * in the Software without restriction, including without limitation the rights 27 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 28 | * copies of the Software, and to permit persons to whom the Software is 29 | * furnished to do so, subject to the following conditions: 30 | * 31 | * The above copyright notice and this permission notice shall be included in 32 | * all copies or substantial portions of the Software. 33 | * 34 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 38 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 39 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 40 | * THE SOFTWARE. 41 | */ 42 | class Mailbox 43 | { 44 | /** 45 | * Takes a date, removes comments and cleans up some common formatting inconsistencies 46 | * 47 | * @param string $date The date to clean 48 | * @return string The cleaned date 49 | */ 50 | private static function cleanDate($date) 51 | { 52 | $date = preg_replace('#\([^)]+\)#', ' ', trim($date)); 53 | $date = preg_replace('#\s+#', ' ', $date); 54 | $date = preg_replace('#(\d+)-([a-z]+)-(\d{4})#i', '\1 \2 \3', $date); 55 | $date = preg_replace('#^[a-z]+\s*,\s*#i', '', trim($date)); 56 | return trim($date); 57 | } 58 | 59 | /** 60 | * Decodes encoded-word headers of any encoding into raw UTF-8 61 | * 62 | * @param string $text The header value to decode 63 | * @return string The decoded UTF-8 64 | */ 65 | private static function decodeHeader($text) 66 | { 67 | $parts = preg_split('#(=\?[^\?]+\?[QB]\?[^\?]+\?=)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE); 68 | 69 | $part_with_encoding = array(); 70 | $output = ''; 71 | foreach ($parts as $part) { 72 | if ($part === '') { 73 | continue; 74 | } 75 | 76 | if (preg_match_all('#=\?([^\?]+)\?([QB])\?([^\?]+)\?=#i', $part, $matches, PREG_SET_ORDER)) { 77 | foreach ($matches as $match) { 78 | if (strtoupper($match[2]) == 'Q') { 79 | $part_string = rawurldecode(strtr( 80 | $match[3], 81 | array( 82 | '=' => '%', 83 | '_' => ' ' 84 | ) 85 | )); 86 | } else { 87 | $part_string = base64_decode($match[3]); 88 | } 89 | $lower_encoding = strtolower($match[1]); 90 | $last_key = count($part_with_encoding) - 1; 91 | if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == $lower_encoding) { 92 | $part_with_encoding[$last_key]['string'] .= $part_string; 93 | } else { 94 | $part_with_encoding[] = array('encoding' => $lower_encoding, 'string' => $part_string); 95 | } 96 | } 97 | } else { 98 | $last_key = count($part_with_encoding) - 1; 99 | if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == 'iso-8859-1') { 100 | $part_with_encoding[$last_key]['string'] .= $part; 101 | } else { 102 | $part_with_encoding[] = array('encoding' => 'iso-8859-1', 'string' => $part); 103 | } 104 | } 105 | } 106 | 107 | foreach ($part_with_encoding as $part) { 108 | $output .= self::iconv($part['encoding'], 'UTF-8', $part['string']); 109 | } 110 | 111 | return $output; 112 | } 113 | 114 | /** 115 | * Handles an individual part of a multipart message 116 | * 117 | * @param array $info An array of information about the message 118 | * @param array $structure An array describing the structure of the message 119 | * @return array The modified $info array 120 | */ 121 | private static function handlePart($info, $structure) 122 | { 123 | if ($structure['type'] == 'multipart') { 124 | foreach ($structure['parts'] as $part) { 125 | $info = self::handlePart($info, $part); 126 | } 127 | return $info; 128 | } 129 | 130 | if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-mime', 'x-pkcs7-mime'))) { 131 | $to = null; 132 | if (isset($info['headers']['to'][0])) { 133 | $to = $info['headers']['to'][0]['mailbox']; 134 | if (!empty($info['headers']['to'][0]['host'])) { 135 | $to .= '@' . $info['headers']['to'][0]['host']; 136 | } 137 | } 138 | } 139 | 140 | if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-signature', 'x-pkcs7-signature'))) { 141 | $from = null; 142 | if (isset($info['headers']['from'])) { 143 | $from = $info['headers']['from']['mailbox']; 144 | if (!empty($info['headers']['from']['host'])) { 145 | $from .= '@' . $info['headers']['from']['host']; 146 | } 147 | } 148 | } 149 | 150 | $data = $structure['data']; 151 | 152 | if ($structure['encoding'] == 'base64') { 153 | $content = ''; 154 | foreach (explode("\r\n", $data) as $line) { 155 | $content .= base64_decode($line); 156 | } 157 | } elseif ($structure['encoding'] == 'quoted-printable') { 158 | $content = quoted_printable_decode($data); 159 | } else { 160 | $content = $data; 161 | } 162 | 163 | if ($structure['type'] == 'text') { 164 | $charset = 'iso-8859-1'; 165 | foreach ($structure['type_fields'] as $field => $value) { 166 | if (strtolower($field) == 'charset') { 167 | $charset = $value; 168 | break; 169 | } 170 | } 171 | $content = self::iconv($charset, 'UTF-8', $content); 172 | if ($structure['subtype'] == 'html') { 173 | $content = preg_replace('#(content=(["\'])text/html\s*;\s*charset=(["\']?))' . preg_quote($charset, '#') . '(\3\2)#i', '\1utf-8\4', $content); 174 | } 175 | } 176 | 177 | // This indicates a content-id which is used for multipart/related 178 | if ($structure['content_id']) { 179 | if (!isset($info['related'])) { 180 | $info['related'] = array(); 181 | } 182 | $cid = $structure['content_id'][0] == '<' ? substr($structure['content_id'], 1, -1) : $structure['content_id']; 183 | $info['related']['cid:' . $cid] = array( 184 | 'mimetype' => $structure['type'] . '/' . $structure['subtype'], 185 | 'data' => $content 186 | ); 187 | return $info; 188 | } 189 | 190 | 191 | $has_disposition = !empty($structure['disposition']); 192 | $is_html = $structure['type'] == 'text' && $structure['subtype'] == 'html'; 193 | $is_text = $structure['type'] == 'text' && $structure['subtype'] == 'plain'; 194 | if ($is_text) { 195 | $info['flowed'] = strtolower($structure['type_fields']['format'] ?? "") == 'flowed'; 196 | $info['delsp'] = strtolower($structure['type_fields']['delsp'] ?? "") == 'yes'; 197 | } 198 | 199 | // If the part doesn't have a disposition and is not the default text or html, set the disposition to inline 200 | if (!$has_disposition && ((!$is_text || !empty($info['text'])) && (!$is_html || !empty($info['html'])))) { 201 | $is_web_image = $structure['type'] == 'image' && in_array($structure['subtype'], array('gif', 'png', 'jpeg', 'pjpeg')); 202 | $structure['disposition'] = $is_text || $is_html || $is_web_image ? 'inline' : 'attachment'; 203 | $structure['disposition_fields'] = array(); 204 | $has_disposition = true; 205 | } 206 | 207 | 208 | // Attachments or inline content 209 | if ($has_disposition) { 210 | $filename = ''; 211 | foreach ($structure['disposition_fields'] as $field => $value) { 212 | if (strtolower($field) == 'filename') { 213 | $filename = $value; 214 | break; 215 | } 216 | } 217 | foreach ($structure['type_fields'] as $field => $value) { 218 | if (strtolower($field) == 'name') { 219 | $filename = $value; 220 | break; 221 | } 222 | } 223 | 224 | // This automatically handles primary content that has a content-disposition header on it 225 | if ($structure['disposition'] == 'inline' && $filename === '') { 226 | if ($is_text && !isset($info['text'])) { 227 | $info['text'] = $content; 228 | return $info; 229 | } 230 | if ($is_html && !isset($info['html'])) { 231 | $info['html'] = $content; 232 | return $info; 233 | } 234 | } 235 | 236 | if (!isset($info[$structure['disposition']])) { 237 | $info[$structure['disposition']] = array(); 238 | } 239 | 240 | $info[$structure['disposition']][] = array( 241 | 'filename' => $filename, 242 | 'mimetype' => $structure['type'] . '/' . $structure['subtype'], 243 | 'data' => $content, 244 | 'description' => $structure['description'], 245 | ); 246 | return $info; 247 | } 248 | 249 | if ($is_text) { 250 | $info['text'] = $content; 251 | return $info; 252 | } 253 | 254 | if ($is_html) { 255 | $info['html'] = $content; 256 | return $info; 257 | } 258 | } 259 | 260 | /** 261 | * This works around a bug in MAMP 1.9.4+ and PHP 5.3 where iconv() 262 | * does not seem to properly assign the return value to a variable, but 263 | * does work when returning the value. 264 | * 265 | * @param string $in_charset The incoming character encoding 266 | * @param string $out_charset The outgoing character encoding 267 | * @param string $string The string to convert 268 | * @return string The converted string 269 | */ 270 | private static function iconv($in_charset, $out_charset, $string) 271 | { 272 | return iconv($in_charset, $out_charset, $string); 273 | } 274 | 275 | /** 276 | * Parses a string representation of an email into the persona, mailbox and host parts 277 | * 278 | * @param string $string The email string to parse 279 | * @return array An associative array with the key `mailbox`, and possibly `host` and `personal` 280 | */ 281 | private static function parseEmail($string) 282 | { 283 | $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])\])'; 284 | $name_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*)(?:\.?[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*))*)'; 285 | 286 | if (preg_match('~^[ \t]*' . $name_regex . '[ \t]*<[ \t]*' . $email_regex . '[ \t]*>[ \t]*$~ixD', $string, $match)) { 287 | $match[1] = trim($match[1]); 288 | if ($match[1][0] == '"' && substr($match[1], -1) == '"') { 289 | $match[1] = substr($match[1], 1, -1); 290 | } 291 | return array( 292 | 'personal' => self::decodeHeader($match[1]), 293 | 'mailbox' => self::decodeHeader($match[2]), 294 | 'host' => self::decodeHeader($match[3]), 295 | 'raw' => $string, 296 | ); 297 | } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*$~ixD', $string, $match)) { 298 | return array( 299 | 'mailbox' => self::decodeHeader($match[1]), 300 | 'host' => self::decodeHeader($match[2]), 301 | 'raw' => $string, 302 | ); 303 | 304 | // This handles the outdated practice of including the personal 305 | // part of the email in a comment after the email address 306 | } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*\(([^)]+)\)[ \t]*$~ixD', $string, $match)) { 307 | $match[3] = trim($match[1]); 308 | if ($match[3][0] == '"' && substr($match[3], -1) == '"') { 309 | $match[3] = substr($match[3], 1, -1); 310 | } 311 | 312 | return array( 313 | 'personal' => self::decodeHeader($match[3]), 314 | 'mailbox' => self::decodeHeader($match[1]), 315 | 'host' => self::decodeHeader($match[2]), 316 | 'raw' => $string, 317 | ); 318 | } 319 | 320 | if (strpos($string, '@') !== false) { 321 | list ($mailbox, $host) = explode('@', $string, 2); 322 | return array( 323 | 'mailbox' => self::decodeHeader($mailbox), 324 | 'host' => self::decodeHeader($host), 325 | 'raw' => $string, 326 | ); 327 | } 328 | 329 | return array( 330 | 'mailbox' => self::decodeHeader($string), 331 | 'host' => '', 332 | 'raw' => $string, 333 | ); 334 | } 335 | 336 | /** 337 | * Parses full email headers into an associative array 338 | * 339 | * @param string $headers The header to parse 340 | * @param string $filter Remove any headers that match this 341 | * @return array The parsed headers 342 | */ 343 | private static function parseHeaders($headers, $filter = null) 344 | { 345 | $headers = trim($headers); 346 | if (!strlen($headers)) { 347 | return array(); 348 | } 349 | $header_lines = preg_split("#\r\n(?!\s)#", $headers); 350 | 351 | $single_email_fields = array('from', 'sender', 'reply-to'); 352 | $multi_email_fields = array('to', 'cc'); 353 | $additional_info_fields = array('content-type', 'content-disposition'); 354 | 355 | $parsed_headers = array(); 356 | foreach ($header_lines as $header_line) { 357 | # "unfolding" headers means just removing \r\n followed by WS per RFC 5322 358 | $header_line = preg_replace("#\r\n(\s)#", '\1', $header_line); 359 | $header_line = trim($header_line); 360 | 361 | list ($header, $value) = preg_split('#:\s*#', $header_line, 2); 362 | $header = strtolower($header); 363 | 364 | if ($filter !== null && strpos($header, $filter) !== false) { 365 | continue; 366 | } 367 | 368 | $is_single_email = in_array($header, $single_email_fields); 369 | $is_multi_email = in_array($header, $multi_email_fields); 370 | $is_additional_info_field = in_array($header, $additional_info_fields); 371 | 372 | if ($is_additional_info_field) { 373 | $pieces = preg_split('#;\s*#', $value, 2); 374 | $value = $pieces[0]; 375 | 376 | $parsed_headers[$header] = array('value' => self::decodeHeader($value)); 377 | 378 | $fields = array(); 379 | if (!empty($pieces[1])) { 380 | preg_match_all('#(\w+)=("([^"]+)"|([^\s;]+))(?=;|$)#', $pieces[1], $matches, PREG_SET_ORDER); 381 | foreach ($matches as $match) { 382 | $fields[strtolower($match[1])] = self::decodeHeader(!empty($match[4]) ? $match[4] : $match[3]); 383 | } 384 | } 385 | $parsed_headers[$header]['fields'] = $fields; 386 | } elseif ($is_single_email) { 387 | $parsed_headers[$header] = self::parseEmail($value); 388 | } elseif ($is_multi_email) { 389 | $strings = array(); 390 | 391 | preg_match_all('#"[^"]+?"#', $value, $matches, PREG_SET_ORDER); 392 | foreach ($matches as $i => $match) { 393 | $strings[] = $match[0]; 394 | $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1); 395 | } 396 | preg_match_all('#\([^)]+?\)#', $value, $matches, PREG_SET_ORDER); 397 | foreach ($matches as $i => $match) { 398 | $strings[] = $match[0]; 399 | $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1); 400 | } 401 | 402 | $emails = explode(',', $value); 403 | array_map('trim', $emails); 404 | foreach ($strings as $i => $string) { 405 | $emails = preg_replace( 406 | '#:string' . ($i + 1) . '\b#', 407 | strtr($string, array('\\' => '\\\\', '$' => '\\$')), 408 | $emails, 409 | 1 410 | ); 411 | } 412 | 413 | $parsed_headers[$header] = array(); 414 | foreach ($emails as $email) { 415 | $parsed_headers[$header][] = self::parseEmail($email); 416 | } 417 | } elseif ($header == 'references') { 418 | $parsed_headers[$header] = array_map(array('Flourish\\Mailbox', 'decodeHeader'), preg_split('#(?<=>)\s+(?=<)#', $value)); 419 | } elseif ($header == 'received') { 420 | if (!isset($parsed_headers[$header])) { 421 | $parsed_headers[$header] = array(); 422 | } 423 | $parsed_headers[$header][] = preg_replace('#\s+#', ' ', self::decodeHeader($value)); 424 | } else { 425 | $parsed_headers[$header] = self::decodeHeader($value); 426 | } 427 | } 428 | 429 | return $parsed_headers; 430 | } 431 | 432 | /** 433 | * Parses a MIME message into an associative array of information 434 | * 435 | * The output includes the following keys: 436 | * 437 | * - `'received'`: The date the message was received by the server 438 | * - `'headers'`: An associative array of mail headers, the keys are the header names, in lowercase 439 | * 440 | * And one or more of the following: 441 | * 442 | * - `'text'`: The plaintext body 443 | * - `'html'`: The HTML body 444 | * - `'attachment'`: An array of attachments, each containing: 445 | * - `'filename'`: The name of the file 446 | * - `'mimetype'`: The mimetype of the file 447 | * - `'data'`: The raw contents of the file 448 | * - `'inline'`: An array of inline files, each containing: 449 | * - `'filename'`: The name of the file 450 | * - `'mimetype'`: The mimetype of the file 451 | * - `'data'`: The raw contents of the file 452 | * - `'related'`: An associative array of related files, such as embedded images, with the key `'cid:{content-id}'` and an array value containing: 453 | * - `'mimetype'`: The mimetype of the file 454 | * - `'data'`: The raw contents of the file 455 | * - `'verified'`: If the message contents were verified via an S/MIME certificate - if not verified the smime.p7s will be listed as an attachment 456 | * - `'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 457 | * 458 | * All values in `headers`, `text` and `body` will have been decoded to 459 | * UTF-8. Files in the `attachment`, `inline` and `related` array will all 460 | * retain their original encodings. 461 | * 462 | * @param string $message The full source of the email message 463 | * @param boolean $convert_newlines If `\r\n` should be converted to `\n` in the `text` and `html` parts the message 464 | * @return array The parsed email message - see method description for details 465 | */ 466 | public static function parseMessage($message, $convert_newlines = false) 467 | { 468 | $info = array(); 469 | list ($headers, $body) = explode("\r\n\r\n", $message, 2); 470 | $parsed_headers = self::parseHeaders($headers); 471 | $info['received'] = self::cleanDate(preg_replace('#^.*;\s*([^;]+)$#', '\1', $parsed_headers['received'][0])); 472 | $info['headers'] = array(); 473 | foreach ($parsed_headers as $header => $value) { 474 | if (substr($header, 0, 8) == 'content-') { 475 | continue; 476 | } 477 | $info['headers'][$header] = $value; 478 | } 479 | $info['raw_headers'] = $headers; 480 | $info['raw_message'] = $message; 481 | 482 | $info = self::handlePart($info, self::parseStructure($body, $parsed_headers)); 483 | unset($info['raw_message']); 484 | unset($info['raw_headers']); 485 | 486 | if ($convert_newlines) { 487 | if (isset($info['text'])) { 488 | $info['text'] = str_replace("\r\n", "\n", $info['text']); 489 | } 490 | if (isset($info['html'])) { 491 | $info['html'] = str_replace("\r\n", "\n", $info['html']); 492 | } 493 | } 494 | 495 | if (isset($info['text'])) { 496 | $info['text'] = preg_replace('#\r?\n$#D', '', $info['text']); 497 | } 498 | if (isset($info['html'])) { 499 | $info['html'] = preg_replace('#\r?\n$#D', '', $info['html']); 500 | } 501 | 502 | return $info; 503 | } 504 | 505 | /** 506 | * Takes the raw contents of a MIME message and creates an array that 507 | * describes the structure of the message 508 | * 509 | * @param string $data The contents to get the structure of 510 | * @param string $headers The parsed headers for the message - if not present they will be extracted from the `$data` 511 | * @return array The multi-dimensional, associative array containing the message structure 512 | */ 513 | private static function parseStructure($data, $headers = null) 514 | { 515 | if (!$headers) { 516 | list ($headers, $data) = preg_split("#^\r\n|\r\n\r\n#", $data, 2); 517 | $headers = self::parseHeaders($headers); 518 | } 519 | 520 | if (!isset($headers['content-type'])) { 521 | $headers['content-type'] = array( 522 | 'value' => 'text/plain', 523 | 'fields' => array() 524 | ); 525 | } 526 | 527 | list ($type, $subtype) = explode('/', strtolower($headers['content-type']['value']), 2); 528 | 529 | if ($type == 'multipart') { 530 | $structure = array( 531 | 'type' => $type, 532 | 'subtype' => $subtype, 533 | 'parts' => array() 534 | ); 535 | $boundary = $headers['content-type']['fields']['boundary']; 536 | $start_pos = strpos($data, '--' . $boundary) + strlen($boundary) + 4; 537 | $end_pos = strrpos($data, '--' . $boundary . '--') - 2; 538 | $sub_contents = explode("\r\n--" . $boundary . "\r\n", substr( 539 | $data, 540 | $start_pos, 541 | $end_pos - $start_pos 542 | )); 543 | foreach ($sub_contents as $sub_content) { 544 | $structure['parts'][] = self::parseStructure($sub_content); 545 | } 546 | } else { 547 | $structure = array( 548 | 'type' => $type, 549 | 'type_fields' => !empty($headers['content-type']['fields']) ? $headers['content-type']['fields'] : array(), 550 | 'subtype' => $subtype, 551 | 'content_id' => isset($headers['content-id']) ? $headers['content-id'] : null, 552 | 'encoding' => isset($headers['content-transfer-encoding']) ? strtolower($headers['content-transfer-encoding']) : '8bit', 553 | 'disposition' => isset($headers['content-disposition']) ? strtolower($headers['content-disposition']['value']) : null, 554 | 'disposition_fields' => isset($headers['content-disposition']) ? $headers['content-disposition']['fields'] : array(), 555 | 'description' => isset($headers['content-description']) ? $headers['content-description'] : null, 556 | 'data' => $data 557 | ); 558 | } 559 | 560 | return $structure; 561 | } 562 | } 563 | --------------------------------------------------------------------------------