diff options
Diffstat (limited to 'lib/Fripost/Schema')
-rw-r--r-- | lib/Fripost/Schema/Auth.pm | 24 | ||||
-rw-r--r-- | lib/Fripost/Schema/Domain.pm | 797 | ||||
-rw-r--r-- | lib/Fripost/Schema/Mail.pm | 58 | ||||
-rw-r--r-- | lib/Fripost/Schema/Util.pm | 64 |
4 files changed, 695 insertions, 248 deletions
diff --git a/lib/Fripost/Schema/Auth.pm b/lib/Fripost/Schema/Auth.pm index 2df1a7e..c6325b8 100644 --- a/lib/Fripost/Schema/Auth.pm +++ b/lib/Fripost/Schema/Auth.pm @@ -68,7 +68,7 @@ the virtual entries. =back -Errors can be caught with options B<-die> and B<-errors>, see +Errors can be caught with options B<-die> and B<-error>, see B<Fripost::Schema::Util> for details. =cut @@ -82,7 +82,7 @@ sub SASLauth { my $self = bless {}, $class; $self->suffix( ldap_explode_dn(@{$options{ldap_suffix}}) ); - $self->whoami( $self->mkdn($user) ); + $self->whoami( $self->mail2dn($user) ); $self->ldap( Net::LDAP::->new( $options{ldap_uri} // 'ldap://127.0.0.1:389/' , async => 0 ) ); assert( $self->ldap, -die => "Couldn't connect to the LDAP server." ); @@ -109,7 +109,7 @@ sub SASLauth { , callback => $callback ); my $host = $options{ldap_SASL_service_instance} // 'localhost'; my $conn = $sasl->client_new( 'ldap', $host ); - ldap_error ($conn, %options); + ldap_error ($conn, %options) // return; my $mesg = $self->ldap->bind( undef, sasl => $conn ); ldap_error ($mesg, %options) // return; @@ -145,7 +145,7 @@ the virtual entries. =back -Errors can be caught with options B<-die> and B<-errors>, see +Errors can be caught with options B<-die> and B<-error>, see B<Fripost::Schema::Util> for details. =cut @@ -164,7 +164,7 @@ sub auth { } else { return unless defined $user; - $self->whoami( $self->mkdn($user) ); + $self->whoami( $self->mail2dn($user) ); } $self->ldap( Net::LDAP::->new( $options{ldap_uri} // 'ldap://127.0.0.1:389/' @@ -189,14 +189,14 @@ attribute of the DN associated with I<username>. If I<newpassword> is left undefined, the new password is generated at random, and returned upon success. -Errors can be caught with options B<-die> and B<-errors>, see +Errors can be caught with options B<-die> and B<-error>, see B<Fripost::Schema::Util> for details. =cut sub passwd { my $self = shift; - my $user = $self->mkdn(shift) // $self->whoami; + my $user = $self->mail2dn(shift) // $self->whoami; my $oldpw = shift; my $newpw = shift; my %options = @_; @@ -243,7 +243,7 @@ of hashes), defined in B<Net::LDAP::Util>. sub suffix { shift->_set_or_get('_suffix',@_); } -=item B<mkdn> ({I<username>|I<domainname>}) +=item B<mail2dn> ({I<username>|I<domainname>}) Create the Distinguished Name associated with the I<username> (may be an alias or a list name regardless) or I<domainname>. The argument is first @@ -251,12 +251,16 @@ converted to ASCII. =cut -sub mkdn { +sub mail2dn { my $self = shift; my $user = shift // return; + + $user =~ s/^([^\@]+)$/\@$1/; my ($l,$d) = split_addr($user, -encode => 'ascii'); + my @dn = ({fvd => $d}, @{$self->suffix}); - unshift @dn, {fvl => $l} if defined $l and $l ne ''; + unshift @dn, {fvl => $l} if $l; + canonical_dn( @dn ); } diff --git a/lib/Fripost/Schema/Domain.pm b/lib/Fripost/Schema/Domain.pm index 8c3586f..75a525d 100644 --- a/lib/Fripost/Schema/Domain.pm +++ b/lib/Fripost/Schema/Domain.pm @@ -2,11 +2,11 @@ package Fripost::Schema::Domain; =head1 NAME -Domain.pm - +Domain.pm - Domain related methods in the Fripost Schema =head1 DESCRIPTION -Domain.pm abstracts the LDAP schema definition and provides methods to +This module abstracts the LDAP schema definition and provides methods to add, list or delete virtual domains. =cut @@ -17,188 +17,403 @@ use warnings; use utf8; use parent 'Fripost::Schema'; -use Net::LDAP qw/LDAP_SUCCESS LDAP_COMPARE_TRUE LDAP_NO_SUCH_OBJECT/; -use Fripost::Schema::Util qw/concat get_perms explode must_attrs - email_valid canonical_dn/; -use Net::IDN::Encode qw/domain_to_ascii domain_to_unicode - email_to_ascii email_to_unicode/; +use Fripost::Schema::Util qw/must_attrs softdie canonical_dn dn2mail email_valid + ldap_explode_dn ldap_error ldap_and_filter/; +use Fripost::Schema::Mail; +use Net::IDN::Encode qw/domain_to_ascii domain_to_unicode email_to_unicode/; +use URI::Escape::XS 'encodeURIComponent'; use Encode; use Net::Domain::TLD 'tld_exists'; use Net::DNS::Dig; use Net::Whois::Parser 'parse_whois'; use String::Random; use Template; -use MIME::Entity; + +=head1 REPRESENTATION + +Domains are imported and exported as hash references, having the +following keys: + +=over 4 + +=item B<name> + +A UTF-8 string representing the (internationalized) domain name. + +=item B<isActive> => 0|1 + +Whether or not the domain is active. + +=item B<isPending> => 0|1 + +Whether or not the domain is pending. This is key is ignored when adding +a new domain, unless the user is asking for a confirmation token. + +=item B<canAddAlias> + +An optional array reference containing the (internationalized) e-mails +addresses of users that have permission to create aliases (other than +domain owner or postmaster) under this domain. The local part may be +left empty to grant permissions to a whole domain. + +=item B<canAddList> + +An optional array reference containing the (internationalized) e-mails +addresses of users that have permission to create mailing lists (other +than domain owner or postmaster) under this domain. The local part may +be left empty to grant permissions to a whole domain. + +=item B<owner> + +An optional array reference containing the (internationalized) e-mails +addresses of the domain owners (other than postmasters). + +=item B<postmaster> + +An optional array reference containing the (internationalized) e-mails +addresses of the domain postmasters. + +=item B<catchAlls> + +An optional array reference containing the (internationalized) +catch-alls for that domain. Localparts may be left empty for domain +aliases. + +=item B<description> + +An array reference containing UTF-8 string representing that domain. + +=item B<permissions> + +An optional string representing the permission of the current user +against this domain. (Note that this key is ignored when inserting a new +domain, since the information is redundent with B<canAddAlias>, +B<canAddList>, B<owner> and B<postmaster>.) The string is a sequence of +characters which meaning is: + +=over 4 + +=item B<a> + +The current user can add aliases under that domain (but is not listed +among domain owners or postmasters). + +=item B<l> + +The current user can add mailing lists under that domain (but is not +listed among domain owners or postmasters). + +=item B<o> + +The current user owns that domain. + +=item B<p> + +The current user has postmaster rights on that domain. + +=back + +=back + +Note that when retrieving a domain from the database, only a subset of +these keys may be visible, hence exported. =head1 METHODS =over 4 -=item B<search> (I<OPTIONS>) +=item B<search> (I<domainname>, I<OPTIONS>) + +Search for I<domainname>, or list all the known (and visible) domains +when I<domainname> is not defined. In list context, return a list of +domains represented as hash references, as explained above. In scalar +context, only the first domain found is returned. In void context, no +attributes are returned from the LDAP server, but it may nevertheless be +useful, to ensure that the result set is not empty for instance. + +The following options are considered: + +=over 4 + +=item B<-no-escape> => 0|1 + +By default, I<domainname> - when defined - is safely escaped before +insertion into the LDAP filter. This flag disables escaping. It is +useful if I<domainname> contains wildcards for instance. + +=item B<-filter> => locked|unlocked + +Limit the search scope to pending / non-pending domains only. + +=item B<-keys> + +An array reference containing the attributes that are to be retrived +from the LDAP server. Note that Access Control List may prevent the +current user to read or even search anything, though. The default is to +retrieve every visible attribute, unless in void context where B<-keys> +is set to [] that is, no attribute is sent back to the client. + +=item B<-assert-exists> + +A custom error to be raised on empty result sets. When 0, it makes the +method returns the size of the result set. + +=item B<-sort> => 0|1 + +In list context, sort the results per domain name. + +=back -List every known (and visible) domain. The output is a array of hash -references, sorted by domain names. +Errors can be caught with options B<-die> and B<-error>, see +B<Fripost::Schema::Util> for details. =cut sub search { my $self = shift; + my $domainname = shift; my %options = @_; - my $concat = $options{'-concat'}; - my $filter = '(objectClass=FripostVirtualDomain)'; + # Nothing to do after an error. + return if $options{'-error'} && ${$options{'-error'}}; + + my @filters = ('objectClass=FripostVirtualDomain'); + + if ($domainname) { + $domainname = Net::LDAP::Util::escape_filter_value($domainname) + unless $options{'-no-escape'}; + push @filters, 'fvd='.$domainname; + } if (defined $options{'-filter'}) { - if ($options{'-filter'} eq 'locked') { - # Will only work if we're the owner, but otherwise we - # shouldn't see the domain anyway. - $filter = '(&'.$filter.'(fripostPendingToken=*))'; + push @filters, 'objectClass=FripostPendingEntry' + if $options{'-filter'} eq 'locked'; + push @filters, '!(objectClass=FripostPendingEntry)' + if $options{'-filter'} eq 'unlocked'; + } + + my $attrs = []; + if (not (defined wantarray)) { + # In void context, we are only interested in whether or not the + # result set is empty. + $attrs = [ '1.1' ]; + } + elsif (defined $options{'-keys'}) { + $attrs = @{$options{'-keys'}} ? [ &_keys_to_attrs(@{$options{'-keys'}}) ] + : [ '1.1' ]; + } + + my $domains = $self->ldap->search( base => canonical_dn(@{$self->suffix}) + , scope => 'one' + , deref => 'never' + , filter => ldap_and_filter(@filters) + , attrs => $attrs + ); + ldap_error($domains, %options) // return; + + softdie ($options{'-assert-exists'}, %options) // return + unless $domains->count; + return $domains->count if exists $options{'-assert-exists'}; + + &_entries_to_domains( $self->whoami, $options{'-keys'} // [], + wantarray ? ( $options{'-sort'} ? $domains->sorted('fvd') + : $domains->entries ) + : $domains->pop_entry + ); +} + +# Map a list of LDAP::Entry object into our public representation of +# domains. +sub _entries_to_domains { + my $user = lc shift; + my @dn = @{ldap_explode_dn $user}; + shift @dn; + my $parent = lc (canonical_dn @dn); + my $keys = shift; + + my @domains; + foreach my $entry (@_) { + + # Ignore bogus entries. + return unless defined $entry; + my %domain; + + foreach my $attr ($entry->attributes) { + my $val = $entry->get_value($attr, asref => 1); + if ($attr eq 'fvd') { + $domain{name} = domain_to_unicode($val->[0]) + if not @$keys or grep { $_ eq 'name' } @$keys; + } + elsif ($attr eq 'fripostIsStatusActive') { + $domain{isActive} = $val->[0] eq 'TRUE' + if not @$keys or grep { $_ eq 'isActive' } @$keys; + } + elsif ($attr eq 'objectClass') { + $domain{isPending} = scalar (grep { lc $_ eq lc 'FripostPendingEntry' } + @$val ) + if not @$keys or grep { $_ eq 'isPending' } @$keys; + } + elsif ($attr eq 'fripostCanAddAlias') { + $domain{canAddAlias} = [ map { dn2mail($_) } @$val ] + if not @$keys or grep { $_ eq 'canAddAlias' } @$keys; + } + elsif ($attr eq 'fripostCanAddList') { + $domain{canAddList} = [ map { dn2mail($_) } @$val ] + if not @$keys or grep { $_ eq 'canAddList' } @$keys; + } + elsif ($attr eq 'fripostOwner') { + $domain{owner} = [ map { dn2mail($_) } @$val ] + if not @$keys or grep { $_ eq 'owner' } @$keys; + } + elsif ($attr eq 'fripostPostmaster') { + $domain{postmaster} = [ map { dn2mail($_) } @$val ] + if not @$keys or grep { $_ eq 'postmaster' } @$keys; + } + elsif ($attr eq 'fripostOptionalMaildrop') { + $domain{catchAlls} = [ map { &_email_to_unicode($_) } @$val ] + if not @$keys or grep { $_ eq 'catchAlls' } @$keys; + } + elsif ($attr eq 'description') { + $domain{description} = [ map { Encode::_utf8_on($_); $_ } @$val ] + if not @$keys or grep { $_ eq 'description' } @$keys; + } + else { + die "Missing translation for domain attribute ‘".$attr."’."; + } } - elsif ($options{'-filter'} eq 'nonlocked') { - $filter = '(&'.$filter.'(!(fripostPendingToken=*)))'; + + # Add a 'permissions' key if wanted. + if ((not @$keys or grep { $_ eq 'permissions' } @$keys) and + grep { $entry->exists($_) } qw/fripostCanAddAlias fripostCanAddList + fripostOwner fripostPostmaster/) { + my $perms = ''; + $perms .= 'a' if $entry->exists('fripostCanAddAlias') and + grep { $user eq lc $_ or $parent eq lc $_ } + $entry->get_value('fripostCanAddAlias'); + $perms .= 'l' if $entry->exists('fripostCanAddList') and + grep { $user eq lc $_ or $parent eq lc $_ } + $entry->get_value('fripostCanAddList'); + $perms = 'o' if $entry->exists('fripostOwner') and + grep { $user eq lc $_ } + $entry->get_value('fripostOwner'); + $perms = 'p' if $entry->exists('fripostPostmaster') and + grep { $user eq lc $_ } + $entry->get_value('fripostPostmaster'); + $domain{permissions} = $perms; + } + + # Stop after the first processed domain in scalar mode. + return \%domain unless wantarray; + push @domains, \%domain; + } + return @domains; +} + + +# Map our domain keys into the LDAP attribute(s) that are required to +# fetch this information. +sub _keys_to_attrs { + my %map = ( name => 'fvd' + , isActive => 'fripostIsStatusActive' + , isPending => 'objectClass' + , canAddAlias => 'fripostCanAddAlias' + , canAddList => 'fripostCanAddList' + , owner => 'fripostOwner' + , postmaster => 'fripostPostmaster' + , catchAlls => 'fripostOptionalMaildrop' + , description => 'description' + , permissions => [ qw/fripostCanAddAlias fripostCanAddList + fripostOwner fripostPostmaster/ ] + ); + my %attrs; + foreach my $k (@_) { + die "Missing translation for key ‘".$k."’." + unless exists $map{$k}; + if (ref $map{$k} eq 'ARRAY') { + $attrs{$_} = 1 for @{$map{$k}}; } else { - die "Unknown filter: ".$options{'-filter'}."\n"; + $attrs{$map{$k}} = 1; } } - else { - my @domains0 = map {{ %$_, ispending => 1 }} - $self->search(%options, -filter => 'locked'); - my @domains1 = map {{ %$_, ispending => 0 }} - $self->search(%options, -filter => 'nonlocked'); - return sort {$a->{domain} cmp $b->{domain}} (@domains0, @domains1); - } + return keys %attrs; +} - my $domains = $self->ldap->search( - base => canonical_dn(@{$self->suffix}), - scope => 'one', - deref => 'never', - filter => $filter, - attrs => [ qw/fvd description fripostIsStatusActive/ ] - ); - if ($domains->code) { - die $options{'-die'}."\n" if defined $options{'-die'}; - die $domains->error."\n"; - } - return map { { domain => domain_to_unicode($_->get_value('fvd')) - , isactive => $_->get_value('fripostIsStatusActive') eq 'TRUE' - , description => concat($concat, $_->get_value('description')) - } - } - $domains->sorted('fvd') -} +=item B<canIAdd> (I<username>, I<OPTIONS>) -=item B<get> (I<domain>, I<OPTIONS>) +Returns 0 or 1, depending on whether or not I<username> has permission +to add new domains. If I<username> is undefined, the current user is +considered instead. -Returns a hash with all the (visible) attributes for the given -*non-pending* domain. +Errors can be caught with options B<-die> and B<-error>, see +B<Fripost::Schema::Util> for details. =cut -sub get { +sub canIAdd { my $self = shift; - my $d = domain_to_ascii(shift); + my @dn = @{ldap_explode_dn ($self->mail2dn(shift) // $self->whoami)}; + my $user = lc (canonical_dn @dn); + shift @dn; + my $parent = lc (canonical_dn @dn); my %options = @_; - my $concat = $options{'-concat'}; - - my $attrs = $options{'-attrs'} // - [ qw/fvd description - fripostIsStatusActive - fripostOptionalMaildrop - fripostCanAddAlias - fripostCanAddList - fripostOwner - fripostPostmaster/ ]; - - $attrs = [ '1.1' ] if $options{'-assert_exist'}; - - my $domains = $self->ldap->search( - base => canonical_dn({fvd => $d}, @{$self->suffix}), - scope => 'base', - deref => 'never', - filter => '(&(objectClass=FripostVirtualDomain) - (!(fripostPendingToken=*)))', - attrs => $attrs - ); - if ($domains->code) { - die $options{'-die'}."\n" if defined $options{'-die'}; - die $domains->error."\n"; - } - # The following is not supposed to happen. - die "Error: Multiple matching entries found." if $domains->count > 1; - my $domain = $domains->pop_entry; - unless (defined $domain) { - die $options{'-die'}."\n" if defined $options{'-die'}; - die "No such such domain: ‘$d’\n"; - } - return if $options{'-assert_exist'}; - - return ( domain => domain_to_unicode($domain->get_value('fvd')) - , isactive => $domain->get_value('fripostIsStatusActive') eq 'TRUE' - , description => concat($concat, $domain->get_value('description')) - , catchalls => concat($concat, map { &_email_to_unicode ($_) } - $domain->get_value('fripostOptionalMaildrop')) - , permissions => get_perms($domain, $self->whoami) - , canAddAlias => concat($concat, map { &_email_to_unicode ($self->_dn2fvu($_)) } - $domain->get_value('fripostCanAddAlias')) - , canAddList => concat($concat, map { &_email_to_unicode ($self->_dn2fvu($_)) } - $domain->get_value('fripostCanAddList')) - ) + # Nothing to do after an error. + return if $options{'-error'} && ${$options{'-error'}}; + + my $mesg = $self->ldap->search( base => canonical_dn(@{$self->suffix}) + , scope => 'base' + , deref => 'never' + , filter => 'objectClass=FripostVirtual' + , attrs => [ 'fripostCanAddDomain' ] + ); + ldap_error($mesg, %options) // return; + + # If these are raised, something is seriously wrong. + die "Empty virtual directory?" unless $mesg->count; + die "Multiple virtual directories?" unless $mesg->count == 1; + + my $base = $mesg->pop_entry // die "Empty virtual directory?"; + scalar (grep { lc $_ eq $user or lc $_ eq $parent } + $base->get_value('fripostCanAddDomain')); } -=item B<replace> (I<domain>, I<OPTIONS>) -Replace an existing domain with the given one. +=item B<list_admin_emails> (I<domainname>, I<OPTIONS>) + +Search for postmaster e-mail addresses for I<domainname>. For +I<domainname> itself and each of its parents, this routine searches for +a valid WHOIS containing e-mails, and lists postmaster@hostname (RFC +822, appendix C.6) if hostname has a MX record and does not use ours +yet. + +Errors can be caught with options B<-die> and B<-error>, see +B<Fripost::Schema::Util> for details. =cut -sub replace { +sub list_admin_emails { my $self = shift; - my $d = shift; + my $domainname = shift; my %options = @_; - foreach (qw/description catchalls canAddAlias canAddList/) { - $d->{$_} = explode ($options{'-concat'}, $d->{$_}) - if defined $d->{$_}; - } + # Nothing to do after an error. + return if $options{'-error'} && ${$options{'-error'}}; - eval { - &_is_valid($d); - my $dn = canonical_dn( {fvd => $d->{domain}}, @{$self->suffix} ); - my $mesg = $self->ldap->modify( $dn, - replace => { fripostIsStatusActive => $d->{isactive} ? - 'TRUE' : 'FALSE' - , description => $d->{description} - , fripostOptionalMaildrop => $d->{catchalls} - , fripostCanAddAlias => - [ map $self->_fvu2dn($_), - @{$d->{canAddAlias}} ] - , fripostCanAddList => - [ map $self->_fvu2dn($_), - @{$d->{canAddList}} ] - } ); - die $mesg->error."\n" if $mesg->code; - }; - return $@; -} - - -sub list_postmasters { - my $self = shift; - my $hostname = shift; my @postmasters; - - my $tld = domain_to_ascii($hostname); + my $tld = domain_to_ascii($domainname); my $domain; + until ( tld_exists($tld) ) { - die "‘".$hostname."’ has an Invalid TLD.\n" unless $tld =~ /\./; + softdie ("‘".$domainname."’ has an Invalid TLD.", %options) // return + unless $tld =~ /\./; $domain = $tld; + # Look for a valid MX record that is not ours. my %mx = Net::DNS::Dig->new()->for( $domain, 'MX' )->rdata; push @postmasters, 'postmaster@'.$domain # RFC 822, appendix C.6 if grep {!/\bfripost\.org$/} (values %mx); @@ -206,153 +421,278 @@ sub list_postmasters { $tld =~ s/^[^\.]*\.//; } + # Look for e-mail addresses in the WHOIS. my $info = parse_whois( domain => $domain ); - die "Cannot WHOIS ‘".$domain."’.\n" unless defined $info; if (defined $info) { push @postmasters, @{$info->{emails}}; } + else { + softdie ("Cannot WHOIS ‘".$domain."’", %options); + return; + } + # Apply a unique sort on the list. my %hash; - $hash{$_} = 1 for grep {email_valid($_ // '', -nodie => 1 )} - @postmasters; + $hash{$_} = 1 for grep {email_valid($_ // '', -nodie => 1 )} @postmasters; sort keys %hash; } + + +=item B<add> (I<domain>, I<OPTIONS>) + +Add the domain I<domain>, represented by a hash reference as explained +above. If no owner is specified in I<domain>, the current user is +automatically promoted owner instead. + +The following options are considered: + +=over 4 + +=item B<-append> => 0|1 + +When I<domain>'s is already present, the default is to raise an error. +This flag appends the attributes in the new I<domain> to the existing +one (or replace the old values in case of single-valued attributes). + +=item B<-dry-run> => 0|1 + +Merely simulate the insertion. I<domain> is still checked to be valid +and, unless B<-append> is set, its name is ensured not to be present in +the directory. + +=item B<-send-confirmation-token> => I<email> + +When set, this option locks down the domain before inserting it, and +send a message to I<email> with the unlocking token. + +=item B<webapp_url> + +The URL to send, together with the token, to provide instructions how to +unlock the domain. + +=item B<tmpl_path> + +Where to find the e-mail template with the instructions how to unlock +the domain. + +=back + +Errors can be caught with options B<-die> and B<-error>, see +B<Fripost::Schema::Util> for details. + +=cut + sub add { my $self = shift; - my $d = shift; + my $domain = shift; my %options = @_; - foreach (qw/description catchalls canAddAlias canAddList/) { - $d->{$_} = explode ($options{'-concat'}, $d->{$_}) - if defined $d->{$_}; + # Nothing to do after an error. + return if $options{'-error'} && ${$options{'-error'}}; + + my $domainname = $domain->{name}; + # Check domain validity. + &_is_valid($domain, %options) // return; + my $dn = $self->mail2dn( $domain->{name} ); + + # Search for an existing domain with the same name. + my $exists = $self->search($domain->{name}, %options, '-assert-exists' => 0); + softdie ( "Domain ‘".$domainname."’ already exists.", %options ) // return + if not $options{'-append'} and $exists; + # Stop here in dry-run mode. + return 1 if $options{'-dry-run'}; + + # Convert the domain into a LDAP entry, and remove keys to empty values. + my %attrs = $self->_domain_to_entry (%$domain); + Fripost::Schema::Util::clean_ldap_entry( \%attrs ); + + my ($mesg, $token); + if ($options{'-append'} and $exists) { + # Replace single valued attributes; Add other attributes. + my %unique = ( fripostIsStatusActive => $attrs{fripostIsStatusActive} ); + delete $attrs{$_} for (keys %unique); + $mesg = $self->ldap->modify( $dn, replace => \%unique, add => \%attrs ); } - - eval { - my $domain = $d->{domain}; - Encode::_utf8_on($domain); - &_is_valid($d); - - my $dn = canonical_dn( {fvd => $d->{domain}}, @{$self->suffix} ); - - my $mesg = $self->ldap->search( - base => $dn, - scope => 'base', - deref => 'never', - filter => 'objectClass=FripostVirtualDomain', - attrs => [ '1.1' ] ); - if ($mesg->code == LDAP_SUCCESS) { - die "Domain ‘".$domain."’ already exists.\n"; + else { + if ($options{'-send-confirmation-token'}) { + # Add the pending class, and generate a random token. + $attrs{objectClass} = [ qw/FripostVirtualDomain FripostPendingEntry/ ]; + $token = String::Random::->new->randregex('\w{32}'); + $attrs{fripostPendingToken} = $token; } - elsif ($mesg->code != LDAP_NO_SUCH_OBJECT) { - die $mesg->error."\n"; + else { + $attrs{objectClass} = 'FripostVirtualDomain'; } + # The default owner is the current user. + $attrs{fripostOwner} //= [ $self->whoami ]; + $mesg = $self->ldap->add( $dn, attrs => [ %attrs ] ); + } - return if $options{'-dry-run'}; + ldap_error($mesg, %options) // return; + return 1 unless $options{'-send-confirmation-token'}; + + # Send token + email_valid ($options{'-send-confirmation-token'}); + my $tt = Template->new({ INCLUDE_PATH => $options{tmpl_path} + , INTERPOLATE => 1 }) + or die $Template::ERROR; + my $vars = { domain => $domainname, token => $token }; + $vars->{unlockurl} = $options{webapp_url} + .encodeURIComponent($domainname) + .'/?unlock='.$token + if defined $options{webapp_url}; + + my $data; + $tt->process( 'new-domain.tt', $vars, \$data) or die $tt->error; + Fripost::Schema::Mail::->new( From => $options{email_from} // + $ENV{USER}.'@localhost' + , To => $options{'-send-confirmation-token'} + , Subject => "Your new domain ".$domain->{name} + , Data => $data + )->send; + + 1; +} - my %attrs = ( objectClass => 'FripostVirtualDomain' - , fripostIsStatusActive => $d->{isactive} ? - 'TRUE' : 'FALSE' - ); - $attrs{description} = $d->{description} - if defined $d->{description} and @{$d->{description}}; - $attrs{fripostOptionalMaildrop} = $d->{catchalls} - if defined $d->{catchalls} and @{$d->{catchalls}}; +# Convert our representation of domains into a hash which keys are LDAP +# attributes. +sub _domain_to_entry { + my $self = shift; + my %domain = @_; + my %entry; - if (defined $d->{owner}) { - $attrs{fripostOwner} = $self->_fvu2dn($d->{owner}) - if $d->{owner} ne ''; + foreach my $key (keys %domain) { + if ($key eq 'name') { + # Its value is forced by the DN. + } + elsif ($key eq 'isActive') { + $entry{fripostIsStatusActive} = $domain{isActive} ? 'TRUE' : 'FALSE'; + } + elsif ($key eq 'description') { + $entry{description} = $domain{description}; + } + elsif ($key eq 'catchAlls') { + $entry{fripostOptionalMaildrop} = $domain{catchAlls}; + } + elsif ($key eq 'canAddAlias') { + $entry{fripostCanAddAlias} = + [ map { $self->mail2dn($_) } @{$domain{canAddAlias}} ]; + } + elsif ($key eq 'canAddList') { + $entry{fripostCanAddList} = + [ map { $self->mail2dn($_) } @{$domain{canAddList}} ]; + } + elsif ($key eq 'owner') { + $entry{fripostOwner} = + [ map { $self->mail2dn($_) } @{$domain{owner}} ]; + } + elsif ($key eq 'postmaster') { + $entry{fripostPostmaster} = + [ map { $self->mail2dn($_) } @{$domain{postmaster}} ]; } else { - $attrs{fripostOwner} = $self->whoami; + die "Missing translation for domain key ‘".$key."’."; } + } + return %entry; +} - my $token; - if (defined $d->{send_token_to}) { - $token = String::Random::->new->randregex('\w{32}'); - $attrs{fripostPendingToken} = $token - } - $mesg = $self->ldap->add( $dn, attrs => [ %attrs ] ); - die $mesg->error."\n" if $mesg->code; - - - if (defined $d->{send_token_to}) { - my $tt = Template->new({ - INCLUDE_PATH => './templates', # TODO: use a config option - INTERPOLATE => 1, - }) or die $Template::ERROR."\n"; - - my $data; - my $vars = { domain => $domain, token => $token }; - $vars->{unlockurl} = $options{'-domainurl'}.'?unlock='.$token - if defined $options{'-domainurl'}; - $tt->process( 'new-domain.tt', $vars, \$data) - or die $tt->error."\n"; - - my $mail = MIME::Entity::->build( - From => 'Fripost Admin Panel <AdminWebPanel@fripost.org>', - To => $d->{send_token_to}, - Subject => "Your new domain ".$d->{domain}, - Encoding => 'quoted-printable', - Charset => 'utf-8', - Data => $data - ); - $mail->sign( Signature => 'The Fripost administration team.'); - $mail->send; - } - }; - return $@; -} +=item B<unlock> (I<domainname>, I<token>, I<OPTIONS>) + +Unlock the pending domain I<domainname>, locked with I<token>. + +Errors can be caught with options B<-die> and B<-error>, see +B<Fripost::Schema::Util> for details. + +=cut + sub unlock { my $self = shift; - my $d = shift; + my $domainname = shift; my $token = shift; + my %options = @_; + + # Nothing to do after an error. + return if $options{'-error'} && ${$options{'-error'}}; - my $dn = canonical_dn({fvd => domain_to_ascii($d)}, @{$self->suffix}); + my $dn = $self->mail2dn( $domainname ); my $mesg = $self->ldap->compare( $dn , attr => 'fripostPendingToken' , value => $token ); - die "Wrong unlock code for ‘".$d."’\n" - unless $mesg->code eq LDAP_COMPARE_TRUE; - - $mesg = $self->ldap->modify( $dn, delete => 'fripostPendingToken' ); - die $mesg->error."\n" if $mesg->code; + my $catch = { Net::LDAP::Constant::LDAP_COMPARE_TRUE => 0 + , Net::LDAP::Constant::LDAP_COMPARE_FALSE => + "Wrong unlock code for ‘".$domainname."’" + }; + ldap_error($mesg, %options, -die => $catch) // return; + + $mesg = $self->ldap->modify( $dn, + delete => { 'objectClass' => 'FripostPendingEntry' + , 'fripostPendingToken' => [] + }); + ldap_error($mesg, %options); } -=back -=head1 GLOBAL OPTIONS -If the B<-concat> option is present, it will intersperse multi-valued -attributes. Otherwise, an array reference containing every values will -be returned for these attributes. -The B<-die> option, if present, overides LDAP croaks and errors. + +=item B<replace> (I<domain>, I<OPTIONS>) + +Replace an existing domain with the given one. + +Errors can be caught with options B<-die> and B<-error>, see +B<Fripost::Schema::Util> for details. =cut +sub replace { + my $self = shift; + my $domain = shift; + my %options = @_; + + # Nothing to do after an error. + return if $options{'-error'} && ${$options{'-error'}}; + + &_is_valid($domain, %options); + my %entry = $self->_domain_to_entry (%$domain); + my $mesg = $self->ldap->modify( $self->mail2dn($domain->{name}) + , replace => \%entry ); + ldap_error($mesg, %options); +} + # Ensure that the given domain is valid. sub _is_valid { my $d = shift; - must_attrs( $d, qw/domain isactive/ ); - $d->{domain} = email_valid( $d->{domain}, -prefix => 'fake@', + my %options = @_; + eval { + must_attrs( $d, qw/name isActive/ ); + $d->{name} = email_valid( $d->{name}, -prefix => 'fake@', -error => 'Invalid domain', -exact => 1 ); - $d->{catchalls} = [ map { email_valid($_, -prefix => 'fake') } - @{$d->{catchalls}} ]; - $d->{canAddAlias} = [ map { email_valid($_, -prefix => 'fake') } - @{$d->{canAddAlias}} ]; - $d->{canAddList} = [ map { email_valid($_, -prefix => 'fake') } - @{$d->{canAddList}} ]; - - $d->{send_token_to} = email_valid( $d->{send_token_to} ) - if defined $d->{send_token_to}; + $d->{catchAlls} = [ map { email_valid($_, -prefix => 'fake') } + @{$d->{catchAlls}} ] + if $d->{catchAlls}; + $d->{canAddAlias} = [ map { email_valid($_, -prefix => 'fake') } + @{$d->{canAddAlias}} ] + if $d->{canAddAlias}; + $d->{canAddList} = [ map { email_valid($_, -prefix => 'fake') } + @{$d->{canAddList}} ] + if $d->{canAddList}; + $d->{owner} = [ map { email_valid($_, -prefix => 'fake') } + @{$d->{postmaster}} ] + if $d->{postmaster}; + $d->{postmaster} = [ map { email_valid($_, -prefix => 'fake') } + @{$d->{postmaster}} ] + if $d->{postmaster}; + }; + softdie ($@, %options); } @@ -363,6 +703,7 @@ sub _email_to_unicode { return email_to_unicode($email); } +=back =head1 AUTHOR diff --git a/lib/Fripost/Schema/Mail.pm b/lib/Fripost/Schema/Mail.pm new file mode 100644 index 0000000..309dad8 --- /dev/null +++ b/lib/Fripost/Schema/Mail.pm @@ -0,0 +1,58 @@ +package Fripost::Schema::Mail; + +=head1 NAME + +Mail.pm - Send clear, signed or encrypted e-mails. + +=head1 DESCRIPTION + +This module is adds GnuPG signing on top of MIME::Lite. Most of the +code comes from Mail::GnuPG. + +=cut + +use 5.010_000; +use strict; +use warnings; +use utf8; + +use MIME::Lite; + +sub new { + my $class = shift; + my $self = bless {}, $class; + my %msg = @_; + + $msg{Encoding} //= 'quoted-printable'; + $msg{Charset} //= 'utf-8'; + + $self->{_msg} = MIME::Lite->new(@_); + return $self; +} + +sub send { + my $self = shift; + print STDERR $self->{_msg}->as_string; + $self->{_msg}->send; +} + +=back + +=head1 AUTHOR + +Guilhem Moulin C<< <guilhem at fripost.org> >> + +=head1 COPYRIGHT + +Copyright 2013 Guilhem Moulin. + +=head1 LICENSE + +This program is free software; you can redistribute it and/or modify it +under the same terms as perl itself. + +=cut + +1; + +__END__ diff --git a/lib/Fripost/Schema/Util.pm b/lib/Fripost/Schema/Util.pm index 0f6821c..2c71411 100644 --- a/lib/Fripost/Schema/Util.pm +++ b/lib/Fripost/Schema/Util.pm @@ -13,11 +13,12 @@ use utf8; use Exporter 'import'; our @EXPORT_OK = qw /concat get_perms explode - must_attrs email_valid split_addr - canonical_dn ldap_explode_dn ldap_error + must_attrs email_valid split_addr dn2mail + canonical_dn ldap_explode_dn ldap_error ldap_and_filter + clean_ldap_entry assert softdie/; use Email::Valid; -use Net::IDN::Encode; +use Net::IDN::Encode qw/domain_to_unicode email_to_unicode/; use Net::LDAP::Util; use Encode; @@ -139,20 +140,24 @@ sub split_addr { my $addr = shift; my %options = @_; + $addr =~ /^(.*)\@([^@]+)$/s; + my ($l,$d) = ($1, $2); + if (defined $options{'-encode'}) { my $e = $options{'-encode'}; if ($e eq 'ascii') { - $addr = Net::IDN::Encode::email_to_ascii($addr); + Encode::_utf8_on($d); + $addr = Net::IDN::Encode::domain_to_ascii($d); } elsif ($e eq 'unicode') { - $addr = Net::IDN::Encode::email_to_unicode($addr); + $d = Net::IDN::Encode::domain_to_unicode($d); } else { - die "Unknown encoding: ". $e; + softdie ("Unknown encoding: ". $e, %options); + return; } } - - split /\@/, $addr, 2; + return ($l,$d); } sub ldap_error { @@ -180,8 +185,7 @@ sub ldap_error { $error = $mesg->error if $mesg->code; } - return $mesg unless defined $error; - return unless $error; + return 1 unless $error; if (defined $options{'-error'}) { ${$options{'-error'}} = $error; @@ -210,10 +214,50 @@ sub softdie { my $mesg = shift; my %options = @_; + return 1 unless $mesg; $options{'-die'} = $mesg; &assert (undef, %options); } +sub dn2mail { + my $dn = ldap_explode_dn(shift); + + return '@'. domain_to_unicode(lc $dn->[0]->{fvd}) + if exists $dn->[0]->{fvd}; + return email_to_unicode(lc $dn->[0]->{fvl} .'@'. lc $dn->[1]->{fvd}); +} + +sub ldap_and_filter { + my @filters = @_; + + if ($#filters == 0) { + return $filters[0]; + } + else { + @filters = map {'('.$_.')'} @filters; + return '(&'.(join '', @filters).')'; + } +} + + +sub clean_ldap_entry { + my $attrs = shift; + + foreach (keys %$attrs) { + if (defined $attrs->{$_}) { + if (ref $attrs->{$_} eq 'ARRAY') { + delete $attrs->{$_} unless @{$attrs->{$_}} + } + elsif (ref $attrs->{$_} eq '') { + delete $attrs->{$_} if $attrs->{$_} eq ''; + } + } + else { + delete $attrs->{$_}; + } + } +} + =head1 AUTHOR Guilhem Moulin C<< <guilhem at fripost.org> >> |