Download attachments of a message as a zip archive

pull/233/head
Francis Lachapelle 2016-12-07 14:37:59 -05:00
parent 2a2ebd553e
commit 011fae8a65
17 changed files with 487 additions and 289 deletions

3
NEWS
View File

@ -1,6 +1,9 @@
3.2.5 (2016-12-DD)
------------------
New features
- [web] download attachments of a message as a zip archive
Enhancements
- [web] prevent using localhost on additional IMAP accounts

View File

@ -33,6 +33,7 @@
fromIndex: (int) start;
- (int) indexOf: (unichar) _c;
- (NSString *) decodedHeader;
- (NSString *) asSafeFilename;
@end

View File

@ -688,4 +688,26 @@ convertChars (const char *oldString, unsigned int oldLength,
return decodedHeader;
}
- (NSString *) asSafeFilename
{
NSRange r;
NSMutableString *safeName;
r = [self rangeOfString: @"\\"
options: NSBackwardsSearch];
if (r.length > 0)
safeName = [NSMutableString stringWithString: [self substringFromIndex: r.location + 1]];
else
safeName = [NSMutableString stringWithString: self];
[safeName replaceString: @"/" withString: @"_"];
if ([self isEqualToString: @"."])
return @"_";
if ([self isEqualToString: @".."])
return @"__";
return safeName;
}
@end

View File

@ -97,7 +97,6 @@
/* attachments */
- (NSArray *) fetchAttachmentAttrs;
- (BOOL) isValidAttachmentName: (NSString *) _name;
- (NGMimeBodyPart *) bodyPartForAttachmentWithName: (NSString *) _name;
- (NSString *) pathToAttachmentWithName: (NSString *) _name;
- (NSException *) saveAttachment: (NSData *) _attach

View File

