import rt 3.8.7
[freeside.git] / rt / lib / RT / Attachment_Overlay.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2
3 # COPYRIGHT:
4
5 # This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC
6 #                                          <jesse@bestpractical.com>
7
8 # (Except where explicitly superseded by other copyright notices)
9
10
11 # LICENSE:
12
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
16 # from www.gnu.org.
17
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21 # General Public License for more details.
22
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28
29
30 # CONTRIBUTION SUBMISSION POLICY:
31
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
37
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
46
47 # END BPS TAGGED BLOCK }}}
48
49 =head1 SYNOPSIS
50
51     use RT::Attachment;
52
53 =head1 DESCRIPTION
54
55 This module should never be instantiated directly by client code. it's an internal 
56 module which should only be instantiated through exported APIs in Ticket, Queue and other 
57 similar objects.
58
59 =head1 METHODS
60
61
62
63 =cut
64
65
66 package RT::Attachment;
67
68 use strict;
69 no warnings qw(redefine);
70
71 use RT::Transaction;
72 use MIME::Base64;
73 use MIME::QuotedPrint;
74
75 sub _OverlayAccessible {
76   {
77     TransactionId   => { 'read'=>1, 'public'=>1, 'write' => 0 },
78     MessageId       => { 'read'=>1, 'write' => 0 },
79     Parent          => { 'read'=>1, 'write' => 0 },
80     ContentType     => { 'read'=>1, 'write' => 0 },
81     Subject         => { 'read'=>1, 'write' => 0 },
82     Content         => { 'read'=>1, 'write' => 0 },
83     ContentEncoding => { 'read'=>1, 'write' => 0 },
84     Headers         => { 'read'=>1, 'write' => 0 },
85     Filename        => { 'read'=>1, 'write' => 0 },
86     Creator         => { 'read'=>1, 'auto'=>1, },
87     Created         => { 'read'=>1, 'auto'=>1, },
88   };
89 }
90
91 =head2 Create
92
93 Create a new attachment. Takes a paramhash:
94     
95     'Attachment' Should be a single MIME body with optional subparts
96     'Parent' is an optional id of the parent attachment
97     'TransactionId' is the mandatory id of the transaction this attachment is associated with.;
98
99 =cut
100
101 sub Create {
102     my $self = shift;
103     my %args = ( id            => 0,
104                  TransactionId => 0,
105                  Parent        => 0,
106                  Attachment    => undef,
107                  @_ );
108
109     # For ease of reference
110     my $Attachment = $args{'Attachment'};
111
112     # if we didn't specify a ticket, we need to bail
113     unless ( $args{'TransactionId'} ) {
114         $RT::Logger->crit( "RT::Attachment->Create couldn't, as you didn't specify a transaction" );
115         return (0);
116     }
117
118     # If we possibly can, collapse it to a singlepart
119     $Attachment->make_singlepart;
120
121     # Get the subject
122     my $Subject = $Attachment->head->get( 'subject', 0 );
123     defined($Subject) or $Subject = '';
124     chomp($Subject);
125
126     #Get the Message-ID
127     my $MessageId = $Attachment->head->get( 'Message-ID', 0 );
128     defined($MessageId) or $MessageId = '';
129     chomp ($MessageId);
130     $MessageId =~ s/^<(.*?)>$/$1/o;
131
132     #Get the filename
133     my $Filename = $Attachment->head->recommended_filename;
134
135     # MIME::Head doesn't support perl strings well and can return
136     # octets which later will be double encoded in low-level code
137     my $head = $Attachment->head->as_string;
138     utf8::decode( $head );
139
140     # If a message has no bodyhandle, that means that it has subparts (or appears to)
141     # and we should act accordingly.  
142     unless ( defined $Attachment->bodyhandle ) {
143         my ($id) = $self->SUPER::Create(
144             TransactionId => $args{'TransactionId'},
145             Parent        => $args{'Parent'},
146             ContentType   => $Attachment->mime_type,
147             Headers       => $head,
148             MessageId     => $MessageId,
149             Subject       => $Subject,
150         );
151
152         unless ($id) {
153             $RT::Logger->crit("Attachment insert failed - ". $RT::Handle->dbh->errstr);
154         }
155
156         foreach my $part ( $Attachment->parts ) {
157             my $SubAttachment = new RT::Attachment( $self->CurrentUser );
158             my ($id) = $SubAttachment->Create(
159                 TransactionId => $args{'TransactionId'},
160                 Parent        => $id,
161                 Attachment    => $part,
162             );
163             unless ($id) {
164                 $RT::Logger->crit("Attachment insert failed: ". $RT::Handle->dbh->errstr);
165             }
166         }
167         return ($id);
168     }
169
170     #If it's not multipart
171     else {
172
173         my ($ContentEncoding, $Body) = $self->_EncodeLOB(
174             $Attachment->bodyhandle->as_string,
175             $Attachment->mime_type
176         );
177
178         my $id = $self->SUPER::Create(
179             TransactionId   => $args{'TransactionId'},
180             ContentType     => $Attachment->mime_type,
181             ContentEncoding => $ContentEncoding,
182             Parent          => $args{'Parent'},
183             Headers         => $head,
184             Subject         => $Subject,
185             Content         => $Body,
186             Filename        => $Filename,
187             MessageId       => $MessageId,
188         );
189
190         unless ($id) {
191             $RT::Logger->crit("Attachment insert failed: ". $RT::Handle->dbh->errstr);
192         }
193         return $id;
194     }
195 }
196
197 =head2 Import
198
199 Create an attachment exactly as specified in the named parameters.
200
201 =cut
202
203 sub Import {
204     my $self = shift;
205     my %args = ( ContentEncoding => 'none', @_ );
206
207     ( $args{'ContentEncoding'}, $args{'Content'} ) =
208         $self->_EncodeLOB( $args{'Content'}, $args{'MimeType'} );
209
210     return ( $self->SUPER::Create(%args) );
211 }
212
213 =head2 TransactionObj
214
215 Returns the transaction object asscoiated with this attachment.
216
217 =cut
218
219 sub TransactionObj {
220     my $self = shift;
221
222     unless ( $self->{_TransactionObj} ) {
223         $self->{_TransactionObj} = RT::Transaction->new( $self->CurrentUser );
224         $self->{_TransactionObj}->Load( $self->TransactionId );
225     }
226
227     unless ($self->{_TransactionObj}->Id) {
228         $RT::Logger->crit(  "Attachment ". $self->id
229                            ." can't find transaction ". $self->TransactionId
230                            ." which it is ostensibly part of. That's bad");
231     }
232     return $self->{_TransactionObj};
233 }
234
235 =head2 ParentObj
236
237 Returns a parent's L<RT::Attachment> object if this attachment
238 has a parent, otherwise returns undef.
239
240 =cut
241
242 sub ParentObj {
243     my $self = shift;
244     return undef unless $self->Parent;
245
246     my $parent = RT::Attachment->new( $self->CurrentUser );
247     $parent->LoadById( $self->Parent );
248     return $parent;
249 }
250
251 =head2 Children
252
253 Returns an L<RT::Attachments> object which is preloaded with
254 all attachments objects with this attachment\'s Id as their
255 C<Parent>.
256
257 =cut
258
259 sub Children {
260     my $self = shift;
261     
262     my $kids = RT::Attachments->new( $self->CurrentUser );
263     $kids->ChildrenOf( $self->Id );
264     return($kids);
265 }
266
267 =head2 Content
268
269 Returns the attachment's content. if it's base64 encoded, decode it 
270 before returning it.
271
272 =cut
273
274 sub Content {
275     my $self = shift;
276     return $self->_DecodeLOB(
277         $self->ContentType,
278         $self->ContentEncoding,
279         $self->_Value('Content', decode_utf8 => 0),
280     );
281 }
282
283 =head2 OriginalContent
284
285 Returns the attachment's content as octets before RT's mangling.
286 Currently, this just means restoring text content back to its
287 original encoding.
288
289 =cut
290
291 sub OriginalContent {
292     my $self = shift;
293
294     return $self->Content unless RT::I18N::IsTextualContentType($self->ContentType);
295     my $enc = $self->OriginalEncoding;
296
297     my $content;
298     if ( !$self->ContentEncoding || $self->ContentEncoding eq 'none' ) {
299         $content = $self->_Value('Content', decode_utf8 => 0);
300     } elsif ( $self->ContentEncoding eq 'base64' ) {
301         $content = MIME::Base64::decode_base64($self->_Value('Content', decode_utf8 => 0));
302     } elsif ( $self->ContentEncoding eq 'quoted-printable' ) {
303         $content = MIME::QuotedPrint::decode($self->_Value('Content', decode_utf8 => 0));
304     } else {
305         return( $self->loc("Unknown ContentEncoding [_1]", $self->ContentEncoding));
306     }
307
308     # Turn *off* the SvUTF8 bits here so decode_utf8 and from_to below can work.
309     local $@;
310     Encode::_utf8_off($content);
311
312     if (!$enc || $enc eq '' ||  $enc eq 'utf8' || $enc eq 'utf-8') {
313         # If we somehow fail to do the decode, at least push out the raw bits
314         eval { return( Encode::decode_utf8($content)) } || return ($content);
315     }
316
317     eval { Encode::from_to($content, 'utf8' => $enc) } if $enc;
318     if ($@) {
319         $RT::Logger->error("Could not convert attachment from assumed utf8 to '$enc' :".$@);
320     }
321     return $content;
322 }
323
324 =head2 OriginalEncoding
325
326 Returns the attachment's original encoding.
327
328 =cut
329
330 sub OriginalEncoding {
331     my $self = shift;
332     return $self->GetHeader('X-RT-Original-Encoding');
333 }
334
335 =head2 ContentLength
336
337 Returns length of L</Content> in bytes.
338
339 =cut
340
341 sub ContentLength {
342     my $self = shift;
343
344     return undef unless $self->TransactionObj->CurrentUserCanSee;
345
346     my $len = $self->GetHeader('Content-Length');
347     unless ( defined $len ) {
348         use bytes;
349         no warnings 'uninitialized';
350         $len = length($self->Content);
351         $self->SetHeader('Content-Length' => $len);
352     }
353     return $len;
354 }
355
356 =head2 Quote
357
358 =cut
359
360 sub Quote {
361     my $self=shift;
362     my %args=(Reply=>undef, # Prefilled reply (i.e. from the KB/FAQ system)
363               @_);
364
365     my ($quoted_content, $body, $headers);
366     my $max=0;
367
368     # TODO: Handle Multipart/Mixed (eventually fix the link in the
369     # ShowHistory web template?)
370     if (RT::I18N::IsTextualContentType($self->ContentType)) {
371         $body=$self->Content;
372
373         # Do we need any preformatting (wrapping, that is) of the message?
374
375         # Remove quoted signature.
376         $body =~ s/\n-- \n(.*)$//s;
377
378         # What's the longest line like?
379         foreach (split (/\n/,$body)) {
380             $max=length if ( length > $max);
381         }
382
383         if ($max>76) {
384             require Text::Wrapper;
385             my $wrapper=new Text::Wrapper
386                 (
387                  columns => 70, 
388                  body_start => ($max > 70*3 ? '   ' : ''),
389                  par_start => ''
390                  );
391             $body=$wrapper->wrap($body);
392         }
393
394         $body =~ s/^/> /gm;
395
396         $body = '[' . $self->TransactionObj->CreatorObj->Name() . ' - ' . $self->TransactionObj->CreatedAsString()
397                     . "]:\n\n"
398                 . $body . "\n\n";
399
400     } else {
401         $body = "[Non-text message not quoted]\n\n";
402     }
403     
404     $max=60 if $max<60;
405     $max=70 if $max>78;
406     $max+=2;
407
408     return (\$body, $max);
409 }
410
411 =head2 ContentAsMIME
412
413 Returns MIME entity built from this attachment.
414
415 =cut
416
417 sub ContentAsMIME {
418     my $self = shift;
419
420     my $entity = new MIME::Entity;
421     foreach my $header ($self->SplitHeaders) {
422         my ($h_key, $h_val) = split /:/, $header, 2;
423         $entity->head->add( $h_key, RT::Interface::Email::EncodeToMIME( String => $h_val ) );
424     }
425
426     use MIME::Body;
427     $entity->bodyhandle(
428         MIME::Body::Scalar->new( $self->OriginalContent )
429     );
430
431     return $entity;
432 }
433
434
435 =head2 Addresses
436
437 Returns a hashref of all addresses related to this attachment.
438 The keys of the hash are C<From>, C<To>, C<Cc>, C<Bcc>, C<RT-Send-Cc>
439 and C<RT-Send-Bcc>. The values are references to lists of
440 L<Email::Address> objects.
441
442 =cut
443
444 sub Addresses {
445     my $self = shift;
446
447     my %data = ();
448     my $current_user_address = lc $self->CurrentUser->EmailAddress;
449     my $correspond = lc $self->TransactionObj->TicketObj->QueueObj->CorrespondAddress;
450     my $comment = lc $self->TransactionObj->TicketObj->QueueObj->CommentAddress;
451     foreach my $hdr (qw(From To Cc Bcc RT-Send-Cc RT-Send-Bcc)) {
452         my @Addresses;
453         my $line      = $self->GetHeader($hdr);
454         
455         foreach my $AddrObj ( Email::Address->parse( $line )) {
456             my $address = $AddrObj->address;
457             $address = lc RT::User->CanonicalizeEmailAddress($address);
458             next if ( $current_user_address eq $address );
459             next if ( $comment              eq $address );
460             next if ( $correspond           eq $address );
461             next if ( RT::EmailParser->IsRTAddress($address) );
462             push @Addresses, $AddrObj ;
463         }
464                 $data{$hdr} = \@Addresses;
465     }
466         return \%data;
467 }
468
469 =head2 NiceHeaders
470
471 Returns a multi-line string of the To, From, Cc, Date and Subject headers.
472
473 =cut
474
475 sub NiceHeaders {
476     my $self = shift;
477     my $hdrs = "";
478     my @hdrs = $self->_SplitHeaders;
479     while (my $str = shift @hdrs) {
480             next unless $str =~ /^(To|From|RT-Send-Cc|Cc|Bcc|Date|Subject):/i;
481             $hdrs .= $str . "\n";
482             $hdrs .= shift( @hdrs ) . "\n" while ($hdrs[0] =~ /^[ \t]+/);
483     }
484     return $hdrs;
485 }
486
487 =head2 Headers
488
489 Returns this object's headers as a string.  This method specifically
490 removes the RT-Send-Bcc: header, so as to never reveal to whom RT sent a Bcc.
491 We need to record the RT-Send-Cc and RT-Send-Bcc values so that we can actually send
492 out mail. The mailing rules are separated from the ticket update code by
493 an abstraction barrier that makes it impossible to pass this data directly.
494
495 =cut
496
497 sub Headers {
498     return join("\n", $_[0]->SplitHeaders);
499 }
500
501 =head2 EncodedHeaders
502
503 Takes encoding as argument and returns the attachment's headers as octets in encoded
504 using the encoding.
505
506 This is not protection using quoted printable or base64 encoding.
507
508 =cut
509
510 sub EncodedHeaders {
511     my $self = shift;
512     my $encoding = shift || 'utf8';
513     return Encode::encode( $encoding, $self->Headers );
514 }
515
516 =head2 GetHeader $TAG
517
518 Returns the value of the header Tag as a string. This bypasses the weeding out
519 done in Headers() above.
520
521 =cut
522
523 sub GetHeader {
524     my $self = shift;
525     my $tag = shift;
526     foreach my $line ($self->_SplitHeaders) {
527         next unless $line =~ /^\Q$tag\E:\s+(.*)$/si;
528
529         #if we find the header, return its value
530         return ($1);
531     }
532     
533     # we found no header. return an empty string
534     return undef;
535 }
536
537 =head2 DelHeader $TAG
538
539 Delete a field from the attachment's headers.
540
541 =cut
542
543 sub DelHeader {
544     my $self = shift;
545     my $tag = shift;
546
547     my $newheader = '';
548     foreach my $line ($self->_SplitHeaders) {
549         next if $line =~ /^\Q$tag\E:\s+(.*)$/is;
550         $newheader .= "$line\n";
551     }
552     return $self->__Set( Field => 'Headers', Value => $newheader);
553 }
554
555 =head2 AddHeader $TAG, $VALUE, ...
556
557 Add one or many fields to the attachment's headers.
558
559 =cut
560
561 sub AddHeader {
562     my $self = shift;
563
564     my $newheader = $self->__Value( 'Headers' );
565     while ( my ($tag, $value) = splice @_, 0, 2 ) {
566         $value = '' unless defined $value;
567         $value =~ s/\s+$//s;
568         $value =~ s/\r+\n/\n /g;
569         $newheader .= "$tag: $value\n";
570     }
571     return $self->__Set( Field => 'Headers', Value => $newheader);
572 }
573
574 =head2 SetHeader ( 'Tag', 'Value' )
575
576 Replace or add a Header to the attachment's headers.
577
578 =cut
579
580 sub SetHeader {
581     my $self = shift;
582     my $tag = shift;
583
584     my $newheader = '';
585     foreach my $line ($self->_SplitHeaders) {
586         if (defined $tag and $line =~ /^\Q$tag\E:\s+(.*)$/i) {
587             $newheader .= "$tag: $_[0]\n";
588             undef $tag;
589         }
590         else {
591             $newheader .= "$line\n";
592         }
593     }
594
595     $newheader .= "$tag: $_[0]\n" if defined $tag;
596     $self->__Set( Field => 'Headers', Value => $newheader);
597 }
598
599 =head2 SplitHeaders
600
601 Returns an array of this attachment object's headers, with one header 
602 per array entry. Multiple lines are folded.
603
604 B<Never> returns C<RT-Send-Bcc> field.
605
606 =cut
607
608 sub SplitHeaders {
609     my $self = shift;
610     return (grep !/^RT-Send-Bcc/i, $self->_SplitHeaders(@_) );
611 }
612
613 =head2 _SplitHeaders
614
615 Returns an array of this attachment object's headers, with one header 
616 per array entry. multiple lines are folded.
617
618
619 =cut
620
621 sub _SplitHeaders {
622     my $self = shift;
623     my $headers = (shift || $self->SUPER::Headers());
624     my @headers;
625     for (split(/\n(?=\w|\z)/,$headers)) {
626         push @headers, $_;
627
628     }
629     return(@headers);
630 }
631
632
633 sub Encrypt {
634     my $self = shift;
635
636     my $txn = $self->TransactionObj;
637     return (0, $self->loc('Permission Denied')) unless $txn->CurrentUserCanSee;
638     return (0, $self->loc('Permission Denied'))
639         unless $txn->TicketObj->CurrentUserHasRight('ModifyTicket');
640     return (0, $self->loc('GnuPG integration is disabled'))
641         unless RT->Config->Get('GnuPG')->{'Enable'};
642     return (0, $self->loc('Attachments encryption is disabled'))
643         unless RT->Config->Get('GnuPG')->{'AllowEncryptDataInDB'};
644
645     require RT::Crypt::GnuPG;
646
647     my $type = $self->ContentType;
648     if ( $type =~ /^x-application-rt\/gpg-encrypted/i ) {
649         return (1, $self->loc('Already encrypted'));
650     } elsif ( $type =~ /^multipart\//i ) {
651         return (1, $self->loc('No need to encrypt'));
652     } else {
653         $type = qq{x-application-rt\/gpg-encrypted; original-type="$type"};
654     }
655
656     my $queue = $txn->TicketObj->QueueObj;
657     my $encrypt_for;
658     foreach my $address ( grep $_,
659         $queue->CorrespondAddress,
660         $queue->CommentAddress,
661         RT->Config->Get('CorrespondAddress'),
662         RT->Config->Get('CommentAddress'),
663     ) {
664         my %res = RT::Crypt::GnuPG::GetKeysInfo( $address, 'private' );
665         next if $res{'exit_code'} || !$res{'info'};
666         %res = RT::Crypt::GnuPG::GetKeysForEncryption( $address );
667         next if $res{'exit_code'} || !$res{'info'};
668         $encrypt_for = $address;
669     }
670     unless ( $encrypt_for ) {
671         return (0, $self->loc('No key suitable for encryption'));
672     }
673
674     $self->__Set( Field => 'ContentType', Value => $type );
675     $self->SetHeader( 'Content-Type' => $type );
676
677     my $content = $self->Content;
678     my %res = RT::Crypt::GnuPG::SignEncryptContent(
679         Content => \$content,
680         Sign => 0,
681         Encrypt => 1,
682         Recipients => [ $encrypt_for ],
683     );
684     if ( $res{'exit_code'} ) {
685         return (0, $self->loc('GnuPG error. Contact with administrator'));
686     }
687
688     my ($status, $msg) = $self->__Set( Field => 'Content', Value => $content );
689     unless ( $status ) {
690         return ($status, $self->loc("Couldn't replace content with encrypted data: [_1]", $msg));
691     }
692     return (1, $self->loc('Successfuly encrypted data'));
693 }
694
695 sub Decrypt {
696     my $self = shift;
697
698     my $txn = $self->TransactionObj;
699     return (0, $self->loc('Permission Denied')) unless $txn->CurrentUserCanSee;
700     return (0, $self->loc('Permission Denied'))
701         unless $txn->TicketObj->CurrentUserHasRight('ModifyTicket');
702     return (0, $self->loc('GnuPG integration is disabled'))
703         unless RT->Config->Get('GnuPG')->{'Enable'};
704
705     require RT::Crypt::GnuPG;
706
707     my $type = $self->ContentType;
708     if ( $type =~ /^x-application-rt\/gpg-encrypted/i ) {
709         ($type) = ($type =~ /original-type="(.*)"/i);
710         $type ||= 'application/octeat-stream';
711     } else {
712         return (1, $self->loc('Is not encrypted'));
713     }
714     $self->__Set( Field => 'ContentType', Value => $type );
715     $self->SetHeader( 'Content-Type' => $type );
716
717     my $content = $self->Content;
718     my %res = RT::Crypt::GnuPG::DecryptContent( Content => \$content, );
719     if ( $res{'exit_code'} ) {
720         return (0, $self->loc('GnuPG error. Contact with administrator'));
721     }
722
723     my ($status, $msg) = $self->__Set( Field => 'Content', Value => $content );
724     unless ( $status ) {
725         return ($status, $self->loc("Couldn't replace content with decrypted data: [_1]", $msg));
726     }
727     return (1, $self->loc('Successfuly decrypted data'));
728 }
729
730 =head2 _Value
731
732 Takes the name of a table column.
733 Returns its value as a string, if the user passes an ACL check
734
735 =cut
736
737 sub _Value {
738     my $self  = shift;
739     my $field = shift;
740
741     #if the field is public, return it.
742     if ( $self->_Accessible( $field, 'public' ) ) {
743         return ( $self->__Value( $field, @_ ) );
744     }
745
746     return undef unless $self->TransactionObj->CurrentUserCanSee;
747     return $self->__Value( $field, @_ );
748 }
749
750 # Transactions don't change. by adding this cache congif directiove,
751 # we don't lose pathalogically on long tickets.
752 sub _CacheConfig {
753     {
754         'cache_p'       => 1,
755         'fast_update_p' => 1,
756         'cache_for_sec' => 180,
757     }
758 }
759
760 1;