aboutsummaryrefslogtreecommitdiff
path: root/src/data/attachments.js
blob: 40b13b0442e5426afc7c90150fa6e600af29128e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
const Eris = require('eris');
const fs = require('fs');
const https = require('https');
const {promisify} = require('util');
const tmp = require('tmp');
const config = require('../config');
const utils = require('../utils');
const mv = promisify(require('mv'));

const getUtils = () => require('../utils');

const access = promisify(fs.access);
const readFile = promisify(fs.readFile);

const localAttachmentDir = config.attachmentDir || `${__dirname}/../../attachments`;

const attachmentSavePromises = {};

const attachmentStorageTypes = {};

function getErrorResult(msg = null) {
  return {
    url: `Attachment could not be saved${msg ? ': ' + msg : ''}`,
    failed: true
  };
}

/**
 * Attempts to download and save the given attachement
 * @param {Object} attachment
 * @param {Number=0} tries
 * @returns {Promise<{ url: string }>}
 */
async function saveLocalAttachment(attachment) {
  const targetPath = getLocalAttachmentPath(attachment.id);

  try {
    // If the file already exists, resolve immediately
    await access(targetPath);
    const url = await getLocalAttachmentUrl(attachment.id, attachment.filename);
    return { url };
  } catch (e) {}

  // Download the attachment
  const downloadResult = await downloadAttachment(attachment);

  // Move the temp file to the attachment folder
  await mv(downloadResult.path, targetPath);

  // Resolve the attachment URL
  const url = await getLocalAttachmentUrl(attachment.id, attachment.filename);

  return { url };
}

/**
 * @param {Object} attachment
 * @param {Number} tries
 * @returns {Promise<{ path: string, cleanup: function }>}
 */
function downloadAttachment(attachment, tries = 0) {
  return new Promise((resolve, reject) => {
    if (tries > 3) {
      console.error('Attachment download failed after 3 tries:', attachment);
      reject('Attachment download failed after 3 tries');
      return;
    }

    tmp.file((err, filepath, fd, cleanupCallback) => {
      const writeStream = fs.createWriteStream(filepath);

      https.get(attachment.url, (res) => {
        res.pipe(writeStream);
        writeStream.on('finish', () => {
          writeStream.end();
          resolve({
            path: filepath,
            cleanup: cleanupCallback
          });
        });
      }).on('error', (err) => {
        fs.unlink(filepath);
        console.error('Error downloading attachment, retrying');
        resolve(downloadAttachment(attachment, tries++));
      });
    });
  });
}

/**
 * Returns the filesystem path for the given attachment id
 * @param {String} attachmentId
 * @returns {String}
 */
function getLocalAttachmentPath(attachmentId) {
  return `${localAttachmentDir}/${attachmentId}`;
}

/**
 * Returns the self-hosted URL to the given attachment ID
 * @param {String} attachmentId
 * @param {String=null} desiredName Custom name for the attachment as a hint for the browser
 * @returns {Promise<String>}
 */
function getLocalAttachmentUrl(attachmentId, desiredName = null) {
  if (desiredName == null) desiredName = 'file.bin';
  return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`);
}

/**
 * @param {Object} attachment
 * @returns {Promise<{ url: string }>}
 */
async function saveDiscordAttachment(attachment) {
  if (attachment.size > 1024 * 1024 * 8) {
    return getErrorResult('attachment too large (max 8MB)');
  }

  const attachmentChannelId = config.attachmentStorageChannelId;
  const inboxGuild = utils.getInboxGuild();

  if (! inboxGuild.channels.has(attachmentChannelId)) {
    throw new Error('Attachment storage channel not found!');
  }

  const attachmentChannel = inboxGuild.channels.get(attachmentChannelId);
  if (! (attachmentChannel instanceof Eris.TextChannel)) {
    throw new Error('Attachment storage channel must be a text channel!');
  }

  const file = await attachmentToDiscordFileObject(attachment);
  const savedAttachment = await createDiscordAttachmentMessage(attachmentChannel, file);
  if (! savedAttachment) return getErrorResult();

  return { url: savedAttachment.url };
}

async function createDiscordAttachmentMessage(channel, file, tries = 0) {
  tries++;

  try {
    const attachmentMessage = await channel.createMessage(undefined, file);
    return attachmentMessage.attachments[0];
  } catch (e) {
    if (tries > 3) {
      console.error(`Attachment storage message could not be created after 3 tries: ${e.message}`);
      return;
    }

    return createDiscordAttachmentMessage(channel, file, tries);
  }
}

/**
 * Turns the given attachment into a file object that can be sent forward as a new attachment
 * @param {Object} attachment
 * @returns {Promise<{file, name: string}>}
 */
async function attachmentToDiscordFileObject(attachment) {
  const downloadResult = await downloadAttachment(attachment);
  const data = await readFile(downloadResult.path);
  downloadResult.cleanup();
  return {file: data, name: attachment.filename};
}

/**
 * Saves the given attachment based on the configured storage system
 * @param {Object} attachment
 * @returns {Promise<{ url: string }>}
 */
function saveAttachment(attachment) {
  if (attachmentSavePromises[attachment.id]) {
    return attachmentSavePromises[attachment.id];
  }

  if (attachmentStorageTypes[config.attachmentStorage]) {
    attachmentSavePromises[attachment.id] = Promise.resolve(attachmentStorageTypes[config.attachmentStorage](attachment));
  } else {
    throw new Error(`Unknown attachment storage option: ${config.attachmentStorage}`);
  }

  attachmentSavePromises[attachment.id].then(() => {
    delete attachmentSavePromises[attachment.id];
  });

  return attachmentSavePromises[attachment.id];
}

function addStorageType(name, handler) {
  attachmentStorageTypes[name] = handler;
}

attachmentStorageTypes.local = saveLocalAttachment;
attachmentStorageTypes.discord = saveDiscordAttachment;

module.exports = {
  getLocalAttachmentPath,
  attachmentToDiscordFileObject,
  saveAttachment,
  addStorageType,
  downloadAttachment
};