123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547 |
- # Copyright (C) 2009–2022 Alex Schroeder <alex@gnu.org>
- # Copyright (C) 2015 Aleks-Daniel Jakimenko <alex.jakimenko@gmail.com>
- #
- # 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 <http://www.gnu.org/licenses/>.
- 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".*?)</p>!$1 $addition</p>!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", encode_utf8($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!(.*)(<br /></span>)!$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", encode_utf8($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();
- push(@$menuref,
- ScriptLink('action=subscribers',
- T('All mail subscribers'),
- 'subscribers')) 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", encode_utf8($MailFile);
- my @result = split(/$FS/, UrlDecode($h{UrlEncode($mail)}));
- untie %h;
- @result = sort @result;
- return @result;
- }
- =head1 Administrator Access
- The C<subscriptionlist> action will show you the subscription database, if
- you're an administrator. With the C<raw> parameter set 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.')),
- '<ul>';
- }
- require DB_File;
- tie my %h, "DB_File", encode_utf8($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 '</ul></div>' 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);
- }
- =pod
- The C<subscribers> action lists each unique email address for easier mass
- unsubscribing of email addresses after a wave of wiki spam.
- =cut
- $Action{subscribers} = \&DoMailSubscribers;
- sub DoMailSubscribers {
- 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.')),
- '<ul>';
- }
- my %authors;
- require DB_File;
- tie my %h, "DB_File", encode_utf8($MailFile);
- for my $author (sort grep /\@/, map { UrlDecode($_) } keys %h) {
- if ($raw) {
- print "$author\n";
- } else {
- print $q->li(ScriptLink("action=unsubscribe;who=$author", $author));
- }
- }
- print '</ul></div>' unless $raw;
- PrintFooter() unless $raw;
- untie %h;
- }
- =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", encode_utf8($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. Without naming
- pages, you will be unsubscribed from all pages.
- 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', ''));
- return DoMailSubscriptions(@_) unless $mail;
- local $CGI::LIST_CONTEXT_WARN = 0;
- my @pages = $q->param('pages');
- MailUnsubscribe($mail, @pages);
- # MailUnsubscribe will set a parameter and must run before printing
- # the header.
- print GetHeader('', T('Subscriptions')),
- $q->start_div({-class=>'content unsubscribe'});
- if (@pages) {
- print $q->p(Ts('Unsubscribed %s from the following pages:', $mail));
- print $q->ul($q->li([map { GetPageLink($_) } @pages]));
- } else {
- print $q->p(Ts('Unsubscribed %s from all pages.', $mail));
- }
- print $q->p(ScriptLink('action=subscriptions', T('Your mail subscriptions'),
- 'subscriptions') . '.');
- print $q->end_div();
- PrintFooter();
- }
- sub MailUnsubscribe {
- my ($mail, @pages) = @_;
- return unless $mail;
- require DB_File;
- tie my %h, "DB_File", encode_utf8($MailFile);
- my %subscriptions = map {$_=>1} split(/$FS/, UrlDecode($h{UrlEncode($mail)}));
- @pages = keys %subscriptions unless @pages;
- 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 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", encode_utf8($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 '</div>';
- untie %h;
- PrintFooter();
- }
|