From ee8a751addc5af76dc94d621e124f159debfe8ed Mon Sep 17 00:00:00 2001 From: Andrew Ruthven Date: Sat, 23 Jan 2021 15:38:14 +1300 Subject: [PATCH] WIP: Multiple emails for principles --- dba/patches/1.3.4.sql | 36 +++++++ dba/views/dav_principal.sql | 9 +- inc/Principal.php | 46 ++++++++- inc/caldav-POST.php | 34 ++++++- inc/caldav-REPORT-principal.php | 6 +- inc/schedule-functions.php | 2 +- inc/ui/principal-browse.php | 2 +- inc/ui/principal-edit.php | 160 +++++++++++++++++++++++++++++--- 8 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 dba/patches/1.3.4.sql diff --git a/dba/patches/1.3.4.sql b/dba/patches/1.3.4.sql new file mode 100644 index 00000000..0b2f1e0e --- /dev/null +++ b/dba/patches/1.3.4.sql @@ -0,0 +1,36 @@ + +-- Notable enhancement: allow users to have multiple email addresses + +BEGIN; +SELECT check_db_revision(1,3,3); + +-- We now enforce uniqueness of email addresses. +-- This may want to be relaxed to allow duplication with inactive +-- accounts, but I don't want to think about how to enforce that in +-- the database. +CREATE TABLE usr_emails ( + user_no INTEGER NOT NULL REFERENCES usr(user_no) ON UPDATE CASCADE ON DELETE CASCADE, + email VARCHAR, + main boolean DEFAULT true, + UNIQUE (email) +); + +-- Ensure that a principal only has one primary email address. +CREATE UNIQUE INDEX usr_emails_primary + ON usr_emails + USING btree + (user_no) + WHERE main = true; + +-- Move email addresses over, by default they'll be the primary email address. +INSERT INTO usr_emails (user_no, email) + SELECT user_no, email FROM usr WHERE email IS NOT NULL AND email <> ''; + +ALTER TABLE usr + DROP COLUMN email; + +-- http://blogs.transparent.com/polish/names-of-the-months-and-their-meaning/ +SELECT new_db_revision(1,3,4, 'KwiecieĊ„' ); + +COMMIT; +ROLLBACK; diff --git a/dba/views/dav_principal.sql b/dba/views/dav_principal.sql index 4f318c21..d5ac93be 100644 --- a/dba/views/dav_principal.sql +++ b/dba/views/dav_principal.sql @@ -1,11 +1,11 @@ --- Define an updateable view for dav_principal which conbines the AWL usr +-- Define an updateable view for dav_principal which combines the AWL usr -- record 1:1 with the principal table DROP VIEW IF EXISTS dav_principal CASCADE; CREATE OR REPLACE VIEW dav_principal AS SELECT user_no, usr.active AS user_active, joined AS created, updated AS modified, - username, password, fullname, email, + username, password, fullname, email_ok, date_format_type, locale, principal_id, type_id, displayname, default_privileges, TRUE AS is_principal, @@ -19,7 +19,7 @@ CREATE OR REPLACE VIEW dav_principal AS CREATE or REPLACE RULE dav_principal_insert AS ON INSERT TO dav_principal DO INSTEAD ( - INSERT INTO usr ( user_no, active, joined, updated, username, password, fullname, email, email_ok, date_format_type, locale ) + INSERT INTO usr ( user_no, active, joined, updated, username, password, fullname, email_ok, date_format_type, locale ) VALUES( COALESCE( NEW.user_no, nextval('usr_user_no_seq')), COALESCE( NEW.user_active, TRUE), @@ -27,7 +27,7 @@ DO INSTEAD COALESCE( NEW.modified, current_timestamp), NEW.username, NEW.password, COALESCE( NEW.fullname, NEW.displayname ), - NEW.email, NEW.email_ok, + NEW.email_ok, COALESCE( NEW.date_format_type, 'E'), NEW.locale ); @@ -53,7 +53,6 @@ DO INSTEAD username=NEW.username, password=NEW.password, fullname=NEW.fullname, - email=NEW.email, email_ok=NEW.email_ok, date_format_type=NEW.date_format_type, locale=NEW.locale diff --git a/inc/Principal.php b/inc/Principal.php index f23bac0d..c77cdb9e 100644 --- a/inc/Principal.php +++ b/inc/Principal.php @@ -202,7 +202,7 @@ class Principal { $sql .= '0::BIT(24) AS privileges '; $params = array( ); } - $sql .= 'FROM dav_principal WHERE '; + $sql .= 'FROM dav_principal LEFT JOIN usr_emails USING (user_no) WHERE '; switch ( $type ) { case 'username': $sql .= 'lower(username)=lower(text(:param))'; @@ -215,7 +215,7 @@ class Principal { break; case 'email': $this->by_email = true; - $sql .= 'lower(email)=lower(:param)'; + $sql .= 'lower(usr_emails.email)=lower(:param)'; break; } $params[':param'] = $value; @@ -529,11 +529,22 @@ class Principal { $update_list = array(); } $sql_params = array(); + $email = ""; foreach( self::updateableFields() AS $k ) { if ( !isset($field_values->{$k}) && !isset($this->{$k}) ) continue; + $param_name = ':'.$k; + + // special case email, it now goes in a different table, this is taken + // as being the primary email address for the principal. + if ( $k == 'email' ) { + $email = (isset($field_values->{$k}) ? $field_values->{$k} : $this->{$k}); + continue; + } + $sql_params[$param_name] = (isset($field_values->{$k}) ? $field_values->{$k} : $this->{$k}); + if ( $k == 'default_privileges' ) { $sql_params[$param_name] = sprintf('%024s',$sql_params[$param_name]); $param_name = 'cast('.$param_name.' as text)::BIT(24)'; @@ -580,11 +591,29 @@ class Principal { $qry = new AwlQuery($sql, $sql_params); if ( $qry->Exec('Principal',__FILE__,__LINE__) ) { $this->unCache(); + $new_principal = new Principal('username', $sql_params[':username']); foreach( $new_principal AS $k => $v ) { $this->{$k} = $v; } } + + // email + if ($email != "") { + $sql_params[':email'] = $email; + $sql_params[':user_no'] = $this->user_no; + + if ( $inserting ) { + $sql = 'INSERT INTO usr_emails (user_no, email) VALUES (:user_no, :email)'; + } else { + $sql = 'UPDATE usr_emails SET email = :email WHERE user_no = :user_no AND main = true'; + } + + $qry = new AwlQuery($sql, $sql_params); + if ( $qry->Exec('Principal',__FILE__,__LINE__) ) { + $this->{email} = $email; + } + } } @@ -618,4 +647,17 @@ class Principal { } $cache->delete('principal-'.$value, null); } + + /** + * Find if an email address is associated with this principle. + * @return boolean True if found. + */ + protected function searchEmails( $email ) { + $qry = new AwlQuery('SELECT * FROM usr_emails WHERE user_no = :user_no, email = :email', + array(':user_no' => $this->user_no(), ':email' => $email) ); + + if ( $qry->Exec('Principal') ) { + return $qry->Rows; + } + } } diff --git a/inc/caldav-POST.php b/inc/caldav-POST.php index bcb6266f..488bffb8 100644 --- a/inc/caldav-POST.php +++ b/inc/caldav-POST.php @@ -64,7 +64,39 @@ function handle_freebusy_request( $ic ) { } if ($localname) { dbg_error_log( 'POST', 'try to resolve local attendee %s', $localname); - $qry = new AwlQuery('SELECT fullname, email FROM usr WHERE user_no = (SELECT user_no FROM principal WHERE type_id = 1 AND user_no = (SELECT user_no FROM usr WHERE lower(username) = (text(:username)))) UNION SELECT fullname, email FROM usr WHERE user_no IN (SELECT user_no FROM principal WHERE principal_id IN (SELECT member_id FROM group_member WHERE group_id = (SELECT principal_id FROM principal WHERE type_id = 3 AND user_no = (SELECT user_no FROM usr WHERE lower(username) = (text(:username))))))', array(':username' => strtolower($localname))); + $qry = new AwlQuery(' SELECT fullname, email + FROM usr + WHERE user_no = ( + SELECT user_no + FROM principal + WHERE type_id = 1 + AND user_no = ( + SELECT user_no + FROM usr + WHERE lower(username) = (text(:username)) + ) + ) + UNION + SELECT fullname, email + FROM usr + WHERE user_no IN ( + SELECT user_no + FROM principal + WHERE principal_id IN ( + SELECT member_id + FROM group_member + WHERE group_id = ( + SELECT principal_id + FROM principal + WHERE type_id = 3 + AND user_no = ( + SELECT user_no + FROM usr + WHERE lower(username) = (text(:username)) + ) + ) + ) + )', array(':username' => strtolower($localname))); if ( $qry->Exec('POST',__LINE__,__FILE__) && $qry->rows() >= 1 ) { dbg_error_log( 'POST', 'resolved local name %s to %d individual attendees', $localname, $qry->rows()); while ($row = $qry->Fetch()) { diff --git a/inc/caldav-REPORT-principal.php b/inc/caldav-REPORT-principal.php index e9da1041..92351ac1 100644 --- a/inc/caldav-REPORT-principal.php +++ b/inc/caldav-REPORT-principal.php @@ -28,6 +28,7 @@ foreach( $searches AS $k => $search ) { dbg_log_array( "principal", "MATCH", $match, true ); $match = $match[0]->GetContent(); $subwhere = ""; + $from_extra = ""; foreach( $qry_props AS $k1 => $v1 ) { if ( $subwhere != "" ) $subwhere .= " OR "; switch( $v1->GetNSTag() ) { @@ -39,7 +40,8 @@ foreach( $searches AS $k => $search ) { case 'urn:ietf:params:xml:ns:caldav:calendar-user-address-set': $match = preg_replace('{^.*/caldav.php/([^/]+)(/.*)?$}', '\\1', $match); $match = preg_replace('{^mailto:}', '', $match); - $subwhere .= ' (email ILIKE :user_address_match OR username ILIKE :user_address_match) '; + $subwhere .= ' (usr_emails.email ILIKE :user_address_match OR username ILIKE :user_address_match) '; + $from_extra = " LEFT JOIN usr_emails USING (user_no) "; $params[':user_address_match'] = '%'.$match.'%'; break; @@ -62,7 +64,7 @@ foreach( $searches AS $k => $search ) { } } if ( $where != "" ) $where = "WHERE $where"; -$sql = "SELECT * FROM dav_principal $where ORDER BY principal_id LIMIT 100"; +$sql = "SELECT * FROM dav_principal $from_extra $where ORDER BY principal_id LIMIT 100"; $qry = new AwlQuery($sql, $params); diff --git a/inc/schedule-functions.php b/inc/schedule-functions.php index c3ce82e7..b54821f4 100644 --- a/inc/schedule-functions.php +++ b/inc/schedule-functions.php @@ -99,7 +99,7 @@ function doItipAttendeeReply( vCalendar $resource, $partstat ) { $attendees = $vcal->GetAttendees(); foreach( $attendees AS $v ) { $email = preg_replace( '/^mailto:/i', '', $v->Value() ); - if ( $email == $request->principal->email() ) { + if ( $request->principal->searchEmails($email) ) { $attendee = $v; break; } diff --git a/inc/ui/principal-browse.php b/inc/ui/principal-browse.php index 28ad917b..e6853b90 100644 --- a/inc/ui/principal-browse.php +++ b/inc/ui/principal-browse.php @@ -24,7 +24,7 @@ if ( !isset($principal_type) || $principal_type == 3 ) { } $browser->SetOrdering( 'username', 'A' ); -$browser->SetJoins( "dav_principal " ); +$browser->SetJoins( "dav_principal LEFT JOIN usr_emails ON (dav_principal.user_no = usr_emails.user_no AND usr_emails.main = true)" ); if ( isset($principal_active) && $principal_active == 'f' ) $browser->SetWhere( 'NOT user_active' ); diff --git a/inc/ui/principal-edit.php b/inc/ui/principal-edit.php index 6646b7c2..f415755e 100644 --- a/inc/ui/principal-edit.php +++ b/inc/ui/principal-edit.php @@ -41,6 +41,7 @@ $delete_principal_confirmation_required = null; $delete_ticket_confirmation_required = null; $delete_bind_in_confirmation_required = null; $delete_binding_confirmation_required = null; +$delete_email_confirmation_required = null; function handle_subaction( $subaction ) { global $session, $c, $id, $editor; @@ -49,6 +50,7 @@ function handle_subaction( $subaction ) { global $delete_ticket_confirmation_required; global $delete_bind_in_confirmation_required; global $delete_binding_confirmation_required; + global $delete_email_confirmation_required; global $can_write_principal; dbg_error_log('admin-principal-edit',':handle_action: Action %s', $subaction ); @@ -164,6 +166,33 @@ function handle_subaction( $subaction ) { } break; +# This isn't used because the classEditor handles deleting, but there is no confirmation. Which is better? +# case 'delete_email': +# dbg_error_log('admin-principal-edit',':handle_action: Deleting email "%s" for principal %d', $_GET['email'], $id ); +# if ($can_write_principal) { +# if ( $session->CheckConfirmationHash('GET', 'confirm') ) { +# dbg_error_log('admin-principal-edit',':handle_action: Allowed to delete email "%s" for principal %d', $_GET['email'], $id ); +# $qry = new AwlQuery('DELETE FROM usr_emails WHERE user_no=? AND email=?;', $id, $_GET['email'] ); +# if ( $qry->Exec() ) { +# $c->messages[] = i18n('Email deleted.'); +# return true; +# } +# else { +# $c->messages[] = i18n('There was an error writing to the database.'); +# return false; +# } +# } +# else { +# $c->messages[] = i18n('Please confirm deletion of email - see below'); +# $delete_email_confirmation_required = $session->BuildConfirmationHash('GET', 'confirm'); +# return false; +# } +# } +# else { +# $c->messages[] = i18n('You are not allowed to delete emails for this principal.'); +# } +# break; + default: return false; } @@ -179,7 +208,6 @@ function principal_editor() { $editor->SetLookup( 'locale', 'SELECT \'\', \''.translate("*** Default Locale ***").'\' UNION SELECT locale, locale_name_locale FROM supported_locales ORDER BY 1 ASC' ); $editor->AddAttribute( 'locale', 'title', translate("The preferred language for this person.") ); $editor->AddAttribute( 'fullname', 'title', translate("The full name for this person, group or other type of principal.") ); - $editor->AddAttribute( 'email', 'title', translate("The email address identifies principals when processing invitations and freebusy lookups. It should be set to a unique value.") ); $editor->SetWhere( 'principal_id='.$id ); if($_SERVER['REQUEST_METHOD'] === "POST" && !verifyCsrfPost()) { @@ -302,7 +330,6 @@ function principal_editor() { $prompt_password_2 = translate('Confirm Password'); $prompt_fullname = translate('Fullname'); $prompt_displayname = translate('Display Name'); - $prompt_email = translate('Email Address'); $prompt_date_format = translate('Date Format Style'); $prompt_admin = translate('Administrator'); $prompt_active = translate('Active'); @@ -327,14 +354,6 @@ function principal_editor() { $delete_principal_button = '' . translate("Delete Principal") . ''; } - $email_unique = ''; - $qry = new AwlQuery('SELECT user_no FROM usr WHERE lower(usr.email) = lower(:email)', - array( ':email' => $editor->Value('email') )); - $qry->Exec('principal-edit', __LINE__, __FILE__); - if ($qry->rows() > 1 ) { - $email_unique = ' ' . translate('Attention: email address not unique, scheduling may not work!') . ''; - } - $id = $editor->Value('principal_id'); $template = << $prompt_password_1: ##newpass1.password.$pwstars## $prompt_password_2: ##newpass2.password.$pwstars## $prompt_fullname: ##fullname.input.50## - $prompt_email: ##email.input.50##$email_unique $prompt_locale: ##locale.select## $prompt_date_format: ##date_format_type.select## $prompt_type: ##type_id.select## @@ -487,6 +505,121 @@ function confirm_delete_principal($confirmation_hash, $displayname ) { return $html; } +function email_row_editor() { + global $c, $id, $editor, $can_write_principal, $email; + + $emailrow = new Editor("Email Addresses", "usr_emails"); + $emailrow->SetSubmitName( 'saveemailrow' ); + $edit_email_clause = ''; + if ( $can_write_principal ) { + if ( $emailrow->IsSubmit() ) { + + $_POST['user_no'] = $id; + $email = $_POST['email']; + $orig_email = $_POST['orig_email']; + $emailrow->SetWhere( 'user_no='.$id.' AND email=\''.$orig_email.'\''); + $emailrow->Assign('email', $email); + $emailrow->Assign('main', $_POST['main']); + $emailrow->Write( ); + unset($_GET['email']); + } + elseif ( isset($_GET['delete_email']) ) { + $qry = new AwlQuery("DELETE FROM usr_emails WHERE user_no=:user_no AND email = :email", + array( ':user_no' => $id, ':email' => $_GET['delete_email'] )); + $qry->Exec('email-delete'); + $c->messages[] = translate('Deleted an email from this Principal'); + } + } + return $emailrow; +} + + +function edit_email_row_email( $row_data ) { + global $id, $emailrow; + + $email = $row_data->email; + if ( ! empty($email) ) { + $emailrow->SetRecord( $row_data ); + } + else { + $emailrow->Initialise( $row_data ); + } + + $form_id = $emailrow->Id(); + $form_url = preg_replace( '#&(edit|delete)_email=\d+#', '', $_SERVER['REQUEST_URI'] ); + + $csrf_field = getCsrfField(); + + $template = << + $csrf_field + + + ##main.checkbox## + ##submit## + + +EOTEMPLATE; + + $emailrow->SetTemplate( $template ); + $emailrow->Title(""); + + return $emailrow->Render(); +} + +function format_boolean($col_val, $field, $row) { + if ($col_val == 0 || $col_val == '') { + return Translate('No'); + } else if ($col_val == '1') { + return Translate('Yes'); + } + + return $col_val; +} + +function email_browser() { + global $c, $id, $editor, $can_write_principal; + $browser = new Browser(translate('Email Addresses')); + + $browser->AddColumn( 'email', translate('Email'), '', '##email_link##' ); + $rowurl = $c->base_url . '/admin.php?action=edit&t=principal&id='.$id.'&edit_email='; + $browser->AddHidden( 'email_link', "'' || email || ''" ); + $browser->AddColumn( 'main', translate('Primary'), 'center', '', '', '', '', 'format_boolean'); + + if ( $can_write_principal ) { + $del_link = ''.translate('Delete').''; + $edit_link = ''.translate('Edit').''; + $browser->AddColumn( 'action', translate('Action'), 'center', '', "'$edit_link $del_link'" ); + } + + $browser->SetOrdering( 'email', 'A' ); + + $browser->SetJoins( "usr_emails " ); + $browser->SetWhere( 'user_no = '.$id ); + + if ( $c->enable_row_linking ) { + $browser->RowFormat( '', '', '#even' ); + } + else { + $browser->RowFormat( '', '', '#even' ); + } + $browser->DoQuery(); + + + if ( $can_write_principal ) { + if ( isset($_GET['edit_email']) ) { + $browser->MatchedRow('email', $_GET['edit_email'], 'edit_email_row_email'); + } + else if ( isset($id ) ) { + $browser->ExtraRowFormat( '', '', '#even' ); + $extra_row = array( 'email' => "" ); + $browser->MatchedRow('email', "", 'edit_email_row_email'); + $extra_row = (object) $extra_row; + $browser->AddRow($extra_row); + } + } + return $browser; +} function group_memberships_browser() { @@ -737,7 +870,6 @@ function principal_grants_browser() { return $browser; } - function ticket_row_editor() { global $c, $id, $editor, $can_write_principal, $privilege_names; @@ -1151,6 +1283,10 @@ if ( isset($id) && $id > 0 ) { $page_elements[] = confirm_delete_principal($delete_principal_confirmation_required, $editor->Value('displayname')); + $emailrow = email_row_editor(); + $page_elements[] = email_browser(); + if ( isset($delete_email_confirmation_required) ) $page_elements[] = confirm_delete_email($delete_email_confirmation_required); + $page_elements[] = group_memberships_browser(); if ( $editor->Value('type_id') == 3 ) { $grouprow = group_row_editor();