Release 2015-08-10 "Detritus"
[dokuwiki.git] / inc / subscription.php
blob74bec656dd57bc41664470b44a47ac9103a6dc0d
1 <?php
2 /**
3 * Class for handling (email) subscriptions
5 * @author Adrian Lang <lang@cosmocode.de>
6 * @author Andreas Gohr <andi@splitbrain.org>
7 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
8 */
9 class Subscription {
11 /**
12 * Check if subscription system is enabled
14 * @return bool
16 public function isenabled() {
17 return actionOK('subscribe');
20 /**
21 * Return the subscription meta file for the given ID
23 * @author Adrian Lang <lang@cosmocode.de>
25 * @param string $id The target page or namespace, specified by id; Namespaces
26 * are identified by appending a colon.
27 * @return string
29 protected function file($id) {
30 $meta_fname = '.mlist';
31 if((substr($id, -1, 1) === ':')) {
32 $meta_froot = getNS($id);
33 $meta_fname = '/'.$meta_fname;
34 } else {
35 $meta_froot = $id;
37 return metaFN((string) $meta_froot, $meta_fname);
40 /**
41 * Lock subscription info
43 * We don't use io_lock() her because we do not wait for the lock and use a larger stale time
45 * @author Adrian Lang <lang@cosmocode.de>
46 * @param string $id The target page or namespace, specified by id; Namespaces
47 * are identified by appending a colon.
48 * @return bool true, if you got a succesful lock
50 protected function lock($id) {
51 global $conf;
53 $lock = $conf['lockdir'].'/_subscr_'.md5($id).'.lock';
55 if(is_dir($lock) && time() - @filemtime($lock) > 60 * 5) {
56 // looks like a stale lock - remove it
57 @rmdir($lock);
60 // try creating the lock directory
61 if(!@mkdir($lock, $conf['dmode'])) {
62 return false;
65 if(!empty($conf['dperm'])) chmod($lock, $conf['dperm']);
66 return true;
69 /**
70 * Unlock subscription info
72 * @author Adrian Lang <lang@cosmocode.de>
73 * @param string $id The target page or namespace, specified by id; Namespaces
74 * are identified by appending a colon.
75 * @return bool
77 protected function unlock($id) {
78 global $conf;
79 $lock = $conf['lockdir'].'/_subscr_'.md5($id).'.lock';
80 return @rmdir($lock);
83 /**
84 * Construct a regular expression for parsing a subscription definition line
86 * @author Andreas Gohr <andi@splitbrain.org>
88 * @param string|array $user
89 * @param string|array $style
90 * @param string|array $data
91 * @return string complete regexp including delimiters
92 * @throws Exception when no data is passed
94 protected function buildregex($user = null, $style = null, $data = null) {
95 // always work with arrays
96 $user = (array) $user;
97 $style = (array) $style;
98 $data = (array) $data;
100 // clean
101 $user = array_filter(array_map('trim', $user));
102 $style = array_filter(array_map('trim', $style));
103 $data = array_filter(array_map('trim', $data));
105 // user names are encoded
106 $user = array_map('auth_nameencode', $user);
108 // quote
109 $user = array_map('preg_quote_cb', $user);
110 $style = array_map('preg_quote_cb', $style);
111 $data = array_map('preg_quote_cb', $data);
113 // join
114 $user = join('|', $user);
115 $style = join('|', $style);
116 $data = join('|', $data);
118 // any data at all?
119 if($user.$style.$data === '') throw new Exception('no data passed');
121 // replace empty values, set which ones are optional
122 $sopt = '';
123 $dopt = '';
124 if($user === '') {
125 $user = '\S+';
127 if($style === '') {
128 $style = '\S+';
129 $sopt = '?';
131 if($data === '') {
132 $data = '\S+';
133 $dopt = '?';
136 // assemble
137 return "/^($user)(?:\\s+($style))$sopt(?:\\s+($data))$dopt$/";
141 * Recursively search for matching subscriptions
143 * This function searches all relevant subscription files for a page or
144 * namespace.
146 * @author Adrian Lang <lang@cosmocode.de>
148 * @param string $page The target object’s (namespace or page) id
149 * @param string|array $user
150 * @param string|array $style
151 * @param string|array $data
152 * @return array
154 public function subscribers($page, $user = null, $style = null, $data = null) {
155 if(!$this->isenabled()) return array();
157 // Construct list of files which may contain relevant subscriptions.
158 $files = array(':' => $this->file(':'));
159 do {
160 $files[$page] = $this->file($page);
161 $page = getNS(rtrim($page, ':')).':';
162 } while($page !== ':');
164 $re = $this->buildregex($user, $style, $data);
166 // Handle files.
167 $result = array();
168 foreach($files as $target => $file) {
169 if(!file_exists($file)) continue;
171 $lines = file($file);
172 foreach($lines as $line) {
173 // fix old style subscription files
174 if(strpos($line, ' ') === false) $line = trim($line)." every\n";
176 // check for matching entries
177 if(!preg_match($re, $line, $m)) continue;
179 $u = rawurldecode($m[1]); // decode the user name
180 if(!isset($result[$target])) $result[$target] = array();
181 $result[$target][$u] = array($m[2], $m[3]); // add to result
184 return array_reverse($result);
188 * Adds a new subscription for the given page or namespace
190 * This will automatically overwrite any existent subscription for the given user on this
191 * *exact* page or namespace. It will *not* modify any subscription that may exist in higher namespaces.
193 * @param string $id The target page or namespace, specified by id; Namespaces
194 * are identified by appending a colon.
195 * @param string $user
196 * @param string $style
197 * @param string $data
198 * @throws Exception when user or style is empty
199 * @return bool
201 public function add($id, $user, $style, $data = '') {
202 if(!$this->isenabled()) return false;
204 // delete any existing subscription
205 $this->remove($id, $user);
207 $user = auth_nameencode(trim($user));
208 $style = trim($style);
209 $data = trim($data);
211 if(!$user) throw new Exception('no subscription user given');
212 if(!$style) throw new Exception('no subscription style given');
213 if(!$data) $data = time(); //always add current time for new subscriptions
215 $line = "$user $style $data\n";
216 $file = $this->file($id);
217 return io_saveFile($file, $line, true);
221 * Removes a subscription for the given page or namespace
223 * This removes all subscriptions matching the given criteria on the given page or
224 * namespace. It will *not* modify any subscriptions that may exist in higher
225 * namespaces.
227 * @param string $id The target object’s (namespace or page) id
228 * @param string|array $user
229 * @param string|array $style
230 * @param string|array $data
231 * @return bool
233 public function remove($id, $user = null, $style = null, $data = null) {
234 if(!$this->isenabled()) return false;
236 $file = $this->file($id);
237 if(!file_exists($file)) return true;
239 $re = $this->buildregex($user, $style, $data);
240 return io_deleteFromFile($file, $re, true);
244 * Get data for $INFO['subscribed']
246 * $INFO['subscribed'] is either false if no subscription for the current page
247 * and user is in effect. Else it contains an array of arrays with the fields
248 * “target”, “style”, and optionally “data”.
250 * @param string $id Page ID, defaults to global $ID
251 * @param string $user User, defaults to $_SERVER['REMOTE_USER']
252 * @return array
253 * @author Adrian Lang <lang@cosmocode.de>
255 function user_subscription($id = '', $user = '') {
256 if(!$this->isenabled()) return false;
258 global $ID;
259 /** @var Input $INPUT */
260 global $INPUT;
261 if(!$id) $id = $ID;
262 if(!$user) $user = $INPUT->server->str('REMOTE_USER');
264 $subs = $this->subscribers($id, $user);
265 if(!count($subs)) return false;
267 $result = array();
268 foreach($subs as $target => $info) {
269 $result[] = array(
270 'target' => $target,
271 'style' => $info[$user][0],
272 'data' => $info[$user][1]
276 return $result;
280 * Send digest and list subscriptions
282 * This sends mails to all subscribers that have a subscription for namespaces above
283 * the given page if the needed $conf['subscribe_time'] has passed already.
285 * This function is called form lib/exe/indexer.php
287 * @param string $page
288 * @return int number of sent mails
290 public function send_bulk($page) {
291 if(!$this->isenabled()) return 0;
293 /** @var DokuWiki_Auth_Plugin $auth */
294 global $auth;
295 global $conf;
296 global $USERINFO;
297 /** @var Input $INPUT */
298 global $INPUT;
299 $count = 0;
301 $subscriptions = $this->subscribers($page, null, array('digest', 'list'));
303 // remember current user info
304 $olduinfo = $USERINFO;
305 $olduser = $INPUT->server->str('REMOTE_USER');
307 foreach($subscriptions as $target => $users) {
308 if(!$this->lock($target)) continue;
310 foreach($users as $user => $info) {
311 list($style, $lastupdate) = $info;
313 $lastupdate = (int) $lastupdate;
314 if($lastupdate + $conf['subscribe_time'] > time()) {
315 // Less than the configured time period passed since last
316 // update.
317 continue;
320 // Work as the user to make sure ACLs apply correctly
321 $USERINFO = $auth->getUserData($user);
322 $INPUT->server->set('REMOTE_USER',$user);
323 if($USERINFO === false) continue;
324 if(!$USERINFO['mail']) continue;
326 if(substr($target, -1, 1) === ':') {
327 // subscription target is a namespace, get all changes within
328 $changes = getRecentsSince($lastupdate, null, getNS($target));
329 } else {
330 // single page subscription, check ACL ourselves
331 if(auth_quickaclcheck($target) < AUTH_READ) continue;
332 $meta = p_get_metadata($target);
333 $changes = array($meta['last_change']);
336 // Filter out pages only changed in small and own edits
337 $change_ids = array();
338 foreach($changes as $rev) {
339 $n = 0;
340 while(!is_null($rev) && $rev['date'] >= $lastupdate &&
341 ($INPUT->server->str('REMOTE_USER') === $rev['user'] ||
342 $rev['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT)) {
343 $pagelog = new PageChangeLog($rev['id']);
344 $rev = $pagelog->getRevisions($n++, 1);
345 $rev = (count($rev) > 0) ? $rev[0] : null;
348 if(!is_null($rev) && $rev['date'] >= $lastupdate) {
349 // Some change was not a minor one and not by myself
350 $change_ids[] = $rev['id'];
354 // send it
355 if($style === 'digest') {
356 foreach($change_ids as $change_id) {
357 $this->send_digest(
358 $USERINFO['mail'], $change_id,
359 $lastupdate
361 $count++;
363 } elseif($style === 'list') {
364 $this->send_list($USERINFO['mail'], $change_ids, $target);
365 $count++;
367 // TODO: Handle duplicate subscriptions.
369 // Update notification time.
370 $this->add($target, $user, $style, time());
372 $this->unlock($target);
375 // restore current user info
376 $USERINFO = $olduinfo;
377 $INPUT->server->set('REMOTE_USER',$olduser);
378 return $count;
382 * Send the diff for some page change
384 * @param string $subscriber_mail The target mail address
385 * @param string $template Mail template ('subscr_digest', 'subscr_single', 'mailtext', ...)
386 * @param string $id Page for which the notification is
387 * @param int|null $rev Old revision if any
388 * @param string $summary Change summary if any
389 * @return bool true if successfully sent
391 public function send_diff($subscriber_mail, $template, $id, $rev = null, $summary = '') {
392 global $DIFF_INLINESTYLES;
394 // prepare replacements (keys not set in hrep will be taken from trep)
395 $trep = array(
396 'PAGE' => $id,
397 'NEWPAGE' => wl($id, '', true, '&'),
398 'SUMMARY' => $summary,
399 'SUBSCRIBE' => wl($id, array('do' => 'subscribe'), true, '&')
401 $hrep = array();
403 if($rev) {
404 $subject = 'changed';
405 $trep['OLDPAGE'] = wl($id, "rev=$rev", true, '&');
407 $old_content = rawWiki($id, $rev);
408 $new_content = rawWiki($id);
410 $df = new Diff(explode("\n", $old_content),
411 explode("\n", $new_content));
412 $dformat = new UnifiedDiffFormatter();
413 $tdiff = $dformat->format($df);
415 $DIFF_INLINESTYLES = true;
416 $df = new Diff(explode("\n", $old_content),
417 explode("\n", $new_content));
418 $dformat = new InlineDiffFormatter();
419 $hdiff = $dformat->format($df);
420 $hdiff = '<table>'.$hdiff.'</table>';
421 $DIFF_INLINESTYLES = false;
422 } else {
423 $subject = 'newpage';
424 $trep['OLDPAGE'] = '---';
425 $tdiff = rawWiki($id);
426 $hdiff = nl2br(hsc($tdiff));
429 $trep['DIFF'] = $tdiff;
430 $hrep['DIFF'] = $hdiff;
432 $headers = array('Message-Id' => $this->getMessageID($id));
433 if ($rev) {
434 $headers['In-Reply-To'] = $this->getMessageID($id, $rev);
437 return $this->send(
438 $subscriber_mail, $subject, $id,
439 $template, $trep, $hrep, $headers
444 * Send the diff for some media change
446 * @fixme this should embed thumbnails of images in HTML version
448 * @param string $subscriber_mail The target mail address
449 * @param string $template Mail template ('uploadmail', ...)
450 * @param string $id Media file for which the notification is
451 * @param int|bool $rev Old revision if any
453 public function send_media_diff($subscriber_mail, $template, $id, $rev = false) {
454 global $conf;
456 $file = mediaFN($id);
457 list($mime, /* $ext */) = mimetype($id);
459 $trep = array(
460 'MIME' => $mime,
461 'MEDIA' => ml($id,'',true,'&',true),
462 'SIZE' => filesize_h(filesize($file)),
465 if ($rev && $conf['mediarevisions']) {
466 $trep['OLD'] = ml($id, "rev=$rev", true, '&', true);
467 } else {
468 $trep['OLD'] = '---';
471 $headers = array('Message-Id' => $this->getMessageID($id, @filemtime($file)));
472 if ($rev) {
473 $headers['In-Reply-To'] = $this->getMessageID($id, $rev);
476 $this->send($subscriber_mail, 'upload', $id, $template, $trep, null, $headers);
481 * Send a notify mail on new registration
483 * @author Andreas Gohr <andi@splitbrain.org>
485 * @param string $login login name of the new user
486 * @param string $fullname full name of the new user
487 * @param string $email email address of the new user
488 * @return bool true if a mail was sent
490 public function send_register($login, $fullname, $email) {
491 global $conf;
492 if(empty($conf['registernotify'])) return false;
494 $trep = array(
495 'NEWUSER' => $login,
496 'NEWNAME' => $fullname,
497 'NEWEMAIL' => $email,
500 return $this->send(
501 $conf['registernotify'],
502 'new_user',
503 $login,
504 'registermail',
505 $trep
510 * Send a digest mail
512 * Sends a digest mail showing a bunch of changes of a single page. Basically the same as send_diff()
513 * but determines the last known revision first
515 * @author Adrian Lang <lang@cosmocode.de>
517 * @param string $subscriber_mail The target mail address
518 * @param string $id The ID
519 * @param int $lastupdate Time of the last notification
520 * @return bool
522 protected function send_digest($subscriber_mail, $id, $lastupdate) {
523 $pagelog = new PageChangeLog($id);
524 $n = 0;
525 do {
526 $rev = $pagelog->getRevisions($n++, 1);
527 $rev = (count($rev) > 0) ? $rev[0] : null;
528 } while(!is_null($rev) && $rev > $lastupdate);
530 return $this->send_diff(
531 $subscriber_mail,
532 'subscr_digest',
533 $id, $rev
538 * Send a list mail
540 * Sends a list mail showing a list of changed pages.
542 * @author Adrian Lang <lang@cosmocode.de>
544 * @param string $subscriber_mail The target mail address
545 * @param array $ids Array of ids
546 * @param string $ns_id The id of the namespace
547 * @return bool true if a mail was sent
549 protected function send_list($subscriber_mail, $ids, $ns_id) {
550 if(count($ids) === 0) return false;
552 $tlist = '';
553 $hlist = '<ul>';
554 foreach($ids as $id) {
555 $link = wl($id, array(), true);
556 $tlist .= '* '.$link.NL;
557 $hlist .= '<li><a href="'.$link.'">'.hsc($id).'</a></li>'.NL;
559 $hlist .= '</ul>';
561 $id = prettyprint_id($ns_id);
562 $trep = array(
563 'DIFF' => rtrim($tlist),
564 'PAGE' => $id,
565 'SUBSCRIBE' => wl($id, array('do' => 'subscribe'), true, '&')
567 $hrep = array(
568 'DIFF' => $hlist
571 return $this->send(
572 $subscriber_mail,
573 'subscribe_list',
574 $ns_id,
575 'subscr_list', $trep, $hrep
580 * Helper function for sending a mail
582 * @author Adrian Lang <lang@cosmocode.de>
584 * @param string $subscriber_mail The target mail address
585 * @param string $subject The lang id of the mail subject (without the
586 * prefix “mail_”)
587 * @param string $context The context of this mail, eg. page or namespace id
588 * @param string $template The name of the mail template
589 * @param array $trep Predefined parameters used to parse the
590 * template (in text format)
591 * @param array $hrep Predefined parameters used to parse the
592 * template (in HTML format), null to default to $trep
593 * @param array $headers Additional mail headers in the form 'name' => 'value'
594 * @return bool
596 protected function send($subscriber_mail, $subject, $context, $template, $trep, $hrep = null, $headers = array()) {
597 global $lang;
598 global $conf;
600 $text = rawLocale($template);
601 $subject = $lang['mail_'.$subject].' '.$context;
602 $mail = new Mailer();
603 $mail->bcc($subscriber_mail);
604 $mail->subject($subject);
605 $mail->setBody($text, $trep, $hrep);
606 if(in_array($template, array('subscr_list', 'subscr_digest'))){
607 $mail->from($conf['mailfromnobody']);
609 if(isset($trep['SUBSCRIBE'])) {
610 $mail->setHeader('List-Unsubscribe', '<'.$trep['SUBSCRIBE'].'>', false);
613 foreach ($headers as $header => $value) {
614 $mail->setHeader($header, $value);
617 return $mail->send();
621 * Get a valid message id for a certain $id and revision (or the current revision)
623 * @param string $id The id of the page (or media file) the message id should be for
624 * @param string $rev The revision of the page, set to the current revision of the page $id if not set
625 * @return string
627 protected function getMessageID($id, $rev = null) {
628 static $listid = null;
629 if (is_null($listid)) {
630 $server = parse_url(DOKU_URL, PHP_URL_HOST);
631 $listid = join('.', array_reverse(explode('/', DOKU_BASE))).$server;
632 $listid = urlencode($listid);
633 $listid = strtolower(trim($listid, '.'));
636 if (is_null($rev)) {
637 $rev = @filemtime(wikiFN($id));
640 return "<$id?rev=$rev@$listid>";
644 * Default callback for COMMON_NOTIFY_ADDRESSLIST
646 * Aggregates all email addresses of user who have subscribed the given page with 'every' style
648 * @author Steven Danz <steven-danz@kc.rr.com>
649 * @author Adrian Lang <lang@cosmocode.de>
651 * @todo move the whole functionality into this class, trigger SUBSCRIPTION_NOTIFY_ADDRESSLIST instead,
652 * use an array for the addresses within it
654 * @param array &$data Containing the entries:
655 * - $id (the page id),
656 * - $self (whether the author should be notified,
657 * - $addresslist (current email address list)
658 * - $replacements (array of additional string substitutions, @KEY@ to be replaced by value)
660 public function notifyaddresses(&$data) {
661 if(!$this->isenabled()) return;
663 /** @var DokuWiki_Auth_Plugin $auth */
664 global $auth;
665 global $conf;
666 /** @var Input $INPUT */
667 global $INPUT;
669 $id = $data['id'];
670 $self = $data['self'];
671 $addresslist = $data['addresslist'];
673 $subscriptions = $this->subscribers($id, null, 'every');
675 $result = array();
676 foreach($subscriptions as $target => $users) {
677 foreach($users as $user => $info) {
678 $userinfo = $auth->getUserData($user);
679 if($userinfo === false) continue;
680 if(!$userinfo['mail']) continue;
681 if(!$self && $user == $INPUT->server->str('REMOTE_USER')) continue; //skip our own changes
683 $level = auth_aclcheck($id, $user, $userinfo['grps']);
684 if($level >= AUTH_READ) {
685 if(strcasecmp($userinfo['mail'], $conf['notify']) != 0) { //skip user who get notified elsewhere
686 $result[$user] = $userinfo['mail'];
691 $data['addresslist'] = trim($addresslist.','.implode(',', $result), ',');