1 package BSE::TB::SiteUser;
3 # represents a registered user
6 @ISA = qw/Squirrel::Row/;
7 use Constants qw($SHOP_FROM);
9 use BSE::Util::SQL qw/now_datetime now_sqldate sql_normal_date sql_add_date_days/;
13 SiteUser - represent a site user (or member)
21 our $VERSION = "1.016";
23 use constant MAX_UNACKED_CONF_MSGS => 3;
24 use constant MIN_UNACKED_CONF_GAP => 2 * 24 * 60 * 60;
25 use constant OWNER_TYPE => "U";
28 return qw/id idUUID userId password password_type email whenRegistered
30 title name1 name2 street street2
31 suburb state postcode country
32 telephone facsimile mobile organization
33 confirmed confirmSecret waitingForConfirmation
34 textOnlyMail previousLogon
35 delivTitle delivEmail delivFirstName delivLastName delivStreet
36 delivStreet2 delivSuburb delivState delivPostCode delivCountry
37 delivTelephone delivFacsimile delivMobile delivOrganization
38 instructions adminNotes disabled flags
39 affiliate_name lost_today lost_date lost_id
40 customText1 customText2 customText3
41 customStr1 customStr2 customStr3
42 customInt1 customInt2 customWhen1
48 return "bse_siteusers";
52 require BSE::Util::SQL;
55 # idUUID handled by default_idUUID()
57 # password - required (and generates password and password_type)
58 # password_type - generated
60 whenRegistered => BSE::Util::SQL::now_datetime(),
61 lastLogon => BSE::Util::SQL::now_datetime(),
77 waitingForConfirmation => 0,
79 previousLogon => BSE::Util::SQL::now_datetime(),
93 delivOrganization => "",
102 customText1 => undef,
103 customText2 => undef,
104 customText3 => undef,
111 lockout_end => undef,
117 my $ug = Data::UUID->new;
118 return $ug->create_str;
122 my ($class, $cfg, $admin) = @_;
126 email => { rules=>'email', description=>'Email Address',
128 title => { description => 'Title', rules => 'dh_one_line', maxlen => 127 },
129 name1 => { description=>'First Name', rules=>"dh_one_line", maxlen=>127 },
130 name2 => { description=>'Last Name', rules=>"dh_one_line", maxlen=>127 },
131 street => { description => 'Address', rules=>"dh_one_line", maxlen=>127 },
132 street2 => { description => 'Address', rules=>"dh_one_line", maxlen=>127 },
133 suburb => { description=>'City/Suburb', rules=>"dh_one_line", maxlen=>127 },
134 state => { description => 'State', rules=>"dh_one_line", maxlen=>40 },
135 postcode => { rules=>'postcode', description=>'Post Code', maxlen=>40 },
136 country => { description=>'Country', rules=>"dh_one_line", maxlen=>127 },
137 telephone => { rules=>'phone', description=>'Telephone', maxlen=>80 },
138 facsimile => { rules=>'phone', description=>'Facsimile', maxlen=>80 },
139 mobile => { description => "Mobile", rules=>"phone", maxlen => 80 },
140 organization => { description=>'Organization', rules=>"dh_one_line",
142 textOnlyEmail => { description => "Text Only Email", type=>"boolean" },
143 delivTitle => { description=>"Delivery Title",
144 rules=>"dh_one_line", maxlen=>127 },
145 delivEmail => { description => "Delivery Email", rules=>"email",
147 delivFirstName => { description=>"Delivery First Name",
148 rules=>"dh_one_line", maxlen=>127 },
149 delivLastName => { descriptin=>"Delivery Last Name", rules=>"dh_one_line" },
150 delivStreet => { description => "Delivery Street Address",
151 rules=>"dh_one_line", maxlen=>127 },
152 delivStreet2 => { description => 'Delivery Street Address 2',
153 rules => "dh_one_line", maxlen=> 127 },
154 delivSuburb => { description => "Delivery Suburb", rules=>"dh_one_line",
156 delivState => { description => "Delivery State", rules=>"dh_one_line",
158 delivPostCode => { description => "Delivery Post Code", rules=>"postcode",
160 delivCountry => { description => "Delivery Country", rules=>"dh_one_line",
162 delivTelephone => { description => "Delivery Phone", rules=>"phone",
164 delivFacsimile => { description => "Delivery Facsimie", rules=>"phone",
166 delivMobile => { description => "Delivery Mobile", rules=>"phone",
168 delivOrganization => { description => "Delivery Organization",
169 rules=>"dh_one_line", maxlen => 127 },
170 instructions => { description => "Delivery Instructions" },
171 customText1 => { description => "Custom Text 1" },
172 customText2 => { description => "Custom Text 2" },
173 customText3 => { description => "Custom Text 3" },
174 customStr1 => { description => "Custom String 1", rules=>"dh_one_line",
176 customStr2 => { description => "Custom String 2", rules=>"dh_one_line",
178 customStr3 => { description => "Custom String 3", rules=>"dh_one_line",
183 $fields{adminNotes} =
184 { description => "Administrator Notes" };
186 { description => "User Disabled", type=>"boolean" };
196 sub removeSubscriptions {
199 BSE::TB::SiteUsers->doSpecial('removeSubscriptions', $self->{id});
202 sub removeSubscription {
203 my ($self, $subid) = @_;
205 BSE::TB::SiteUsers->doSpecial('removeSub', $self->{id}, $subid);
209 my ($class, $checkemail) = @_;
211 # Build a generic form for the email - since an attacker could
212 # include comments or extra spaces or a bunch of other stuff.
213 # this isn't strictly correct, but it's good enough
214 1 while $checkemail =~ s/\([^)]\)//g;
215 if ($checkemail =~ /<([^>]+)>/) {
218 $checkemail = lc $checkemail;
219 $checkemail =~ s/\s+//g;
226 The subscriptions the user is subscribed to.
233 require BSE::SubscriptionTypes;
234 return BSE::SubscriptionTypes->getSpecial(userSubscribedTo => $self->{id});
237 sub send_conf_request {
238 my ($user, $cgi, $cfg, $rcode, $rmsg) = @_;
240 if ($user->is_disabled) {
241 $$rmsg = "User is disabled";
245 my $nopassword = $cfg->entryBool('site users', 'nopassword', 0);
247 # check for existing in-progress confirmations
248 my $checkemail = $user->generic_email($user->{email});
250 # check the blacklist
251 require BSE::EmailBlacklist;
253 # check that the from address has been configured
254 my $from = $cfg->entry('confirmations', 'from') ||
255 $cfg->entry('shop', 'from')|| $SHOP_FROM;
258 $$rmsg = "Configuration Error: The confirmations from address has not been configured";
262 my $blackentry = BSE::EmailBlacklist->getEntry($checkemail);
265 $$rcode = "blacklist";
266 $$rmsg = $blackentry->{why};
270 unless ($user->{confirmSecret}) {
271 use BSE::Util::Secure qw/make_secret/;
272 # print STDERR "Generating secret\n";
273 $user->{confirmSecret} = make_secret($cfg);
277 # check for existing confirmations
278 require BSE::EmailRequests;
279 my $confirm = BSE::EmailRequests->getBy(genEmail=>$checkemail);
281 if ($confirm->{unackedConfMsgs} >= MAX_UNACKED_CONF_MSGS) {
283 $$rmsg = "Too many confirmations have been sent to this email address";
286 use BSE::Util::SQL qw/sql_datetime_to_epoch/;
287 my $lastSentEpoch = sql_datetime_to_epoch($confirm->{lastConfSent});
288 if ($lastSentEpoch + MIN_UNACKED_CONF_GAP > time) {
290 $$rmsg = "The last confirmation was sent too recently, please wait before trying again";
296 my @cols = BSE::EmailRequest->columns;
298 $confirm{email} = $user->{email};
299 $confirm{genEmail} = $checkemail;
300 # prevents silliness on error
301 use BSE::Util::SQL qw(sql_datetime);
302 $confirm{lastConfSent} = sql_datetime(time - MIN_UNACKED_CONF_GAP);
303 $confirm{unackedConfMsgs} = 0;
304 $confirm = BSE::EmailRequests->add(@confirm{@cols});
307 # ok, now we can send the confirmation request
311 BSE::Util::Tags->basic(\%confacts, $cgi, $cfg),
312 user => sub { $user->{$_[0]} },
313 confirm => sub { $confirm->{$_[0]} },
314 remote_addr => sub { $ENV{REMOTE_ADDR} },
317 $nopassword ? 'user/email_confirm_nop' : 'user/email_confirm';
319 require BSE::ComposeMail;
320 my $mail = BSE::ComposeMail->new(cfg => $cfg);
322 my $subject = $cfg->entry('confirmations', 'subject')
323 || 'Subscription Confirmation';
324 unless ($mail->send(template => $email_template,
328 subject=>$subject)) {
329 # a problem sending the mail
331 $$rmsg = $mail->errstr;
334 ++$confirm->{unackedConfMsgs};
335 $confirm->{lastConfSent} = now_datetime;
343 The shop orders made by the user.
350 require BSE::TB::Orders;
352 return BSE::TB::Orders->getBy(userId => $self->{userId});
355 sub _user_sub_entry {
356 my ($self, $sub) = @_;
358 my ($entry) = BSE::DB->query(userSubscribedEntry => $self->{id},
359 $sub->{subscription_id})
367 return true if the user is subcribed to the given subscription.
371 # check if the user is subscribed to the given subscription
373 my ($self, $sub) = @_;
375 my $entry = $self->_user_sub_entry($sub)
378 my $today = now_sqldate;
379 my $end_date = sql_normal_date($entry->{ends_at});
380 return $today le $end_date;
383 # check if the user is subscribed to the given subscription, and allow
384 # for the max_lapsed grace period
385 sub subscribed_to_grace {
386 my ($self, $sub) = @_;
388 my $entry = $self->_user_sub_entry($sub)
391 my $today = now_sqldate;
392 my $end_date = sql_add_date_days($entry->{ends_at}, $entry->{max_lapsed});
393 return $today le $end_date;
397 qw(siteuser_id image_id filename width height bytes content_type alt);
400 my ($self, $cfg) = @_;
403 my %ids = $cfg->entries('BSE Siteuser Images');
404 for my $id (keys %ids) {
405 my %image = ( id => $id );
407 my $sect = "BSE Siteuser Image $id";
408 for my $key (qw(description help minwidth minheight maxwidth maxheight
409 minratio maxratio properror
410 widthsmallerror heightsmallerror smallerror
411 widthlargeerror heightlargeerror largeerror
412 maxspace spaceerror)) {
413 my $value = $cfg->entry($sect, $key);
414 if (defined $value) {
415 $image{$key} = $value;
418 push @images, \%image;
426 Return images associated with the user.
433 BSE::DB->query(getBSESiteuserImages => $self->{id});
437 my ($self, $id) = @_;
439 my ($image) = BSE::DB->query(getBSESiteuserImage => $self->{id}, $id)
446 my ($self, $cfg, $id, $image) = @_;
449 $image{siteuser_id} = $self->{id};
450 my $old = $self->get_image($id);
454 BSE::DB->run(replaceBSESiteuserImage => @image{@image_cols});
457 my $image_dir = $cfg->entryVar('paths', 'siteuser_images');
458 unlink "$image_dir/$old->{filename}";
463 BSE::DB->run(addBSESiteuserImage => @image{@image_cols});
468 my ($self, $cfg, $id) = @_;
470 if (my $old = $self->get_image($id)) {
472 BSE::DB->run(deleteBSESiteuserImage => $self->{id}, $id);
475 my $image_dir = $cfg->entryVar('paths', 'siteuser_images');
476 unlink "$image_dir/$old->{filename}";
480 sub recalculate_subscriptions {
481 my ($self, $cfg) = @_;
483 require BSE::TB::Subscriptions;
484 my @subs = BSE::TB::Subscriptions->all;
485 for my $sub (@subs) {
486 $sub->update_user_expiry($self, $cfg);
490 sub subscribed_services {
493 BSE::DB->query(siteuserSubscriptions => $self->{id});
498 Return true if the user is disabled.
505 return $self->{disabled};
508 sub seminar_sessions_booked {
509 my ($self, $seminar_id) = @_;
511 return map $_->{session_id},
512 BSE::DB->query(userSeminarSessionBookings => $seminar_id, $self->{id});
516 my ($self, $group) = @_;
518 my $group_id = ref $group ? $group->{id} : $group;
520 my @result = BSE::DB->query(siteuserMemberOfGroup => $self->{id}, $group_id);
522 return scalar(@result);
528 map $_->{id}, BSE::DB->query(siteuserGroupsForUser => $self->{id});
531 sub allow_html_email {
534 !$self->{textOnlyMail};
537 sub seminar_bookings_detail {
540 BSE::DB->query(bse_siteuserSeminarBookingsDetail => $self->{id});
545 return the user's wishlist products.
551 require BSE::TB::Products;
552 return BSE::TB::Products->getSpecial(userWishlist => $self->{id});
557 return BSE::DB->query(bse_userWishlistOrder => $self->{id});
560 sub product_in_wishlist {
561 my ($self, $product) = @_;
563 grep $_->{product_id} == $product->{id}, $self->wishlist_order;
566 sub add_to_wishlist {
567 my ($self, $product) = @_;
571 BSE::DB->run(bse_addToWishlist => $self->{id}, $product->{id}, time());
576 sub remove_from_wishlist {
577 my ($self, $product) = @_;
579 BSE::DB->run(bse_removeFromWishlist => $self->{id}, $product->{id});
582 sub _set_wishlist_order {
583 my ($self, $product_id, $display_order) = @_;
585 print STDERR "_set_wishlist_order($product_id, $display_order)\n";
587 BSE::DB->run(bse_userWishlistReorder => $display_order, $self->{id}, $product_id);
590 sub _find_in_wishlist {
591 my ($self, $product_id) = @_;
593 my @order = $self->wishlist_order;
595 my ($index) = grep $order[$_]{product_id} == $product_id, 0 .. $#order
598 return \@order, $index;
601 sub move_to_wishlist_top {
602 my ($self, $product) = @_;
604 my ($order, $move_index) = $self->_find_in_wishlist($product->{id})
607 or return; # nothing to do
609 my $top_order = $order->[0]{display_order};
610 for my $index (0 .. $move_index-1) {
611 $self->_set_wishlist_order($order->[$index]{product_id}, $order->[$index+1]{display_order});
613 $self->_set_wishlist_order($product->{id}, $top_order);
616 sub move_to_wishlist_bottom {
617 my ($self, $product) = @_;
619 my ($order, $move_index) = $self->_find_in_wishlist($product->{id})
621 $move_index < $#$order
622 or return; # nothing to do
624 my $bottom_order = $order->[-1]{display_order};
625 for my $index (reverse($move_index+1 .. $#$order)) {
626 $self->_set_wishlist_order($order->[$index]{product_id}, $order->[$index-1]{display_order});
628 $self->_set_wishlist_order($product->{id}, $bottom_order);
631 sub move_down_wishlist {
632 my ($self, $product) = @_;
634 my ($order, $index) = $self->_find_in_wishlist($product->{id})
637 or return; # nothing to do
639 $self->_set_wishlist_order($product->{id}, $order->[$index+1]{display_order});
640 $self->_set_wishlist_order($order->[$index+1]{product_id}, $order->[$index]{display_order});
643 sub move_up_wishlist {
644 my ($self, $product) = @_;
646 my ($order, $index) = $self->_find_in_wishlist($product->{id})
649 or return; # nothing to do
651 $self->_set_wishlist_order($product->{id}, $order->[$index-1]{display_order});
652 $self->_set_wishlist_order($order->[$index-1]{product_id}, $order->[$index]{display_order});
655 # files owned specifically by this user
659 require BSE::TB::OwnedFiles;
660 return BSE::TB::OwnedFiles->getBy(owner_type => OWNER_TYPE,
661 owner_id => $self->id);
664 sub admin_group_files {
667 require BSE::TB::OwnedFiles;
668 return BSE::TB::OwnedFiles->getSpecial(userVisibleGroupFiles => $self->{id});
671 sub query_group_files {
672 my ($self, $cfg) = @_;
674 require BSE::TB::SiteUserGroups;
677 map $_->files, BSE::TB::SiteUserGroups->query_groups($cfg)
683 files the user can see, both owned and owned by groups
688 my ($self, $cfg) = @_;
693 $self->admin_group_files,
694 $self->query_group_files($cfg)
698 sub file_owner_type {
702 sub subscribed_file_categories {
705 return map $_->{category}, BSE::DB->query(siteuserSubscribedFileCategories => $self->{id});
708 sub set_subscribed_file_categories {
709 my ($self, $cfg, @new) = @_;
711 require BSE::TB::OwnedFiles;
712 my %current = map { $_ => 1 } $self->subscribed_file_categories;
713 my %new = map { $_ => 1 } @new;
714 my @all = BSE::TB::OwnedFiles->categories($cfg);
716 if ($new{$cat->{id}} && !$current{$cat->{id}}) {
718 BSE::DB->run(siteuserAddFileCategory => $self->{id}, $cat->{id});
719 }; # a race condition might cause a duplicate key error here
721 elsif (!$new{$cat->{id}} && $current{$cat->{id}}) {
722 BSE::DB->run(siteuserRemoveFileCategory => $self->{id}, $cat->{id});
729 Returns a description of the user
736 return "Member: " . $self->userId;
741 Files that require payment that the user has paid for.
748 require BSE::TB::ArticleFiles;
749 return BSE::TB::ArticleFiles->getSpecial(userPaidFor => $self->id);
753 my ($self, $cfg) = @_;
755 $cfg or confess "Missing parameter cfg";
757 # remove any owned files
758 for my $file ($self->files) {
763 BSE::DB->run(bseRemoveUserFileSubs => $self->id);
766 BSE::DB->run(bseRemoveUserFileNotifies => $self->id);
769 BSE::DB->run(bseMarkUserFileAccessesAnon => $self->id);
771 # mark any orders owned by the user as anonymous
772 BSE::DB->run(bseMarkOwnedOrdersAnon => $self->id);
774 # newsletter subscriptions
775 BSE::DB->run(bseRemoveUserSubs => $self->id);
778 BSE::DB->run(bseRemoveUserWishlist => $self->id);
781 BSE::DB->run(bseRemoveUserMemberships => $self->id);
784 BSE::DB->run(bseRemoveUserBookings => $self->id);
787 BSE::DB->run(bseRemoveUserProdSubs => $self->id);
790 for my $im ($self->images) {
791 $self->remove_image($cfg, $im->{image_id});
794 $self->SUPER::remove();
800 return BSE::Cfg->single->admin_url(siteusers => { a_edit => 1, id => $self->id });
803 =item send_registration_notify(remote_addr => $ip_address)
805 Send an email to the customer with registration information.
807 Template: user/email_register
809 Basic static tags and:
815 host - IP address of the machine that registered the user.
819 user - the user registered.
825 sub send_registration_notify {
826 my ($self, %opts) = @_;
828 defined $opts{remote_addr}
829 or confess "Missing remote_addr parameter";
831 require BSE::ComposeMail;
832 require BSE::Util::Tags;
833 BSE::ComposeMail->send_simple
835 id => 'notify_register_customer',
836 template => 'user/email_register',
837 subject => 'Thank you for registering',
841 host => $opts{remote_addr},
842 user => [ \&BSE::Util::Tags::tag_hash_plain, $self ],
844 log_msg => "Send registration email to Site User (" . $self->email .")",
845 log_component => "member:register:notifyuser",
850 my ($self, $password, $who, %log) = @_;
852 require BSE::Passwords;
854 my ($hash, $type) = BSE::Passwords->new_password_hash($password);
856 $self->set_password($hash);
857 $self->set_password_type($type);
859 require BSE::TB::AuditLog;
860 BSE::TB::AuditLog->log
862 component => "siteusers::changepw",
866 msg => "Site User '" . $self->userId . "' changed their password",
874 my ($self, $password, $error) = @_;
876 require BSE::Passwords;
877 return BSE::Passwords->check_password_hash($self->password, $self->password_type, $password, $error);
882 Call to send a lost password email.
887 my ($self, $error) = @_;
889 my $cfg = BSE::Cfg->single;
890 require BSE::CfgInfo;
891 my $custom = BSE::CfgInfo::custom_class($cfg);
892 my $email_user = $self;
894 if ($custom->can('send_user_email_to')) {
896 $email_user = $custom->send_user_email_to($self, $cfg);
898 $to = $email_user->{email};
901 require BSE::Util::SQL;
902 my $lost_limit = $cfg->entry("lost password", "daily_limit", 3);
903 my $today = BSE::Util::SQL::now_sqldate();
906 && $self->lost_date eq $today) {
907 $lost_today = $self->lost_today;
909 if ($lost_today+1 > $lost_limit) {
910 $$error = "Too many password recovery attempts today, please try again tomorrow";
913 $self->set_lost_date($today);
914 $self->set_lost_today($lost_today+1);
915 $self->set_lost_id(BSE::Util::Secure::make_secret($cfg));
918 require BSE::ComposeMail;
919 my $mail = BSE::ComposeMail->new(cfg => $cfg);
921 require BSE::Util::Tags;
925 BSE::Util::Tags->mail_tags(),
926 user => [ \&BSE::Util::Tags::tag_object_plain, $self ],
927 host => $ENV{REMOTE_ADDR},
928 site => $cfg->entryErr('site', 'url'),
929 emailuser => [ \&BSE::Util::Tags::tag_hash_plain, $email_user ],
931 my $from = $cfg->entry('confirmations', 'from') ||
932 $cfg->entry('shop', 'from') || $SHOP_FROM;
933 my $nopassword = $cfg->entryBool('site users', 'nopassword', 0);
934 my $subject = $cfg->entry('basic', 'lostpasswordsubject')
935 || ($nopassword ? "Your options" : "Your password");
938 template => 'user/lostpwdemail',
943 log_msg => "Send password recovery email to Site User (" . $self->email . ")",
944 log_component => "siteusers:lost:send",
947 $$error = $mail->errstr;
955 sub check_password_rules {
956 my ($class, %opts) = @_;
958 require BSE::Util::PasswordValidate;
960 my %rules = BSE::Cfg->single->entries("siteuser passwords");
962 return BSE::Util::PasswordValidate->validate
969 sub password_check_fields {
970 return qw(name1 name2);
975 Return true if logons are disabled due to too many authentication
983 return $self->lockout_end && $self->lockout_end gt now_datetime();
987 my ($class, %opts) = @_;
989 require BSE::Util::Lockouts;
990 BSE::Util::Lockouts->check_lockouts
993 section => "site user lockouts",
994 component => "siteuser",
996 type => $class->lockout_type,
1001 my ($self, %opts) = @_;
1003 require BSE::Util::Lockouts;
1004 BSE::Util::Lockouts->unlock_user
1008 component => "siteuser",
1013 sub unlock_ip_address {
1014 my ($class, %opts) = @_;
1016 require BSE::Util::Lockouts;
1017 BSE::Util::Lockouts->unlock_ip_address
1020 component => "siteuser",
1022 type => $class->lockout_type,
1031 # for duck-type compatibility with BSE::TB::AdminUser
1035 return $self->userId;