diff options
Diffstat (limited to 'rt/lib/RT/Shredder.pm')
-rw-r--r-- | rt/lib/RT/Shredder.pm | 868 |
1 files changed, 0 insertions, 868 deletions
diff --git a/rt/lib/RT/Shredder.pm b/rt/lib/RT/Shredder.pm deleted file mode 100644 index 5e1e86ecf..000000000 --- a/rt/lib/RT/Shredder.pm +++ /dev/null @@ -1,868 +0,0 @@ -# BEGIN BPS TAGGED BLOCK {{{ -# -# COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC -# <jesse@bestpractical.com> -# -# (Except where explicitly superseded by other copyright notices) -# -# -# LICENSE: -# -# This work is made available to you under the terms of Version 2 of -# the GNU General Public License. A copy of that license should have -# been provided with this software, but in any event can be snarfed -# from www.gnu.org. -# -# This work is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA -# 02110-1301 or visit their web page on the internet at -# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. -# -# -# CONTRIBUTION SUBMISSION POLICY: -# -# (The following paragraph is not intended to limit the rights granted -# to you to modify and distribute this software under the terms of -# the GNU General Public License and is only of importance to you if -# you choose to contribute your changes and enhancements to the -# community by submitting them to Best Practical Solutions, LLC.) -# -# By intentionally submitting any modifications, corrections or -# derivatives to this work, or any other work intended for use with -# Request Tracker, to Best Practical Solutions, LLC, you confirm that -# you are the copyright holder for those contributions and you grant -# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, -# royalty-free, perpetual, license to use, copy, create derivative -# works based on those contributions, and sublicense and distribute -# those contributions and any derivatives thereof. -# -# END BPS TAGGED BLOCK }}} - -package RT::Shredder; - -use strict; -use warnings; - - -=head1 NAME - -RT::Shredder - Permanently wipeout data from RT - - -=head1 SYNOPSIS - -=head2 CLI - - rt-shredder --force --plugin 'Tickets=queue,general;status,deleted' - - -=head1 DESCRIPTION - -RT::Shredder is extension to RT which allows you to permanently wipeout -data from the RT database. Shredder supports the wiping of almost -all RT objects (Tickets, Transactions, Attachments, Users...). - - -=head2 "Delete" vs "Wipeout" - -RT uses the term "delete" to mean "deactivate". To avoid confusion, -RT::Shredder uses the term "Wipeout" to mean "permanently erase" (or -what most people would think of as "delete"). - - -=head2 Why do you want this? - -Normally in RT, "deleting" an item simply deactivates it and makes it -invisible from view. This is done to retain full history and -auditability of your tickets. For most RT users this is fine and they -have no need of RT::Shredder. - -But in some large and heavily used RT instances the database can get -clogged up with junk, particularly spam. This can slow down searches -and bloat the size of the database. For these users, RT::Shredder -allows them to completely clear the database of this unwanted junk. - -An additional use of Shredder is to obliterate sensitive information -(passwords, credit card numbers, ...) which might have made their way -into RT. - - -=head2 Command line tools (CLI) - -L<rt-shredder> is a program which allows you to wipe objects from -command line or with system tasks scheduler (cron, for example). -See also 'rt-shredder --help'. - - -=head2 Web based interface (WebUI) - -Shredder's WebUI integrates into RT's WebUI. You can find it in the -Configuration->Tools->Shredder tab. The interface is similar to the -CLI and gives you the same functionality. You can find 'Shredder' link -at the bottom of tickets search results, so you could wipeout tickets -in the way similar to the bulk update. - - -=head1 DATA STORAGE AND BACKUPS - -Shredder allows you to store data you wiped in files as scripts with SQL -commands. - -=head3 Restoring from backup - -Should you wipeout something you did not intend to the objects can be -restored by using the storage files. These files are a simple set of -SQL commands to re-insert your objects into the RT database. - -1) Locate the appropriate shredder SQL dump file. In the WebUI, when - you use shredder, the path to the dump file is displayed. It also - gives the option to download the dump file after each wipeout. Or - it can be found in your C<$ShredderStoragePath>. - -2) Load the shredder SQL dump into your RT database. The details will - be different for each database and RT configuration, consult your - database manual and RT config. For example, in MySQL... - - mysql -u your_rt_user -p your_rt_database < /path/to/rt/var/data/shredder/dump.sql - -That's it.i This will restore everything you'd deleted during a -shredding session when the file had been created. - -=head1 CONFIGURATION - -=head2 $RT::DependenciesLimit - -Shredder stops with an error if the object has more than -C<$RT::DependenciesLimit> dependencies. For example: a ticket has 1000 -transactions or a transaction has 1000 attachments. This is protection -from bugs in shredder from wiping out your whole database, but -sometimes when you have big mail loops you may hit it. - -Defaults to 1000. - -You can change the default value, in F<RT_SiteConfig.pm> add C<Set( -$DependenciesLimit, new_limit );> - - -=head2 $ShredderStoragePath - -Directory containing Shredder backup dumps. - -Defaults to F</path-to-RT-var-dir/data/RT-Shredder>. - -You can change the default value, in F<RT_SiteConfig.pm> add C<Set( -$ShredderStoragePath, new_path );> Be sure to use an absolute path. - - -=head1 INFORMATION FOR DEVELOPERS - -=head2 General API - -L<RT::Shredder> is an extension to RT which adds shredder methods to -RT objects and classes. The API is not well documented yet, but you -can find usage examples in L<rt-shredder> and the -F<lib/t/regression/shredder/*.t> test files. - -However, here is a small example that do the same action as in CLI -example from L</SYNOPSIS>: - - use RT::Shredder; - RT::Shredder::Init( force => 1 ); - my $deleted = RT::Tickets->new( $RT::SystemUser ); - $deleted->{'allow_deleted_search'} = 1; - $deleted->LimitQueue( VALUE => 'general' ); - $deleted->LimitStatus( VALUE => 'deleted' ); - while( my $t = $deleted->Next ) { - $t->Wipeout; - } - - -=head2 RT::Shredder class' API - -L<RT::Shredder> implements interfaces to objects cache, actions on the -objects in the cache and backups storage. - -=cut - -our $VERSION = '0.04'; -use File::Spec (); - - -BEGIN { -# I can't use 'use lib' here since it breakes tests -# because test suite uses old RT::Shredder setup from -# RT lib path - -### after: push @INC, qw(@RT_LIB_PATH@); - push @INC, qw(/opt/rt3/local/lib /opt/rt3/lib); - use RT::Shredder::Constants; - use RT::Shredder::Exceptions; - - require RT; - - require RT::Shredder::Record; - - require RT::Shredder::ACE; - require RT::Shredder::Attachment; - require RT::Shredder::CachedGroupMember; - require RT::Shredder::CustomField; - require RT::Shredder::CustomFieldValue; - require RT::Shredder::GroupMember; - require RT::Shredder::Group; - require RT::Shredder::Link; - require RT::Shredder::Principal; - require RT::Shredder::Queue; - require RT::Shredder::Scrip; - require RT::Shredder::ScripAction; - require RT::Shredder::ScripCondition; - require RT::Shredder::Template; - require RT::Shredder::ObjectCustomFieldValue; - require RT::Shredder::Ticket; - require RT::Shredder::Transaction; - require RT::Shredder::User; -} - -our @SUPPORTED_OBJECTS = qw( - ACE - Attachment - CachedGroupMember - CustomField - CustomFieldValue - GroupMember - Group - Link - Principal - Queue - Scrip - ScripAction - ScripCondition - Template - ObjectCustomFieldValue - Ticket - Transaction - User -); - -=head3 GENERIC - -=head4 Init - - RT::Shredder::Init( %default_options ); - -C<RT::Shredder::Init()> should be called before creating an -RT::Shredder object. It iniitalizes RT and loads the RT -configuration. - -%default_options are passed to every C<<RT::Shredder->new>> call. - -=cut - -our %opt = (); - -sub Init -{ - %opt = @_; - RT::LoadConfig(); - RT::Init(); -} - -=head4 new - - my $shredder = RT::Shredder->new(%options); - -Construct a new RT::Shredder object. - -There currently are no %options. - -=cut - -sub new -{ - my $proto = shift; - my $self = bless( {}, ref $proto || $proto ); - $self->_Init( @_ ); - return $self; -} - -sub _Init -{ - my $self = shift; - $self->{'opt'} = { %opt, @_ }; - $self->{'cache'} = {}; - $self->{'resolver'} = {}; - $self->{'dump_plugins'} = []; -} - -=head4 CastObjectsToRecords( Objects => undef ) - -Cast objects to the C<RT::Record> objects or its ancesstors. -Objects can be passed as SCALAR (format C<< <class>-<id> >>), -ARRAY, C<RT::Record> ancesstors or C<RT::SearchBuilder> ancesstor. - -Most methods that takes C<Objects> argument use this method to -cast argument value to list of records. - -Returns an array of records. - -For example: - - my @objs = $shredder->CastObjectsToRecords( - Objects => [ # ARRAY reference - 'RT::Attachment-10', # SCALAR or SCALAR reference - $tickets, # RT::Tickets object (isa RT::SearchBuilder) - $user, # RT::User object (isa RT::Record) - ], - ); - -=cut - -sub CastObjectsToRecords -{ - my $self = shift; - my %args = ( Objects => undef, @_ ); - - my @res; - my $targets = delete $args{'Objects'}; - unless( $targets ) { - RT::Shredder::Exception->throw( "Undefined Objects argument" ); - } - - if( UNIVERSAL::isa( $targets, 'RT::SearchBuilder' ) ) { - #XXX: try to use ->_DoSearch + ->ItemsArrayRef in feature - # like we do in Record with links, but change only when - # more tests would be available - while( my $tmp = $targets->Next ) { push @res, $tmp }; - } elsif ( UNIVERSAL::isa( $targets, 'RT::Record' ) ) { - push @res, $targets; - } elsif ( UNIVERSAL::isa( $targets, 'ARRAY' ) ) { - foreach( @$targets ) { - push @res, $self->CastObjectsToRecords( Objects => $_ ); - } - } elsif ( UNIVERSAL::isa( $targets, 'SCALAR' ) || !ref $targets ) { - $targets = $$targets if ref $targets; - my ($class, $id) = split /-/, $targets; - $class = 'RT::'. $class unless $class =~ /^RTx?::/i; - eval "require $class"; - die "Couldn't load '$class' module" if $@; - my $obj = $class->new( $RT::SystemUser ); - die "Couldn't construct new '$class' object" unless $obj; - $obj->Load( $id ); - unless ( $obj->id ) { - $RT::Logger->error( "Couldn't load '$class' object with id '$id'" ); - RT::Shredder::Exception::Info->throw( 'CouldntLoadObject' ); - } - die "Loaded object has different id" unless( $id eq $obj->id ); - push @res, $obj; - } else { - RT::Shredder::Exception->throw( "Unsupported type ". ref $targets ); - } - return @res; -} - -=head3 OBJECTS CACHE - -=head4 PutObjects( Objects => undef ) - -Puts objects into cache. - -Returns array of the cache entries. - -See C<CastObjectsToRecords> method for supported types of the C<Objects> -argument. - -=cut - -sub PutObjects -{ - my $self = shift; - my %args = ( Objects => undef, @_ ); - - my @res; - for( $self->CastObjectsToRecords( Objects => delete $args{'Objects'} ) ) { - push @res, $self->PutObject( %args, Object => $_ ) - } - - return @res; -} - -=head4 PutObject( Object => undef ) - -Puts record object into cache and returns its cache entry. - -B<NOTE> that this method support B<only C<RT::Record> object or its ancesstor -objects>, if you want put mutliple objects or objects represented by different -classes then use C<PutObjects> method instead. - -=cut - -sub PutObject -{ - my $self = shift; - my %args = ( Object => undef, @_ ); - - my $obj = $args{'Object'}; - unless( UNIVERSAL::isa( $obj, 'RT::Record' ) ) { - RT::Shredder::Exception->throw( "Unsupported type '". (ref $obj || $obj || '(undef)')."'" ); - } - - my $str = $obj->_AsString; - return ($self->{'cache'}->{ $str } ||= { State => ON_STACK, Object => $obj } ); -} - -=head4 GetObject, GetState, GetRecord( String => ''| Object => '' ) - -Returns record object from cache, cache entry state or cache entry accordingly. - -All three methods takes C<String> (format C<< <class>-<id> >>) or C<Object> argument. -C<String> argument has more priority than C<Object> so if it's not empty then methods -leave C<Object> argument unchecked. - -You can read about possible states and their meanings in L<RT::Shredder::Constants> docs. - -=cut - -sub _ParseRefStrArgs -{ - my $self = shift; - my %args = ( - String => '', - Object => undef, - @_ - ); - if( $args{'String'} && $args{'Object'} ) { - require Carp; - Carp::croak( "both String and Object args passed" ); - } - return $args{'String'} if $args{'String'}; - return $args{'Object'}->_AsString if UNIVERSAL::can($args{'Object'}, '_AsString' ); - return ''; -} - -sub GetObject { return (shift)->GetRecord( @_ )->{'Object'} } -sub GetState { return (shift)->GetRecord( @_ )->{'State'} } -sub GetRecord -{ - my $self = shift; - my $str = $self->_ParseRefStrArgs( @_ ); - return $self->{'cache'}->{ $str }; -} - -=head3 Dependencies resolvers - -=head4 PutResolver, GetResolvers and ApplyResolvers - -TODO: These methods have no documentation. - -=cut - -sub PutResolver -{ - my $self = shift; - my %args = ( - BaseClass => '', - TargetClass => '', - Code => undef, - @_, - ); - unless( UNIVERSAL::isa( $args{'Code'} => 'CODE' ) ) { - die "Resolver '$args{Code}' is not code reference"; - } - - my $resolvers = ( - ( - $self->{'resolver'}->{ $args{'BaseClass'} } ||= {} - )->{ $args{'TargetClass'} || '' } ||= [] - ); - unshift @$resolvers, $args{'Code'}; - return; -} - -sub GetResolvers -{ - my $self = shift; - my %args = ( - BaseClass => '', - TargetClass => '', - @_, - ); - - my @res; - if( $args{'TargetClass'} && exists $self->{'resolver'}->{ $args{'BaseClass'} }->{ $args{'TargetClass'} } ) { - push @res, @{ $self->{'resolver'}->{ $args{'BaseClass'} }->{ $args{'TargetClass'} || '' } }; - } - if( exists $self->{'resolver'}->{ $args{'BaseClass'} }->{ '' } ) { - push @res, @{ $self->{'resolver'}->{ $args{'BaseClass'} }->{''} }; - } - - return @res; -} - -sub ApplyResolvers -{ - my $self = shift; - my %args = ( Dependency => undef, @_ ); - my $dep = $args{'Dependency'}; - - my @resolvers = $self->GetResolvers( - BaseClass => $dep->BaseClass, - TargetClass => $dep->TargetClass, - ); - - unless( @resolvers ) { - RT::Shredder::Exception::Info->throw( - tag => 'NoResolver', - error => "Couldn't find resolver for dependency '". $dep->AsString ."'", - ); - } - $_->( - Shredder => $self, - BaseObject => $dep->BaseObject, - TargetObject => $dep->TargetObject, - ) foreach @resolvers; - - return; -} - -sub WipeoutAll -{ - my $self = $_[0]; - - while ( my ($k, $v) = each %{ $self->{'cache'} } ) { - next if $v->{'State'} & (WIPED | IN_WIPING); - $self->Wipeout( Object => $v->{'Object'} ); - } -} - -sub Wipeout -{ - my $self = shift; - my $mark; - eval { - die "Couldn't begin transaction" unless $RT::Handle->BeginTransaction; - $mark = $self->PushDumpMark or die "Couldn't get dump mark"; - $self->_Wipeout( @_ ); - $self->PopDumpMark( Mark => $mark ); - die "Couldn't commit transaction" unless $RT::Handle->Commit; - }; - if( $@ ) { - $RT::Handle->Rollback('force'); - $self->RollbackDumpTo( Mark => $mark ) if $mark; - die $@ if RT::Shredder::Exception::Info->caught; - die "Couldn't wipeout object: $@"; - } -} - -sub _Wipeout -{ - my $self = shift; - my %args = ( CacheRecord => undef, Object => undef, @_ ); - - my $record = $args{'CacheRecord'}; - $record = $self->PutObject( Object => $args{'Object'} ) unless $record; - return if $record->{'State'} & (WIPED | IN_WIPING); - - $record->{'State'} |= IN_WIPING; - my $object = $record->{'Object'}; - - $self->DumpObject( Object => $object, State => 'before any action' ); - - unless( $object->BeforeWipeout ) { - RT::Shredder::Exception->throw( "BeforeWipeout check returned error" ); - } - - my $deps = $object->Dependencies( Shredder => $self ); - $deps->List( - WithFlags => DEPENDS_ON | VARIABLE, - Callback => sub { $self->ApplyResolvers( Dependency => $_[0] ) }, - ); - $self->DumpObject( Object => $object, State => 'after resolvers' ); - - $deps->List( - WithFlags => DEPENDS_ON, - WithoutFlags => WIPE_AFTER | VARIABLE, - Callback => sub { $self->_Wipeout( Object => $_[0]->TargetObject ) }, - ); - $self->DumpObject( Object => $object, State => 'after wiping dependencies' ); - - $object->__Wipeout; - $record->{'State'} |= WIPED; delete $record->{'Object'}; - $self->DumpObject( Object => $object, State => 'after wipeout' ); - - $deps->List( - WithFlags => DEPENDS_ON | WIPE_AFTER, - WithoutFlags => VARIABLE, - Callback => sub { $self->_Wipeout( Object => $_[0]->TargetObject ) }, - ); - $self->DumpObject( Object => $object, State => 'after late dependencies' ); - - return; -} - -sub ValidateRelations -{ - my $self = shift; - my %args = ( @_ ); - - foreach my $record( values %{ $self->{'cache'} } ) { - next if( $record->{'State'} & VALID ); - $record->{'Object'}->ValidateRelations( Shredder => $self ); - } -} - -=head3 Data storage and backups - -=head4 GetFileName( FileName => '<ISO DATETIME>-XXXX.sql', FromStorage => 1 ) - -Takes desired C<FileName> and flag C<FromStorage> then translate file name to absolute -path by next rules: - -* Default value of the C<FileName> option is C<< <ISO DATETIME>-XXXX.sql >>; - -* if C<FileName> has C<XXXX> (exactly four uppercase C<X> letters) then it would be changed with digits from 0000 to 9999 range, with first one free value; - -* if C<FileName> has C<%T> then it would be replaced with the current date and time in the C<YYYY-MM-DDTHH:MM:SS> format. Note that using C<%t> may still generate not unique names, using C<XXXX> recomended. - -* if C<FromStorage> argument is true (default behaviour) then result path would always be relative to C<StoragePath>; - -* if C<FromStorage> argument is false then result would be relative to the current dir unless it's already absolute path. - -Returns an absolute path of the file. - -Examples: - # file from storage with default name format - my $fname = $shredder->GetFileName; - - # file from storage with custom name format - my $fname = $shredder->GetFileName( FileName => 'shredder-XXXX.backup' ); - - # file with path relative to the current dir - my $fname = $shredder->GetFileName( - FromStorage => 0, - FileName => 'backups/shredder.sql', - ); - - # file with absolute path - my $fname = $shredder->GetFileName( - FromStorage => 0, - FileName => '/var/backups/shredder-XXXX.sql' - ); - -=cut - -sub GetFileName -{ - my $self = shift; - my %args = ( FileName => '', FromStorage => 1, @_ ); - - # default value - my $file = $args{'FileName'} || '%t-XXXX.sql'; - if( $file =~ /\%t/i ) { - require POSIX; - my $date_time = POSIX::strftime( "%Y%m%dT%H%M%S", gmtime ); - $file =~ s/\%t/$date_time/gi; - } - - # convert to absolute path - if( $args{'FromStorage'} ) { - $file = File::Spec->catfile( $self->StoragePath, $file ); - } elsif( !File::Spec->file_name_is_absolute( $file ) ) { - $file = File::Spec->rel2abs( $file ); - } - - # check mask - if( $file =~ /XXXX[^\/\\]*$/ ) { - my( $tmp, $i ) = ( $file, 0 ); - do { - $i++; - $tmp = $file; - $tmp =~ s/XXXX([^\/\\]*)$/sprintf("%04d", $i).$1/e; - } while( -e $tmp && $i < 9999 ); - $file = $tmp; - } - - if( -f $file ) { - unless( -w _ ) { - die "File '$file' exists, but is read-only"; - } - } elsif( !-e _ ) { - unless( File::Spec->file_name_is_absolute( $file ) ) { - $file = File::Spec->rel2abs( $file ); - } - - # check base dir - my $dir = File::Spec->join( (File::Spec->splitpath( $file ))[0,1] ); - unless( -e $dir && -d _) { - die "Base directory '$dir' for file '$file' doesn't exist"; - } - unless( -w $dir ) { - die "Base directory '$dir' is not writable"; - } - } else { - die "'$file' is not regular file"; - } - - return $file; -} - -=head4 StoragePath - -Returns an absolute path to the storage dir. See -L<CONFIGURATION/$ShredderStoragePath>. - -See also description of the L</GetFileName> method. - -=cut - -sub StoragePath -{ - return scalar( RT->Config->Get('ShredderStoragePath') ) - || File::Spec->catdir( $RT::VarPath, qw(data RT-Shredder) ); -} - -my %active_dump_state = (); -sub AddDumpPlugin { - my $self = shift; - my %args = ( Object => undef, Name => 'SQLDump', Arguments => undef, @_ ); - - my $plugin = $args{'Object'}; - unless ( $plugin ) { - require RT::Shredder::Plugin; - $plugin = RT::Shredder::Plugin->new; - my( $status, $msg ) = $plugin->LoadByName( $args{'Name'} ); - die "Couldn't load dump plugin: $msg\n" unless $status; - } - die "Plugin is not of correct type" unless lc $plugin->Type eq 'dump'; - - if ( my $pargs = $args{'Arguments'} ) { - my ($status, $msg) = $plugin->TestArgs( %$pargs ); - die "Couldn't set plugin args: $msg\n" unless $status; - } - - my @applies_to = $plugin->AppliesToStates; - die "Plugin doesn't apply to any state" unless @applies_to; - $active_dump_state{ lc $_ } = 1 foreach @applies_to; - - push @{ $self->{'dump_plugins'} }, $plugin; - - return $plugin; -} - -sub DumpObject { - my $self = shift; - my %args = (Object => undef, State => undef, @_); - die "No state passed" unless $args{'State'}; - return unless $active_dump_state{ lc $args{'State'} }; - - foreach (@{ $self->{'dump_plugins'} }) { - next unless grep lc $args{'State'} eq lc $_, $_->AppliesToStates; - my ($state, $msg) = $_->Run( %args ); - die "Couldn't run plugin: $msg" unless $state; - } -} - -{ my $mark = 1; # XXX: integer overflows? -sub PushDumpMark { - my $self = shift; - $mark++; - foreach (@{ $self->{'dump_plugins'} }) { - my ($state, $msg) = $_->PushMark( Mark => $mark ); - die "Couldn't push mark: $msg" unless $state; - } - return $mark; -} -sub PopDumpMark { - my $self = shift; - foreach (@{ $self->{'dump_plugins'} }) { - my ($state, $msg) = $_->PushMark( @_ ); - die "Couldn't pop mark: $msg" unless $state; - } -} -sub RollbackDumpTo { - my $self = shift; - foreach (@{ $self->{'dump_plugins'} }) { - my ($state, $msg) = $_->RollbackTo( @_ ); - die "Couldn't rollback to mark: $msg" unless $state; - } -} -} - -1; -__END__ - -=head1 NOTES - -=head2 Database transactions support - -Since 0.03_01 RT::Shredder uses database transactions and should be -much safer to run on production servers. - -=head2 Foreign keys - -Mainstream RT doesn't use FKs, but at least I posted DDL script that creates them -in mysql DB, note that if you use FKs then this two valid keys don't allow delete -Tickets because of bug in MySQL: - - ALTER TABLE Tickets ADD FOREIGN KEY (EffectiveId) REFERENCES Tickets(id); - ALTER TABLE CachedGroupMembers ADD FOREIGN KEY (Via) REFERENCES CachedGroupMembers(id); - -L<http://bugs.mysql.com/bug.php?id=4042> - -=head1 BUGS AND HOW TO CONTRIBUTE - -We need your feedback in all cases: if you use it or not, -is it works for you or not. - -=head2 Testing - -Don't skip C<make test> step while install and send me reports if it's fails. -Add your own tests, it's easy enough if you've writen at list one perl script -that works with RT. Read more about testing in F<t/utils.pl>. - -=head2 Reporting - -Send reports to L</AUTHOR> or to the RT mailing lists. - -=head2 Documentation - -Many bugs in the docs: insanity, spelling, gramar and so on. -Patches are wellcome. - -=head2 Todo - -Please, see Todo file, it has some technical notes -about what I plan to do, when I'll do it, also it -describes some problems code has. - -=head2 Repository - -Since RT-3.7 shredder is a part of the RT distribution. -Versions of the RTx::Shredder extension could -be downloaded from the CPAN. Those work with older -RT versions or you can find repository at -L<https://opensvn.csie.org/rtx_shredder> - -=head1 AUTHOR - - Ruslan U. Zakirov <Ruslan.Zakirov@gmail.com> - -=head1 COPYRIGHT - -This program is free software; you can redistribute -it and/or modify it under the same terms as Perl itself. - -The full text of the license can be found in the -Perl distribution. - -=head1 SEE ALSO - -L<rt-shredder>, L<rt-validator> - -=cut |