# Copyright (C) 2009–2015 Alex Schroeder # Copyright (C) 2015 Aleks-Daniel Jakimenko # # This program is free software; you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation; either version 3 of the License, or (at your option) any later # version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along with # this program. If not, see . use strict; use v5.10; =head1 NAME tags - an Oddmuse module that implements email subscription to pages =head1 SYNOPSIS Visitors can add their email address and click a checkbox to subscribe to changes when they edit a page. The requirement to successfully edit a page acts as a defense mechanism against spammers and vandals. Email addresses are stored in a file. Each mail contains an unsubscribe link, and from there users can see (and unsubscribe from) all other pages they are subscribed to. The link contains a hash of the email address which prevents others from guessing what email addresses have subscriptions. There is also an admin interface that shows which email addresses are subscribed to which pages, allowing the easy removal of email addresses from the database. =head1 INSTALLATION Installing a module is easy: Create a modules subdirectory in your data directory, and put the Perl file in there. It will be loaded automatically. =cut AddModuleDescription('mail.pl', 'Mail Extension'); our ($q, %Action, %IndexHash, $FS, $DataDir, %CookieParameters, @MyInitVariables, @MyAdminCode, $Message, @MyFormChanges); our ($MailFile, $MailPattern); push (@MyInitVariables, sub { $MailFile = "$DataDir/mail.db"; }); # May contain neither space nor @; I'm too scared to put # Mail::RFC822::Address here. $MailPattern = '^[^ ]+@[^ ]+$'; =head1 Commenting When commenting, users are presented with a form where they can provide username and homepage. With this extension, users can also provide their mail address and choose to subscribe to comment pages. In order to get caching right, we also use an invisible cookie parameter to make sure that visitors will get a new page when they subscribe or unsubscribe. The alternative would have been to touch the index file at the end of the subscribe and unsubscribe function. =cut *MailOldInitCookie = \&InitCookie; *InitCookie = \&MailNewInitCookie; $CookieParameters{mail} = ''; $CookieParameters{sub} = ''; sub MailNewInitCookie { MailOldInitCookie(@_); my $mail = GetParam('mail', ''); $q->delete('mail'); if (!$mail) { # do nothing } elsif (!($mail =~ /$MailPattern/)) { $Message .= $q->p(Ts('Invalid Mail %s: not saved.', $mail)); } else { SetParam('mail', $mail); } } push(@MyFormChanges, \&MailFormAddition); sub MailFormAddition { my $html = shift; my $id = GetId(); my $mail = GetParam('mail', ''); my $addition; if (MailIsSubscribed($id, $mail)) { $addition = ' ' . ScriptLink("action=unsubscribe;pages=$id", T('unsubscribe'), 'unsubscribe'); } else { $addition = $q->input({-type=>'checkbox', -name=>'notify', -value=>'1'}) . ScriptLink("action=subscribe;pages=$id", T('subscribe'), 'subscribe'); } $addition = $q->span({-class=>'mail'}, $q->label({-for=>'mail'}, T('Email: ')) . ' ' . $q->textfield(-name=>'mail', -id=>'mail', -default=>GetParam('mail', '')) . $addition); $html =~ s!(name="homepage".*?)

!$1 $addition

