UNPKG

7.46 kBJavaScriptView Raw
1'use strict';
2
3const PassThrough = require('stream').PassThrough;
4
5const Promise = require('bluebird');
6const Ftp = require('jsftp');
7const Mime = require('mime');
8
9const Tools = require('unifile-common-tools');
10const {UnifileError} = require('./error');
11
12const NAME = 'ftp';
13
14/**
15 * Initialize a new FTP client
16 * @param {Credentials} credentials - Access info for the FTP server
17 * @return {Promise<Ftp>} a promise for a FTP client
18 */
19function getClient(credentials) {
20 return new Promise((resolve, reject) => {
21 const ftp = new Ftp(credentials);
22 ftp.once('connect', () => {
23 resolve(ftp);
24 });
25 });
26}
27
28function callAPI(session, action, client, ...params) {
29 function execute(ftpClient) {
30 return new Promise((resolve, reject) => {
31 const handler = (err, res) => {
32 if(err) reject(err);
33 else resolve(res);
34 };
35 // Makes paths in params absolute
36 const absParams = params.map((p) => {
37 if(p.constructor === String) return '/' + p;
38 return p;
39 });
40 switch (action) {
41 case 'delete':
42 ftpClient.raw('DELE', ...absParams, handler);
43 break;
44 case 'rmdir':
45 ftpClient.raw('RMD', ...absParams, handler);
46 break;
47 case 'mkdir':
48 ftpClient.raw('MKD', ...absParams, handler);
49 break;
50 default:
51 ftpClient[action](...absParams, handler);
52 }
53 });
54 }
55
56 let ftp = client;
57 let promise = null;
58 if(client) {
59 promise = execute(client);
60 } else {
61 promise = getClient(session)
62 .then((client) => {
63 ftp = client;
64 // Adds a error handler on the client
65 return Promise.race([
66 new Promise((resolve, reject) => {
67 ftp.on('error', (err) => {
68 ftp.destroy();
69 reject(err);
70 });
71 }),
72 execute(ftp)
73 ]);
74 });
75 }
76
77 return promise.catch((err) => {
78 if(err.code === 530) {
79 throw new UnifileError(UnifileError.EACCES, 'Invalid credentials');
80 }
81 throw new UnifileError(UnifileError.EIO, err.message);
82 })
83 .then((result) => {
84 // Client was not provided, we can close it
85 if(!client && result && !result.readable) {
86 ftp.destroy();
87 }
88 return result;
89 });
90}
91
92function toFileInfos(entry) {
93 const isDir = entry.type === 1;
94 return {
95 size: parseInt(entry.size, 10),
96 modified: new Date(entry.time).toISOString(),
97 name: entry.name,
98 isDir: isDir,
99 mime: isDir ? 'application/directory' : Mime.getType(entry.name)
100 };
101}
102
103/**
104 * Service connector for {@link https://en.wikipedia.org/wiki/File_Transfer_Protocol|FTP} server
105 */
106class FtpConnector {
107 /**
108 * @constructor
109 * @param {Object} config - Configuration object
110 * @param {string} config.redirectUri - URI of the login page
111 * @param {boolean} [config.showHiddenFiles=false] - Flag to show hidden files.
112 * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
113 */
114 constructor(config) {
115 if(!config || !config.redirectUri)
116 throw new Error('You should at least set a redirectUri for this connector');
117
118 this.redirectUri = config.redirectUri;
119 this.showHiddenFile = config.showHiddenFile || false;
120 this.infos = Tools.mergeInfos(config.infos || {}, {
121 name: NAME,
122 displayName: 'FTP',
123 icon: '../assets/ftp.png',
124 description: 'Edit files on a web FTP server.'
125 });
126 this.name = this.infos.name;
127 }
128
129 getInfos(session) {
130 return Object.assign({
131 isLoggedIn: (session && 'token' in session),
132 isOAuth: false,
133 username: session.user
134 }, this.infos);
135 }
136
137 getAuthorizeURL(session) {
138 return Promise.resolve(this.redirectUri);
139 }
140
141 setAccessToken(session, token) {
142 session.token = token;
143 return Promise.resolve(token);
144 }
145
146 clearAccessToken(session) {
147 Tools.clearSession(session);
148 return Promise.resolve();
149 }
150
151 login(session, loginInfos) {
152 const ftpConf = {};
153 try {
154 Object.assign(ftpConf, Tools.parseBasicAuth(loginInfos));
155 ftpConf.pass = ftpConf.password;
156 } catch (e) {
157 return Promise.reject(e);
158 }
159
160 return new Promise((resolve, reject) => {
161 const client = new Ftp(ftpConf);
162 client.on('error', (err) => {
163 reject(err);
164 });
165 // Successful connection
166 client.once('connect', () => {
167 client.auth(ftpConf.user, ftpConf.password, (err) => {
168 if(err) reject(err);
169 else resolve();
170 });
171 });
172 })
173 .catch((err) => {
174 if(err.code === 'ETIMEDOUT')
175 throw new UnifileError(UnifileError.EIO, 'Unable to reach server');
176 else
177 throw new UnifileError(UnifileError.EACCES, 'Invalid credentials');
178 })
179 .then(() => {
180 Object.assign(session, ftpConf);
181 this.setAccessToken(session, ftpConf.user);
182 });
183 }
184
185 //Filesystem commands
186
187 readdir(session, path, ftpSession) {
188 return callAPI(session, 'ls', ftpSession, path)
189 .then((list) => {
190 return list.reduce((memo, entry) => {
191 if(this.showHiddenFile || entry.name.charAt(0) != '.')
192 memo.push(toFileInfos(entry));
193 return memo;
194 }, []);
195 });
196 }
197
198 stat(session, path, ftpSession) {
199 return callAPI(session, 'ls', ftpSession, path)
200 .then((entries) => {
201 // It's a file
202 if(entries.length === 1) return toFileInfos(entries[0]);
203 // It's a folder
204 const lastTime = entries.reduce((memo, stat) => {
205 // eslint-disable-next-line no-param-reassign
206 if(stat.time > memo) memo = stat.time;
207 return memo;
208 }, 0);
209 return toFileInfos({
210 name: path.split('/').pop(),
211 type: 1,
212 time: lastTime
213 });
214 });
215 }
216
217 mkdir(session, path, ftpSession) {
218 return callAPI(session, 'mkdir', ftpSession, path);
219 }
220
221 writeFile(session, path, data, ftpSession) {
222 return callAPI(session, 'put', ftpSession, new Buffer(data), path);
223 }
224
225 createWriteStream(session, path, ftpSession) {
226 var through = new PassThrough();
227 callAPI(session, 'put', ftpSession, through, path);
228 return through;
229 }
230
231 readFile(session, path, ftpSession) {
232 const promise = ftpSession ? Promise.resolve(ftpSession) : getClient(session);
233 return promise.then((client) => {
234 return callAPI(session, 'get', client, path)
235 .then((fileStream) => {
236 return new Promise((resolve, reject) => {
237 const chunks = [];
238 fileStream.on('data', (chunk) => chunks.push(chunk));
239 fileStream.on('end', () => resolve(Buffer.concat(chunks)));
240 fileStream.on('error', (err) => {
241 client.end();
242 reject(err);
243 });
244 fileStream.resume();
245 });
246 });
247 });
248 }
249
250 createReadStream(session, path, ftpSession) {
251 var through = new PassThrough();
252 callAPI(session, 'get', ftpSession, path)
253 .then((fileStream) => {
254 fileStream.pipe(through);
255 fileStream.resume();
256 })
257 .catch((err) => through.emit('error', err));
258
259 return through;
260 }
261
262 rename(session, src, dest, ftpSession) {
263 return callAPI(session, 'rename', ftpSession, src, dest);
264 }
265
266 unlink(session, path, ftpSession) {
267 return callAPI(session, 'delete', ftpSession, path);
268 }
269
270 rmdir(session, path, ftpSession) {
271 return callAPI(session, 'rmdir', ftpSession, path);
272 }
273
274 batch(session, actions, message) {
275 let ftpClient;
276 return getClient(session)
277 .then((ftp) => {
278 ftpClient = ftp;
279 return Promise.each(actions, (action) => {
280 const act = action.name.toLowerCase();
281 switch (act) {
282 case 'unlink':
283 case 'rmdir':
284 case 'mkdir':
285 return this[act](session, action.path, ftpClient);
286 case 'rename':
287 return this[act](session, action.path, action.destination, ftpClient);
288 case 'writefile':
289 return this.writeFile(session, action.path, action.content, ftpClient);
290 default:
291 console.warn(`Unsupported batch action: ${action.name}`);
292 }
293 });
294 });
295 }
296}
297
298module.exports = FtpConnector;