File Coverage

blib/lib/meon/Web/Member.pm
Criterion Covered Total %
statement 1 3 33.3
branch n/a
condition n/a
subroutine 1 1 100.0
pod n/a
total 2 4 50.0


line stmt bran cond sub pod time code
1             package meon::Web::Member;
2              
3 5     5   6575349 use Moose;
  0            
  0            
4             use 5.010;
5              
6             use Path::Class 'file';
7             use DateTime;
8             use XML::LibXML 'XML_TEXT_NODE';
9             use Email::Valid;
10             use Carp 'croak';
11             use meon::Web::ResponseXML;
12             use meon::Web::env;
13             use DateTime::Format::Strptime;
14             use Data::UUID::LibUUID 'new_uuid_string';
15             use Email::MIME;
16             use Email::Sender::Simple qw(sendmail);
17             use Data::asXML;
18             use Scalar::Util;
19             use Catalyst::Plugin::Authentication::Store::UserXML::User;
20              
21             has 'members_folder' => (is=>'rw',isa=>'Any',required=>1);
22             has 'username' => (is=>'rw',isa=>'Str',required=>1);
23             has 'xml' => (is=>'ro', isa=>'XML::LibXML::Document', lazy => 1, builder => '_build_xml');
24             has 'member_meta' => (is=>'ro', isa=>'XML::LibXML::Node',lazy=>1,builder=>'_build_member_meta');
25              
26             my $dxml = Data::asXML->new(pretty => 0);
27              
28             sub _build_xml {
29             my ($self) = @_;
30              
31             return XML::LibXML->load_xml(
32             location => $self->member_index_filename
33             );
34             }
35              
36             sub _build_member_meta {
37             my ($self) = @_;
38              
39             my $xml = $self->xml;
40             my $xpc = meon::Web::env->xpc;
41             my ($member_meta) = $xpc->findnodes('//w:member-profile',$xml);
42             return $member_meta;
43             }
44              
45             sub set_member_meta {
46             my ($self, $name, $value) = @_;
47              
48             my $meta = $self->member_meta;
49             my $xc = XML::LibXML::XPathContext->new($meta);
50             $xc->registerNs('w', 'http://web.meon.eu/');
51             my ($element) = $xc->findnodes('//w:'.$name);
52              
53             my $encoded = 0;
54             if (ref($value) && !blessed($value)) {
55             $value = $dxml->encode($value);
56             $encoded = 1;
57             }
58              
59             if ($element) {
60             foreach my $child ($element->childNodes()) {
61             $element->removeChild($child);
62             }
63             }
64             else {
65             my $meta_element = $self->member_meta;
66             $meta_element->appendText(q{ }x4);
67             $element = $meta_element->addNewChild($meta_element->namespaceURI,$name);
68             $meta_element->appendText("\n");
69             }
70              
71             if ($encoded) {
72             $element->setAttribute('encoded' => 1);
73             $element->appendChild($value);
74             }
75             else {
76             $element->appendText($value);
77             }
78             }
79              
80             sub get_member_meta {
81             my ($self, $name) = @_;
82              
83             my $meta = $self->member_meta;
84             my $xc = XML::LibXML::XPathContext->new($meta);
85             $xc->registerNs('w', 'http://web.meon.eu/');
86             my ($element) = $xc->findnodes('//w:'.$name);
87             return undef unless $element;
88              
89             if ($element->getAttribute('encoded')) {
90             ($element) = $xc->findnodes('w:*',$element);
91             return $dxml->decode($element)
92             }
93             else {
94             return $element->textContent;
95             }
96             }
97              
98             sub delete_member_meta {
99             my ($self, $name) = @_;
100              
101             my $meta = $self->member_meta;
102             my $xc = XML::LibXML::XPathContext->new($meta);
103             $xc->registerNs('w', 'http://web.meon.eu/');
104             my ($element) = $xc->findnodes('//w:'.$name);
105             return unless $element;
106              
107             map { $meta->removeChild($_) }
108             grep { $_->nodeType == XML_TEXT_NODE }
109             grep { $_ }
110             ($element->previousSibling(), $element->nextSibling());
111              
112             $meta->removeChild($element);
113             $meta->appendText("\n");
114              
115             return $element;
116             }
117              
118             sub create {
119             my ($self, %args) = @_;
120              
121             my $filename = $self->member_index_filename;
122             my $username = $self->username;
123             my $name = $args{name};
124             my $email = $args{email};
125             my $address = $args{address};
126             my $lat = $args{lat};
127             my $lng = $args{lng};
128             my $reg_form = $args{registration_form};
129             my $created = DateTime->now('time_zone' => 'UTC')->iso8601;
130              
131             # FIXME instead of direct string interpolation, use setters so that XML special chars are properly escaped
132             $filename->spew(qq{<?xml version="1.0" encoding="UTF-8"?>
133             <page
134             xmlns:xhtml="http://www.w3.org/1999/xhtml"
135             xmlns="http://web.meon.eu/"
136             xmlns:w="http://web.meon.eu/"
137             >
138              
139             <meta>
140             <title></title>
141             <user xmlns="http://search.cpan.org/perldoc?Catalyst%3A%3APlugin%3A%3AAuthentication%3A%3AStore%3A%3AUserXML">
142             <status>registration-pending</status>
143             <username>$username</username>
144             <password>***DISABLED***</password>
145             </user>
146             </meta>
147              
148             <content><div xmlns="http://www.w3.org/1999/xhtml">
149             <w:member-profile xmlns="http://web.meon.eu/">
150             <dir-listing path="archive/"/>
151             <public-listing>1</public-listing>
152             <full-name></full-name>
153             <email></email>
154             <email-validated>0</email-validated>
155             <created>$created</created>
156             <address></address>
157             <lat></lat>
158             <lng></lng>
159             <registration-form></registration-form>
160             </w:member-profile>
161             </div></content>
162              
163             </page>
164             });
165              
166             $self->set_member_meta('title',$name);
167             $self->set_member_meta('full-name',$name);
168             $self->set_member_meta('email',$email);
169             $self->set_member_meta('address',$address);
170             $self->set_member_meta('lat',$lat);
171             $self->set_member_meta('lng',$lng);
172             $self->set_member_meta('registration-form',$reg_form);
173             $self->store;
174             }
175              
176             sub dir {
177             my $self = shift;
178              
179             return Path::Class::dir($self->members_folder, $self->username);
180             }
181              
182             sub member_index_filename {
183             my $self = shift;
184              
185             return file($self->members_folder, $self->username, 'index.xml');
186             }
187              
188             sub store {
189             my $self = shift;
190              
191             my $filename = $self->member_index_filename;
192             my $xml = $self->xml;
193             $filename->spew($xml->toString);
194             }
195              
196             sub _find_by_callback {
197             my ($class, %args) = @_;
198              
199             my $members_folder = $args{members_folder};
200             croak 'need members_folder as argument'
201             unless $members_folder;
202             $members_folder = Path::Class::dir($members_folder);
203             my $callback = $args{callback};
204             croak 'need callback as argument'
205             unless $members_folder;
206              
207             foreach my $member_folder ($members_folder->children) {
208             my $username = $member_folder->basename;
209             my $member = meon::Web::Member->new(
210             members_folder => $members_folder,
211             username => $username,
212             );
213             next unless -r $member->member_index_filename;
214             return $member
215             if $callback->($member);
216             }
217              
218             return;
219             }
220              
221             sub find_by_email {
222             my ($class, %args) = @_;
223              
224             my $members_folder = $args{members_folder};
225             croak 'need members_folder as argument'
226             unless $members_folder;
227             $members_folder = Path::Class::dir($members_folder);
228             my $email = $args{email};
229             croak 'need email as argument'
230             unless $members_folder;
231              
232             return $class->_find_by_callback(
233             members_folder => $members_folder,
234             callback => sub {
235             return 1 if $_[0]->plain_email eq $email;
236             },
237             );
238             }
239              
240             sub find_by_token {
241             my ($class, %args) = @_;
242              
243             my $members_folder = $args{members_folder};
244             croak 'need members_folder as argument'
245             unless $members_folder;
246             $members_folder = Path::Class::dir($members_folder);
247             my $token = $args{token};
248             croak 'need token as argument'
249             unless $members_folder;
250              
251             return $class->_find_by_callback(
252             members_folder => $members_folder,
253             callback => sub {
254             return 1 if $_[0]->valid_token($token);
255             },
256             );
257             }
258              
259             sub email {
260             my $self = shift;
261             return $self->get_member_meta('email');
262             }
263              
264             sub plain_email {
265             my $self = shift;
266             return Email::Valid->address($self->get_member_meta('email')).'';
267             }
268              
269             sub valid_token {
270             my ($self, $token) = @_;
271             return unless $token;
272              
273             my $member_token = $self->get_member_meta('token');
274             return unless $member_token;
275              
276             my $valid_until = DateTime::Format::Strptime->new(
277             pattern => '%FT%T',
278             )->parse_datetime($self->get_member_meta('token-valid'));
279             return unless $valid_until;
280             return unless DateTime->now < $valid_until;
281              
282             return 0 unless $token eq $member_token;
283              
284             $self->delete_member_meta('token');
285             $self->delete_member_meta('token-valid');
286             $self->store;
287              
288             return 1;
289             }
290              
291             sub set_token {
292             my ($self, $hours) = @_;
293              
294             $hours //= 4;
295             my $token = new_uuid_string(4);
296             my $token_valid = DateTime->now->add(hours => $hours);
297              
298             $self->set_member_meta('token',$token);
299             $self->set_member_meta('token-valid',$token_valid);
300             $self->store;
301             return $token;
302             }
303              
304             sub send_password_reset {
305             my ($self, $from, $change_pw_url) = @_;
306              
307             croak 'need from' unless $from;
308             croak 'need change_pw_url' unless $change_pw_url;
309              
310             my $token = $self->set_token;
311             $change_pw_url->query_param('auth-token' => $token);
312             $change_pw_url = $change_pw_url->absolute;
313              
314             my $display_name = $self->get_member_meta('full-name') // 'Madam or Sir';
315             my $body = qq{Dear $display_name,
316              
317             here is your one-time authentication token url for resetting your password:
318              
319             $change_pw_url
320              
321             Best regards
322             Support team
323             };
324             my $email = Email::MIME->create(
325             header_str => [
326             From => $from,
327             To => $self->email,
328             Subject => 'Your password reset',
329             ],
330             attributes => {
331             content_type => "text/plain",
332             charset => "UTF-8",
333             encoding => "8bit",
334             },
335             body_str => $body,
336             );
337             sendmail($email->as_string);
338             }
339              
340             sub last_name {
341             my ($self) = @_;
342              
343             my $full_name = $self->get_member_meta('full-name');
344             return undef unless defined($full_name);
345             $full_name =~ s/\s+$//; # remove trailing space
346             $full_name =~ s/,.+?$//; # remove title
347             my @names = split(/\s+/,$full_name);
348             return $names[-1];
349             }
350              
351             sub user {
352             my ($self) = @_;
353             return Catalyst::Plugin::Authentication::Store::UserXML::User->new({
354             xml_filename => $self->member_index_filename,
355             xml => $self->xml,
356             });
357             }
358              
359             sub expires {
360             my ($self) = @_;
361             my $expires = DateTime::Format::Strptime->new(
362             pattern => '%F',
363             )->parse_datetime($self->get_member_meta('expires'));
364             return $expires;
365             }
366              
367             sub extend_expiration_by_1y {
368             my ($self) = @_;
369             my $now = DateTime->now;
370             my $expires = $self->expires;
371             $expires = $now
372             if (!$expires || $expires < $now);
373             $expires->add('years' => 1);
374             $self->set_member_meta('expires',$expires->strftime('%Y-%m-%d'));
375             $self->user->set_status('active');
376             }
377              
378             sub shred_password {
379             my ($self) = @_;
380             my $xml = $self->xml;
381             my $xpc = meon::Web::env->xpc;
382             my ($pw_el) = $xpc->findnodes('//u:password',$xml);
383             $pw_el->removeChildNodes();
384             $pw_el->appendText('***');
385              
386             }
387              
388             sub is_active {
389             my ($self) = @_;
390             return $self->user->status eq 'active';
391             }
392              
393             sub section {
394             my ($self) = @_;
395             return lc(substr($self->last_name // '-',0,1))
396             }
397              
398             __PACKAGE__->meta->make_immutable;
399              
400             1;