!i; return $html; } sub MailIsSubscribed { # is not called within a lock my ($id, $mail) = @_; return 0 unless $mail; # open the DB file require DB_File; tie my %h, "DB_File", $MailFile; my %subscribers = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($id)})); untie %h; return $subscribers{$mail}; } *MailOldGetFooterTimestamp = \&GetFooterTimestamp; *GetFooterTimestamp = \&MailNewGetFooterTimestamp; sub MailNewGetFooterTimestamp { my $html = MailOldGetFooterTimestamp(@_); my $id = shift; my $mail = GetParam('mail', ''); my $addition; if (MailIsSubscribed($id, $mail)) { $addition = ScriptLink("action=unsubscribe;pages=$id", T('unsubscribe'), 'unsubscribe'); } else { $addition = ScriptLink("action=subscribe;pages=$id", T('subscribe'), 'subscribe'); } $html =~ s!(.*)(
)!$1 $addition$2!i; return $html; } =head1 Saving When saving a comment page users can subscribe using a checkbox. To do this via an URL you need to provide the parameters id, mail, aftertext (a new comment), and notify (1). =cut *MailOldSave = \&Save; *Save = \&MailNewSave; sub MailNewSave { # is called within a lock! :) MailOldSave(@_); my $id = shift; my $mail = GetParam('mail', ''); my $comment = GetParam('aftertext', ''); # Compare to GetId() in order to prevent subscription to LocalNames # page and other automatic saves. if ($id and $id eq GetId() and $comment and $mail and GetParam('notify', '')) { my $valid = 1; eval { local $SIG{__DIE__}; require Mail::RFC822::Address; $valid = Mail::RFC822::Address::valid($mail); SetParam('msg', Ts('%s appears to be an invalid mail address', $mail)) unless $valid; }; MailSubscribe($mail, $id) if $valid; } } *OldMailDeletePage = \&DeletePage; *DeletePage = \&NewMailDeletePage; =head1 Deleting When a page is deleted, the appropriate subscriptions have to be deleted as well. =cut sub NewMailDeletePage { my $id = shift; MailDeletePage($id); return OldMailDeletePage($id, @_); } sub MailDeletePage { my $id = shift; require DB_File; tie my %h, "DB_File", $MailFile; foreach my $mail (split(/$FS/, UrlDecode(delete $h{UrlEncode($id)}))) { my %subscriptions = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($mail)})); delete $subscriptions{$id}; if (%subscriptions) { $h{UrlEncode($mail)} = UrlEncode(join($FS, keys %subscriptions)); } else { delete $h{UrlEncode($mail)}; } } untie %h; } =head1 Administration menu The Administration page will have a list to your subscriptions, and if you are an administrator, it will also have a link to all subscriptions. =cut push(@MyAdminCode, \&MailMenu); sub MailMenu { my ($id, $menuref, $restref) = @_; push(@$menuref, ScriptLink('action=subscriptions', T('Your mail subscriptions'), 'subscriptions')); push(@$menuref, ScriptLink('action=subscriptionlist', T('All mail subscriptions'), 'subscriptionlist')) if UserIsAdmin(); } =head1 Your subscriptions The subscriptions action will show you subscriptions and offer to unsubscribe. =cut $Action{subscriptions} = \&DoMailSubscriptions; sub DoMailSubscriptions { my $mail = GetParam('mail', ''); print GetHeader('', T('Subscriptions')), $q->start_div({-class=>'content subscriptions'}), GetFormStart(undef, 'get', 'mail'); if (not $mail) { print $q->p($q->span($q->label({-for=>'mail'}, T('Email: ')) . ' ' . $q->textfield(-name=>'mail', -id=>'mail'))), $q->input({-type=>'hidden',-name=>'action',-value=>'subscriptions'}), ' ', $q->submit(-name=>'Show', -value=>T('Show')); } else { my @subscriptions = MailSubscription($mail); if (@subscriptions) { print $q->p(Ts('Subscriptions for %s:', $mail), $q->input({-type=>'hidden',-name=>'action',-value=>'unsubscribe'})); print $q->p(join($q->br(), map { $q->input({-type=>'checkbox', -name=>'pages', -value=>"$_"}) . GetPageLink($_) } @subscriptions)); print $q->p($q->submit(-name=>'Unsubscribe', -value=>T('Unsubscribe'))); } else { print $q->p(Ts('There are no subscriptions for %s.', $mail)); } print $q->p(ScriptLink('action=subscriptions;mail=', T('Change email address'), 'change subscriptions')); } print $q->end_form(), $q->end_div(); PrintFooter(); } sub MailSubscription { my $mail = shift; return unless $mail; require DB_File; tie my %h, "DB_File", $MailFile; my @result = split(/$FS/, UrlDecode($h{UrlEncode($mail)})); untie %h; @result = sort @result; return @result; } =head1 Administrator Access The subscriptionlist action will show you the subscription database, if you're an administrator. It's a plain text file of the data, which you can use for debugging and scripting purposes. =cut $Action{subscriptionlist} = \&DoMailSubscriptionList; sub DoMailSubscriptionList { UserIsAdminOrError(); my $raw = GetParam('raw', 0); if ($raw) { print GetHttpHeader('text/plain'); } else { print GetHeader('', T('Subscriptions')), $q->start_div({-class=>'content subscribtionlist'}), $q->p(T('Mail addresses are linked to unsubscription links.')), '
    '; } require DB_File; tie my %h, "DB_File", $MailFile; foreach my $encodedkey (sort keys %h) { my @values = sort split(/$FS/, UrlDecode($h{$encodedkey})); my $key = UrlDecode($encodedkey); if ($raw) { print join(' ', $key, @values) . "\n"; } else { print $q->li(Ts('%s: ', MailLink($key, @values)), join(' ', map { MailLink($_, $key) } @values)); } } print '
' unless $raw; PrintFooter() unless $raw; untie %h; } sub MailLink { my ($str, @pages) = @_; # The @ is not a legal character for pagenames. return GetPageLink($str) if index($str, '@') == -1; return ScriptLink("action=unsubscribe;who=$str;" . join(';', map { "pages=$_" } @pages), $str); } =head1 Subscription The subscribe action will subscribe you to pages. The mail parameter contains the mail address to use and defaults to the value store in your cookie. Multiple pages parameters contain the pages to subscribe. =cut $Action{subscribe} = \&DoMailSubscribe; sub DoMailSubscribe { local $CGI::LIST_CONTEXT_WARN = 0; my @pages = $q->param('pages'); return DoMailSubscriptions(@_) unless @pages; my $mail = GetParam('mail', ''); if (not $mail) { print GetHeader('', T('Subscriptions')), $q->start_div({-class=>'content subscribe'}), GetFormStart(undef, 'get', 'subscribe'); print $q->p(Ts('Subscribe to %s.', join(', ', map { GetPageLink($_) } @pages))); print $q->p($q->span($q->label({-for=>'mail'}, T('Email: ')) . ' ' . $q->textfield(-name=>'mail', -id=>'mail'))); print $q->hidden('pages', @pages); print $q->input({-type=>'hidden',-name=>'action',-value=>'subscribe'}), ' ', $q->submit(-name=>'Subscribe', -value=>T('Subscribe')); } else { my @real = (); foreach my $id (@pages) { push @real, $id if $IndexHash{$id}; } # subscriptions have to be added in a lock RequestLockOrError(); MailSubscribe($mail, @real); ReleaseLock(); # MailSubscribe will set a parameter and must run before printing # the header. print GetHeader('', T('Subscriptions')), $q->start_div({-class=>'content subscribe'}); print $q->p(Ts('Subscribed %s to the following pages:', $mail)); print $q->ul($q->li([map { GetPageLink($_) } @real])); print $q->p(T('The remaining pages do not exist.')) if $#real < $#pages; print $q->p(ScriptLink('action=subscriptions', T('Your mail subscriptions'), 'subscriptions') . '.'); } print $q->end_div(); PrintFooter(); } sub MailSubscribe { # is called within a lock! :) my ($mail, @pages) = @_; return unless $mail and @pages; # open the DB file require DB_File; tie my %h, "DB_File", $MailFile; # add to the mail entry my %subscriptions = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($mail)})); for my $id (@pages) { $subscriptions{$id} = 1; } $h{UrlEncode($mail)} = UrlEncode(join($FS, keys %subscriptions)); # add to the page entries for my $id (@pages) { my %subscribers = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($id)})); $subscribers{$mail} = 1; $h{UrlEncode($id)} = UrlEncode(join($FS, keys %subscribers)); } untie %h; # changes made will affect how pages look SetParam('sub', GetParam('sub', 0) + 1); } =head1 Unsubscription The unsubscribe action will unsubscribe you from pages. The mail parameter contains the mail address to use and defaults to the value store in your cookie. Multiple pages parameters contain the pages to unsubscribe. The who parameter overrides the mail parameter and is used for administrator unsubscription from the subscriptionlist action. =cut $Action{unsubscribe} = \&DoMailUnsubscribe; sub DoMailUnsubscribe { my $mail = GetParam('who', GetParam('mail', '')); local $CGI::LIST_CONTEXT_WARN = 0; my @pages = $q->param('pages'); return DoMailSubscriptions(@_) unless $mail; my @real = (); foreach my $id (@pages) { push @real, $id if $IndexHash{$id}; } MailUnsubscribe($mail, @real); # MailUnsubscribe will set a parameter and must run before printing # the header. print GetHeader('', T('Subscriptions')), $q->start_div({-class=>'content unsubscribe'}); print $q->p(Ts('Unsubscribed %s from the following pages:', $mail)); print $q->ul($q->li([map { GetPageLink($_) } @real])); print $q->p(T('The remaining pages do not exist.')) if $#real < $#pages; print $q->p(ScriptLink('action=subscriptions', T('Your mail subscriptions'), 'subscriptions') . '.'); print $q->end_div(); PrintFooter(); } sub MailUnsubscribe { my ($mail, @pages) = @_; return unless $mail and @pages; require DB_File; tie my %h, "DB_File", $MailFile; my %subscriptions = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($mail)})); foreach my $id (@pages) { delete $subscriptions{$id}; # take care of reverse lookup my %subscribers = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($id)})); delete $subscribers{$mail}; if (%subscribers) { $h{UrlEncode($id)} = UrlEncode(join($FS, keys %subscribers)); } else { delete $h{UrlEncode($id)}; } } if (%subscriptions) { $h{UrlEncode($mail)} = UrlEncode(join($FS, keys %subscriptions)); } else { delete $h{UrlEncode($mail)} unless %subscriptions; } untie %h; # changes made will affect how pages look SetParam('sub', GetParam('sub', 0) + 1); } =head1 Migrate The mailmigrate action will migrate your subscription list from the old format to the new format. This is necessary because these days because the keys and values of the DB_File are URL encoded. =cut $Action{'migrate-subscriptions'} = \&DoMailMigration; sub DoMailMigration { UserIsAdminOrError(); print GetHeader('', T('Migrating Subscriptions')), $q->start_div({-class=>'content mailmigrate'}); require DB_File; tie my %h, "DB_File", $MailFile; my $found = 0; foreach my $key (keys %h) { if (index($key, '@') != -1) { $found = 1; last; } } if (not $found) { print $q->p(T('No non-migrated email addresses found, migration not necessary.')); } else { my %n; foreach my $key (sort keys %h) { my $value = $h{$key}; my @values = sort split(/$FS/, $value); $n{UrlEncode($key)} = join($FS, map { UrlEncode($_) } @values); } %h = %n; print $q->p(Ts('Migrated %s rows.', scalar(keys %n))); } print ''; untie %h; PrintFooter(); }