@ -845,59 +845,22 @@ static NSString *userAgent = nil;
}
}
//
//
//
- (NSArray *) _attachmentBodiesFromPaths: (NSArray *) paths
fromResponseFetch: (NSDictionary *) fetch;
{
NSEnumerator *attachmentKeys;
NSMutableArray *bodies;
NSString *currentKey;
NSDictionary *body;
bodies = [NSMutableArray array];
attachmentKeys = [paths objectEnumerator];
while ((currentKey = [attachmentKeys nextObject]))
{
body = [fetch objectForKey: [currentKey lowercaseString]];
if (body)
[bodies addObject: [body objectForKey: @"data"]];
else
[bodies addObject: [NSData data]];
}
return bodies;
}
//
//
//
- (void) _fetchAttachmentsFromMail: (SOGoMailObject *) sourceMail
{
unsigned int count, max;
NSArray *parts, *paths, *bodies;
NSData *body;
unsigned int max, count;
NSArray *attachments;
NSDictionary *currentInfo;
NGHashMap *response;
parts = [sourceMail fetchFileAttachmentKeys];
max = [parts count];
if (max > 0)
attachments = [sourceMail fetchFileAttachments];
max = [attachments count];
for (count = 0; count < max; count++)
{
paths = [parts keysWithFormat: @"BODY[%{path}]"];
response = [[sourceMail fetchParts: paths] objectForKey: @"RawResponse"];
bodies = [self _attachmentBodiesFromPaths: paths
fromResponseFetch: [response objectForKey: @"fetch"]];
for (count = 0; count < max; count++)
{
currentInfo = [parts objectAtIndex: count];
body = [[bodies objectAtIndex: count]
bodyDataFromEncoding: [currentInfo
objectForKey: @"encoding"]];
[self saveAttachment: body withMetadata: currentInfo];
}
currentInfo = [attachments objectAtIndex: count];
[self saveAttachment: [currentInfo objectForKey: @"body"]
withMetadata: currentInfo];
}
}
@ -1098,12 +1061,6 @@ static NSString *userAgent = nil;
return ma;
}
- (BOOL) isValidAttachmentName: (NSString *) filename
{
return (!([filename rangeOfString: @"/"].length
|| [filename isEqualToString: @"."]
|| [filename isEqualToString: @".."]));
}
- (NSString *) pathToAttachmentWithName: (NSString *) _name
{
@ -1113,17 +1070,15 @@ static NSString *userAgent = nil;
return [[self draftFolderPath] stringByAppendingPathComponent:_name];
}
- (NSException *) invalidAttachmentNameError: (NSString *) _name
{
return [NSException exceptionWithHTTPStatus:400 /* Bad Request */
reason: @"Invalid attachment name!"];
}
/**
* Write attachment file to the spool directory of the draft and write a dot
* file with its mime type.
*/
- (NSException *) saveAttachment: (NSData *) _attach
withMetadata: (NSDictionary *) metadata
{
NSString *p, *pmime, *name, *mimeType;
NSRange r;
if (![_attach isNotNull]) {
return [NSException exceptionWithHTTPStatus:400 /* Bad Request */
@ -1135,15 +1090,7 @@ static NSString *userAgent = nil;
reason: @"Could not create folder for draft!"];
}
name = [metadata objectForKey: @"filename"];
r = [name rangeOfString: @"\\"
options: NSBackwardsSearch];
if (r.length > 0)
name = [name substringFromIndex: r.location + 1];
if (![self isValidAttachmentName: name])
return [self invalidAttachmentNameError: name];
name = [[metadata objectForKey: @"filename"] asSafeFilename];
p = [self pathToAttachmentWithName: name];
if (![_attach writeToFile: p atomically: YES])
{
@ -1173,19 +1120,12 @@ static NSString *userAgent = nil;
NSException *error;
error = nil;
if ([self isValidAttachmentName:_name])
{
fm = [NSFileManager defaultManager];
p = [self pathToAttachmentWithName:_name];
if ([fm fileExistsAtPath: p])
if (![fm removeFileAtPath: p handler: nil])
error
= [NSException exceptionWithHTTPStatus: 500 /* Server Error */
reason: @"Could not delete attachment from draft!"];
}
else
error = [self invalidAttachmentNameError:_name];
fm = [NSFileManager defaultManager];
p = [self pathToAttachmentWithName: [_name asSafeFilename]];
if ([fm fileExistsAtPath: p])
if (![fm removeFileAtPath: p handler: nil])
error = [NSException exceptionWithHTTPStatus: 500 /* Server Error */
reason: @"Could not delete attachment from draft!"];
return error;
}
@ -1288,8 +1228,7 @@ static NSString *userAgent = nil;
NSString *s, *p;
NSData *mimeData;
p = [self pathToAttachmentWithName:
[NSString stringWithFormat: @".%@.mime", _name]];
p = [self pathToAttachmentWithName: [NSString stringWithFormat: @".%@.mime", _name]];
mimeData = [NSData dataWithContentsOfFile: p];
if (mimeData)
{
@ -1308,20 +1247,18 @@ static NSString *userAgent = nil;
}
- (NSString *) contentDispositionForAttachmentWithName: (NSString *) _name
andContentType: (NSString *) _type
{
NSString *type;
NSString *cdtype;
NSString *cd;
SOGoDomainDefaults *dd;
type = [self contentTypeForAttachmentWithName:_name];
if ([type hasPrefix: @"text/"])
if ([_type hasPrefix: @"text/"])
{
dd = [[context activeUser] domainDefaults];
cdtype = [dd mailAttachTextDocumentsInline] ? @"inline" : @"attachment";
}
else if ([type hasPrefix: @"image/"] || [type hasPrefix: @"message"])
else if ([_type hasPrefix: @"image/"] || [_type hasPrefix: @"message"])
cdtype = @"inline";
else
cdtype = @"attachment";
@ -1369,7 +1306,7 @@ static NSString *userAgent = nil;
else if ([s hasPrefix: @"message/rfc822"])
attachAsRFC822 = YES;
}
if ((s = [self contentDispositionForAttachmentWithName: _name]))
if ((s = [self contentDispositionForAttachmentWithName: _name andContentType: s]))
{
NGMimeContentDispositionHeaderField *o;

View File

@ -1,4 +1,5 @@
/*
Copyright (C) 2009-2016 Inverse inc.
Copyright (C) 2004-2005 SKYRIX Software AG
This file is part of OpenGroupware.org.
@ -48,6 +49,8 @@
@class NGImap4Envelope;
@class NGImap4EnvelopeAddress;
@class WOResponse;
NSArray *SOGoMailCoreInfoKeys;
@interface SOGoMailObject : SOGoMailBaseObject
@ -107,6 +110,8 @@ NSArray *SOGoMailCoreInfoKeys;
- (BOOL) hasAttachment;
- (NSDictionary *) fetchFileAttachmentIds;
- (NSArray *) fetchFileAttachmentKeys;
- (NSArray *) fetchFileAttachments;
- (WOResponse *) archiveAllFilesinArchiveNamed: (NSString *) archiveName;
/* flags */

View File

@ -1,5 +1,5 @@
/*
Copyright (C) 2007-2014 Inverse inc.
Copyright (C) 2007-2016 Inverse inc.
Copyright (C) 2004-2005 SKYRIX Software AG
This file is part of SOGo.
@ -20,6 +20,7 @@
02111-1307, USA.
*/
#import <Foundation/NSTask.h>
#import <Foundation/NSURL.h>
#import <Foundation/NSValue.h>
@ -27,6 +28,7 @@
#import <NGObjWeb/WORequest.h>
#import <NGObjWeb/NSException+HTTP.h>
#import <NGExtensions/NGHashMap.h>
#import <NGExtensions/NSFileManager+Extensions.h>
#import <NGExtensions/NSNull+misc.h>
#import <NGExtensions/NSObject+Logs.h>
#import <NGExtensions/NSString+Encoding.h>
@ -38,7 +40,9 @@
#import <SOGo/NSArray+Utilities.h>
#import <SOGo/NSDictionary+Utilities.h>
#import <SOGo/NSString+Utilities.h>
#import <SOGo/SOGoPermissions.h>
#import <SOGo/SOGoSystemDefaults.h>
#import <SOGo/SOGoUser.h>
#import <SOGo/SOGoUserDefaults.h>
#import <SOGo/NSCalendarDate+SOGo.h>
@ -913,6 +917,160 @@ static BOOL debugSoParts = NO;
return keys;
}
/**
* Returns an array of dictionaries with the following keys:
* - encoding
* - filename
* - mimetype
* - path
* - size
* - url
* - urlAsAttachment
* - body (NSData)
*/
- (NSArray *) fetchFileAttachments
{
unsigned int count, max;
NGHashMap *response;
NSArray *parts, *paths; //, *bodies;
NSData *body;
NSDictionary *fetch, *currentInfo, *currentBody;
NSMutableArray *attachments;
NSMutableDictionary *currentAttachment;
NSString *currentPath;
parts = [self fetchFileAttachmentKeys];
max = [parts count];
attachments = [NSMutableArray arrayWithCapacity: max];
if (max > 0)
{
paths = [parts keysWithFormat: @"BODY[%{path}]"];
response = [[self fetchParts: paths] objectForKey: @"RawResponse"];
fetch = [response objectForKey: @"fetch"];
for (count = 0; count < max; count++)
{
currentInfo = [parts objectAtIndex: count];
currentPath = [[paths objectAtIndex: count] lowercaseString];
currentBody = [fetch objectForKey: currentPath];
if (currentBody)
{
body = [currentBody objectForKey: @"data"];
body = [body bodyDataFromEncoding: [currentInfo objectForKey: @"encoding"]];
}
else
body = [NSData data];
currentAttachment = [NSMutableDictionary dictionaryWithDictionary: currentInfo];
[currentAttachment setObject: body forKey: @"body"];
[attachments addObject: currentAttachment];
}
}
return attachments;
}
- (WOResponse *) archiveAllFilesinArchiveNamed: (NSString *) archiveName
{
NSArray *attachments;
NSData *body, *zipContent;
NSDictionary *currentAttachment;
NSException *error;
NSFileManager *fm;
NSMutableArray *zipTaskArguments;
NSString *spoolPath, *name, *fileName, *baseName, *extension, *zipPath, *qpFileName;
NSTask *zipTask;
SOGoMailFolder *folder;
WOResponse *response;
unsigned int max, count;
if (!archiveName)
archiveName = @"attachments.zip";
folder = [self container];
spoolPath = [folder userSpoolFolderPath];
if (![folder ensureSpoolFolderPath])
{
[self errorWithFormat: @"spool directory '%@' doesn't exist", spoolPath];
error = [NSException exceptionWithHTTPStatus: 500
reason: @"spool directory does not exist"];
return (WOResponse *)error;
}
// Prepare execution of zip
zipPath = [[SOGoSystemDefaults sharedSystemDefaults] zipPath];
fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath: zipPath])
{
error = [NSException exceptionWithHTTPStatus: 500
reason: @"zip not available"];
return (WOResponse *)error;
}
zipTask = [[NSTask alloc] init];
[zipTask setCurrentDirectoryPath: spoolPath];
[zipTask setLaunchPath: zipPath];
zipTaskArguments = [NSMutableArray arrayWithObjects: nil];
[zipTaskArguments addObject: @"attachments.zip"];
// Fetch attachments and write them on disk
attachments = [self fetchFileAttachments];
max = [attachments count];
for (count = 0; count < max; count++)
{
currentAttachment = [attachments objectAtIndex: count];
body = [currentAttachment objectForKey: @"body"];
name = [[currentAttachment objectForKey: @"filename"] asSafeFilename];
fileName = [NSString stringWithFormat:@"%@/%@", spoolPath, name];
[body writeToFile: fileName atomically: YES];
[zipTaskArguments addObject: name];
}
// Zip files
[zipTask setArguments: zipTaskArguments];
[zipTask launch];
[zipTask waitUntilExit];
[zipTask release];
zipContent = [[NSData alloc] initWithContentsOfFile:
[NSString stringWithFormat: @"%@/attachments.zip", spoolPath]];
// Delete attachments from disk
max = [zipTaskArguments count];
for (count = 0; count < max; count++)
{
fileName = [zipTaskArguments objectAtIndex: count];
[fm removeFileAtPath:
[NSString stringWithFormat: @"%@/%@", spoolPath, fileName] handler: nil];
}
// Prepare response
response = [context response];
baseName = [archiveName stringByDeletingPathExtension];
extension = [archiveName pathExtension];
if ([extension length] > 0)
extension = [@"." stringByAppendingString: extension];
else
extension = @"";
qpFileName = [NSString stringWithFormat: @"%@%@",
[baseName asQPSubjectString: @"utf-8"], extension];
[response setHeader: [NSString stringWithFormat: @"application/zip;"
@" name=\"%@\"", qpFileName]
forKey: @"content-type"];
[response setHeader: [NSString stringWithFormat: @"attachment; filename=\"%@\"",
qpFileName]
forKey: @"Content-Disposition"];
[response setContent: zipContent];
[zipContent release];
return response;
}
/* convert parts to strings */
- (NSString *) stringForData: (NSData *) _data
partInfo: (NSDictionary *) _info

