mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-09-20 15:26:03 +08:00
v1.16.0
This commit is contained in:
parent
84cd3be16c
commit
26f75ff081
|
@ -44,7 +44,7 @@ If you have a blank VPS and a free domain name that you can point to that VPS th
|
|||
|
||||
Install script installs and configures all required dependencies and services, including Let's Encrypt based certs, to run WildDuck as a mail server.
|
||||
|
||||
Tested on a 10$ DigitalOcean Ubuntu 16.04 instance.
|
||||
Tested on a 10\$ DigitalOcean Ubuntu 16.04 instance.
|
||||
|
||||
![](https://cldup.com/TZoTfxPugm.png)
|
||||
|
||||
|
@ -265,7 +265,7 @@ This is a list of known differences from the IMAP specification. Listed differen
|
|||
5. `CHARSET` argument for the `SEARCH` command is ignored (RFC3501 6.4.4.)
|
||||
6. Metadata arguments for `SEARCH MODSEQ` are ignored (RFC7162 3.1.5.). You can define `<entry-name>` and `<entry-type-req>` values but these are not used for
|
||||
anything
|
||||
7. `SEARCH TEXT` and `SEARCH BODY` both use MongoDB [$text index](https://docs.mongodb.com/v3.4/reference/operator/query/text/) against decoded plaintext
|
||||
7. `SEARCH TEXT` and `SEARCH BODY` both use MongoDB [\$text index](https://docs.mongodb.com/v3.4/reference/operator/query/text/) against decoded plaintext
|
||||
version of the message. RFC3501 assumes that it should be a string match either against full message (`TEXT`) or body section (`BODY`).
|
||||
8. What happens when FETCH is called for messages that were deleted in another session? _Not sure, need to check_
|
||||
9. **Autoexpunge**, meaning that an EXPUNGE is called on background whenever a messages gets a `\Deleted` flag set. This is not in conflict with IMAP RFCs.
|
||||
|
@ -343,6 +343,10 @@ sh.enableSharding('attachments');
|
|||
// attachment _id is a sha256 hash of attachment contents
|
||||
sh.shardCollection('attachments.attachments.files', { _id: 'hashed' });
|
||||
sh.shardCollection('attachments.attachments.chunks', { files_id: 'hashed' });
|
||||
|
||||
// storage _id is an ObjectID
|
||||
sh.shardCollection('attachments.storage.files', { _id: 'hashed' });
|
||||
sh.shardCollection('attachments.storage.chunks', { files_id: 'hashed' });
|
||||
```
|
||||
|
||||
### Disk usage
|
||||
|
|
13
api.js
13
api.js
|
@ -7,6 +7,7 @@ const logger = require('restify-logger');
|
|||
const UserHandler = require('./lib/user-handler');
|
||||
const MailboxHandler = require('./lib/mailbox-handler');
|
||||
const MessageHandler = require('./lib/message-handler');
|
||||
const StorageHandler = require('./lib/storage-handler');
|
||||
const ImapNotifier = require('./lib/imap-notifier');
|
||||
const db = require('./lib/db');
|
||||
const certs = require('./lib/certs');
|
||||
|
@ -20,6 +21,7 @@ const usersRoutes = require('./lib/api/users');
|
|||
const addressesRoutes = require('./lib/api/addresses');
|
||||
const mailboxesRoutes = require('./lib/api/mailboxes');
|
||||
const messagesRoutes = require('./lib/api/messages');
|
||||
const storageRoutes = require('./lib/api/storage');
|
||||
const filtersRoutes = require('./lib/api/filters');
|
||||
const aspsRoutes = require('./lib/api/asps');
|
||||
const totpRoutes = require('./lib/api/2fa/totp');
|
||||
|
@ -35,6 +37,7 @@ const dkimRoutes = require('./lib/api/dkim');
|
|||
let userHandler;
|
||||
let mailboxHandler;
|
||||
let messageHandler;
|
||||
let storageHandler;
|
||||
let notifier;
|
||||
let loggelf;
|
||||
|
||||
|
@ -371,6 +374,13 @@ module.exports = done => {
|
|||
loggelf: message => loggelf(message)
|
||||
});
|
||||
|
||||
storageHandler = new StorageHandler({
|
||||
database: db.database,
|
||||
users: db.users,
|
||||
gridfs: db.gridfs,
|
||||
loggelf: message => loggelf(message)
|
||||
});
|
||||
|
||||
userHandler = new UserHandler({
|
||||
database: db.database,
|
||||
users: db.users,
|
||||
|
@ -393,7 +403,8 @@ module.exports = done => {
|
|||
usersRoutes(db, server, userHandler);
|
||||
addressesRoutes(db, server);
|
||||
mailboxesRoutes(db, server, mailboxHandler);
|
||||
messagesRoutes(db, server, messageHandler, userHandler);
|
||||
messagesRoutes(db, server, messageHandler, userHandler, storageHandler);
|
||||
storageRoutes(db, server, storageHandler);
|
||||
filtersRoutes(db, server);
|
||||
aspsRoutes(db, server, userHandler);
|
||||
totpRoutes(db, server, userHandler);
|
||||
|
|
|
@ -40,6 +40,13 @@
|
|||
"delete:any": ["*"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"create:any": ["*"],
|
||||
"read:any": ["*"],
|
||||
"update:any": ["*"],
|
||||
"delete:any": ["*"]
|
||||
},
|
||||
|
||||
"mailboxes": {
|
||||
"create:any": ["*"],
|
||||
"read:any": ["*"],
|
||||
|
@ -170,6 +177,13 @@
|
|||
"delete:any": ["*"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"create:any": ["*"],
|
||||
"read:any": ["*"],
|
||||
"update:any": ["*"],
|
||||
"delete:any": ["*"]
|
||||
},
|
||||
|
||||
"mailboxes": {
|
||||
"create:any": ["*"],
|
||||
"read:any": ["*"],
|
||||
|
@ -230,6 +244,13 @@
|
|||
"delete:own": ["*"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"create:own": ["*"],
|
||||
"read:own": ["*"],
|
||||
"update:own": ["*"],
|
||||
"delete:own": ["*"]
|
||||
},
|
||||
|
||||
"mailboxes": {
|
||||
"create:own": ["*"],
|
||||
"read:own": ["*"],
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1 +1 @@
|
|||
define({
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-03-21T08:29:21.845Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
});
|
||||
define({
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-03-26T12:14:03.120Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
});
|
||||
|
|
|
@ -1 +1 @@
|
|||
{
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-03-21T08:29:21.845Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
}
|
||||
{
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-03-26T12:14:03.120Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
}
|
||||
|
|
1081
indexes.yaml
1081
indexes.yaml
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
// TODO: finish converting methods to async..await with ACL
|
||||
|
||||
const config = require('wild-config');
|
||||
const log = require('npmlog');
|
||||
const libmime = require('libmime');
|
||||
|
@ -20,7 +18,7 @@ const Maildropper = require('../maildropper');
|
|||
const util = require('util');
|
||||
const roles = require('../roles');
|
||||
|
||||
module.exports = (db, server, messageHandler, userHandler) => {
|
||||
module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
||||
let maildrop = new Maildropper({
|
||||
db,
|
||||
zone: config.sender.zone,
|
||||
|
@ -1945,6 +1943,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
* @apiParam {Object[]} [headers] Custom headers for the message. If reference message is set then In-Reply-To and References headers are set automaticall y
|
||||
* @apiParam {String} headers.key Header key ('X-Mailer')
|
||||
* @apiParam {String} headers.value Header value ('My Awesome Mailing Service')
|
||||
* @apiParam {String[]} [files] Attachments as storage file IDs. These attachments are also listed to message metaData.attachedFiles array
|
||||
* @apiParam {Object[]} [attachments] Attachments for the message
|
||||
* @apiParam {String} attachments.content Base64 encoded attachment content
|
||||
* @apiParam {String} [attachments.filename] Attachment filename
|
||||
|
@ -2111,6 +2110,13 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
.empty('')
|
||||
.max(1024 * 1024),
|
||||
|
||||
files: Joi.array().items(
|
||||
Joi.string()
|
||||
.hex()
|
||||
.lowercase()
|
||||
.length(24)
|
||||
),
|
||||
|
||||
attachments: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
filename: Joi.string()
|
||||
|
@ -2196,10 +2202,11 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
return next();
|
||||
}
|
||||
|
||||
let metaData;
|
||||
if (result.value.metaData) {
|
||||
try {
|
||||
let value = JSON.parse(result.value.metaData);
|
||||
if (!value || typeof value !== 'object') {
|
||||
metaData = JSON.parse(result.value.metaData);
|
||||
if (!metaData || typeof metaData !== 'object') {
|
||||
throw new Error('Not an object');
|
||||
}
|
||||
} catch (err) {
|
||||
|
@ -2224,7 +2231,6 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
let date = result.value.date || new Date();
|
||||
|
||||
let mailboxData;
|
||||
|
||||
try {
|
||||
mailboxData = await db.database.collection('mailboxes').findOne({
|
||||
_id: mailbox,
|
||||
|
@ -2288,6 +2294,31 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
result.value.draft = true; // only draft messages can reference to another message
|
||||
}
|
||||
|
||||
if (result.value.files && result.value.files.length) {
|
||||
for (let file of result.value.files) {
|
||||
try {
|
||||
let fileData = await storageHandler.get(userData._id, new ObjectID(file));
|
||||
if (fileData) {
|
||||
if (!metaData) {
|
||||
metaData = {};
|
||||
}
|
||||
if (!metaData.attachedFiles) {
|
||||
metaData.attachedFiles = [];
|
||||
}
|
||||
extraAttachments.push(fileData);
|
||||
metaData.attachedFiles.push({
|
||||
id: fileData.id.toString(),
|
||||
filename: fileData.filename,
|
||||
contentType: fileData.contentType,
|
||||
size: fileData.size
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('API', 'STORAGEFAIL user=%s file=%s error=%s', userData._id, file, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = {
|
||||
from: result.value.from || { name: userData.name, address: userData.address },
|
||||
date,
|
||||
|
@ -2368,7 +2399,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
origin: result.value.ip || '127.0.0.1',
|
||||
transtype: 'UPLOAD',
|
||||
time: date,
|
||||
custom: result.value.metaData || '',
|
||||
custom: (metaData ? JSON.stringify(metaData) : result.value.metaData) || '',
|
||||
reference: referencedMessage
|
||||
? {
|
||||
action: result.value.reference.action,
|
||||
|
@ -2665,6 +2696,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
* @apiParam {String} user ID of the User
|
||||
* @apiParam {String} mailbox ID of the Mailbox
|
||||
* @apiParam {Number} message Message ID
|
||||
* @apiParam {Boolean} deleteFiles If true then deletes attachment files listed in metaData.attachedFiles array
|
||||
*
|
||||
* @apiSuccess {Boolean} success Indicates successful response
|
||||
* @apiSuccess {String} queueId Message ID in outbound queue
|
||||
|
@ -2677,7 +2709,9 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
* @apiExample {curl} Submit a Message:
|
||||
* curl -i -XPOST "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a13e54454869460e57/messages/1/submit" \
|
||||
* -H 'Content-type: application/json' \
|
||||
* -d '{}'
|
||||
* -d '{
|
||||
* "deleteFiles": true
|
||||
* }'
|
||||
*
|
||||
* @apiSuccessExample {json} Submit Response:
|
||||
* HTTP/1.1 200 OK
|
||||
|
@ -2713,6 +2747,9 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
.length(24)
|
||||
.required(),
|
||||
message: Joi.number().required(),
|
||||
deleteFiles: Joi.boolean()
|
||||
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
|
||||
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
|
||||
sess: Joi.string().max(255),
|
||||
ip: Joi.string().ip({
|
||||
version: ['ipv4', 'ipv6'],
|
||||
|
@ -2743,6 +2780,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
let user = new ObjectID(result.value.user);
|
||||
let mailbox = new ObjectID(result.value.mailbox);
|
||||
let message = result.value.message;
|
||||
let deleteFiles = result.value.deleteFiles;
|
||||
|
||||
let userData;
|
||||
try {
|
||||
|
@ -2796,6 +2834,13 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
return next();
|
||||
}
|
||||
|
||||
let metaData;
|
||||
try {
|
||||
metaData = messageData.meta.custom ? JSON.parse(messageData.meta.custom) : false;
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let envelope = messageData.meta.envelope;
|
||||
if (!envelope) {
|
||||
// fetch envelope data from message headers
|
||||
|
@ -2891,6 +2936,16 @@ module.exports = (db, server, messageHandler, userHandler) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (deleteFiles && metaData && metaData.attachedFiles) {
|
||||
for (let fileData of metaData.attachedFiles) {
|
||||
try {
|
||||
await storageHandler.delete(userData._id, new ObjectID(fileData.id));
|
||||
} catch (err) {
|
||||
log.error('API', 'STORAGEDELFAIL user=%s file=%s error=%s', userData._id, fileData.id, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
return next();
|
||||
})
|
||||
|
|
554
lib/api/storage.js
Normal file
554
lib/api/storage.js
Normal file
|
@ -0,0 +1,554 @@
|
|||
'use strict';
|
||||
|
||||
const Joi = require('../joi');
|
||||
const MongoPaging = require('mongo-cursor-pagination');
|
||||
const ObjectID = require('mongodb').ObjectID;
|
||||
const tools = require('../tools');
|
||||
const roles = require('../roles');
|
||||
|
||||
module.exports = (db, server, storageHandler) => {
|
||||
/**
|
||||
* @api {post} /users/:user/storage Upload File
|
||||
* @apiName UploadStorage
|
||||
* @apiGroup Storage
|
||||
* @apiDescription This method allows to upload an attachment to be linked from a draft
|
||||
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
|
||||
* @apiHeaderExample {json} Header-Example:
|
||||
* {
|
||||
* "X-Access-Token": "59fc66a03e54454869460e45"
|
||||
* }
|
||||
*
|
||||
* @apiParam {String} user ID of the User
|
||||
* @apiParam {String} content Base64 encoded file content
|
||||
* @apiParam {String} [filename] Filename
|
||||
* @apiParam {String} [contentType] MIME type for the file
|
||||
* @apiParam {String} [sess] Session identifier for the logs
|
||||
* @apiParam {String} [ip] IP address for the logs
|
||||
*
|
||||
* @apiSuccess {Boolean} success Indicates successful response
|
||||
* @apiSuccess {Object} id File ID
|
||||
*
|
||||
* @apiError error Description of the error
|
||||
*
|
||||
* @apiExample {curl} Upload a file:
|
||||
* curl -i -XPOST "http://localhost:8080/users/5a2f9ca57308fc3a6f5f811d/storage" \
|
||||
* -H 'Content-type: application/json' \
|
||||
* -d '{
|
||||
* "content": "aGVsbG93IGZyb20=",
|
||||
* "filename": "test.txt"
|
||||
* }'
|
||||
*
|
||||
* @apiSuccessExample {json} Forward Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "success": true,
|
||||
* "id": "5a2f9ca57308fc3a6f5f811e"
|
||||
* }
|
||||
*
|
||||
* @apiErrorExample {json} Error-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "error": "Database error"
|
||||
* }
|
||||
*/
|
||||
server.post(
|
||||
'/users/:user/storage',
|
||||
tools.asyncifyJson(async (req, res, next) => {
|
||||
res.charSet('utf-8');
|
||||
|
||||
const schema = Joi.object().keys({
|
||||
user: Joi.string()
|
||||
.hex()
|
||||
.lowercase()
|
||||
.length(24)
|
||||
.required(),
|
||||
|
||||
filename: Joi.string()
|
||||
.empty('')
|
||||
.max(255),
|
||||
contentType: Joi.string()
|
||||
.empty('')
|
||||
.max(255),
|
||||
encoding: Joi.string()
|
||||
.empty('')
|
||||
.default('base64'),
|
||||
content: Joi.string().required(),
|
||||
|
||||
sess: Joi.string().max(255),
|
||||
ip: Joi.string().ip({
|
||||
version: ['ipv4', 'ipv6'],
|
||||
cidr: 'forbidden'
|
||||
})
|
||||
});
|
||||
|
||||
const result = Joi.validate(req.params, schema, {
|
||||
abortEarly: false,
|
||||
convert: true
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
res.json({
|
||||
error: result.error.message,
|
||||
code: 'InputValidationError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
// permissions check
|
||||
if (req.user && req.user === result.value.user) {
|
||||
req.validate(roles.can(req.role).createOwn('storage'));
|
||||
} else {
|
||||
req.validate(roles.can(req.role).createAny('storage'));
|
||||
}
|
||||
|
||||
let user = new ObjectID(result.value.user);
|
||||
|
||||
let userData;
|
||||
try {
|
||||
userData = await db.users.collection('users').findOne(
|
||||
{
|
||||
_id: user
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
address: true
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
res.json({
|
||||
error: 'MongoDB Error: ' + err.message,
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
if (!userData) {
|
||||
res.json({
|
||||
error: 'This user does not exist',
|
||||
code: 'UserNotFound'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
let id = await storageHandler.add(user, result.value);
|
||||
|
||||
res.json({
|
||||
success: !!id,
|
||||
id
|
||||
});
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @api {get} /users/:user/storage List stored files
|
||||
* @apiName GetStorage
|
||||
* @apiGroup Storage
|
||||
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
|
||||
* @apiHeaderExample {json} Header-Example:
|
||||
* {
|
||||
* "X-Access-Token": "59fc66a03e54454869460e45"
|
||||
* }
|
||||
*
|
||||
* @apiParam {String} user ID of the User
|
||||
* @apiParam {String} [query] Partial match of a filename
|
||||
* @apiParam {Number} [limit=20] How many records to return
|
||||
* @apiParam {Number} [page=1] Current page number. Informational only, page numbers start from 1
|
||||
* @apiParam {Number} [next] Cursor value for next page, retrieved from <code>nextCursor</code> response value
|
||||
* @apiParam {Number} [previous] Cursor value for previous page, retrieved from <code>previousCursor</code> response value
|
||||
*
|
||||
* @apiSuccess {Boolean} success Indicates successful response
|
||||
* @apiSuccess {Number} total How many results were found
|
||||
* @apiSuccess {Number} page Current page number. Derived from <code>page</code> query argument
|
||||
* @apiSuccess {String} previousCursor Either a cursor string or false if there are not any previous results
|
||||
* @apiSuccess {String} nextCursor Either a cursor string or false if there are not any next results
|
||||
* @apiSuccess {Object[]} results File listing
|
||||
* @apiSuccess {String} results.id ID of the File
|
||||
* @apiSuccess {String} results.filename Filename
|
||||
* @apiSuccess {String} results.contentType Content-Type of the file
|
||||
* @apiSuccess {Number} results.size File size
|
||||
*
|
||||
* @apiError error Description of the error
|
||||
*
|
||||
* @apiExample {curl} Example usage:
|
||||
* curl -i http://localhost:8080/users/59fc66a03e54454869460e45/storage
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "success": true,
|
||||
* "total": 1,
|
||||
* "page": 1,
|
||||
* "previousCursor": false,
|
||||
* "nextCursor": false,
|
||||
* "results": [
|
||||
* {
|
||||
* "id": "59ef21aef255ed1d9d790e81",
|
||||
* "filename": "hello.txt",
|
||||
* "size": 1024
|
||||
* },
|
||||
* {
|
||||
* "id": "59ef21aef255ed1d9d790e82",
|
||||
* "filename": "finances.xls",
|
||||
* "size": 2084
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @apiErrorExample {json} Error-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "error": "Database error"
|
||||
* }
|
||||
*/
|
||||
server.get(
|
||||
'/users/:user/storage',
|
||||
tools.asyncifyJson(async (req, res, next) => {
|
||||
res.charSet('utf-8');
|
||||
|
||||
const schema = Joi.object().keys({
|
||||
user: Joi.string()
|
||||
.hex()
|
||||
.lowercase()
|
||||
.length(24)
|
||||
.required(),
|
||||
query: Joi.string()
|
||||
.trim()
|
||||
.empty('')
|
||||
.max(255),
|
||||
limit: Joi.number()
|
||||
.default(20)
|
||||
.min(1)
|
||||
.max(250),
|
||||
next: Joi.string()
|
||||
.empty('')
|
||||
.mongoCursor()
|
||||
.max(1024),
|
||||
previous: Joi.string()
|
||||
.empty('')
|
||||
.mongoCursor()
|
||||
.max(1024),
|
||||
page: Joi.number().default(1),
|
||||
sess: Joi.string().max(255),
|
||||
ip: Joi.string().ip({
|
||||
version: ['ipv4', 'ipv6'],
|
||||
cidr: 'forbidden'
|
||||
})
|
||||
});
|
||||
|
||||
req.query.user = req.params.user;
|
||||
const result = Joi.validate(req.query, schema, {
|
||||
abortEarly: false,
|
||||
convert: true,
|
||||
allowUnknown: true
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
res.json({
|
||||
error: result.error.message,
|
||||
code: 'InputValidationError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
// permissions check
|
||||
if (req.user && req.user === result.value.user) {
|
||||
req.validate(roles.can(req.role).readOwn('storage'));
|
||||
} else {
|
||||
req.validate(roles.can(req.role).readAny('storage'));
|
||||
}
|
||||
|
||||
let user = new ObjectID(result.value.user);
|
||||
|
||||
let userData;
|
||||
try {
|
||||
userData = await db.users.collection('users').findOne(
|
||||
{
|
||||
_id: user
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
address: true
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
res.json({
|
||||
error: 'MongoDB Error: ' + err.message,
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
if (!userData) {
|
||||
res.json({
|
||||
error: 'This user does not exist',
|
||||
code: 'UserNotFound'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
let query = result.value.query;
|
||||
let limit = result.value.limit;
|
||||
let page = result.value.page;
|
||||
let pageNext = result.value.next;
|
||||
let pagePrevious = result.value.previous;
|
||||
|
||||
let filter = (query && {
|
||||
'metadata.user': user,
|
||||
filename: {
|
||||
$regex: query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
|
||||
$options: ''
|
||||
}
|
||||
}) || {
|
||||
'metadata.user': user
|
||||
};
|
||||
|
||||
let total = await db.gridfs.collection('storage.files').countDocuments(filter);
|
||||
|
||||
let opts = {
|
||||
limit,
|
||||
query: filter,
|
||||
paginatedField: 'filename',
|
||||
sortAscending: true
|
||||
};
|
||||
|
||||
if (pageNext) {
|
||||
opts.next = pageNext;
|
||||
} else if ((!page || page > 1) && pagePrevious) {
|
||||
opts.previous = pagePrevious;
|
||||
}
|
||||
|
||||
let listing;
|
||||
try {
|
||||
listing = await MongoPaging.find(db.gridfs.collection('storage.files'), opts);
|
||||
} catch (err) {
|
||||
res.json({
|
||||
error: 'MongoDB Error: ' + err.message,
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!listing.hasPrevious) {
|
||||
page = 1;
|
||||
}
|
||||
|
||||
let response = {
|
||||
success: true,
|
||||
query,
|
||||
total,
|
||||
page,
|
||||
previousCursor: listing.hasPrevious ? listing.previous : false,
|
||||
nextCursor: listing.hasNext ? listing.next : false,
|
||||
results: (listing.results || []).map(fileData => ({
|
||||
id: fileData._id.toString(),
|
||||
filename: fileData.filename || false,
|
||||
contentType: fileData.contentType || false,
|
||||
size: fileData.length,
|
||||
created: fileData.uploadDate.toISOString(),
|
||||
md5: fileData.md5
|
||||
}))
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @api {delete} /users/:user/storage/:file Delete a File
|
||||
* @apiName DeleteStorage
|
||||
* @apiGroup Storage
|
||||
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
|
||||
* @apiHeaderExample {json} Header-Example:
|
||||
* {
|
||||
* "X-Access-Token": "59fc66a03e54454869460e45"
|
||||
* }
|
||||
*
|
||||
* @apiParam {String} user ID of the User
|
||||
* @apiParam {String} address ID of the File
|
||||
*
|
||||
* @apiSuccess {Boolean} success Indicates successful response
|
||||
*
|
||||
* @apiError error Description of the error
|
||||
*
|
||||
* @apiExample {curl} Example usage:
|
||||
* curl -i -XDELETE http://localhost:8080/users/59ef21aef255ed1d9d790e7a/storage/59ef21aef255ed1d9d790e81
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "success": true
|
||||
* }
|
||||
*
|
||||
* @apiErrorExample {json} Error-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "error": "Trying to delete main address. Set a new main address first"
|
||||
* }
|
||||
*/
|
||||
server.del(
|
||||
'/users/:user/storage/:file',
|
||||
tools.asyncifyJson(async (req, res, next) => {
|
||||
res.charSet('utf-8');
|
||||
|
||||
const schema = Joi.object().keys({
|
||||
user: Joi.string()
|
||||
.hex()
|
||||
.lowercase()
|
||||
.length(24)
|
||||
.required(),
|
||||
file: Joi.string()
|
||||
.hex()
|
||||
.lowercase()
|
||||
.length(24)
|
||||
.required(),
|
||||
sess: Joi.string().max(255),
|
||||
ip: Joi.string().ip({
|
||||
version: ['ipv4', 'ipv6'],
|
||||
cidr: 'forbidden'
|
||||
})
|
||||
});
|
||||
|
||||
const result = Joi.validate(req.params, schema, {
|
||||
abortEarly: false,
|
||||
convert: true
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
res.json({
|
||||
error: result.error.message,
|
||||
code: 'InputValidationError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
let user = new ObjectID(result.value.user);
|
||||
|
||||
// permissions check
|
||||
if (req.user && req.user === result.value.user) {
|
||||
req.validate(roles.can(req.role).deleteOwn('storage'));
|
||||
} else {
|
||||
req.validate(roles.can(req.role).deleteAny('storage'));
|
||||
}
|
||||
|
||||
let file = new ObjectID(result.value.file);
|
||||
await storageHandler.delete(user, file);
|
||||
|
||||
res.json({
|
||||
success: true
|
||||
});
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @api {get} /users/:user/storage/:file Download File
|
||||
* @apiName GetStorageFile
|
||||
* @apiGroup Storage
|
||||
* @apiDescription This method returns stored file contents in binary form
|
||||
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
|
||||
* @apiHeaderExample {json} Header-Example:
|
||||
* {
|
||||
* "X-Access-Token": "59fc66a03e54454869460e45"
|
||||
* }
|
||||
*
|
||||
* @apiParam {String} user ID of the User
|
||||
* @apiParam {String} file ID of the File
|
||||
*
|
||||
* @apiError error Description of the error
|
||||
*
|
||||
* @apiExample {curl} Example usage:
|
||||
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/storage/59fc66a13e54454869460e57"
|
||||
*
|
||||
* @apiSuccessExample {text} Success-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* Content-Type: image/png
|
||||
*
|
||||
* <89>PNG...
|
||||
*
|
||||
* @apiErrorExample {json} Error-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "error": "This attachment does not exist"
|
||||
* }
|
||||
*/
|
||||
server.get(
|
||||
{ name: 'storagefile', path: '/users/:user/storage/:file' },
|
||||
tools.asyncifyJson(async (req, res, next) => {
|
||||
const schema = Joi.object().keys({
|
||||
user: Joi.string()
|
||||
.hex()
|
||||
.lowercase()
|
||||
.length(24)
|
||||
.required(),
|
||||
file: Joi.string()
|
||||
.hex()
|
||||
.lowercase()
|
||||
.length(24)
|
||||
.required()
|
||||
});
|
||||
|
||||
const result = Joi.validate(req.params, schema, {
|
||||
abortEarly: false,
|
||||
convert: true
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
res.json({
|
||||
error: result.error.message,
|
||||
code: 'InputValidationError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
// permissions check
|
||||
if (req.user && req.user === result.value.user) {
|
||||
req.validate(roles.can(req.role).readOwn('storage'));
|
||||
} else {
|
||||
req.validate(roles.can(req.role).readAny('storage'));
|
||||
}
|
||||
|
||||
let user = new ObjectID(result.value.user);
|
||||
let file = new ObjectID(result.value.file);
|
||||
|
||||
let fileData;
|
||||
try {
|
||||
fileData = await db.gridfs.collection('storage.files').findOne({
|
||||
_id: file,
|
||||
'metadata.user': user
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
error: 'MongoDB Error: ' + err.message,
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!fileData) {
|
||||
res.json({
|
||||
error: 'This file does not exist',
|
||||
code: 'FileNotFound'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': fileData.contentType || 'application/octet-stream'
|
||||
});
|
||||
|
||||
let stream = storageHandler.gridstore.openDownloadStream(file);
|
||||
|
||||
stream.once('error', err => {
|
||||
try {
|
||||
res.end(err.message);
|
||||
} catch (err) {
|
||||
//ignore
|
||||
}
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
})
|
||||
);
|
||||
};
|
|
@ -1,77 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// TODO: handle drafts
|
||||
|
||||
/*
|
||||
|
||||
{
|
||||
_id: "aaaaa",
|
||||
user: "bbbbb",
|
||||
|
||||
reference: 'message_id',
|
||||
action: 'reply',
|
||||
references: ['from-ref-message'],
|
||||
inReplyTo: ['from-ref-message'],
|
||||
|
||||
messageId: '<draft.id@local>',
|
||||
date: date_obj,
|
||||
|
||||
from: identity_ref,
|
||||
to: [{name, address}],
|
||||
cc: [{name, address}],
|
||||
bcc: [{name, address}],
|
||||
|
||||
subject: 'test',
|
||||
html: '<html>',
|
||||
|
||||
attachments: [
|
||||
{
|
||||
filename: 'aaa.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
content: binary,
|
||||
cid: 'only.for@embedded.images'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
class DraftHandler {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.redis = options.redis;
|
||||
}
|
||||
|
||||
// should create a new Draft object and return ID
|
||||
create(user, options, callback) {
|
||||
options = options || {};
|
||||
callback(new Error('Future feature'));
|
||||
}
|
||||
|
||||
// should retrieve draft info
|
||||
get(user, draft, callback) {
|
||||
callback(new Error('Future feature'));
|
||||
}
|
||||
|
||||
// should add new attachment to draft and return attachment ID
|
||||
addAttachment(user, draft, attachmentData, callback) {
|
||||
callback(new Error('Future feature'));
|
||||
}
|
||||
|
||||
// should delete an attachment from a draft
|
||||
deleteAttachment(user, draft, attachment, callback) {
|
||||
callback(new Error('Future feature'));
|
||||
}
|
||||
|
||||
// should submit message to queue and delete draft
|
||||
send(user, draft, envelope, callback) {
|
||||
callback(new Error('Future feature'));
|
||||
}
|
||||
|
||||
// should cancel the draft and delete contents
|
||||
discard(user, draft, callback) {
|
||||
callback(new Error('Future feature'));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DraftHandler;
|
140
lib/storage-handler.js
Normal file
140
lib/storage-handler.js
Normal file
|
@ -0,0 +1,140 @@
|
|||
'use strict';
|
||||
|
||||
const GridFSBucket = require('mongodb').GridFSBucket;
|
||||
const libbase64 = require('libbase64');
|
||||
const libmime = require('libmime');
|
||||
|
||||
class StorageHandler {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.gridfs = options.gridfs || options.database;
|
||||
this.users = options.users || options.database;
|
||||
this.bucketName = 'storage';
|
||||
|
||||
this.gridstore = new GridFSBucket(this.gridfs, {
|
||||
bucketName: this.bucketName,
|
||||
chunkSizeBytes: 255 * 1024
|
||||
});
|
||||
}
|
||||
|
||||
add(user, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let filename = options.filename;
|
||||
let contentType = options.contentType;
|
||||
|
||||
let filebase = 'upload-' + new Date().toISOString().substr(0, 10);
|
||||
if (!contentType && !filename) {
|
||||
filename = filebase + '.bin';
|
||||
contentType = 'application/octet-stream';
|
||||
} else if (!contentType) {
|
||||
contentType = libmime.detectMimeType(filename) || 'application/octet-stream';
|
||||
} else {
|
||||
filename = filebase + '.' + libmime.detectExtension(contentType);
|
||||
}
|
||||
|
||||
let store = this.gridstore.openUploadStream(filename, {
|
||||
contentType,
|
||||
metadata: {
|
||||
user
|
||||
}
|
||||
});
|
||||
|
||||
store.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
store.once('finish', () => {
|
||||
resolve(store.id);
|
||||
});
|
||||
|
||||
let decoder = new libbase64.Decoder();
|
||||
decoder.pipe(store);
|
||||
|
||||
decoder.once('error', err => {
|
||||
// pass error forward
|
||||
store.emit('error', err);
|
||||
});
|
||||
|
||||
try {
|
||||
decoder.end(options.content);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get(user, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.gridfs.collection('storage.files').findOne(
|
||||
{
|
||||
_id: file,
|
||||
'metadata.user': user
|
||||
},
|
||||
(err, fileData) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
if (!fileData) {
|
||||
let err = 'This file does not exist';
|
||||
err.code = 'FileNotFound';
|
||||
return reject(err);
|
||||
}
|
||||
let stream = this.gridstore.openDownloadStream(file);
|
||||
let chunks = [];
|
||||
let chunklen = 0;
|
||||
|
||||
stream.once('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = stream.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
|
||||
stream.once('end', () => {
|
||||
resolve({
|
||||
id: fileData._id,
|
||||
filename: fileData.filename,
|
||||
contentType: fileData.contentType,
|
||||
size: fileData.length,
|
||||
content: Buffer.concat(chunks, chunklen)
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
delete(user, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.gridfs.collection('storage.files').findOne(
|
||||
{
|
||||
_id: file,
|
||||
'metadata.user': user
|
||||
},
|
||||
(err, fileData) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
if (!fileData) {
|
||||
let err = 'This file does not exist';
|
||||
err.code = 'FileNotFound';
|
||||
return reject(err);
|
||||
}
|
||||
this.gridstore.delete(file, err => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StorageHandler;
|
14
package.json
14
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "wildduck",
|
||||
"version": "1.15.2",
|
||||
"version": "1.16.0",
|
||||
"description": "IMAP/POP3 server built with Node.js and MongoDB",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
@ -22,7 +22,7 @@
|
|||
"eslint": "5.15.3",
|
||||
"eslint-config-nodemailer": "1.2.0",
|
||||
"eslint-config-prettier": "4.1.0",
|
||||
"grunt": "1.0.3",
|
||||
"grunt": "1.0.4",
|
||||
"grunt-cli": "1.3.2",
|
||||
"grunt-eslint": "21.0.0",
|
||||
"grunt-mocha-test": "0.13.3",
|
||||
|
@ -41,14 +41,14 @@
|
|||
"gelf": "2.0.1",
|
||||
"generate-password": "1.4.1",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "4.0.0",
|
||||
"html-to-text": "5.0.0",
|
||||
"humanname": "0.2.2",
|
||||
"iconv-lite": "0.4.24",
|
||||
"ioredfour": "1.0.2-ioredis-02",
|
||||
"ioredis": "4.6.2",
|
||||
"ioredis": "4.9.0",
|
||||
"isemail": "3.2.0",
|
||||
"joi": "14.3.1",
|
||||
"js-yaml": "3.12.2",
|
||||
"js-yaml": "3.13.0",
|
||||
"key-fingerprint": "1.1.0",
|
||||
"libbase64": "1.0.3",
|
||||
"libmime": "4.0.1",
|
||||
|
@ -56,10 +56,10 @@
|
|||
"mailsplit": "4.2.4",
|
||||
"mobileconfig": "2.2.0",
|
||||
"mongo-cursor-pagination": "7.1.0",
|
||||
"mongodb": "3.1.13",
|
||||
"mongodb": "3.2.2",
|
||||
"mongodb-extended-json": "1.10.1",
|
||||
"node-forge": "0.8.2",
|
||||
"nodemailer": "5.1.1",
|
||||
"nodemailer": "6.0.0",
|
||||
"npmlog": "4.1.2",
|
||||
"openpgp": "4.4.10",
|
||||
"pem": "1.14.2",
|
||||
|
|
Loading…
Reference in a new issue