|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2010 - 2019 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a network reply class for FTP resources. |
|
8 """ |
|
9 |
|
10 from __future__ import unicode_literals |
|
11 try: |
|
12 str = unicode |
|
13 except NameError: |
|
14 pass |
|
15 |
|
16 import ftplib |
|
17 import socket |
|
18 import errno |
|
19 import mimetypes |
|
20 |
|
21 from PyQt5.QtCore import QByteArray, QIODevice, Qt, QUrl, QTimer, QBuffer, \ |
|
22 QCoreApplication |
|
23 from PyQt5.QtGui import QPixmap |
|
24 from PyQt5.QtWidgets import QDialog |
|
25 from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest, QAuthenticator |
|
26 from PyQt5.QtWebKit import QWebSettings |
|
27 |
|
28 from E5Network.E5Ftp import E5Ftp, E5FtpProxyError, E5FtpProxyType |
|
29 |
|
30 import UI.PixmapCache |
|
31 |
|
32 from Utilities.FtpUtilities import FtpDirLineParser, FtpDirLineParserError |
|
33 import Utilities |
|
34 |
|
35 import Preferences |
|
36 |
|
37 ftpListPage_html = """\ |
|
38 <?xml version="1.0" encoding="UTF-8" ?> |
|
39 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" |
|
40 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> |
|
41 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> |
|
42 <head> |
|
43 <title>{0}</title> |
|
44 <style type="text/css"> |
|
45 body {{ |
|
46 padding: 3em 0em; |
|
47 background: -webkit-gradient(linear, left top, left bottom, from(#85784A), |
|
48 to(#FDFDFD), color-stop(0.5, #FDFDFD)); |
|
49 background-repeat: repeat-x; |
|
50 }} |
|
51 #box {{ |
|
52 background: white; |
|
53 border: 1px solid #85784A; |
|
54 width: 80%; |
|
55 padding: 30px; |
|
56 margin: auto; |
|
57 -webkit-border-radius: 0.8em; |
|
58 }} |
|
59 h1 {{ |
|
60 font-size: 130%; |
|
61 font-weight: bold; |
|
62 border-bottom: 1px solid #85784A; |
|
63 }} |
|
64 th {{ |
|
65 background-color: #B8B096; |
|
66 color: black; |
|
67 }} |
|
68 table {{ |
|
69 border: solid 1px #85784A; |
|
70 margin: 5px 0; |
|
71 width: 100%; |
|
72 }} |
|
73 tr.odd {{ |
|
74 background-color: white; |
|
75 color: black; |
|
76 }} |
|
77 tr.even {{ |
|
78 background-color: #CEC9B8; |
|
79 color: black; |
|
80 }} |
|
81 .modified {{ |
|
82 text-align: left; |
|
83 vertical-align: top; |
|
84 white-space: nowrap; |
|
85 }} |
|
86 .size {{ |
|
87 text-align: right; |
|
88 vertical-align: top; |
|
89 white-space: nowrap; |
|
90 padding-right: 22px; |
|
91 }} |
|
92 .name {{ |
|
93 text-align: left; |
|
94 vertical-align: top; |
|
95 white-space: pre-wrap; |
|
96 width: 100% |
|
97 }} |
|
98 {1} |
|
99 </style> |
|
100 </head> |
|
101 <body> |
|
102 <div id="box"> |
|
103 <h1>{2}</h1> |
|
104 {3} |
|
105 <table align="center" cellspacing="0" width="90%"> |
|
106 {4} |
|
107 </table> |
|
108 </div> |
|
109 </body> |
|
110 </html> |
|
111 """ |
|
112 |
|
113 |
|
114 class FtpReply(QNetworkReply): |
|
115 """ |
|
116 Class implementing a network reply for FTP resources. |
|
117 """ |
|
118 def __init__(self, url, accessHandler, parent=None): |
|
119 """ |
|
120 Constructor |
|
121 |
|
122 @param url requested FTP URL (QUrl) |
|
123 @param accessHandler reference to the access handler (FtpAccessHandler) |
|
124 @param parent reference to the parent object (QObject) |
|
125 """ |
|
126 super(FtpReply, self).__init__(parent) |
|
127 |
|
128 self.__manager = parent |
|
129 self.__handler = accessHandler |
|
130 |
|
131 self.__ftp = E5Ftp() |
|
132 |
|
133 self.__items = [] |
|
134 self.__content = QByteArray() |
|
135 self.__units = ["Bytes", "KB", "MB", "GB", "TB", |
|
136 "PB", "EB", "ZB", "YB"] |
|
137 self.__dirLineParser = FtpDirLineParser() |
|
138 self.__fileBytesReceived = 0 |
|
139 |
|
140 if url.path() == "": |
|
141 url.setPath("/") |
|
142 self.setUrl(url) |
|
143 |
|
144 # do proxy setup |
|
145 if not Preferences.getUI("UseProxy"): |
|
146 proxyType = E5FtpProxyType.NoProxy |
|
147 else: |
|
148 proxyType = Preferences.getUI("ProxyType/Ftp") |
|
149 if proxyType != E5FtpProxyType.NoProxy: |
|
150 self.__ftp.setProxy( |
|
151 proxyType, |
|
152 Preferences.getUI("ProxyHost/Ftp"), |
|
153 Preferences.getUI("ProxyPort/Ftp")) |
|
154 if proxyType != E5FtpProxyType.NonAuthorizing: |
|
155 self.__ftp.setProxyAuthentication( |
|
156 Preferences.getUI("ProxyUser/Ftp"), |
|
157 Preferences.getUI("ProxyPassword/Ftp"), |
|
158 Preferences.getUI("ProxyAccount/Ftp")) |
|
159 |
|
160 QTimer.singleShot(0, self.__doFtpCommands) |
|
161 |
|
162 def abort(self): |
|
163 """ |
|
164 Public slot to abort the operation. |
|
165 """ |
|
166 # do nothing |
|
167 pass |
|
168 |
|
169 def bytesAvailable(self): |
|
170 """ |
|
171 Public method to determined the bytes available for being read. |
|
172 |
|
173 @return bytes available (integer) |
|
174 """ |
|
175 return self.__content.size() |
|
176 |
|
177 def isSequential(self): |
|
178 """ |
|
179 Public method to check for sequential access. |
|
180 |
|
181 @return flag indicating sequential access (boolean) |
|
182 """ |
|
183 return True |
|
184 |
|
185 def readData(self, maxlen): |
|
186 """ |
|
187 Public method to retrieve data from the reply object. |
|
188 |
|
189 @param maxlen maximum number of bytes to read (integer) |
|
190 @return string containing the data (bytes) |
|
191 """ |
|
192 if self.__content.size(): |
|
193 len_ = min(maxlen, self.__content.size()) |
|
194 buffer = bytes(self.__content[:len_]) |
|
195 self.__content.remove(0, len_) |
|
196 return buffer |
|
197 else: |
|
198 return b"" |
|
199 |
|
200 def __doFtpCommands(self): |
|
201 """ |
|
202 Private slot doing the sequence of FTP commands to get the requested |
|
203 result. |
|
204 """ |
|
205 retry = True |
|
206 try: |
|
207 username = self.url().userName() |
|
208 password = self.url().password() |
|
209 byAuth = False |
|
210 |
|
211 while retry: |
|
212 try: |
|
213 self.__ftp.connect(self.url().host(), |
|
214 self.url().port(ftplib.FTP_PORT), |
|
215 timeout=10) |
|
216 except E5FtpProxyError as err: |
|
217 self.setError(QNetworkReply.ProxyNotFoundError, str(err)) |
|
218 self.error.emit(QNetworkReply.ProxyNotFoundError) |
|
219 self.finished.emit() |
|
220 ok, retry = self.__doFtpLogin(username, password, byAuth) |
|
221 if not ok and retry: |
|
222 auth = self.__handler.getAuthenticator(self.url().host()) |
|
223 if auth and not auth.isNull() and auth.user(): |
|
224 username = auth.user() |
|
225 password = auth.password() |
|
226 byAuth = True |
|
227 else: |
|
228 retry = False |
|
229 if ok: |
|
230 self.__ftp.retrlines("LIST " + self.url().path(), |
|
231 self.__dirCallback) |
|
232 if len(self.__items) == 1 and \ |
|
233 self.__items[0].isFile(): |
|
234 self.__fileBytesReceived = 0 |
|
235 self.__setContent() |
|
236 self.__ftp.retrbinary( |
|
237 "RETR " + self.url().path(), self.__retrCallback) |
|
238 self.__content.append(512 * b' ') |
|
239 self.readyRead.emit() |
|
240 else: |
|
241 self.__setListContent() |
|
242 self.__ftp.quit() |
|
243 except ftplib.all_errors as err: |
|
244 if isinstance(err, socket.gaierror): |
|
245 errCode = QNetworkReply.HostNotFoundError |
|
246 elif isinstance(err, socket.error) and \ |
|
247 err.errno == errno.ECONNREFUSED: |
|
248 errCode = QNetworkReply.ConnectionRefusedError |
|
249 else: |
|
250 errCode = QNetworkReply.ProtocolFailure |
|
251 self.setError(errCode, str(err)) |
|
252 self.error.emit(errCode) |
|
253 self.finished.emit() |
|
254 |
|
255 def __doFtpLogin(self, username, password, byAuth=False): |
|
256 """ |
|
257 Private method to do the FTP login with asking for a username and |
|
258 password, if the login fails with an error 530. |
|
259 |
|
260 @param username user name to use for the login (string) |
|
261 @param password password to use for the login (string) |
|
262 @param byAuth flag indicating that the login data was provided by an |
|
263 authenticator (boolean) |
|
264 @return tuple of two flags indicating a successful login and |
|
265 if the login should be retried (boolean, boolean) |
|
266 """ |
|
267 try: |
|
268 self.__ftp.login(username, password) |
|
269 return True, False |
|
270 except E5FtpProxyError as err: |
|
271 code = str(err)[:3] |
|
272 if code[1] == "5": |
|
273 # could be a 530, check second line |
|
274 lines = str(err).splitlines() |
|
275 if lines[1][:3] == "530": |
|
276 if "usage" in "\n".join(lines[1:].lower()): |
|
277 # found a not supported proxy |
|
278 self.setError( |
|
279 QNetworkReply.ProxyConnectionRefusedError, |
|
280 self.tr("The proxy type seems to be wrong." |
|
281 " If it is not in the list of" |
|
282 " supported proxy types please report" |
|
283 " it with the instructions given by" |
|
284 " the proxy.\n{0}").format( |
|
285 "\n".join(lines[1:]))) |
|
286 self.error.emit( |
|
287 QNetworkReply.ProxyConnectionRefusedError) |
|
288 return False, False |
|
289 else: |
|
290 from UI.AuthenticationDialog import \ |
|
291 AuthenticationDialog |
|
292 info = self.tr( |
|
293 "<b>Connect to proxy '{0}' using:</b>")\ |
|
294 .format(Utilities.html_encode( |
|
295 Preferences.getUI("ProxyHost/Ftp"))) |
|
296 dlg = AuthenticationDialog( |
|
297 info, Preferences.getUI("ProxyUser/Ftp"), True) |
|
298 dlg.setData(Preferences.getUI("ProxyUser/Ftp"), |
|
299 Preferences.getUI("ProxyPassword/Ftp")) |
|
300 if dlg.exec_() == QDialog.Accepted: |
|
301 username, password = dlg.getData() |
|
302 if dlg.shallSave(): |
|
303 Preferences.setUI("ProxyUser/Ftp", username) |
|
304 Preferences.setUI( |
|
305 "ProxyPassword/Ftp", password) |
|
306 self.__ftp.setProxyAuthentication(username, |
|
307 password) |
|
308 return False, True |
|
309 return False, False |
|
310 except (ftplib.error_perm, ftplib.error_temp) as err: |
|
311 code = err.args[0].strip()[:3] |
|
312 if code in ["530", "421"]: |
|
313 # error 530 -> Login incorrect |
|
314 # error 421 -> Login may be incorrect (reported by some |
|
315 # proxies) |
|
316 if byAuth: |
|
317 self.__handler.setAuthenticator(self.url().host(), None) |
|
318 auth = None |
|
319 else: |
|
320 auth = self.__handler.getAuthenticator(self.url().host()) |
|
321 if not auth or auth.isNull() or not auth.user(): |
|
322 auth = QAuthenticator() |
|
323 self.__manager.authenticationRequired.emit(self, auth) |
|
324 if not auth.isNull(): |
|
325 if auth.user(): |
|
326 self.__handler.setAuthenticator(self.url().host(), |
|
327 auth) |
|
328 return False, True |
|
329 return False, False |
|
330 return False, True |
|
331 else: |
|
332 raise |
|
333 |
|
334 def __dirCallback(self, line): |
|
335 """ |
|
336 Private slot handling the receipt of directory listings. |
|
337 |
|
338 @param line the received line of the directory listing (string) |
|
339 """ |
|
340 try: |
|
341 urlInfo = self.__dirLineParser.parseLine(line) |
|
342 except FtpDirLineParserError: |
|
343 # silently ignore parser errors |
|
344 urlInfo = None |
|
345 |
|
346 if urlInfo: |
|
347 self.__items.append(urlInfo) |
|
348 |
|
349 QCoreApplication.processEvents() |
|
350 |
|
351 def __retrCallback(self, data): |
|
352 """ |
|
353 Private slot handling the reception of data. |
|
354 |
|
355 @param data data received from the FTP server (bytes) |
|
356 """ |
|
357 self.__content += QByteArray(data) |
|
358 self.__fileBytesReceived += len(data) |
|
359 self.downloadProgress.emit( |
|
360 self.__fileBytesReceived, self.__items[0].size()) |
|
361 self.readyRead.emit() |
|
362 |
|
363 QCoreApplication.processEvents() |
|
364 |
|
365 def __setContent(self): |
|
366 """ |
|
367 Private method to finish the setup of the data. |
|
368 """ |
|
369 mtype, encoding = mimetypes.guess_type(self.url().toString()) |
|
370 self.open(QIODevice.ReadOnly | QIODevice.Unbuffered) |
|
371 self.setHeader(QNetworkRequest.ContentLengthHeader, |
|
372 self.__items[0].size()) |
|
373 if mtype: |
|
374 self.setHeader(QNetworkRequest.ContentTypeHeader, mtype) |
|
375 self.setAttribute(QNetworkRequest.HttpStatusCodeAttribute, 200) |
|
376 self.setAttribute(QNetworkRequest.HttpReasonPhraseAttribute, "Ok") |
|
377 self.metaDataChanged.emit() |
|
378 |
|
379 def __cssLinkClass(self, icon, size=32): |
|
380 """ |
|
381 Private method to generate a link class with an icon. |
|
382 |
|
383 @param icon icon to be included (QIcon) |
|
384 @param size size of the icon to be generated (integer) |
|
385 @return CSS class string (string) |
|
386 """ |
|
387 cssString = \ |
|
388 """a.{{0}} {{{{\n"""\ |
|
389 """ padding-left: {0}px;\n"""\ |
|
390 """ background: transparent url(data:image/png;base64,{1})"""\ |
|
391 """ no-repeat center left;\n"""\ |
|
392 """ font-weight: bold;\n"""\ |
|
393 """}}}}\n""" |
|
394 pixmap = icon.pixmap(size, size) |
|
395 imageBuffer = QBuffer() |
|
396 imageBuffer.open(QIODevice.ReadWrite) |
|
397 if not pixmap.save(imageBuffer, "PNG"): |
|
398 # write a blank pixmap on error |
|
399 pixmap = QPixmap(size, size) |
|
400 pixmap.fill(Qt.transparent) |
|
401 imageBuffer.buffer().clear() |
|
402 pixmap.save(imageBuffer, "PNG") |
|
403 return cssString.format( |
|
404 size + 4, |
|
405 str(imageBuffer.buffer().toBase64(), encoding="ascii")) |
|
406 |
|
407 def __setListContent(self): |
|
408 """ |
|
409 Private method to prepare the content for the reader. |
|
410 """ |
|
411 u = self.url() |
|
412 if not u.path().endswith("/"): |
|
413 u.setPath(u.path() + "/") |
|
414 |
|
415 baseUrl = self.url().toString() |
|
416 basePath = u.path() |
|
417 |
|
418 linkClasses = {} |
|
419 iconSize = QWebSettings.globalSettings().fontSize( |
|
420 QWebSettings.DefaultFontSize) |
|
421 |
|
422 parent = u.resolved(QUrl("..")) |
|
423 if parent.isParentOf(u): |
|
424 icon = UI.PixmapCache.getIcon("up.png") |
|
425 linkClasses["link_parent"] = \ |
|
426 self.__cssLinkClass(icon, iconSize).format("link_parent") |
|
427 parentStr = self.tr( |
|
428 """ <p><a class="link_parent" href="{0}">""" |
|
429 """Change to parent directory</a></p>""" |
|
430 ).format(parent.toString()) |
|
431 else: |
|
432 parentStr = "" |
|
433 |
|
434 row = \ |
|
435 """ <tr class="{0}">"""\ |
|
436 """<td class="name"><a class="{1}" href="{2}">{3}</a></td>"""\ |
|
437 """<td class="size">{4}</td>"""\ |
|
438 """<td class="modified">{5}</td>"""\ |
|
439 """</tr>\n""" |
|
440 table = self.tr( |
|
441 """ <tr>""" |
|
442 """<th align="left">Name</th>""" |
|
443 """<th>Size</th>""" |
|
444 """<th align="left">Last modified</th>""" |
|
445 """</tr>\n""" |
|
446 ) |
|
447 |
|
448 i = 0 |
|
449 for item in self.__items: |
|
450 name = item.name() |
|
451 if item.isDir() and not name.endswith("/"): |
|
452 name += "/" |
|
453 child = u.resolved(QUrl(name.replace(":", "%3A"))) |
|
454 |
|
455 if item.isFile(): |
|
456 size = item.size() |
|
457 unit = 0 |
|
458 while size: |
|
459 newSize = size // 1024 |
|
460 if newSize and unit < len(self.__units): |
|
461 size = newSize |
|
462 unit += 1 |
|
463 else: |
|
464 break |
|
465 |
|
466 sizeStr = self.tr("{0} {1}", "size unit")\ |
|
467 .format(size, self.__units[unit]) |
|
468 linkClass = "link_file" |
|
469 if linkClass not in linkClasses: |
|
470 icon = UI.PixmapCache.getIcon("fileMisc.png") |
|
471 linkClasses[linkClass] = \ |
|
472 self.__cssLinkClass(icon, iconSize).format(linkClass) |
|
473 else: |
|
474 sizeStr = "" |
|
475 linkClass = "link_dir" |
|
476 if linkClass not in linkClasses: |
|
477 icon = UI.PixmapCache.getIcon("dirClosed.png") |
|
478 linkClasses[linkClass] = \ |
|
479 self.__cssLinkClass(icon, iconSize).format(linkClass) |
|
480 table += row.format( |
|
481 i == 0 and "odd" or "even", |
|
482 linkClass, |
|
483 child.toString(), |
|
484 Utilities.html_encode(item.name()), |
|
485 sizeStr, |
|
486 item.lastModified().toString("yyyy-MM-dd hh:mm"), |
|
487 ) |
|
488 i = 1 - i |
|
489 |
|
490 content = ftpListPage_html.format( |
|
491 Utilities.html_encode(baseUrl), |
|
492 "".join(linkClasses.values()), |
|
493 self.tr("Listing of {0}").format(basePath), |
|
494 parentStr, |
|
495 table |
|
496 ) |
|
497 self.__content = QByteArray(content.encode("utf8")) |
|
498 self.__content.append(512 * b' ') |
|
499 |
|
500 self.open(QIODevice.ReadOnly | QIODevice.Unbuffered) |
|
501 self.setHeader( |
|
502 QNetworkRequest.ContentTypeHeader, "text/html; charset=UTF-8") |
|
503 self.setHeader( |
|
504 QNetworkRequest.ContentLengthHeader, self.__content.size()) |
|
505 self.setAttribute(QNetworkRequest.HttpStatusCodeAttribute, 200) |
|
506 self.setAttribute(QNetworkRequest.HttpReasonPhraseAttribute, "Ok") |
|
507 self.metaDataChanged.emit() |
|
508 self.downloadProgress.emit( |
|
509 self.__content.size(), self.__content.size()) |
|
510 self.readyRead.emit() |