View File

@ -217,6 +217,12 @@
/* Message view "more" menu: create a task from message */
"Convert To Task" = "Convert To Task";
/* Message view "more" menu: download all attachments as a zip archive */
"Download all attachments" = "Download all attachments";
/* Filename prefix when downloading all attachments as a zip archive */
"attachments" = "attachments";
"Print..." = "Print...";
"Delete Message" = "Delete Message";
"Delete Selected Messages" = "Delete Selected Messages";

View File

@ -312,6 +312,18 @@ static NSString *mailETag = nil;
return response;
}
- (id <WOActionResults>) archiveAttachmentsAction
{
NSString *name;
SOGoMailObject *co;
co = [self clientObject];
name = [NSString stringWithFormat: @"%@-%@.zip",
[self labelForKey: @"attachments"], [co nameInContainer]];
return [co archiveAllFilesinArchiveNamed: name];
}
/* MDN */
- (BOOL) _userHasEMail: (NSString *) email

View File

@ -244,6 +244,11 @@
pageName = "UIxMailView";
actionName = "sendMDN";
};
archiveAttachments = {
protectedBy = "View";
pageName = "UIxMailView";
actionName = "archiveAttachments";
};
viewsource = {
protectedBy = "View";
actionClass = "UIxMailSourceView";

View File

@ -84,10 +84,16 @@
</md-menu-item>
<md-menu-item>
<md-button label:aria-label="Save As..."
ng-click="viewer.message.saveMessage()">
ng-click="viewer.message.download()">
<var:string label:value="Save As..."/>
</md-button>
</md-menu-item>
<md-menu-item ng-show="::viewer.message.attachmentAttrs.length">
<md-button label:aria-label="Download all attachments"
ng-click="viewer.message.downloadAttachments()">
<var:string label:value="Download all attachments"/>
</md-button>
</md-menu-item>
<md-menu-item>
<md-button label:aria-label="View Message Source"
ng-click="viewer.toggleRawSource($event)">

View File

@ -192,14 +192,21 @@
},
responseType: 'arraybuffer',
cache: false,
transformResponse: function (data, headers) {
transformResponse: function (data, headers, status) {
var fileName, result, blob = null;
if (status < 200 || status > 299) {
throw new Error('Bad gateway');
}
if (data) {
blob = new Blob([data], { type: type });
}
fileName = getFileNameFromHeader(headers('content-disposition'));
if (options && options.filename) {
fileName = options.filename;
}
else {
getFileNameFromHeader(headers('content-disposition'));
}
if (!saveAs) {
throw new Error('To use Resource.download, FileSaver.js must be loaded.');
}

View File

@ -713,14 +713,19 @@
* @returns a promise of the HTTP operation
*/
AddressBook.prototype.exportCards = function(selectedOnly) {
var selectedUIDs;
var data = null, options, selectedCards;
options = {
type: 'application/octet-stream',
filename: this.name + '.ldif'
};
if (selectedOnly) {
var selectedCards = _.filter(this.$cards, function(card) { return card.selected; });
selectedUIDs = _.map(selectedCards, 'id');
selectedCards = _.filter(this.$cards, function(card) { return card.selected; });
data = { uids: _.map(selectedCards, 'id') };
}
return AddressBook.$$resource.download(this.id, 'export', (angular.isDefined(selectedUIDs) ? {uids: selectedUIDs} : null), {type: 'application/octet-stream'});
return AddressBook.$$resource.download(this.id, 'export', data, options);
};
/**

View File

@ -269,11 +269,15 @@
* @returns a promise of the HTTP operation
*/
Card.prototype.export = function() {
var selectedIDs;
var data, options;
selectedIDs = [ this.id ];
data = { uids: [ this.id ] };
options = {
type: 'application/octet-stream',
filename: this.$$fullname + '.ldif'
};
return Card.$$resource.download(this.pid, 'export', {uids: selectedIDs}, {type: 'application/octet-stream'});
return Card.$$resource.download(this.pid, 'export', data, options);
};
Card.prototype.$fullname = function(options) {

View File

@ -598,10 +598,12 @@
* @returns a promise of the HTTP operation
*/
Mailbox.prototype.saveSelectedMessages = function() {
var selectedMessages, selectedUIDs;
var data, options, selectedMessages, selectedUIDs;
selectedMessages = _.filter(this.$messages, function(message) { return message.selected; });
selectedUIDs = _.map(selectedMessages, 'uid');
data = { uids: selectedUIDs };
options = { filename: l('Saved Messages.zip') };
return Mailbox.$$resource.download(this.id, 'saveMessages', {uids: selectedUIDs});
};
@ -613,7 +615,11 @@
* @returns a promise of the HTTP operation
*/
Mailbox.prototype.exportFolder = function() {
return Mailbox.$$resource.download(this.id, 'exportFolder');
var options;
options = { filename: this.name + '.zip' };
return Mailbox.$$resource.download(this.id, 'exportFolder', null, options);
};
/**

View File

@ -708,17 +708,32 @@
};
/**
* @function saveMessage
* @function download
* @memberof Message.prototype
* @desc Download the current message
* @returns a promise of the HTTP operation
*/
Message.prototype.saveMessage = function() {
var selectedUIDs;
Message.prototype.download = function() {
var data, options;
selectedUIDs = [ this.uid ];
data = { uids: [this.uid] };
options = { filename: this.subject + '.zip' };
return Message.$$resource.download(this.$mailbox.id, 'saveMessages', {uids: selectedUIDs});
return Message.$$resource.download(this.$mailbox.id, 'saveMessages', data, options);
};
/**
* @function downloadAttachments
* @memberof Message.prototype
* @desc Download an archive of all attachments
* @returns a promise of the HTTP operation
*/
Message.prototype.downloadAttachments = function() {
var options;
options = { filename: l('attachments') + "-" + this.uid + ".zip" };
return Message.$$resource.download(this.$absolutePath(), 'archiveAttachments', null, options);
};
})();

View File

@ -530,7 +530,14 @@
* @returns a promise of the HTTP operation
*/
Calendar.prototype.export = function() {
return Calendar.$$resource.download(this.id + '.ics', 'export', null, {type: 'application/octet-stream'});
var options;
options = {
type: 'application/octet-stream',
filename: this.name + '.ics'
};
return Calendar.$$resource.download(this.id + '.ics', 'export', null, options);
};
/**