Changing e-mail address now sends a confirmation mail to the old address too. Implemented by making changing fields a generic mechanism. Also fixed some minor nits.

This commit is contained in:
ian%hixie.ch 2001-12-30 00:33:36 +00:00
Родитель 8b3fbe9c3a
Коммит 03752da2a2
5 изменённых файлов: 264 добавлений и 124 удалений

Просмотреть файл

@ -89,14 +89,14 @@ sub getUserByID {
my $self = shift;
my($app, $id) = @_;
$self->notImplemented();
# return userID, disabled, password, adminMessage, newFieldID, newFieldValue,
# newFieldKey, [ fieldID, data ]*, [ groupID, name, level ]*, [rightNames]*
# return userID, disabled, password, adminMessage,
# [ fieldID, data ]*, [ groupID, name, level ]*, [rightNames]*
# or () if unsuccessful
}
sub setUser {
my $self = shift;
my($app, $userID, $disabled, $password, $adminMessage, $newFieldID, $newFieldValue, $newFieldKey) = @_;
my($app, $userID, $disabled, $password, $adminMessage) = @_;
# if userID is undefined, then add a new entry and return the
# userID (so that it can be used in setUserField and
# setUserGroups, later).
@ -115,6 +115,39 @@ sub removeUserField {
$self->notImplemented();
}
sub setUserFieldChange {
my $self = shift;
my($app, $userID, $fieldID, $newData, $password, $type) = @_;
$self->notImplemented();
# return change ID
}
sub getUserFieldChangeFromChangeID {
my $self = shift;
my($app, $changeID) = @_;
$self->notImplemented();
# return [userID, fieldID, newData, password, createTime, type]
}
sub getUserFieldChangesFromUserIDAndFieldID {
my $self = shift;
my($app, $userID, $fieldID) = @_;
$self->notImplemented();
# return [changeID, newData, password, createTime, type]*
}
sub removeUserFieldChangeByChangeID {
my $self = shift;
my($changeID) = @_;
$self->notImplemented();
}
sub removeUserFieldChangesByUserIDAndFieldID {
my $self = shift;
my($app, $userID, $fieldID) = @_;
$self->notImplemented();
}
sub setUserGroups {
my $self = shift;
my($app, $userID, $groupIDs) = @_;

Просмотреть файл

@ -68,7 +68,7 @@ sub getUserIDByContactDetails {
sub getUserByID {
my $self = shift;
my($app, $id) = @_;
my @userData = $self->database($app)->execute('SELECT userID, mode, password, adminMessage, newFieldID, newFieldValue, newFieldKey
my @userData = $self->database($app)->execute('SELECT userID, mode, password, adminMessage
FROM user WHERE userID = ?', $id)->row;
if (@userData) {
# fields
@ -93,24 +93,24 @@ sub getUserByID {
} else {
return ();
}
# return userID, mode, password, adminMessage, newFieldID,
# newFieldValue, newFieldKey, [ fieldID, data ]*, [ groupID, name, level ]*,
# return userID, mode, password, adminMessage,
# [ fieldID, data ]*, [ groupID, name, level ]*,
# [rightNames]*; or () if unsuccessful
}
sub setUser {
my $self = shift;
my($app, $userID, $mode, $password, $adminMessage, $newFieldID, $newFieldValue, $newFieldKey) = @_;
my($app, $userID, $mode, $password, $adminMessage) = @_;
# if userID is undefined, then add a new entry and return the
# userID (so that it can be used in setUserField and
# setUserGroups, later).
if (defined($userID)) {
$self->database($app)->execute('UPDATE user SET mode=?, password=?, adminMessage=?, newFieldID=?, newFieldValue=?, newFieldKey=?
WHERE userID = ?', $mode, $password, $adminMessage, $newFieldID, $newFieldValue, $newFieldKey, $userID);
$self->database($app)->execute('UPDATE user SET mode=?, password=?, adminMessage=?
WHERE userID = ?', $mode, $password, $adminMessage, $userID);
return $userID;
} else {
return $self->database($app)->execute('INSERT INTO user SET mode=?, password=?, adminMessage=?, newFieldID=?, newFieldValue=?, newFieldKey=?',
$mode, $password, $adminMessage, $newFieldID, $newFieldValue, $newFieldKey)->MySQLID;
return $self->database($app)->execute('INSERT INTO user SET mode=?, password=?, adminMessage=?',
$mode, $password, $adminMessage)->MySQLID;
}
}
@ -128,6 +128,36 @@ sub removeUserField {
$self->database($app)->execute('DELETE FROM userData WHERE userID = ? AND fieldID = ?', $userID, $fieldID);
}
sub setUserFieldChange {
my $self = shift;
my($app, $userID, $fieldID, $newData, $password, $type) = @_;
return $self->database($app)->execute('INSERT INTO userDataChanges SET userID=?, fieldID=?, newData=?, password=?, type=?', $userID, $fieldID, $newData, $password, $type)->MySQLID;
}
sub getUserFieldChangeFromChangeID {
my $self = shift;
my($app, $changeID) = @_;
return $self->database($app)->execute('SELECT userID, fieldID, newData, password, createTime, type FROM userDataChanges WHERE changeID = ?', $changeID)->row;
}
sub getUserFieldChangesFromUserIDAndFieldID {
my $self = shift;
my($app, $userID, $fieldID) = @_;
return $self->database($app)->execute('SELECT changeID, newData, password, createTime, type FROM userDataChanges WHERE userID = ? AND fieldID = ?', $userID, $fieldID)->rows;
}
sub removeUserFieldChangeByChangeID {
my $self = shift;
my($app, $changeID) = @_;
$self->database($app)->execute('DELETE FROM userDataChanges WHERE changeID = ?', $changeID);
}
sub removeUserFieldChangesByUserIDAndFieldID {
my $self = shift;
my($app, $userID, $fieldID) = @_;
$self->database($app)->execute('DELETE FROM userDataChanges WHERE userID = ? AND fieldID = ?', $userID, $fieldID);
}
sub setUserGroups {
my $self = shift;
my($app, $userID, $groupIDs) = @_; # $groupIDs is a hash of groupID => level
@ -317,19 +347,16 @@ sub setupInstall {
my $self = shift;
my($app) = @_;
my $helper = $self->helper($app);
$self->dump(9, 'about to configure user data source...');
if (not $helper->tableExists($app, $self->database($app), 'user')) {
my $database = $self->database($app);
if (not $helper->tableExists($app, $database, 'user')) {
$app->output->setupProgress('dataSource.user.user');
$self->database($app)->execute('
$database->execute('
CREATE TABLE user (
userID integer unsigned auto_increment NOT NULL PRIMARY KEY,
password varchar(255) NOT NULL,
mode integer unsigned NOT NULL DEFAULT 0,
adminMessage varchar(255),
newFieldID integer unsigned,
newFieldValue varchar(255),
newFieldKey varchar(255)
)
userID integer unsigned auto_increment NOT NULL PRIMARY KEY,
password varchar(255) NOT NULL,
mode integer unsigned NOT NULL DEFAULT 0,
adminMessage varchar(255),
)
');
# +-------------------+
# | user |
@ -338,16 +365,27 @@ sub setupInstall {
# | password |
# | mode | 0 = active, 1 = account disabled
# | adminMessage | string displayed when user (tries to) log in
# | newFieldID | \
# | newFieldValue | > used when user tries to change his e-mail
# | newFieldKey | / address, for example
# +-------------------+
} else {
$app->output->setupProgress('dataSource.user.user.schemaChanges');
# check its schema is up to date
# delete user table's newField* fields.
# note: can't move this data because new format uses more
# fields, such as changeID, which old format knew nothing
# about.
if ($helper->columnExists($app, $database, 'user', 'newFieldID')) {
$database->execute('ALTER TABLE user REMOVE COLUMN newFieldID');
}
if ($helper->columnExists($app, $database, 'user', 'newFieldValue')) {
$database->execute('ALTER TABLE user REMOVE COLUMN newFieldValue');
}
if ($helper->columnExists($app, $database, 'user', 'newFieldKey')) {
$database->execute('ALTER TABLE user REMOVE COLUMN newFieldKey');
}
}
if (not $helper->tableExists($app, $self->database($app), 'userData')) {
if (not $helper->tableExists($app, $database, 'userData')) {
$app->output->setupProgress('dataSource.user.userData');
$self->database($app)->execute('
$database->execute('
CREATE TABLE userData (
userID integer unsigned NOT NULL,
fieldID integer unsigned NOT NULL,
@ -365,9 +403,38 @@ sub setupInstall {
} else {
# check its schema is up to date
}
if (not $helper->tableExists($app, $self->database($app), 'userDataTypes')) {
if (not $helper->tableExists($app, $database, 'userDataChanges')) {
$app->output->setupProgress('dataSource.user.userDataChanges');
$database->execute('
CREATE TABLE userDataChanges (
changeID integer unsigned NOT NULL,
userID integer unsigned NOT NULL,
fieldID integer unsigned NOT NULL,
newData text,
password varchar(255) NOT NULL,
createTime TIMESTAMP DEFAULT NULL,
type integer unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (changeID),
KEY (userID, fieldID)
)
');
# +-------------------+
# | userDataChanges |
# +-------------------+
# | changeID K1 | auto_increment
# | userID K2 | points to entries in the table above
# | fieldID K2 | points to entries in the table below
# | newData | e.g. 'ian@hixie.ch'
# | password | encrypted password
# | createTime | datetime
# | type | 0 = normal, 1 = remove all other user/field reqs
# +-------------------+
} else {
# check its schema is up to date
}
if (not $helper->tableExists($app, $database, 'userDataTypes')) {
$app->output->setupProgress('dataSource.user.userDataTypes');
$self->database($app)->execute('
$database->execute('
CREATE TABLE userDataTypes (
fieldID integer unsigned auto_increment NOT NULL PRIMARY KEY,
category varchar(64) NOT NULL,
@ -393,9 +460,9 @@ sub setupInstall {
} else {
# check its schema is up to date
}
if (not $helper->tableExists($app, $self->database($app), 'userGroupsMapping')) {
if (not $helper->tableExists($app, $database, 'userGroupsMapping')) {
$app->output->setupProgress('dataSource.user.userGroupsMapping');
$self->database($app)->execute('
$database->execute('
CREATE TABLE userGroupsMapping (
userID integer unsigned NOT NULL,
groupID integer unsigned NOT NULL,
@ -412,14 +479,15 @@ sub setupInstall {
# +-------------------+
# [1]: level 0 is reserved for 'not a member' which in the database is represented by absence
} else {
$app->output->setupProgress('dataSource.user.userGroupsMapping.schemaChanges');
# check its schema is up to date
if (not $helper->columnExists($app, $self->database($app), 'userGroupsMapping', 'level')) {
$self->database($app)->execute('ALTER TABLE userGroupsMapping ADD COLUMN level integer unsigned NOT NULL DEFAULT 1');
if (not $helper->columnExists($app, $database, 'userGroupsMapping', 'level')) {
$database->execute('ALTER TABLE userGroupsMapping ADD COLUMN level integer unsigned NOT NULL DEFAULT 1');
}
}
if (not $helper->tableExists($app, $self->database($app), 'groups')) {
if (not $helper->tableExists($app, $database, 'groups')) {
$app->output->setupProgress('dataSource.user.groups');
$self->database($app)->execute('
$database->execute('
CREATE TABLE groups (
groupID integer unsigned auto_increment NOT NULL PRIMARY KEY,
name varchar(255) NOT NULL,
@ -435,9 +503,9 @@ sub setupInstall {
} else {
# check its schema is up to date
}
if (not $helper->tableExists($app, $self->database($app), 'groupRightsMapping')) {
if (not $helper->tableExists($app, $database, 'groupRightsMapping')) {
$app->output->setupProgress('dataSource.user.groupRightsMapping');
$self->database($app)->execute('
$database->execute('
CREATE TABLE groupRightsMapping (
groupID integer unsigned NOT NULL,
rightID integer unsigned NOT NULL,
@ -453,9 +521,9 @@ sub setupInstall {
} else {
# check its schema is up to date
}
if (not $helper->tableExists($app, $self->database($app), 'rights')) {
if (not $helper->tableExists($app, $database, 'rights')) {
$app->output->setupProgress('dataSource.user.rights');
$self->database($app)->execute('
$database->execute('
CREATE TABLE rights (
rightID integer unsigned auto_increment NOT NULL PRIMARY KEY,
name varchar(255) NOT NULL,
@ -471,6 +539,5 @@ sub setupInstall {
} else {
# check its schema is up to date
}
$self->dump(9, 'done configuring user data source');
return;
}

Просмотреть файл

@ -108,6 +108,7 @@ sub populateUserPrefsRights {
$user->hasRight('userPrefs.editOthers.personalDetails'), # XXX
$user->hasRight('userPrefs.editOthers.settings'), # XXX
$user->hasRight('userPrefs.editOthers.groups'));
# note there is no 'userPrefs.editOthers.state'
}
sub populateUserPrefsHash {
@ -132,6 +133,7 @@ sub populateUserPrefsHash {
$self->populateUserPrefsHashFieldCategory($app, $targetUser, $rightContactMethods, 'contact', $editingUserIsTargetUser, $userData);
$self->populateUserPrefsHashFieldCategory($app, $targetUser, $rightPersonalDetails, 'personal', $editingUserIsTargetUser, $userData);
$self->populateUserPrefsHashFieldCategory($app, $targetUser, $rightSettings, 'settings', $editingUserIsTargetUser, $userData);
# don't do the 'state' category
$userData->{'groups'} = {};
if ($rightGroups) {
@ -370,10 +372,15 @@ sub applyUserPrefsFieldChange {
return [$targetUserID, "fields.$fieldCategory.$fieldName", 'contact.cannotRemoveLastContactMethod'];
}
} else {
my($crypt, $password) = $app->getService('service.passwords')->newPassword();
$targetUser->prepareAddressChange($field, $newValue, $crypt);
# send confirmation request:
$app->output($fieldName, $targetUser)->loginDetails($field->username, $password);
my $field = $targetUser->getField('contact', $fieldName);
my($overrideCrypt, $overridePassword) = $app->getService('service.passwords')->newPassword();
my $overrideToken = $targetUser->addFieldChange($field, $field->data, $overrideCrypt, 1); # XXX BARE CONSTANT ALERT
my($changeCrypt, $changePassword) = $app->getService('service.passwords')->newPassword();
my $changeToken = $targetUser->addFieldChange($field, $newValue, $changeCrypt, 0); # XXX BARE CONSTANT ALERT
# send confirmation requests:
$app->output($fieldName, $targetUser)->userPrefsOverrideDetails($overrideToken, $overridePassword);
$field->returnNewData();
$app->output($fieldName, $targetUser)->userPrefsChangeDetails($changeToken, $changePassword);
return [$targetUserID, "fields.$fieldCategory.$fieldName", 'contact.confirmationSent'];
}
} elsif ($rightContactMethods) {
@ -419,6 +426,26 @@ sub applyUserPrefsFieldChange {
return;
}
# dispatcher.commands
sub cmdUserPrefsConfirmSet {
my $self = shift;
my($app) = @_;
my $user = $app->getObject('user');
if (not defined($user)) {
$app->getService('user.login')->requireLogin($app);
return;
}
if ($user->performFieldChange($app,
$app->input->getArgument('changeID'),
$app->input->getArgument('changePassword'),
0)) { # XXX
$self->output->userPrefsSuccess();
} else {
$self->output->userPrefsNotification([[$user->userID, 'change', 'accessDenied']]);
}
}
# dispatcher.output.generic
sub outputUserPrefs {
my $self = shift;
@ -435,7 +462,7 @@ sub outputUserPrefsNotification {
my $self = shift;
my($app, $output, $notifications) = @_;
$output->output('userPrefs.notification', {
'notifications' => $notifications,,
'notifications' => $notifications,
});
}
@ -446,19 +473,37 @@ sub outputUserPrefsSuccess {
$output->output('userPrefs.success', {});
}
# dispatcher.output.generic
sub outputUserPrefsOverrideDetails {
my $self = shift;
my($app, $output, $overrideToken, $overridePassword) = @_;
$output->output('userPrefs.change.overrideDetails', {
'token' => $overrideToken,
'password' => $overridePassword,
});
}
# dispatcher.output.generic
sub outputUserPrefsChangeDetails {
my $self = shift;
my($app, $output, $changeToken, $changePassword) = @_;
$output->output('userPrefs.change.changeDetails', {
'token' => $changeToken,
'password' => $changePassword,
});
}
# dispatcher.output
sub strings {
return (
'userPrefs.index' => 'The user preferences editor. This will be passed a stack of user profiles in a multi-level hash called data.userData, the array of userIDs in data.userIDs, and the details of each field in another multi-level hash as data.metaData.',
'userPrefs.notification' => 'If anything needs to be reported after the prefs are submitted then this will be called. Things to notify are reported in data.notifications, which is an array of arrays containing the userID relatd to the notification, the field related to the notification, and the notification type, which will be one of: contact.confirmationSent (no error occured), contact.cannotRemoveLastContactMethod (user error), password.mismatch.new (user error), password.mismatch.old *user error), user.noSuchUser (internal error), invalid (internal error), field.unknownCategory (internal error), accessDenied (internal error). The internal errors could also be caused by a user attempting to circumvent the security system.',
'userPrefs.success' => 'If the user preferences are successfully submitted, this will be used. Typically this will simply be the main application command index..',
'userPrefs.success' => 'If the user preferences are successfully submitted, this will be used. Typically this will simply be the main application command index.',
'userPrefs.change.overrideDetails' => 'The message that is sent containing the token and password required to override a change of address.',
'userPrefs.change.changeDetails' => 'The message that is sent containing the token and password required to confirm a change of address.',
);
}
# outputLoginDetails() should be provided by some other service
# setup.install
sub setupInstall {
my $self = shift;

Просмотреть файл

@ -96,7 +96,7 @@ sub getUserByID {
sub getNewUser {
my $self = shift;
my($app, $password) = @_;
return $self->objectCreate($app, undef, 0, $password, '', '', '', '', {}, {}, []);
return $self->objectCreate($app, undef, 0, $password, '', '', '', '', {}, [], []);
}
sub objectProvides {
@ -107,16 +107,13 @@ sub objectProvides {
sub objectInit {
my $self = shift;
my($app, $userID, $mode, $password, $adminMessage, $newFieldID, $newFieldValue, $newFieldPassword, $fields, $groups, $rights) = @_;
my($app, $userID, $mode, $password, $adminMessage, $fields, $groups, $rights) = @_;
$self->{'_DIRTY'} = {}; # make sure propertySet is happy
$self->SUPER::objectInit(@_);
$self->userID($userID);
$self->mode($mode); # 0=active, 1=disabled XXX need a way to make this extensible
$self->password($password);
$self->adminMessage($adminMessage);
$self->newFieldID($newFieldID);
$self->newFieldValue($newFieldValue);
$self->newFieldPassword($newFieldPassword);
$self->fields({});
$self->fieldsByID({});
# don't forget to update the 'hash' function if you add more properties/field whatever you want to call them
@ -192,58 +189,37 @@ sub getAddress {
}
}
sub prepareAddressChange {
sub addFieldChange {
my $self = shift;
my($field, $newAddress, $password) = @_;
if ($field->validate($newAddress)) {
$self->newFieldID($field->fieldID);
$self->newFieldValue($newAddress);
$self->newFieldPassword($password);
$field->prepareAddressChange($newAddress);
return $self;
} else {
return undef;
}
my($field, $newData, $password, $type) = @_;
$field->prepareChange($newData);
return $self->app->getService('dataSource.user')->setUserFieldChange($self->app, $self->userID, $field->fieldID, $newData, $password, $type);
}
# Call this if you don't yet have a field handle.
# If you have got a new field already, it is safe to just call prepareAddressChange().
sub prepareAddressAddition {
sub performFieldChange {
my $self = shift;
my($fieldName, $newAddress, $password) = @_;
my $field = $self->insertField($self->app->getService('user.fieldFactory')->createFieldByName($self->app, $self, 'contact', $fieldName, undef));
return $self->prepareAddressChange($field, $newAddress, $password);
}
sub doAddressChange {
my $self = shift;
my($password) = @_;
# it is defined that if $password is undefined, then this method
# will reset the fields to prevent multiple attempts. See the
# resetAddressChange() method.
if ($self->newFieldID) {
my $field = $self->fieldsByID->{$self->newFieldID};
$self->assert(defined($field), 1, 'Database integrity error: newFieldID doesn\'t map to a field!');
if (defined($password) and ($self->app->getService('service.passwords')->checkPassword($self->newFieldPassword, $password))) {
$field->data($self->newFieldValue);
} elsif (not defined($field->data)) {
$field->remove();
}
$self->newFieldID(undef);
$self->newFieldValue(undef);
$self->newFieldPassword(undef);
return $field;
} else {
my($changeID, $candidatePassword, $minTime) = @_;
my $dataSource = $self->app->getService('dataSource.user');
my($userID, $fieldID, $newData, $password, $createTime, $type) = $dataSource->getUserFieldChangeFromChangeID($self->app, $changeID);
# check for valid change
if (($userID != $self->userID) or # wrong change ID
(not $self->app->getService('service.password')->checkPassword($candidatePassword, $password)) or # wrong password
($createTime < $minTime)) { # expired change
return 0;
}
}
sub resetAddressChange {
my $self = shift;
# calling the doAddressChange() function with no arguments is the
# same as calling doAddressChange() with the wrong password, which
# resets the fields (to prevent multiple attempts)
return defined($self->doAddressChange());
# perform the change
$self->getFieldByID($fieldID)->data($newData);
# remove the change from the list of pending changes
if ($type == 1) { # XXX HARDCODED CONSTANT ALERT
# this is an override change
# remove all pending changes for this field (including this one)
$dataSource->removeUserFieldChangesByUserIDAndFieldID($self->app, $userID, $fieldID);
} else {
# this is a normal change
# remove just this change
$dataSource->removeUserFieldChangesByChangeID($self->app, $changeID);
}
return 1;
}
# a convenience method for either setting a user setting from a new

Просмотреть файл

@ -64,8 +64,9 @@ sub init {
$self->name($fieldName); # change this at your peril
$self->typeData($fieldTypeData); # change this at your peril
$self->mode($fieldMode); # change this at your peril
$self->data($fieldData); # this is the only thing you should be changing
$self->{'data'} = $fieldData; # read this via $field->data and write via $field->data($foo)
# don't forget to update the 'hash' function if you add more member variables here
$self->{'_DATAFIELD'} = 'data';
$self->{'_DELETE'} = 0;
$self->{'_DIRTY'} = 0;
}
@ -87,6 +88,22 @@ sub remove {
$self->{'_DIRTY'} = 1;
}
sub data {
my $self = shift;
if (@_) {
my($value) = @_;
if (defined($value)) {
$self->assert($self->validate($value), 0, 'tried to set data to invalid value'); # XXX might want to provide more debugging data
$self->{'data'} = $value;
$self->{'_DIRTY'} = 1;
} else {
$self->remove();
}
} else {
return $self->{$self->{'_DATAFIELD'}};
}
}
sub hash {
my $self = shift;
return $self->data;
@ -98,41 +115,43 @@ sub hash {
# followed by the field data itself
sub username {
my $self = shift;
$self->assert($self->category eq 'contact', 1, 'Tried to get the username from the non-contact field \''.($self->fieldID).'\'');
$self->assert($self->category eq 'contact', 0, 'Tried to get the username from the non-contact field \''.($self->fieldID).'\'');
return $self->typeData.$self->data;
}
sub address {
my $self = shift;
$self->assert($self->category eq 'contact', 1, 'Tried to get the address of the non-contact field \''.($self->fieldID).'\'');
if ($self->propertyExists('newAddress')) {
return $self->newAddress;
} else {
return $self->data;
}
$self->assert($self->category eq 'contact', 0, 'Tried to get the address of the non-contact field \''.($self->fieldID).'\'');
return $self->data;
}
sub prepareAddressChange {
sub prepareChange {
my $self = shift;
my($newAddress) = @_;
$self->assert($self->category eq 'contact', 1, 'Tried to change the address of the non-contact field \''.($self->fieldID).'\'');
$self->{'newAddress'} = $newAddress; # access directly so as not to set the _DIRTY flag
my($newData) = @_;
$self->assert($self->validate($newData), 0, 'tried to prepare change to invalid value'); # XXX might want to provide more debugging data
$self->newData($newData);
}
# sets a flag so that calls to ->data and ->address will return the
# value as stored in the database
sub returnOldData {
my $self = shift;
my($newData) = @_;
$self->{'_DATAFIELD'} = 'data';
}
# sets a flag so that calls to ->data and ->address will return the
# value set by prepareChange() rather than the actual value of the
# field (as stored in the database)
sub returnNewData {
my $self = shift;
my($newData) = @_;
$self->{'_DATAFIELD'} = 'newData';
}
# Internal Routines
sub propertySet {
my $self = shift;
my($name, $value) = @_;
if ($name eq 'data') {
$self->assert($self->validate($value), 0, 'tried to set data to invalid value'); # XXX might want to provide more debugging data
}
my $result = $self->SUPER::propertySet(@_);
$self->{'_DIRTY'} = 1;
return $result;
}
sub DESTROY {
my $self = shift;
if ($self->{'_DIRTY'}) {