mail.pl 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. # Copyright (C) 2009–2015 Alex Schroeder <alex@gnu.org>
  2. # Copyright (C) 2015 Aleks-Daniel Jakimenko <alex.jakimenko@gmail.com>
  3. #
  4. # This program is free software; you can redistribute it and/or modify it under
  5. # the terms of the GNU General Public License as published by the Free Software
  6. # Foundation; either version 3 of the License, or (at your option) any later
  7. # version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
  12. #
  13. # You should have received a copy of the GNU General Public License along with
  14. # this program. If not, see <http://www.gnu.org/licenses/>.
  15. use strict;
  16. use v5.10;
  17. =head1 NAME
  18. tags - an Oddmuse module that implements email subscription to pages
  19. =head1 SYNOPSIS
  20. Visitors can add their email address and click a checkbox to subscribe
  21. to changes when they edit a page. The requirement to successfully edit
  22. a page acts as a defense mechanism against spammers and vandals.
  23. Email addresses are stored in a file. Each mail contains an
  24. unsubscribe link, and from there users can see (and unsubscribe from)
  25. all other pages they are subscribed to. The link contains a hash of
  26. the email address which prevents others from guessing what email
  27. addresses have subscriptions.
  28. There is also an admin interface that shows which email addresses are
  29. subscribed to which pages, allowing the easy removal of email
  30. addresses from the database.
  31. =head1 INSTALLATION
  32. Installing a module is easy: Create a modules subdirectory in your
  33. data directory, and put the Perl file in there. It will be loaded
  34. automatically.
  35. =cut
  36. AddModuleDescription('mail.pl', 'Mail Extension');
  37. our ($q, %Action, %IndexHash, $FS, $DataDir, %CookieParameters,
  38. @MyInitVariables, @MyAdminCode, $Message, @MyFormChanges);
  39. our ($MailFile, $MailPattern);
  40. push (@MyInitVariables, sub {
  41. $MailFile = "$DataDir/mail.db";
  42. });
  43. # May contain neither space nor @; I'm too scared to put
  44. # Mail::RFC822::Address here.
  45. $MailPattern = '^[^ ]+@[^ ]+$';
  46. =head1 Commenting
  47. When commenting, users are presented with a form where they can
  48. provide username and homepage. With this extension, users can also
  49. provide their mail address and choose to subscribe to comment pages.
  50. In order to get caching right, we also use an invisible cookie
  51. parameter to make sure that visitors will get a new page when they
  52. subscribe or unsubscribe. The alternative would have been to touch the
  53. index file at the end of the subscribe and unsubscribe function.
  54. =cut
  55. *MailOldInitCookie = \&InitCookie;
  56. *InitCookie = \&MailNewInitCookie;
  57. $CookieParameters{mail} = '';
  58. $CookieParameters{sub} = '';
  59. sub MailNewInitCookie {
  60. MailOldInitCookie(@_);
  61. my $mail = GetParam('mail', '');
  62. $q->delete('mail');
  63. if (!$mail) {
  64. # do nothing
  65. } elsif (!($mail =~ /$MailPattern/)) {
  66. $Message .= $q->p(Ts('Invalid Mail %s: not saved.', $mail));
  67. } else {
  68. SetParam('mail', $mail);
  69. }
  70. }
  71. push(@MyFormChanges, \&MailFormAddition);
  72. sub MailFormAddition {
  73. my $html = shift;
  74. my $id = GetId();
  75. my $mail = GetParam('mail', '');
  76. my $addition;
  77. if (MailIsSubscribed($id, $mail)) {
  78. $addition = ' ' . ScriptLink("action=unsubscribe;pages=$id",
  79. T('unsubscribe'), 'unsubscribe');
  80. } else {
  81. $addition = $q->input({-type=>'checkbox', -name=>'notify', -value=>'1'})
  82. . ScriptLink("action=subscribe;pages=$id", T('subscribe'), 'subscribe');
  83. }
  84. $addition = $q->span({-class=>'mail'},
  85. $q->label({-for=>'mail'}, T('Email:') . ' ')
  86. . ' ' . $q->textfield(-name=>'mail', -id=>'mail',
  87. -default=>GetParam('mail', ''))
  88. . $addition);
  89. $html =~ s!(name="homepage".*?)</p>!$1 $addition</p>!i;
  90. return $html;
  91. }
  92. sub MailIsSubscribed {
  93. # is not called within a lock
  94. my ($id, $mail) = @_;
  95. return 0 unless $mail;
  96. # open the DB file
  97. require DB_File;
  98. tie my %h, "DB_File", encode_utf8($MailFile);
  99. my %subscribers = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($id)}));
  100. untie %h;
  101. return $subscribers{$mail};
  102. }
  103. *MailOldGetFooterTimestamp = \&GetFooterTimestamp;
  104. *GetFooterTimestamp = \&MailNewGetFooterTimestamp;
  105. sub MailNewGetFooterTimestamp {
  106. my $html = MailOldGetFooterTimestamp(@_);
  107. my $id = shift;
  108. my $mail = GetParam('mail', '');
  109. my $addition;
  110. if (MailIsSubscribed($id, $mail)) {
  111. $addition = ScriptLink("action=unsubscribe;pages=$id",
  112. T('unsubscribe'), 'unsubscribe');
  113. } else {
  114. $addition = ScriptLink("action=subscribe;pages=$id",
  115. T('subscribe'), 'subscribe');
  116. }
  117. $html =~ s!(.*)(<br /></span>)!$1 $addition$2!i;
  118. return $html;
  119. }
  120. =head1 Saving
  121. When saving a comment page users can subscribe using a checkbox. To do
  122. this via an URL you need to provide the parameters id, mail, aftertext
  123. (a new comment), and notify (1).
  124. =cut
  125. *MailOldSave = \&Save;
  126. *Save = \&MailNewSave;
  127. sub MailNewSave {
  128. # is called within a lock! :)
  129. MailOldSave(@_);
  130. my $id = shift;
  131. my $mail = GetParam('mail', '');
  132. my $comment = GetParam('aftertext', '');
  133. # Compare to GetId() in order to prevent subscription to LocalNames
  134. # page and other automatic saves.
  135. if ($id and $id eq GetId() and $comment and $mail
  136. and GetParam('notify', '')) {
  137. my $valid = 1;
  138. eval {
  139. local $SIG{__DIE__};
  140. require Mail::RFC822::Address;
  141. $valid = Mail::RFC822::Address::valid($mail);
  142. SetParam('msg', Ts('%s appears to be an invalid mail address', $mail))
  143. unless $valid;
  144. };
  145. MailSubscribe($mail, $id) if $valid;
  146. }
  147. }
  148. *OldMailDeletePage = \&DeletePage;
  149. *DeletePage = \&NewMailDeletePage;
  150. =head1 Deleting
  151. When a page is deleted, the appropriate subscriptions have to be
  152. deleted as well.
  153. =cut
  154. sub NewMailDeletePage {
  155. my $id = shift;
  156. MailDeletePage($id);
  157. return OldMailDeletePage($id, @_);
  158. }
  159. sub MailDeletePage {
  160. my $id = shift;
  161. require DB_File;
  162. tie my %h, "DB_File", encode_utf8($MailFile);
  163. foreach my $mail (split(/$FS/, UrlDecode(delete $h{UrlEncode($id)}))) {
  164. my %subscriptions = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($mail)}));
  165. delete $subscriptions{$id};
  166. if (%subscriptions) {
  167. $h{UrlEncode($mail)} = UrlEncode(join($FS, keys %subscriptions));
  168. } else {
  169. delete $h{UrlEncode($mail)};
  170. }
  171. }
  172. untie %h;
  173. }
  174. =head1 Administration menu
  175. The Administration page will have a list to your subscriptions, and if
  176. you are an administrator, it will also have a link to all
  177. subscriptions.
  178. =cut
  179. push(@MyAdminCode, \&MailMenu);
  180. sub MailMenu {
  181. my ($id, $menuref, $restref) = @_;
  182. push(@$menuref,
  183. ScriptLink('action=subscriptions',
  184. T('Your mail subscriptions'),
  185. 'subscriptions'));
  186. push(@$menuref,
  187. ScriptLink('action=subscriptionlist',
  188. T('All mail subscriptions'),
  189. 'subscriptionlist')) if UserIsAdmin();
  190. }
  191. =head1 Your subscriptions
  192. The subscriptions action will show you subscriptions and offer to
  193. unsubscribe.
  194. =cut
  195. $Action{subscriptions} = \&DoMailSubscriptions;
  196. sub DoMailSubscriptions {
  197. my $mail = GetParam('mail', '');
  198. print GetHeader('', T('Subscriptions')),
  199. $q->start_div({-class=>'content subscriptions'}),
  200. GetFormStart(undef, 'get', 'mail');
  201. if (not $mail) {
  202. print $q->p($q->span($q->label({-for=>'mail'}, T('Email: '))
  203. . ' ' . $q->textfield(-name=>'mail', -id=>'mail'))),
  204. $q->input({-type=>'hidden',-name=>'action',-value=>'subscriptions'}),
  205. ' ', $q->submit(-name=>'Show', -value=>T('Show'));
  206. } else {
  207. my @subscriptions = MailSubscription($mail);
  208. if (@subscriptions) {
  209. print $q->p(Ts('Subscriptions for %s:', $mail),
  210. $q->input({-type=>'hidden',-name=>'action',-value=>'unsubscribe'}));
  211. print $q->p(join($q->br(),
  212. map { $q->input({-type=>'checkbox', -name=>'pages', -value=>"$_"})
  213. . GetPageLink($_) } @subscriptions));
  214. print $q->p($q->submit(-name=>'Unsubscribe', -value=>T('Unsubscribe')));
  215. } else {
  216. print $q->p(Ts('There are no subscriptions for %s.', $mail));
  217. }
  218. print $q->p(ScriptLink('action=subscriptions;mail=', T('Change email address'),
  219. 'change subscriptions'));
  220. }
  221. print $q->end_form(), $q->end_div();
  222. PrintFooter();
  223. }
  224. sub MailSubscription {
  225. my $mail = shift;
  226. return unless $mail;
  227. require DB_File;
  228. tie my %h, "DB_File", encode_utf8($MailFile);
  229. my @result = split(/$FS/, UrlDecode($h{UrlEncode($mail)}));
  230. untie %h;
  231. @result = sort @result;
  232. return @result;
  233. }
  234. =head1 Administrator Access
  235. The subscriptionlist action will show you the subscription database,
  236. if you're an administrator. It's a plain text file of the data, which
  237. you can use for debugging and scripting purposes.
  238. =cut
  239. $Action{subscriptionlist} = \&DoMailSubscriptionList;
  240. sub DoMailSubscriptionList {
  241. UserIsAdminOrError();
  242. my $raw = GetParam('raw', 0);
  243. if ($raw) {
  244. print GetHttpHeader('text/plain');
  245. } else {
  246. print GetHeader('', T('Subscriptions')),
  247. $q->start_div({-class=>'content subscribtionlist'}),
  248. $q->p(T('Mail addresses are linked to unsubscription links.')),
  249. '<ul>';
  250. }
  251. require DB_File;
  252. tie my %h, "DB_File", encode_utf8($MailFile);
  253. foreach my $encodedkey (sort keys %h) {
  254. my @values = sort split(/$FS/, UrlDecode($h{$encodedkey}));
  255. my $key = UrlDecode($encodedkey);
  256. if ($raw) {
  257. print join(' ', $key, @values) . "\n";
  258. } else {
  259. print $q->li(Ts('%s:', MailLink($key, @values)) . ' '
  260. . join(' ', map { MailLink($_, $key) } @values));
  261. }
  262. }
  263. print '</ul></div>' unless $raw;
  264. PrintFooter() unless $raw;
  265. untie %h;
  266. }
  267. sub MailLink {
  268. my ($str, @pages) = @_;
  269. # The @ is not a legal character for pagenames.
  270. return GetPageLink($str) if index($str, '@') == -1;
  271. return ScriptLink("action=unsubscribe;who=$str;"
  272. . join(';', map { "pages=$_" } @pages), $str);
  273. }
  274. =head1 Subscription
  275. The subscribe action will subscribe you to pages. The mail parameter
  276. contains the mail address to use and defaults to the value store in
  277. your cookie. Multiple pages parameters contain the pages to subscribe.
  278. =cut
  279. $Action{subscribe} = \&DoMailSubscribe;
  280. sub DoMailSubscribe {
  281. local $CGI::LIST_CONTEXT_WARN = 0;
  282. my @pages = $q->param('pages');
  283. return DoMailSubscriptions(@_) unless @pages;
  284. my $mail = GetParam('mail', '');
  285. if (not $mail) {
  286. print GetHeader('', T('Subscriptions')),
  287. $q->start_div({-class=>'content subscribe'}),
  288. GetFormStart(undef, 'get', 'subscribe');
  289. print $q->p(Ts('Subscribe to %s.',
  290. join(', ', map { GetPageLink($_) } @pages)));
  291. print $q->p($q->span($q->label({-for=>'mail'}, T('Email: '))
  292. . ' ' . $q->textfield(-name=>'mail', -id=>'mail')));
  293. print $q->hidden('pages', @pages);
  294. print $q->input({-type=>'hidden',-name=>'action',-value=>'subscribe'}),
  295. ' ', $q->submit(-name=>'Subscribe', -value=>T('Subscribe'));
  296. } else {
  297. my @real = ();
  298. foreach my $id (@pages) {
  299. push @real, $id if $IndexHash{$id};
  300. }
  301. # subscriptions have to be added in a lock
  302. RequestLockOrError();
  303. MailSubscribe($mail, @real);
  304. ReleaseLock();
  305. # MailSubscribe will set a parameter and must run before printing
  306. # the header.
  307. print GetHeader('', T('Subscriptions')),
  308. $q->start_div({-class=>'content subscribe'});
  309. print $q->p(Ts('Subscribed %s to the following pages:', $mail));
  310. print $q->ul($q->li([map { GetPageLink($_) } @real]));
  311. print $q->p(T('The remaining pages do not exist.')) if $#real < $#pages;
  312. print $q->p(ScriptLink('action=subscriptions', T('Your mail subscriptions'),
  313. 'subscriptions') . '.');
  314. }
  315. print $q->end_div();
  316. PrintFooter();
  317. }
  318. sub MailSubscribe {
  319. # is called within a lock! :)
  320. my ($mail, @pages) = @_;
  321. return unless $mail and @pages;
  322. # open the DB file
  323. require DB_File;
  324. tie my %h, "DB_File", encode_utf8($MailFile);
  325. # add to the mail entry
  326. my %subscriptions = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($mail)}));
  327. for my $id (@pages) {
  328. $subscriptions{$id} = 1;
  329. }
  330. $h{UrlEncode($mail)} = UrlEncode(join($FS, keys %subscriptions));
  331. # add to the page entries
  332. for my $id (@pages) {
  333. my %subscribers = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($id)}));
  334. $subscribers{$mail} = 1;
  335. $h{UrlEncode($id)} = UrlEncode(join($FS, keys %subscribers));
  336. }
  337. untie %h;
  338. # changes made will affect how pages look
  339. SetParam('sub', GetParam('sub', 0) + 1);
  340. }
  341. =head1 Unsubscription
  342. The unsubscribe action will unsubscribe you from pages. The mail
  343. parameter contains the mail address to use and defaults to the value
  344. store in your cookie. Multiple pages parameters contain the pages to
  345. unsubscribe.
  346. The who parameter overrides the mail parameter and is used for
  347. administrator unsubscription from the subscriptionlist action.
  348. =cut
  349. $Action{unsubscribe} = \&DoMailUnsubscribe;
  350. sub DoMailUnsubscribe {
  351. my $mail = GetParam('who', GetParam('mail', ''));
  352. local $CGI::LIST_CONTEXT_WARN = 0;
  353. my @pages = $q->param('pages');
  354. return DoMailSubscriptions(@_) unless $mail;
  355. my @real = ();
  356. foreach my $id (@pages) {
  357. push @real, $id if $IndexHash{$id};
  358. }
  359. MailUnsubscribe($mail, @real);
  360. # MailUnsubscribe will set a parameter and must run before printing
  361. # the header.
  362. print GetHeader('', T('Subscriptions')),
  363. $q->start_div({-class=>'content unsubscribe'});
  364. print $q->p(Ts('Unsubscribed %s from the following pages:', $mail));
  365. print $q->ul($q->li([map { GetPageLink($_) } @real]));
  366. print $q->p(T('The remaining pages do not exist.')) if $#real < $#pages;
  367. print $q->p(ScriptLink('action=subscriptions', T('Your mail subscriptions'),
  368. 'subscriptions') . '.');
  369. print $q->end_div();
  370. PrintFooter();
  371. }
  372. sub MailUnsubscribe {
  373. my ($mail, @pages) = @_;
  374. return unless $mail and @pages;
  375. require DB_File;
  376. tie my %h, "DB_File", encode_utf8($MailFile);
  377. my %subscriptions = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($mail)}));
  378. foreach my $id (@pages) {
  379. delete $subscriptions{$id};
  380. # take care of reverse lookup
  381. my %subscribers = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($id)}));
  382. delete $subscribers{$mail};
  383. if (%subscribers) {
  384. $h{UrlEncode($id)} = UrlEncode(join($FS, keys %subscribers));
  385. } else {
  386. delete $h{UrlEncode($id)};
  387. }
  388. }
  389. if (%subscriptions) {
  390. $h{UrlEncode($mail)} = UrlEncode(join($FS, keys %subscriptions));
  391. } else {
  392. delete $h{UrlEncode($mail)} unless %subscriptions;
  393. }
  394. untie %h;
  395. # changes made will affect how pages look
  396. SetParam('sub', GetParam('sub', 0) + 1);
  397. }
  398. =head1 Migrate
  399. The mailmigrate action will migrate your subscription list from the
  400. old format to the new format. This is necessary because these days
  401. because the keys and values of the DB_File are URL encoded.
  402. =cut
  403. $Action{'migrate-subscriptions'} = \&DoMailMigration;
  404. sub DoMailMigration {
  405. UserIsAdminOrError();
  406. print GetHeader('', T('Migrating Subscriptions')),
  407. $q->start_div({-class=>'content mailmigrate'});
  408. require DB_File;
  409. tie my %h, "DB_File", encode_utf8($MailFile);
  410. my $found = 0;
  411. foreach my $key (keys %h) {
  412. if (index($key, '@') != -1) {
  413. $found = 1;
  414. last;
  415. }
  416. }
  417. if (not $found) {
  418. print $q->p(T('No non-migrated email addresses found, migration not necessary.'));
  419. } else {
  420. my %n;
  421. foreach my $key (sort keys %h) {
  422. my $value = $h{$key};
  423. my @values = sort split(/$FS/, $value);
  424. $n{UrlEncode($key)} = join($FS, map { UrlEncode($_) } @values);
  425. }
  426. %h = %n;
  427. print $q->p(Ts('Migrated %s rows.', scalar(keys %n)));
  428. }
  429. print '</div>';
  430. untie %h;
  431. PrintFooter();
  432. }