diff --git a/README.MD b/README.MD index 8316afcf5a..2e588f2e6d 100644 --- a/README.MD +++ b/README.MD @@ -1,3 +1,11 @@ +# ⚠️ Important: This Branch is Deprecated + +pyLoad v0.4.x has reached **end-of-life**. +There will be **no further updates, bug fixes, or new features** here. +Python 2.x has been deprecated for over 6 years, yet pyLoad continued to support it until now. It's time to move on. + +# **please migrate to [pyLoad-ng](https://github.com/pyload/pyload/tree/main) (v0.5.x).** +
# pyLoad [![pyload.net](https://img.shields.io/badge/.net-pyload-orange.svg)](https://pyload.net) @@ -14,7 +22,7 @@ Targeted for headless NASs it is designed to be extremely lightweight, fully cus # Dependencies -You need at least Python 2.5 to run pyLoad and all of these required libraries. +You need at least Python 2.5 to run pyLoad and all of these required libraries, unless shipped with pyLoad. They should be automatically installed when using pip install. The prebuilt pyLoad packages also install these dependencies or have them included, so manual install is only needed when installing pyLoad from source. @@ -22,19 +30,19 @@ is only needed when installing pyLoad from source. ## Required - pycurl a.k.a python-curl -- jinja2 -- beaker -- thrift +- jinja2 (shipped with pyLoad) +- beaker (shipped with pyLoad) +- thrift (shipped with pyLoad) Some plugins require additional packages, only install these when needed. ## Optional - pycrypto: RSDF/CCF/DLC support -- tesseract, python-pil a.k.a python-imaging: Automatic captcha recognition for a small amount of plugins -- jsengine (spidermonkey, ossp-js, pyv8, rhino, js2py): Used for several hoster, ClickNLoad +- tesseract, pillow (or python-pil a.k.a python-imaging): Automatic captcha recognition for a small amount of plugins +- jsengine (js2py, node, spidermonkey, ossp-js, pyv8, rhino): Used for several hoster, ClickNLoad - feedparser -- BeautifulSoup +- BeautifulSoup (shipped with pyLoad) - pyOpenSSL: For SSL connection # First start diff --git a/docs/conf.py b/docs/conf.py index 9d2cf98f9f..39e92cdb88 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,7 +66,7 @@ cog.outl("release = '%s'" % ".".join(v)) ]]]""" version = '0.4' -release = '0.4.9' +release = '0.4.20' # [[[end]]] diff --git a/locale/cli.pot b/locale/cli.pot index 646c6c70ef..84b0c4ef61 100644 --- a/locale/cli.pot +++ b/locale/cli.pot @@ -6,8 +6,8 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: pyLoad 0.4.9\n" -"Report-Msgid-Bugs-To: 'bugs@pyload.org'\n" +"Project-Id-Version: pyLoad 0.4.20\n" +"Report-Msgid-Bugs-To: 'bugs@pyload.net'\n" "POT-Creation-Date: 2011-12-07 19:21+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" diff --git a/locale/core.pot b/locale/core.pot index 546f0e4d3b..84730c3d57 100644 --- a/locale/core.pot +++ b/locale/core.pot @@ -6,8 +6,8 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: pyLoad 0.4.9\n" -"Report-Msgid-Bugs-To: 'bugs@pyload.org'\n" +"Project-Id-Version: pyLoad 0.4.20\n" +"Report-Msgid-Bugs-To: 'bugs@pyload.net'\n" "POT-Creation-Date: 2011-12-07 19:21+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" @@ -484,7 +484,7 @@ msgid "*** New pyLoad Version %s available ***" msgstr "" #: module/plugins/hooks/UpdateManager.py:94 -msgid "*** Get it here: http://pyload.org/download ***" +msgid "*** Get it here: http://pyload.net/download ***" msgstr "" #: module/plugins/hooks/UpdateManager.py:97 diff --git a/locale/django.pot b/locale/django.pot index 81c9c7b6bb..9ea1c98fea 100644 --- a/locale/django.pot +++ b/locale/django.pot @@ -6,8 +6,8 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: pyLoad 0.4.9\n" -"Report-Msgid-Bugs-To: 'bugs@pyload.org'\n" +"Project-Id-Version: pyLoad 0.4.20\n" +"Report-Msgid-Bugs-To: 'bugs@pyload.net'\n" "POT-Creation-Date: 2011-12-07 19:21+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" @@ -17,670 +17,1272 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: module/web/translations.js:1 module/web/templates/default/base.html:123 -#: module/web/templates/default/base.html:124 -#: module/web/templates/default/settings_item.html:14 -msgid "on" +#: module\web\templates\classic\window.html:22 +msgid "Password for RAR-Archive" msgstr "" -#: module/web/translations.js:2 module/web/templates/default/captcha.html:7 -msgid "Please read the text on the captcha." +#: module\web\templates\modern\pathchooser.html:87 module\web\templates\pyplex\pathchooser.html:87 +msgid "Parent Directory" msgstr "" -#: module/web/translations.js:3 -msgid "Settings saved." +#: module\web\templates\modern\admin.html:136 module\web\templates\classic\admin.html:75 +#: module\web\templates\pyplex\admin.html:140 +msgid "Current password" msgstr "" -#: module/web/translations.js:4 module/web/templates/default/base.html:123 -#: module/web/templates/default/base.html:124 -#: module/web/templates/default/settings_item.html:16 -msgid "off" +#: module\web\templates\classic\pathchooser.html:39 module\web\templates\classic\pathchooser.html:41 +#: module\web\templates\pyplex\settings.html:210 module\web\templates\modern\settings.html:210 +msgid "absolute" msgstr "" -#: module/web/translations.js:5 -msgid "Success" +#: module\web\templates\classic\window.html:13 +msgid "Paste your links here or any text and press the filter button." msgstr "" -#: module/web/translations.js:6 -msgid "Passwords did not match." +#: module\web\templates\pyplex\settings.html:63 module\web\templates\pyplex\settings.html:94 +#: module\web\templates\classic\settings.html:45 module\web\templates\classic\settings.html:74 +#: module\web\templates\modern\settings.html:63 module\web\templates\modern\settings.html:94 +msgid "Choose a section from the menu" msgstr "" -#: module/web/translations.js:7 -msgid "Delete Link" +#: module\web\templates\classic\window.html:8 +msgid "The name of the new package." msgstr "" -#: module/web/translations.js:8 -msgid "pyLoad restarted" +#: module\web\templates\pyplex\settings.html:112 module\web\templates\classic\settings.html:97 +#: module\web\templates\modern\settings.html:112 +msgid "Time" msgstr "" -#: module/web/translations.js:9 -msgid "You are really sure you want to quit pyLoad?" +#: module\config\default.conf:68 +msgid "Use Proxy" msgstr "" -#: module/web/translations.js:10 -msgid "Please Enter a packagename." +#: module\web\templates\classic\base.html:278 module\web\templates\pyplex\base.html:220 +#: module\web\templates\modern\base.html:220 +msgid "Back to top" msgstr "" -#: module/web/translations.js:11 -msgid "Please click on the right captcha position." +#: module\config\default.conf:10 +msgid "SSL Certificate" msgstr "" -#: module/web/translations.js:12 -msgid "Error occured." +#: module\web\templates\pyplex\info.html:74 module\web\templates\modern\info.html:74 +msgid "Wiki" msgstr "" -#: module/web/translations.js:13 -msgid "New Captcha Request" +#: module\web\templates\modern\info.html:93 module\web\templates\pyplex\info.html:93 +#: module\web\templates\classic\info.html:52 +msgid "Installation Folder:" msgstr "" -#: module/web/translations.js:14 -msgid "Failed" +#: module\web\templates\modern\admin.html:31 module\web\templates\classic\admin.html:14 +#: module\web\templates\pyplex\admin.html:35 +msgid "Restart pyLoad" msgstr "" -#: module/web/translations.js:15 -msgid "No Captchas to read." +#: module\web\templates\modern\info.html:97 module\web\templates\pyplex\info.html:97 +#: module\web\templates\classic\info.html:60 +msgid "Download Folder:" msgstr "" -#: module/web/translations.js:16 -#: module/web/templates/default/filemanager.html:65 -#: module/web/templates/default/folder.html:14 -msgid "Folder is empty" +#: module\config\default.conf:20 +msgid "Template" msgstr "" -#: module/web/translations.js:17 -msgid "Restart Link" +#: module\web\templates\classic\base.html:238 module\web\templates\pyplex\base.html:187 +#: module\web\templates\modern\base.html:187 +msgid "Speed:" msgstr "" -#: module/web/translations.js:18 -msgid "New folder" +#: module\web\templates\pyplex\info.html:82 module\web\templates\modern\info.html:82 +msgid "Issue Tracker" msgstr "" -#: module/web/translations.js:19 -msgid "Are you sure you want to restart pyLoad?" +#: module\web\templates\classic\home.html:241 module\web\templates\pyplex\home.html:23 +#: module\web\templates\modern\home.html:23 +msgid "Progress" msgstr "" -#: module/web/pyload_app.py:125 -msgid "You dont have permission to access this page." +#: module\web\templates\modern\queue.html:44 module\web\templates\classic\queue.html:46 +#: module\web\templates\pyplex\queue.html:44 +msgid "Restart Package" msgstr "" -#: module/web/pyload_app.py:193 -msgid "Download directory not found." +#: module\web\templates\modern\window.html:17 module\web\templates\classic\window.html:14 +#: module\web\templates\pyplex\window.html:17 +msgid "Filter urls" msgstr "" -#: module/web/pyload_app.py:260 module/web/pyload_app.py:267 -msgid "unlimited" +#: module\config\default.conf:11 +msgid "SSL Key" msgstr "" -#: module/web/pyload_app.py:262 module/web/pyload_app.py:269 -msgid "not available" +#: module\web\templates\modern\admin.html:143 module\web\templates\classic\admin.html:81 +#: module\web\templates\pyplex\admin.html:147 +msgid "The new password." +msgstr "" + +#: module\web\templates\classic\base.html:269 module\web\templates\pyplex\base.html:82 +#: module\web\templates\pyplex\base.html:208 module\web\templates\modern\base.html:82 +#: module\web\templates\modern\base.html:208 +msgid "loading" msgstr "" -#: module/web/pyload_app.py:509 -msgid "Run pyLoadCore.py -s to access the setup." +#: module\web\templates\pyplex\settings.html:109 module\web\templates\classic\settings.html:94 +#: module\web\templates\modern\settings.html:109 +msgid "Premium" msgstr "" -#: module/web/json_app.py:60 -#, python-format -msgid "waiting %s" +#: module\web\templates\pyplex\settings.html:74 module\web\templates\modern\settings.html:74 +msgid " Loading data..." msgstr "" -#: module/web/templates/default/info.html:14 -#: module/web/templates/default/info.html:15 -#: module/web/templates/default/home.html:239 -msgid "Information" +#: module\web\templates\pyplex\settings.html:132 module\web\templates\classic\settings.html:121 +#: module\web\templates\modern\settings.html:132 +msgid "valid" msgstr "" -#: module/web/templates/default/info.html:18 +#: module\web\templates\modern\info.html:110 module\web\templates\pyplex\info.html:110 +#: module\web\templates\classic\info.html:18 msgid "News" msgstr "" -#: module/web/templates/default/info.html:21 -msgid "Support" +#: module\web\templates\modern\info.html:91 module\web\templates\pyplex\info.html:91 +#: module\web\templates\classic\info.html:48 +msgid "pyLoad version:" msgstr "" -#: module/web/templates/default/info.html:37 -msgid "System" +#: module\config\default.conf:52 +msgid "Groupname" msgstr "" -#: module/web/templates/default/info.html:40 -msgid "Python:" +#: module\web\templates\classic\base.html:166 module\web\templates\pyplex\base.html:77 +#: module\web\templates\pyplex\base.html:179 module\web\templates\modern\base.html:77 +#: module\web\templates\modern\base.html:179 +msgid "Captcha waiting" msgstr "" -#: module/web/templates/default/info.html:44 -msgid "OS:" +#: module\web\templates\modern\logs.html:110 module\web\templates\classic\logs.html:36 +#: module\web\templates\pyplex\logs.html:110 +msgid "Jump to time:" msgstr "" -#: module/web/templates/default/info.html:48 -msgid "pyLoad version:" +#: module\web\templates\modern\window.html:38 module\web\templates\classic\window.html:31 +#: module\web\templates\pyplex\window.html:38 +msgid "Destination" msgstr "" -#: module/web/templates/default/info.html:52 -msgid "Installation Folder:" +#: module\web\templates\pyplex\admin.html:106 module\web\templates\pyplex\admin.html:119 +#: module\web\templates\pyplex\admin.html:154 module\web\templates\modern\admin.html:102 +#: module\web\templates\modern\admin.html:115 module\web\templates\modern\admin.html:150 +msgid "Ok" msgstr "" -#: module/web/templates/default/info.html:56 -msgid "Config Folder:" +#: module\config\default.conf:51 +msgid "Change group of running process" msgstr "" -#: module/web/templates/default/info.html:60 -msgid "Download Folder:" +#: module\web\templates\modern\base.html:65 module\web\templates\modern\base.html:140 +#: module\web\templates\pyplex\base.html:65 module\web\templates\pyplex\base.html:140 +msgid "Administration" msgstr "" -#: module/web/templates/default/info.html:64 -msgid "Free Space:" +#: module\web\templates\pyplex\settings.html:105 module\web\templates\classic\settings.html:90 +#: module\web\templates\modern\settings.html:105 +msgid "Plugin" msgstr "" -#: module/web/templates/default/info.html:68 -msgid "Language:" +#: module\web\templates\pyplex\settings.html:110 module\web\templates\classic\settings.html:95 +#: module\web\templates\modern\settings.html:110 +msgid "Valid until" msgstr "" -#: module/web/templates/default/info.html:72 -msgid "Webinterface Port:" +#: module\web\templates\classic\queue.html:25 +msgid "Delete Finished" msgstr "" -#: module/web/templates/default/info.html:76 -msgid "Remote Interface Port:" +#: module\config\default.conf:32 +msgid "Debug Mode" msgstr "" -#: module/web/templates/default/downloads.html:6 -#: module/web/templates/default/base.html:93 -#: module/web/templates/default/home.html:220 -msgid "Downloads" +#: module/web/media/js/pyplex/base.js:330 module/web/media/js/pyplex/base.js:341 module/web/media/js/classic/base.js:2 +#: module/web/media/js/modern/base.js:330 module/web/media/js/modern/base.js:341 +msgid "New Captcha Request" msgstr "" -#: module/web/templates/default/filemanager.html:19 -msgid "FileManager" +#: module\web\templates\modern\login.html:64 module\web\templates\pyplex\login.html:64 +msgid "SIGN IN" msgstr "" -#: module/web/templates/default/admin.html:8 -#: module/web/templates/default/admin.html:9 -#: module/web/templates/default/base.html:59 -msgid "Administrate" +#: module\web\templates\modern\captcha.html:7 module\web\templates\classic\captcha.html:4 +#: module\web\templates\pyplex\captcha.html:7 +msgid "Captcha reading" msgstr "" -#: module/web/templates/default/admin.html:13 -msgid "Quit pyLoad" +#: module\config\default.conf:17 +msgid "Listen on both IPv4 and IPv6 (IP must be set to 0.0.0.0)" msgstr "" -#: module/web/templates/default/admin.html:14 -msgid "Restart pyLoad" +#: module\config\default.conf:65 +msgid "Protocol" msgstr "" -#: module/web/templates/default/admin.html:18 -msgid "To add user or change passwords use:" +#: module\web\templates\classic\pathchooser.html:46 module\web\templates\pyplex\pathchooser.html:78 +#: module\web\templates\modern\pathchooser.html:78 +msgid "name" msgstr "" -#: module/web/templates/default/admin.html:19 -msgid "Important: Admin user have always all permissions!" +#: module\web\templates\modern\window.html:15 module\web\templates\classic\window.html:12 +#: module\web\templates\pyplex\window.html:15 +msgid "Links" msgstr "" -#: module/web/templates/default/admin.html:25 -#: module/web/templates/default/settings.html:91 -#: module/web/templates/default/queue.html:82 -#: module/web/templates/default/window.html:7 -#: module/web/templates/default/home.html:237 -msgid "Name" +#: module\web\templates\pyplex\queue.html:47 module\web\templates\modern\queue.html:47 +msgid "Reverse Entries" msgstr "" -#: module/web/templates/default/admin.html:28 -#: module/web/templates/default/admin.html:67 -msgid "Change Password" +#: module\web\templates\classic\base.html:209 module\web\templates\classic\home.html:223 +#: module\web\templates\classic\filemanager.html:19 module\web\templates\pyplex\base.html:133 +#: module\web\templates\modern\base.html:133 +msgid "FileManager" msgstr "" -#: module/web/templates/default/admin.html:31 -msgid "Admin" +#: module\config\default.conf:16 +msgid "Use HTTPS" msgstr "" -#: module/web/templates/default/admin.html:34 -msgid "Permissions" +#: module/web/templates/classic/filemanager_ui.js:180 module\web\templates\classic\filemanager.html:65 +#: module\web\templates\classic\folder.html:14 +msgid "Folder is empty" msgstr "" -#: module/web/templates/default/admin.html:41 -msgid "change" +#: module\web\templates\modern\captcha.html:14 module\web\templates\classic\captcha.html:9 +#: module\web\templates\pyplex\captcha.html:14 +msgid "Captcha" msgstr "" -#: module/web/templates/default/admin.html:61 -#: module/web/templates/default/admin.html:91 -#: module/web/templates/default/settings.html:167 -#: module/web/templates/default/queue.html:97 -#: module/web/templates/default/captcha.html:33 -msgid "Submit" +#: module\web\templates\pyplex\settings.html:175 module\web\templates\modern\settings.html:175 +msgid "Your username" msgstr "" -#: module/web/templates/default/admin.html:69 -msgid "Enter your current and desired Password." +#: module\web\templates\classic\base.html:137 module\web\templates\classic\base.html:252 module\config\default.conf:13 +#: module\web\templates\pyplex\base.html:34 module\web\templates\modern\base.html:34 +msgid "Webinterface" msgstr "" -#: module/web/templates/default/admin.html:70 -msgid "User" +#: module\web\templates\pyplex\settings.html:44 module\web\templates\classic\settings.html:18 +#: module\web\templates\modern\settings.html:44 +msgid "Accounts" msgstr "" -#: module/web/templates/default/admin.html:71 -#: module/web/templates/default/settings.html:179 -msgid "Your username." +#: module\web\templates\modern\base.html:73 module\web\templates\modern\base.html:171 +#: module\web\templates\pyplex\base.html:73 module\web\templates\pyplex\base.html:171 +#: module\web\templates\modern\window.html:5 module\web\templates\modern\window.html:46 +#: module\web\templates\classic\window.html:5 module\web\templates\classic\window.html:40 +#: module\web\templates\pyplex\window.html:5 module\web\templates\pyplex\window.html:46 +msgid "Add Package" msgstr "" -#: module/web/templates/default/admin.html:75 -msgid "Current password" +#: module\web\templates\pyplex\window.html:28 module\web\templates\modern\window.html:28 +msgid "Upload a container" msgstr "" -#: module/web/templates/default/admin.html:76 -#: module/web/templates/default/settings.html:184 -msgid "The password for this account." +#: module\web\templates\modern\logs.html:112 module\web\templates\pyplex\logs.html:112 +msgid "Go" msgstr "" -#: module/web/templates/default/admin.html:80 -msgid "New password" +#: module\web\templates\classic\pathchooser.html:70 +msgid "no content" msgstr "" -#: module/web/templates/default/admin.html:81 -msgid "The new password." +#: module\config\default.conf:5 +msgid "Adress" msgstr "" -#: module/web/templates/default/admin.html:85 -msgid "New password (repeat)" +#: module\web\templates\modern\queue.html:43 module\web\templates\classic\queue.html:44 +#: module\web\templates\pyplex\queue.html:43 +msgid "Delete Package" msgstr "" -#: module/web/templates/default/admin.html:86 -msgid "Please repeat the new password." +#: module\web\templates\modern\base.html:55 module\web\templates\modern\base.html:127 +#: module\web\templates\modern\window.html:43 module\web\templates\pyplex\queue.html:46 +#: module\web\templates\classic\home.html:217 module\web\templates\pyplex\base.html:55 +#: module\web\templates\pyplex\base.html:127 module\web\templates\classic\base.html:203 +#: module\web\templates\modern\queue.html:46 module\web\templates\pyplex\window.html:43 +#: module\web\templates\classic\window.html:36 +msgid "Collector" msgstr "" -#: module/web/templates/default/admin.html:92 -#: module/web/templates/default/settings.html:198 -#: module/web/templates/default/queue.html:98 -#: module/web/templates/default/window.html:41 -msgid "Reset" +#: module/web/media/js/modern/package.js:213 module/web/media/js/pyplex/package.js:218 +msgid "queued" msgstr "" -#: module/web/templates/default/settings.html:3 -#: module/web/templates/default/settings.html:4 -#: module/web/templates/default/base.html:102 -#: module/web/templates/default/home.html:229 -msgid "Config" +#: module\web\templates\modern\queue.html:23 module\web\templates\classic\queue.html:26 +#: module\web\templates\pyplex\queue.html:23 +msgid "Restart Failed" msgstr "" -#: module/web/templates/default/settings.html:16 -msgid "General" +#: module\web\templates\modern\base.html:169 module\web\templates\pyplex\base.html:169 +msgid "Pause Queue" msgstr "" -#: module/web/templates/default/settings.html:17 -msgid "Plugins" +#: module\config\default.conf:53 +msgid "Change Group and User of Downloads" msgstr "" -#: module/web/templates/default/settings.html:18 -msgid "Accounts" +#: module/web/media/js/classic/base.js:2 module\web\templates\classic\captcha.html:5 +msgid "Please read the text on the captcha." msgstr "" -#: module/web/templates/default/settings.html:45 -#: module/web/templates/default/settings.html:74 -msgid "Choose a section from the menu" +#: module/web/media/js/classic/settings.js:2 +msgid "Settings saved." msgstr "" -#: module/web/templates/default/settings.html:90 -msgid "Plugin" +#: module\web\templates\classic\logs.html:12 module\config\default.conf:58 module\config\default.conf:61 +msgid "End" msgstr "" -#: module/web/templates/default/settings.html:92 -#: module/web/templates/default/settings.html:183 -#: module/web/templates/default/login.html:19 -#: module/web/templates/default/queue.html:92 -#: module/web/templates/default/window.html:21 -msgid "Password" +#: module\web\templates\modern\settings_item.html:27 module\web\templates\modern\settings_item.html:36 +#: module\web\templates\pyplex\settings_item.html:27 module\web\templates\pyplex\settings_item.html:36 +#: module\web\templates\classic\settings_item.html:30 module\web\templates\classic\settings_item.html:36 +msgid "Browse" msgstr "" -#: module/web/templates/default/settings.html:93 -#: module/web/templates/default/home.html:238 -msgid "Status" +#: module\web\templates\pyplex\queue.html:46 module\web\templates\modern\queue.html:46 +msgid "Move Package To" msgstr "" -#: module/web/templates/default/settings.html:94 -msgid "Premium" +#: module\web\templates\modern\admin.html:30 module\web\templates\classic\admin.html:13 +#: module\web\templates\pyplex\admin.html:34 +msgid "Quit pyLoad" msgstr "" -#: module/web/templates/default/settings.html:95 -msgid "Valid until" +#: module\config\default.conf:6 +msgid "No authentication on local connections" msgstr "" -#: module/web/templates/default/settings.html:96 -msgid "Traffic left" +#: module\web\templates\classic\pathchooser.html:39 module\web\templates\classic\pathchooser.html:41 +#: module\web\templates\pyplex\settings.html:213 module\web\templates\modern\settings.html:213 +msgid "relative" msgstr "" -#: module/web/templates/default/settings.html:97 -msgid "Time" +#: module/web/media/js/pyplex/base.js:31 module/web/templates/classic/filemanager_ui.js:43 +#: module/web/media/js/classic/package_ui.js:2 module/web/media/js/modern/base.js:31 +msgid "Failed" msgstr "" -#: module/web/templates/default/settings.html:98 -msgid "Max Parallel" +#: module\web\templates\modern\info.html:89 module\web\templates\pyplex\info.html:89 +#: module\web\templates\classic\info.html:44 +msgid "OS:" msgstr "" -#: module/web/templates/default/settings.html:99 -msgid "Delete?" +#: module\config\default.conf:49 +msgid "Change file mode of downloads" msgstr "" -#: module/web/templates/default/settings.html:121 -msgid "valid" +#: module\web\templates\pyplex\window.html:20 module\web\templates\modern\window.html:20 +msgid "Add a list of links" msgstr "" -#: module/web/templates/default/settings.html:124 +#: module\web\templates\pyplex\settings.html:132 module\web\templates\classic\settings.html:124 +#: module\web\templates\modern\settings.html:132 msgid "not valid" msgstr "" -#: module/web/templates/default/settings.html:131 +#: module\web\templates\pyplex\settings.html:136 module\web\templates\classic\settings.html:131 +#: module\web\templates\modern\settings.html:136 msgid "yes" msgstr "" -#: module/web/templates/default/settings.html:134 -msgid "no" +#: module\config\default.conf:47 module\config\default.conf:66 module\web\templates\classic\login.html:14 +#: module\web\templates\modern\login.html:56 module\web\templates\pyplex\login.html:56 +msgid "Username" msgstr "" -#: module/web/templates/default/settings.html:168 -#: module/web/templates/default/settings.html:197 -#: module/web/templates/default/base.html:117 -msgid "Add" +#: module\web\templates\pyplex\info.html:115 module\web\templates\modern\info.html:115 +msgid "Donate" +msgstr "" + +#: module\web\templates\pyplex\settings.html:171 module\web\templates\modern\settings.html:171 +msgid "Enter your account data to use premium features" +msgstr "" + +#: module\web\templates\modern\base.html:58 module\web\templates\modern\base.html:130 +#: module\web\templates\classic\downloads.html:6 module\web\templates\classic\home.html:220 +#: module\web\templates\modern\downloads.html:6 module\web\templates\pyplex\base.html:58 +#: module\web\templates\pyplex\base.html:130 module\web\templates\classic\base.html:206 +#: module\web\templates\pyplex\downloads.html:6 +msgid "Downloads" msgstr "" -#: module/web/templates/default/settings.html:176 +#: module\config\default.conf:55 +msgid "Use Reconnect" +msgstr "" + +#: module\config\default.conf:39 +msgid "Max Parallel Downloads" +msgstr "" + +#: module\config\default.conf:56 +msgid "Method" +msgstr "" + +#: module/web/media/js/modern/settings.js:178 module/web/media/js/pyplex/settings.js:178 +msgid "Select File" +msgstr "" + +#: module\web\templates\pyplex\settings.html:168 module\web\templates\classic\settings.html:176 +#: module\web\templates\modern\settings.html:168 msgid "Add Account" msgstr "" -#: module/web/templates/default/settings.html:177 -msgid "Enter your account data to use premium features." +#: module\web\templates\modern\captcha.html:36 module\web\templates\classic\captcha.html:34 +#: module\web\templates\pyplex\captcha.html:36 +msgid "Please install the Tampermonkey add-on in your browser and add the " msgstr "" -#: module/web/templates/default/settings.html:178 -#: module/web/templates/default/login.html:3 -msgid "Login" +#: module\web\templates\modern\info.html:87 module\web\templates\pyplex\info.html:87 +#: module\web\templates\classic\info.html:40 +msgid "Python:" msgstr "" -#: module/web/templates/default/settings.html:188 -msgid "Type" +#: module/web/media/js/pyplex/base.js:248 module/web/media/js/modern/settings.js:120 +#: module/web/media/js/modern/settings.js:138 module/web/media/js/modern/settings.js:155 +#: module/web/media/js/modern/base.js:248 module/web/media/js/pyplex/settings.js:120 +#: module/web/media/js/pyplex/settings.js:138 module/web/media/js/pyplex/settings.js:155 +#: module/web/media/js/modern/admin.js:21 module/web/media/js/pyplex/admin.js:21 +msgid "Error occurred" msgstr "" -#: module/web/templates/default/settings.html:189 -msgid "Choose the hoster for your account." +#: module\config\default.conf:36 +msgid "CPU Priority" msgstr "" -#: module/web/templates/default/pathchooser.html:39 -#: module/web/templates/default/pathchooser.html:41 -msgid "Path" +#: module\config\default.conf:44 +msgid "Skip already existing files" msgstr "" -#: module/web/templates/default/pathchooser.html:39 -#: module/web/templates/default/pathchooser.html:41 -msgid "absolute" +#: module\web\templates\classic\base.html:175 module\web\templates\pyplex\base.html:154 +#: module\web\templates\modern\base.html:154 +msgid "Info" msgstr "" -#: module/web/templates/default/pathchooser.html:39 -#: module/web/templates/default/pathchooser.html:41 -msgid "relative" +#: module/web/media/js/modern/admin.js:25 module/web/media/js/pyplex/admin.js:25 module/web/media/js/classic/admin.js:2 +msgid "Passwords did not match." msgstr "" -#: module/web/templates/default/pathchooser.html:46 -msgid "name" +#: module\web\templates\classic\queue.html:83 +msgid "The name of the package." msgstr "" -#: module/web/templates/default/pathchooser.html:47 -msgid "size" +#: module/web/media/js/modern/settings.js:83 module/web/media/js/pyplex/settings.js:83 +msgid "Name of plugin" msgstr "" -#: module/web/templates/default/pathchooser.html:48 -msgid "type" +#: module\web\templates\pyplex\info.html:76 module\web\templates\modern\info.html:76 +msgid "Forum" msgstr "" -#: module/web/templates/default/pathchooser.html:49 -msgid "last modified" +#: module\web\templates\modern\base.html:197 module\web\templates\pyplex\base.html:197 +msgid " " msgstr "" -#: module/web/templates/default/pathchooser.html:54 -msgid "parent directory" +#: module\web\templates\modern\info.html:105 module\web\templates\pyplex\info.html:105 +#: module\web\templates\classic\info.html:72 +msgid "Webinterface Port:" msgstr "" -#: module/web/templates/default/pathchooser.html:70 -msgid "no content" +#: module/web/media/js/modern/settings.js:182 module/web/media/js/pyplex/settings.js:182 +msgid "Select Folder" msgstr "" -#: module/web/templates/default/setup.html:3 -#: module/web/templates/default/setup.html:4 -msgid "Setup" +#: module\web\templates\pyplex\settings.html:113 module\web\templates\classic\settings.html:98 +#: module\web\templates\modern\settings.html:113 +msgid "Max Parallel" msgstr "" -#: module/web/templates/default/login.html:14 -msgid "Username" +#: module/web/media/js/classic/base.js:2 +msgid "Please Enter a packagename." msgstr "" -#: module/web/templates/default/login.html:29 -msgid "Your username and password didn't match. Please try again." +#: module\config\default.conf:15 +msgid "Server" msgstr "" -#: module/web/templates/default/login.html:30 -msgid "To reset your login data or add an user run:" +#: module\web\templates\modern\base.html:52 module\web\templates\modern\base.html:124 +#: module\web\templates\modern\window.html:40 module\web\templates\pyplex\queue.html:46 +#: module\web\templates\classic\home.html:214 module\web\templates\pyplex\base.html:52 +#: module\web\templates\pyplex\base.html:124 module\web\templates\classic\base.html:200 +#: module\web\templates\modern\queue.html:46 module\web\templates\pyplex\window.html:40 +#: module\web\templates\classic\window.html:34 +msgid "Queue" msgstr "" -#: module/web/templates/default/base.html:20 -#: module/web/templates/default/base.html:139 -msgid "Webinterface" +#: module\web\templates\modern\admin.html:141 module\web\templates\classic\admin.html:80 +#: module\web\templates\pyplex\admin.html:145 +msgid "New password" msgstr "" -#: module/web/templates/default/base.html:39 -msgid "pyLoad Update available!" +#: module\web\templates\modern\base.html:170 module\web\templates\pyplex\base.html:170 +msgid "Abort Downloads" msgstr "" -#: module/web/templates/default/base.html:46 -msgid "Plugins updated, please restart!" +#: module\web\templates\pyplex\settings.html:180 module\web\templates\modern\settings.html:180 +msgid "The password for this account" msgstr "" -#: module/web/templates/default/base.html:52 -msgid "Captcha waiting" +#: module\config\default.conf:48 +msgid "Folder Permission mode" msgstr "" -#: module/web/templates/default/base.html:57 -msgid "Logout" +#: module\web\templates\pyplex\admin.html:90 module\web\templates\modern\admin.html:86 +msgid "pyLoad is restarting..." msgstr "" -#: module/web/templates/default/base.html:61 -msgid "Info" +#: module\config\default.conf:38 +msgid "Max connections for one download" msgstr "" -#: module/web/templates/default/base.html:65 -msgid "Please Login!" +#: module\web\templates\modern\admin.html:60 module\web\templates\classic\admin.html:41 +#: module\web\templates\pyplex\admin.html:64 +msgid "change" msgstr "" -#: module/web/templates/default/base.html:84 -#: module/web/templates/default/home.html:211 -msgid "Home" +#: module\web\templates\modern\admin.html:100 module/web/media/js/classic/admin.js:2 +#: module\web\templates\pyplex\admin.html:104 +msgid "Are you sure you want to restart pyLoad?" msgstr "" -#: module/web/templates/default/base.html:87 -#: module/web/templates/default/queue.html:15 -#: module/web/templates/default/window.html:34 -#: module/web/templates/default/home.html:214 -msgid "Queue" +#: module\web\templates\modern\base.html:168 module\web\templates\pyplex\base.html:168 +msgid "Resume Queue" msgstr "" -#: module/web/templates/default/base.html:90 -#: module/web/templates/default/queue.html:17 -#: module/web/templates/default/window.html:36 -#: module/web/templates/default/home.html:217 -msgid "Collector" +#: module/web/media/js/classic/base.js:2 module\web\templates\modern\base.html:185 +#: module\web\templates\modern\base.html:186 module/web/media/js/pyplex/base.js:358 +#: module/web/media/js/pyplex/base.js:363 module\web\templates\modern\settings_item.html:14 +#: module\web\templates\classic\settings_item.html:14 module/web/media/js/modern/base.js:358 +#: module/web/media/js/modern/base.js:363 module\web\templates\classic\base.html:236 +#: module\web\templates\classic\base.html:237 module\web\templates\pyplex\settings_item.html:14 +#: module\web\templates\pyplex\base.html:185 module\web\templates\pyplex\base.html:186 +msgid "on" msgstr "" -#: module/web/templates/default/base.html:99 -#: module/web/templates/default/logs.html:3 -#: module/web/templates/default/logs.html:4 -#: module/web/templates/default/home.html:226 +#: module\web\templates\classic\base.html:236 module\web\templates\pyplex\base.html:185 +#: module\web\templates\modern\base.html:185 +msgid "Download:" +msgstr "" + +#: module\web\templates\modern\admin.html:51 module\web\templates\modern\admin.html:126 +#: module\web\templates\classic\admin.html:28 module\web\templates\classic\admin.html:67 +#: module\web\templates\pyplex\admin.html:55 module\web\templates\pyplex\admin.html:130 +msgid "Change Password" +msgstr "" + +#: module\web\templates\modern\base.html:61 module\web\templates\modern\base.html:136 +#: module\web\templates\classic\home.html:226 module\web\templates\pyplex\logs.html:3 +#: module\web\templates\pyplex\logs.html:4 module\web\templates\classic\logs.html:3 +#: module\web\templates\classic\logs.html:4 module\web\templates\pyplex\base.html:61 +#: module\web\templates\pyplex\base.html:136 module\web\templates\classic\base.html:212 +#: module\web\templates\modern\logs.html:3 module\web\templates\modern\logs.html:4 msgid "Logs" msgstr "" -#: module/web/templates/default/base.html:114 -#: module/web/templates/default/logs.html:12 -msgid "Start" +#: module\web\templates\modern\login.html:49 module\web\templates\pyplex\login.html:49 +msgid "Incorrect username/email or password." msgstr "" -#: module/web/templates/default/base.html:115 -msgid "Stop" +#: module\web\templates\pyplex\info.html:111 module\web\templates\modern\info.html:111 +msgid "Loading..." msgstr "" -#: module/web/templates/default/base.html:116 -msgid "Cancel" +#: module\config\default.conf:43 +msgid "Allow IPv6" msgstr "" -#: module/web/templates/default/base.html:123 -msgid "Download:" +#: module\web\templates\pyplex\queue.html:90 module\web\templates\modern\queue.html:90 +msgid "Name of subfolder for these downloads" msgstr "" -#: module/web/templates/default/base.html:124 -msgid "Reconnect:" +#: module\config\default.conf:28 +msgid "Log Rotate" msgstr "" -#: module/web/templates/default/base.html:125 -msgid "Speed:" +#: module\config\default.conf:8 +msgid "SSL" +msgstr "" + +#: module\web\templates\classic\captcha.html:10 +msgid "The captcha." msgstr "" -#: module/web/templates/default/base.html:126 +#: module\web\templates\modern\info.html:99 module\web\templates\pyplex\info.html:99 +#: module\web\templates\classic\info.html:64 +msgid "Free Space:" +msgstr "" + +#: module\web\templates\pyplex\info.html:78 module\web\templates\modern\info.html:78 +msgid "Chat" +msgstr "" + +#: module\web\templates\pyplex\admin.html:44 module\web\templates\modern\admin.html:40 +msgid "Admin user have always all permissions!" +msgstr "" + +#: module\web\templates\classic\base.html:197 module\web\templates\classic\home.html:211 +#: module\web\templates\pyplex\base.html:49 module\web\templates\pyplex\base.html:121 +#: module\web\templates\modern\base.html:49 module\web\templates\modern\base.html:121 +msgid "Home" +msgstr "" + +#: module\web\templates\modern\admin.html:146 module\web\templates\classic\admin.html:85 +#: module\web\templates\pyplex\admin.html:150 +msgid "New password (repeat)" +msgstr "" + +#: module\config\default.conf:12 +msgid "CA's intermediate certificate bundle (optional)" +msgstr "" + +#: module\web\templates\pyplex\home.html:21 module\web\templates\classic\home.html:239 +#: module\web\templates\classic\info.html:14 module\web\templates\classic\info.html:15 +#: module\web\templates\modern\home.html:21 +msgid "Information" +msgstr "" + +#: module\config\default.conf:24 +msgid "File Log" +msgstr "" + +#: module\web\templates\pyplex\settings.html:205 module\web\templates\pyplex\settings.html:219 +#: module\web\templates\modern\settings.html:205 module\web\templates\modern\settings.html:219 +msgid "Select" +msgstr "" + +#: module\config\default.conf:23 +msgid "Log" +msgstr "" + +#: module\web\templates\classic\base.html:239 module\web\templates\pyplex\base.html:188 +#: module\web\templates\modern\base.html:188 msgid "Active:" msgstr "" -#: module/web/templates/default/base.html:127 -msgid "Reload page" +#: module\web\templates\classic\queue.html:93 +msgid "List of passwords used for unrar." msgstr "" -#: module/web/templates/default/base.html:157 -msgid "loading" +#: module\web\templates\classic\admin.html:19 +msgid "Important: Admin user have always all permissions!" msgstr "" -#: module/web/templates/default/base.html:166 -msgid "Back to top" +#: module\web\templates\modern\info.html:3 module\web\templates\modern\info.html:4 +#: module\web\templates\pyplex\info.html:3 module\web\templates\pyplex\info.html:4 +#: module\web\templates\classic\info.html:21 +msgid "Support" msgstr "" -#: module/web/templates/default/logs.html:12 -msgid "prev" +#: module/web/media/js/modern/package.js:174 module/web/media/js/classic/package_ui.js:2 +#: module/web/media/js/pyplex/package.js:179 +msgid "Restart Link" +msgstr "" + +#: module\web\templates\classic\window.html:27 +msgid "Upload a container." +msgstr "" + +#: module\web\templates\classic\base.html:227 module\web\templates\classic\logs.html:12 module\config\default.conf:57 +#: module\config\default.conf:60 +msgid "Start" +msgstr "" + +#: module\web\templates\classic\base.html:230 module\web\templates\pyplex\settings.html:191 +#: module\web\templates\classic\settings.html:168 module\web\templates\classic\settings.html:197 +#: module\web\templates\modern\settings.html:191 +msgid "Add" +msgstr "" + +#: module\config\default.conf:34 +msgid "Min Free Space (MB)" msgstr "" -#: module/web/templates/default/logs.html:12 +#: module\web\templates\modern\info.html:95 module\web\templates\pyplex\info.html:95 +#: module\web\templates\classic\info.html:56 +msgid "Config Folder:" +msgstr "" + +#: module\web\templates\classic\settings.html:189 +msgid "Choose the hoster for your account." +msgstr "" + +#: module\web\templates\classic\logs.html:12 msgid "next" msgstr "" -#: module/web/templates/default/logs.html:12 -msgid "End" +#: module\web\templates\modern\base.html:69 module\web\templates\modern\base.html:144 +#: module\web\templates\modern\settings.html:3 module\web\templates\modern\settings.html:4 +#: module\web\templates\classic\home.html:229 module\web\templates\pyplex\base.html:69 +#: module\web\templates\pyplex\base.html:144 module\web\templates\classic\base.html:215 +#: module\web\templates\pyplex\settings.html:3 module\web\templates\pyplex\settings.html:4 +#: module\web\templates\classic\settings.html:3 module\web\templates\classic\settings.html:4 +msgid "Config" msgstr "" -#: module/web/templates/default/logout.html:8 -msgid "You were successfully logged out." +#: module\web\templates\modern\admin.html:133 module\web\templates\classic\admin.html:71 +#: module\web\templates\classic\settings.html:179 module\web\templates\pyplex\admin.html:137 +msgid "Your username." msgstr "" -#: module/web/templates/default/queue.html:25 -msgid "Delete Finished" +#: module\web\templates\classic\pathchooser.html:47 module\web\templates\pyplex\pathchooser.html:79 +#: module\web\templates\modern\pathchooser.html:79 +msgid "size" msgstr "" -#: module/web/templates/default/queue.html:26 -msgid "Restart Failed" +#: module\web\templates\pyplex\home.html:18 module\web\templates\modern\settings.html:108 +#: module\web\templates\classic\home.html:238 module\web\templates\modern\home.html:18 +#: module\web\templates\pyplex\settings.html:108 module\web\templates\classic\settings.html:93 +msgid "Status" msgstr "" -#: module/web/templates/default/queue.html:65 -msgid "Folder:" +#: module\web\templates\classic\folder.html:9 module\web\templates\classic\filemanager.html:31 +#: module\web\templates\classic\filemanager.html:46 +msgid "Delete Directory" msgstr "" -#: module/web/templates/default/queue.html:65 -msgid "Password:" +#: module\config\default.conf:27 +msgid "Size in kb" msgstr "" -#: module/web/templates/default/queue.html:79 +#: module\web\templates\modern\captcha.html:30 module\web\templates\classic\captcha.html:28 +#: module\web\templates\pyplex\captcha.html:30 +msgid "Your browser does not support iframes." +msgstr "" + +#: module\web\templates\classic\base.html:171 module\web\templates\pyplex\base.html:153 +#: module\web\templates\modern\base.html:153 +msgid "Logout" +msgstr "" + +#: module\web\templates\modern\admin.html:131 module\web\templates\classic\admin.html:70 +#: module\web\templates\pyplex\admin.html:135 +msgid "User" +msgstr "" + +#: module\web\templates\classic\base.html:239 module\web\templates\pyplex\base.html:188 +#: module\web\templates\modern\base.html:188 +msgid "Active" +msgstr "" + +#: module\config\default.conf:46 +msgid "Change user of running process" +msgstr "" + +#: module\web\templates\classic\logs.html:12 +msgid "prev" +msgstr "" + +#: module\web\templates\classic\pathchooser.html:48 module\web\templates\pyplex\pathchooser.html:80 +#: module\web\templates\modern\pathchooser.html:80 +msgid "type" +msgstr "" + +#: module\web\templates\modern\captcha.html:36 module\web\templates\classic\captcha.html:34 +#: module\web\templates\pyplex\captcha.html:36 +msgid "pyload userscript" +msgstr "" + +#: module\web\templates\modern\admin.html:53 module\web\templates\classic\admin.html:34 module\config\default.conf:45 +#: module\web\templates\pyplex\admin.html:57 +msgid "Permissions" +msgstr "" + +#: module\config\default.conf:26 +msgid "Count" +msgstr "" + +#: module\web\templates\modern\admin.html:39 module\web\templates\classic\admin.html:18 +#: module\web\templates\pyplex\admin.html:43 +msgid "To add user or change passwords use:" +msgstr "" + +#: module\web\templates\pyplex\queue.html:95 module\web\templates\modern\queue.html:95 +msgid "The package password" +msgstr "" + +#: module/web/media/js/pyplex/base.js:16 module/web/templates/classic/filemanager_ui.js:36 +#: module/web/media/js/classic/package_ui.js:2 module/web/media/js/modern/base.js:16 +msgid "Success" +msgstr "" + +#: module\web\templates\pyplex\home.html:20 module\web\templates\modern\home.html:20 +msgid "Hoster" +msgstr "" + +#: module\config\default.conf:22 +msgid "Use basic auth" +msgstr "" + +#: module\web\templates\modern\info.html:85 module\web\templates\pyplex\info.html:85 +#: module\web\templates\classic\info.html:37 +msgid "System" +msgstr "" + +#: module\web\templates\modern\queue.html:45 module\web\templates\modern\queue.html:77 +#: module\web\templates\classic\queue.html:48 module\web\templates\classic\queue.html:79 +#: module\web\templates\pyplex\queue.html:45 module\web\templates\pyplex\queue.html:77 msgid "Edit Package" msgstr "" -#: module/web/templates/default/queue.html:80 -msgid "Edit the package detais below." +#: module\web\templates\pyplex\window.html:34 module\web\templates\modern\window.html:34 +msgid "not available" msgstr "" -#: module/web/templates/default/queue.html:83 -msgid "The name of the package." +#: module\web\templates\classic\base.html:179 +msgid "Please Login!" msgstr "" -#: module/web/templates/default/queue.html:87 -msgid "Folder" +#: module\web\templates\classic\pathchooser.html:39 module\web\templates\classic\pathchooser.html:41 +#: module\web\templates\pyplex\settings.html:208 module\web\templates\modern\settings.html:208 +msgid "Path" msgstr "" -#: module/web/templates/default/queue.html:88 -msgid "Name of subfolder for these downloads." +#: module\web\templates\classic\base.html:173 module\web\templates\classic\admin.html:8 +#: module\web\templates\classic\admin.html:9 module\web\templates\modern\admin.html:25 +#: module\web\templates\modern\admin.html:26 module\web\templates\pyplex\admin.html:29 +#: module\web\templates\pyplex\admin.html:30 +msgid "Administrate" msgstr "" -#: module/web/templates/default/queue.html:93 -msgid "List of passwords used for unrar." +#: module\web\templates\pyplex\settings.html:111 module\web\templates\classic\settings.html:96 +#: module\web\templates\modern\settings.html:111 +msgid "Traffic left" msgstr "" -#: module/web/templates/default/window.html:5 -#: module/web/templates/default/window.html:40 -msgid "Add Package" +#: module\config\default.conf:50 +msgid "Filemode for Downloads" +msgstr "" + +#: module/web/templates/classic/filemanager_ui.js:237 +msgid "New folder" +msgstr "" + +#: module\web\templates\pyplex\queue.html:22 module\web\templates\modern\queue.html:22 +msgid "Clear Finished" +msgstr "" + +#: module/web/media/js/classic/admin.js:2 +msgid "pyLoad restarted" +msgstr "" + +#: module\web\templates\classic\login.html:29 +msgid "Your username and password didn't match. Please try again." +msgstr "" + +#: module\web\templates\classic\pathchooser.html:49 module\web\templates\pyplex\pathchooser.html:81 +#: module\web\templates\modern\pathchooser.html:81 +msgid "last modified" msgstr "" -#: module/web/templates/default/window.html:6 +#: module\web\templates\modern\captcha.html:18 module\web\templates\classic\captcha.html:15 +#: module\web\templates\pyplex\captcha.html:18 +msgid "Text" +msgstr "" + +#: module\config\default.conf:41 +msgid "Limit Download Speed" +msgstr "" + +#: module\config\default.conf:40 +msgid "Max Download Speed in kb/s" +msgstr "" + +#: module\config\default.conf:59 +msgid "Download Time" +msgstr "" + +#: module\config\default.conf:29 module\web\templates\pyplex\settings.html:42 +#: module\web\templates\classic\settings.html:16 module\web\templates\modern\settings.html:42 +msgid "General" +msgstr "" + +#: module\web\templates\modern\window.html:7 module\web\templates\classic\window.html:6 +#: module\web\templates\pyplex\window.html:7 msgid "Paste your links or upload a container." msgstr "" -#: module/web/templates/default/window.html:8 -msgid "The name of the new package." +#: module\config\default.conf:21 +msgid "Path Prefix" msgstr "" -#: module/web/templates/default/window.html:12 -msgid "Links" +#: module\web\templates\classic\queue.html:88 +msgid "Name of subfolder for these downloads." msgstr "" -#: module/web/templates/default/window.html:13 -msgid "Paste your links here or any text and press the filter button." +#: module\web\templates\modern\captcha.html:35 module\web\templates\classic\captcha.html:33 +#: module\web\templates\pyplex\captcha.html:35 +msgid "Note: to solve this interactive captchas" msgstr "" -#: module/web/templates/default/window.html:14 -msgid "Filter urls" +#: module\web\templates\pyplex\settings.html:43 module\web\templates\classic\settings.html:17 +#: module\web\templates\modern\settings.html:43 +msgid "Plugins" msgstr "" -#: module/web/templates/default/window.html:22 -msgid "Password for RAR-Archive" +#: module\web\templates\modern\settings.html:192 module\web\templates\modern\settings.html:220 +#: module\web\templates\modern\window.html:47 module\web\templates\pyplex\admin.html:107 +#: module\web\templates\pyplex\admin.html:120 module\web\templates\pyplex\admin.html:155 +#: module\web\templates\pyplex\queue.html:98 module\web\templates\modern\admin.html:103 +#: module\web\templates\modern\admin.html:116 module\web\templates\modern\admin.html:151 +#: module\web\templates\classic\base.html:229 module\web\templates\pyplex\settings.html:192 +#: module\web\templates\pyplex\settings.html:220 module\web\templates\modern\queue.html:98 +#: module\web\templates\pyplex\window.html:47 +msgid "Cancel" msgstr "" -#: module/web/templates/default/window.html:26 -msgid "File" +#: module\web\templates\modern\captcha.html:39 module\web\templates\classic\captcha.html:40 +#: module\web\templates\pyplex\captcha.html:39 +msgid "Close" msgstr "" -#: module/web/templates/default/window.html:27 -msgid "Upload a container." +#: module\web\templates\pyplex\admin.html:96 module\web\templates\modern\admin.html:92 +msgid "pyLoad was shut down, goodbye!" msgstr "" -#: module/web/templates/default/window.html:31 -msgid "Destination" +#: module\web\templates\pyplex\settings.html:184 module\web\templates\modern\settings.html:184 +msgid "Choose the hoster for your account" +msgstr "" + +#: module\web\templates\classic\home.html:240 module\web\templates\pyplex\home.html:22 +#: module\web\templates\modern\home.html:22 +msgid "Size" +msgstr "" + +#: module\web\templates\classic\queue.html:98 module\web\templates\classic\admin.html:92 +#: module\web\templates\classic\settings.html:198 module\web\templates\classic\window.html:41 +msgid "Reset" +msgstr "" + +#: module\web\templates\pyplex\queue.html:85 module\web\templates\modern\queue.html:85 +#: module\web\templates\pyplex\window.html:12 module\web\templates\modern\window.html:12 +msgid "The name of the package" +msgstr "" + +#: module\web\templates\classic\settings.html:177 +msgid "Enter your account data to use premium features." +msgstr "" + +#: module\config\default.conf:7 module\config\default.conf:9 module\config\default.conf:14 +msgid "Activated" +msgstr "" + +#: module\web\templates\modern\logs.html:91 module\web\templates\pyplex\logs.html:91 +msgid "Lines per page" msgstr "" -#: module/web/templates/default/home.html:206 +#: module\web\templates\modern\admin.html:113 module/web/media/js/classic/admin.js:2 +#: module\web\templates\pyplex\admin.html:117 +msgid "You are really sure you want to quit pyLoad?" +msgstr "" + +#: module\web\templates\classic\base.html:237 module\web\templates\pyplex\base.html:186 +#: module\web\templates\modern\base.html:186 +msgid "Reconnect:" +msgstr "" + +#: module\web\templates\modern\captcha.html:38 module\web\templates\modern\settings.html:66 +#: module\web\templates\modern\settings.html:97 module\web\templates\modern\settings.html:157 +#: module\web\templates\classic\admin.html:61 module\web\templates\classic\admin.html:91 +#: module\web\templates\modern\admin.html:79 module\web\templates\pyplex\admin.html:83 +#: module\web\templates\pyplex\settings.html:66 module\web\templates\pyplex\settings.html:97 +#: module\web\templates\pyplex\settings.html:157 module\web\templates\modern\queue.html:97 +#: module\web\templates\pyplex\captcha.html:38 module\web\templates\pyplex\queue.html:97 +#: module\web\templates\classic\captcha.html:39 module\web\templates\classic\queue.html:97 +#: module\web\templates\classic\settings.html:49 module\web\templates\classic\settings.html:77 +#: module\web\templates\classic\settings.html:167 +msgid "Submit" +msgstr "" + +#: module\web\templates\modern\admin.html:148 module\web\templates\classic\admin.html:86 +#: module\web\templates\pyplex\admin.html:152 +msgid "Please repeat the new password." +msgstr "" + +#: module\web\templates\classic\home.html:206 module\web\templates\pyplex\home.html:13 +#: module\web\templates\modern\home.html:13 msgid "Active Downloads" msgstr "" -#: module/web/templates/default/home.html:240 -msgid "Size" +#: module\config\default.conf:35 +msgid "Create folder for each package" msgstr "" -#: module/web/templates/default/home.html:241 -msgid "Progress" +#: module\web\templates\classic\base.html:240 +msgid "Reload page" msgstr "" -#: module/web/templates/default/captcha.html:6 -msgid "Captcha reading" +#: module\config\default.conf:3 +msgid "Remote" msgstr "" -#: module/web/templates/default/captcha.html:13 -msgid "Captcha" +#: module\web\templates\modern\queue.html:88 module\web\templates\classic\queue.html:87 module\config\default.conf:25 +#: module\web\templates\pyplex\queue.html:88 +msgid "Folder" msgstr "" -#: module/web/templates/default/captcha.html:14 -msgid "The captcha." +#: module\web\templates\classic\queue.html:80 +msgid "Edit the package detais below." msgstr "" -#: module/web/templates/default/captcha.html:20 -msgid "Text" +#: module\web\templates\pyplex\admin.html:44 module\web\templates\modern\admin.html:40 +msgid "Important:" +msgstr "" + +#: module\web\templates\classic\folder.html:7 module\web\templates\classic\filemanager.html:29 +#: module\web\templates\classic\filemanager.html:44 +msgid "Rename Directory" msgstr "" -#: module/web/templates/default/captcha.html:21 +#: module\web\templates\pyplex\info.html:81 module\web\templates\modern\info.html:81 +msgid "Development" +msgstr "" + +#: module\web\templates\pyplex\info.html:103 module\web\templates\modern\info.html:103 +msgid "Webinterface Theme:" +msgstr "" + +#: module\web\templates\classic\base.html:154 module\web\templates\pyplex\base.html:92 +#: module\web\templates\modern\base.html:92 +msgid "pyLoad Update available!" +msgstr "" + +#: module\config\default.conf:30 +msgid "Language" +msgstr "" + +#: module\web\templates\classic\base.html:160 module\web\templates\pyplex\base.html:97 +#: module\web\templates\modern\base.html:97 +msgid "Plugins updated, please restart!" +msgstr "" + +#: module\web\templates\pyplex\settings.html:183 module\web\templates\classic\settings.html:188 +#: module\web\templates\modern\settings.html:183 +msgid "Type" +msgstr "" + +#: module\web\templates\modern\logs.html:89 module\web\templates\pyplex\logs.html:89 +msgid "Reversed" +msgstr "" + +#: module\web\templates\classic\base.html:228 +msgid "Stop" +msgstr "" + +#: module/web/media/js/pyplex/base.js:382 module/web/media/js/classic/base.js:2 +#: module\web\templates\modern\captcha.html:25 module\web\templates\pyplex\captcha.html:25 +#: module/web/media/js/modern/base.js:382 +msgid "Please click on the right captcha position." +msgstr "" + +#: module\web\templates\modern\settings.html:173 module\web\templates\pyplex\login.html:2 +#: module\web\templates\classic\login.html:3 module\web\templates\pyplex\settings.html:173 +#: module\web\templates\modern\login.html:2 module\web\templates\classic\settings.html:178 +msgid "Login" +msgstr "" + +#: module\config\default.conf:31 +msgid "Download Folder" +msgstr "" + +#: module\web\templates\pyplex\settings.html:114 module\web\templates\classic\settings.html:99 +#: module\web\templates\modern\settings.html:114 +msgid "Delete?" +msgstr "" + +#: module\config\default.conf:62 +msgid "Proxy" +msgstr "" + +#: module\config\default.conf:63 +msgid "Address" +msgstr "" + +#: module/web/media/js/modern/admin.js:17 module/web/media/js/pyplex/admin.js:17 +#: module/web/media/js/pyplex/settings.js:116 module/web/media/js/modern/settings.js:116 +msgid "Settings saved" +msgstr "" + +#: module\web\templates\modern\settings.html:107 module\web\templates\modern\settings.html:178 +#: module\web\templates\modern\window.html:23 module\web\templates\pyplex\login.html:60 +#: module\web\templates\pyplex\queue.html:93 module\web\templates\classic\login.html:19 +#: module\web\templates\pyplex\window.html:23 module\web\templates\pyplex\settings.html:107 +#: module\web\templates\pyplex\settings.html:178 module\web\templates\modern\queue.html:93 module\config\default.conf:67 +#: module\web\templates\modern\login.html:60 module\web\templates\classic\queue.html:92 +#: module\web\templates\classic\settings.html:92 module\web\templates\classic\settings.html:183 +#: module\web\templates\classic\window.html:21 +msgid "Password" +msgstr "" + +#: module\web\templates\classic\setup.html:3 module\web\templates\classic\setup.html:4 +msgid "Setup" +msgstr "" + +#: module\web\templates\modern\info.html:101 module\web\templates\pyplex\info.html:101 +#: module\web\templates\classic\info.html:68 +msgid "Language:" +msgstr "" + +#: module\web\templates\classic\pathchooser.html:54 +msgid "parent directory" +msgstr "" + +#: module\web\templates\modern\captcha.html:20 module\web\templates\classic\captcha.html:16 +#: module\web\templates\pyplex\captcha.html:20 msgid "Input the text on the captcha." msgstr "" -#: module/web/templates/default/captcha.html:34 -msgid "Close" +#: module\web\templates\classic\login.html:30 +msgid "To reset your login data or add an user run:" +msgstr "" + +#: module\web\templates\pyplex\home.html:19 module\web\templates\modern\settings.html:106 +#: module\web\templates\modern\window.html:10 module\web\templates\classic\admin.html:25 +#: module\web\templates\classic\home.html:237 module\web\templates\modern\admin.html:50 +#: module\web\templates\modern\home.html:19 module\web\templates\pyplex\admin.html:54 +#: module\web\templates\pyplex\settings.html:106 module\web\templates\modern\queue.html:83 +#: module\web\templates\pyplex\window.html:10 module\web\templates\pyplex\queue.html:83 +#: module\web\templates\classic\queue.html:82 module\web\templates\classic\settings.html:91 +#: module\web\templates\classic\window.html:7 +msgid "Name" +msgstr "" + +#: module\web\templates\modern\admin.html:52 module\web\templates\classic\admin.html:31 +#: module\web\templates\pyplex\admin.html:56 +msgid "Admin" +msgstr "" + +#: module\config\default.conf:18 +msgid "IP" +msgstr "" + +#: module\web\templates\pyplex\info.html:80 module\web\templates\modern\info.html:80 +msgid "Homepage" +msgstr "" + +#: module\web\templates\modern\logout.html:16 module\web\templates\classic\logout.html:8 +#: module\web\templates\pyplex\logout.html:16 +msgid "You were successfully logged out." +msgstr "" + +#: module\config\default.conf:37 +msgid "Download" +msgstr "" + +#: module/web/media/js/pyplex/base.js:416 module/web/media/js/modern/base.js:416 +msgid "No Captchas to read." +msgstr "" + +#: module\web\templates\modern\captcha.html:34 module\web\templates\classic\captcha.html:32 +#: module\web\templates\pyplex\captcha.html:34 +msgid "The captcha may take a few seconds to load." +msgstr "" + +#: module\web\templates\pyplex\queue.html:80 module\web\templates\modern\queue.html:80 +msgid "Edit the package detais below" +msgstr "" + +#: module\web\templates\modern\admin.html:138 module\web\templates\classic\admin.html:76 +#: module\web\templates\classic\settings.html:184 module\web\templates\pyplex\admin.html:142 +msgid "The password for this account." +msgstr "" + +#: module\web\templates\classic\base.html:239 module\web\templates\pyplex\base.html:188 +#: module\web\templates\modern\base.html:188 +msgid "Queued" +msgstr "" + +#: module\web\templates\modern\admin.html:129 module\web\templates\classic\admin.html:69 +#: module\web\templates\pyplex\admin.html:133 +msgid "Enter your current and desired Password." +msgstr "" + +#: module/web/media/js/pyplex/base.js:231 module/web/media/js/modern/base.js:231 +msgid "Please Enter a package name." +msgstr "" + +#: module\config\default.conf:42 +msgid "Download interface to bind (ip or Name)" +msgstr "" + +#: module/web/media/js/classic/base.js:2 module\web\templates\modern\base.html:185 +#: module\web\templates\modern\base.html:186 module/web/media/js/pyplex/base.js:360 +#: module/web/media/js/pyplex/base.js:365 module\web\templates\modern\settings_item.html:15 +#: module\web\templates\classic\settings_item.html:16 module/web/media/js/modern/base.js:360 +#: module/web/media/js/modern/base.js:365 module\web\templates\classic\base.html:236 +#: module\web\templates\classic\base.html:237 module\web\templates\pyplex\settings_item.html:15 +#: module\web\templates\pyplex\base.html:185 module\web\templates\pyplex\base.html:186 +msgid "off" +msgstr "" + +#: module\config\default.conf:33 +msgid "Use Checksum" +msgstr "" + +#: module\web\templates\modern\info.html:107 module\web\templates\pyplex\info.html:107 +#: module\web\templates\classic\info.html:76 +msgid "Remote Interface Port:" msgstr "" + +#: module\web\templates\modern\queue.html:60 module\web\templates\classic\queue.html:65 +#: module\web\templates\pyplex\queue.html:60 +msgid "Folder:" +msgstr "" + +#: module\web\templates\modern\queue.html:61 module\web\templates\classic\queue.html:65 +#: module\web\templates\pyplex\queue.html:61 +msgid "Password:" +msgstr "" + +#: module\web\templates\pyplex\settings.html:136 module\web\templates\classic\settings.html:134 +#: module\web\templates\modern\settings.html:136 +msgid "no" +msgstr "" + +#: module/web/media/js/modern/package.js:173 module/web/media/js/classic/package_ui.js:2 +#: module/web/media/js/pyplex/package.js:178 +msgid "Delete Link" +msgstr "" + +#: module/web/media/js/classic/settings.js:2 +msgid "Error occured." +msgstr "" + +#: module\web\templates\pyplex\window.html:25 module\web\templates\modern\window.html:25 +msgid "Type the package password" +msgstr "" + +#: module\web\templates\classic\queue.html:50 +msgid "Move Package" +msgstr "" + +#: module\web\templates\modern\window.html:32 module\web\templates\classic\window.html:26 +#: module\web\templates\pyplex\window.html:32 +msgid "File" +msgstr "" + +#: module\config\default.conf:54 +msgid "Reconnect" +msgstr "" + +#: module\web\templates\classic\base.html:239 module\web\templates\pyplex\base.html:188 +#: module\web\templates\modern\base.html:188 +msgid "Total" +msgstr "" + +#: module\web\templates\classic\folder.html:11 module\web\templates\classic\filemanager.html:48 +msgid "Add subdirectory" +msgstr "" + +#: module\config\default.conf:4 module\config\default.conf:19 module\config\default.conf:64 +msgid "Port" +msgstr "" + diff --git a/locale/el_GR/LC_MESSAGES/django.mo b/locale/el_GR/LC_MESSAGES/django.mo new file mode 100644 index 0000000000..135530b4a6 Binary files /dev/null and b/locale/el_GR/LC_MESSAGES/django.mo differ diff --git a/locale/el_GR/LC_MESSAGES/pyLoad.mo b/locale/el_GR/LC_MESSAGES/pyLoad.mo new file mode 100644 index 0000000000..a1662ae0aa Binary files /dev/null and b/locale/el_GR/LC_MESSAGES/pyLoad.mo differ diff --git a/locale/el_GR/LC_MESSAGES/pyLoadCli.mo b/locale/el_GR/LC_MESSAGES/pyLoadCli.mo new file mode 100644 index 0000000000..33b83abbb4 Binary files /dev/null and b/locale/el_GR/LC_MESSAGES/pyLoadCli.mo differ diff --git a/locale/el_GR/LC_MESSAGES/setup.mo b/locale/el_GR/LC_MESSAGES/setup.mo new file mode 100644 index 0000000000..3be5e303b9 Binary files /dev/null and b/locale/el_GR/LC_MESSAGES/setup.mo differ diff --git a/locale/gui.pot b/locale/gui.pot index dc74397a01..3678a79e33 100644 --- a/locale/gui.pot +++ b/locale/gui.pot @@ -6,8 +6,8 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: pyLoad 0.4.9\n" -"Report-Msgid-Bugs-To: 'bugs@pyload.org'\n" +"Project-Id-Version: pyLoad 0.4.20\n" +"Report-Msgid-Bugs-To: 'bugs@pyload.net'\n" "POT-Creation-Date: 2011-12-07 19:21+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" diff --git a/locale/setup.pot b/locale/setup.pot index cf4dd8cfcd..48964a5c82 100644 --- a/locale/setup.pot +++ b/locale/setup.pot @@ -6,8 +6,8 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: pyLoad 0.4.9\n" -"Report-Msgid-Bugs-To: 'bugs@pyload.org'\n" +"Project-Id-Version: pyLoad 0.4.20\n" +"Report-Msgid-Bugs-To: 'bugs@pyload.net'\n" "POT-Creation-Date: 2011-12-07 19:21+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" diff --git a/module/Api.py b/module/Api.py index f0bf5e2648..f738ecbc26 100644 --- a/module/Api.py +++ b/module/Api.py @@ -17,7 +17,6 @@ @author: RaNaN """ -from base64 import standard_b64encode from os.path import join from time import time import re @@ -25,6 +24,7 @@ from PyFile import PyFile from utils import freeSpace, compare_time from common.packagetools import parseNames +from common.json_layer import json from network.RequestFactory import getURL from remote import activated @@ -53,7 +53,7 @@ def __new__(cls, func, *args, **kwargs): return _Dec -urlmatcher = re.compile(r"((https?|ftps?|xdcc|sftp):((//)|(\\\\))+[\w\d:#@%/;$()~_?\+\-=\\\.&]*)", re.IGNORECASE) +urlmatcher = re.compile(r"(?:(?:https?|ftps?|xdcc|sftp):(?://|\\\\)+[\w\-._~:/?#\[\]@!$&'()*+,;=]*)|magnet:\?.+", re.IGNORECASE) class PERMS: ALL = 0 # requires no permission, but login @@ -321,7 +321,8 @@ def addPackage(self, name, links, dest=Destination.Queue): else: folder = "" - folder = folder.replace("http://", "").replace(":", "").replace("/", "_").replace("\\", "_") + folder = folder.replace("http://", "").replace("https://", "").rstrip("/") \ + .replace(":", "").replace("/", "_").replace("\\", "_") pid = self.core.files.addPackage(name, folder, dest) @@ -343,11 +344,11 @@ def parseURLs(self, html=None, url=None): urls = [] if html: - urls += [x[0] for x in urlmatcher.findall(html)] + urls += urlmatcher.findall(html) if url: page = getURL(url) - urls += [x[0] for x in urlmatcher.findall(page)] + urls += urlmatcher.findall(page) # remove duplicates return self.checkURLs(set(urls)) @@ -805,7 +806,7 @@ def getCaptchaTask(self, exclusive=False): if task: task.setWatingForUser(exclusive=exclusive) data, type, result = task.getCaptcha() - t = CaptchaTask(int(task.id), standard_b64encode(data), type, result) + t = CaptchaTask(int(task.id), json.dumps(data), type, result) return t else: return CaptchaTask(-1) diff --git a/module/CaptchaManager.py b/module/CaptchaManager.py index 02cd10a118..66bcbd4fca 100644 --- a/module/CaptchaManager.py +++ b/module/CaptchaManager.py @@ -18,19 +18,20 @@ """ from time import time -from traceback import print_exc from threading import Lock +from common.json_layer import json + class CaptchaManager(): def __init__(self, core): self.lock = Lock() self.core = core - self.tasks = [] #task store, for outgoing tasks only + self.tasks = [] # Task store, for outgoing tasks only - self.ids = 0 #only for internal purpose + self.ids = 0 # Only for internal purpose - def newTask(self, img, format, file, result_type): - task = CaptchaTask(self.ids, img, format, file, result_type) + def newTask(self, format, params, result_type): + task = CaptchaTask(self.ids, format, params, result_type) self.ids += 1 return task @@ -52,26 +53,27 @@ def getTask(self): def getTaskByID(self, tid): self.lock.acquire() for task in self.tasks: - if task.id == str(tid): #task ids are strings + if task.id == str(tid): # Task ids are strings self.lock.release() return task self.lock.release() return None - def handleCaptcha(self, task): + def handleCaptcha(self, task, timeout): cli = self.core.isClientConnected() - if cli: #client connected -> should solve the captcha - task.setWaiting(50) #wait 50 sec for response + task.setWaiting(timeout) + + # if cli: # Client connected -> should solve the captcha + # task.setWaiting(50) # Wait minimum 50 sec for response for plugin in self.core.hookManager.activePlugins(): try: plugin.newCaptchaTask(task) except: - if self.core.debug: - print_exc() - - if task.handler or cli: #the captcha was handled + self.core.log.debug(_("Exception in captcha task has occurred" ), exc_info=True) + + if task.handler or cli: # The captcha was handled self.tasks.append(task) return True @@ -81,11 +83,10 @@ def handleCaptcha(self, task): class CaptchaTask(): - def __init__(self, id, img, format, file, result_type='textual'): + def __init__(self, id, format, params={}, result_type='textual'): self.id = str(id) - self.captchaImg = img + self.captchaParams = params self.captchaFormat = format - self.captchaFile = file self.captchaResultType = result_type self.handler = [] #the hook plugins that will take care of the solution self.result = None @@ -96,14 +97,15 @@ def __init__(self, id, img, format, file, result_type='textual'): self.data = {} #handler can store data here def getCaptcha(self): - return self.captchaImg, self.captchaFormat, self.captchaResultType + return self.captchaParams, self.captchaFormat, self.captchaResultType - def setResult(self, text): - if self.isTextual(): - self.result = text - if self.isPositional(): + def setResult(self, result): + if self.isTextual() or self.isInteractive() or self.isInvisible(): + self.result = result + + elif self.isPositional(): try: - parts = text.split(',') + parts = result.split(',') self.result = (int(parts[0]), int(parts[1])) except: self.result = None @@ -137,6 +139,16 @@ def isTextual(self): def isPositional(self): """ returns if user have to click a specific region on the captcha """ return self.captchaResultType == 'positional' + + def isInteractive(self): + """ returns if user has to solve the captcha in an interactive iframe """ + return self.captchaResultType == 'interactive' + + def isInvisible(self): + """ + returns if invisible (browser only, no user interaction) captcha. + """ + return self.captchaResultType == "invisible" def setWatingForUser(self, exclusive): if exclusive: diff --git a/module/ConfigParser.py b/module/ConfigParser.py index 6f5ca9dc84..34774250ca 100644 --- a/module/ConfigParser.py +++ b/module/ConfigParser.py @@ -258,7 +258,7 @@ def cast(self, typ, value): return value elif typ in ("str", "file", "folder"): try: - return value.encode("utf8") + return value.decode("utf8") except: return value else: diff --git a/module/HookManager.py b/module/HookManager.py index d963fbc720..41053f833b 100644 --- a/module/HookManager.py +++ b/module/HookManager.py @@ -19,7 +19,6 @@ """ import __builtin__ -import traceback from thread import start_new_thread from threading import RLock @@ -87,9 +86,7 @@ def new(*args): try: return func(*args) except Exception, e: - args[0].log.error(_("Error executing hooks: %s") % str(e)) - if args[0].core.debug: - traceback.print_exc() + args[0].log.error(_("Error executing hooks: %s") % str(e), exc_info=args[0].core.debug) return new @@ -137,9 +134,7 @@ def createIndex(self): except: - self.log.warning(_("Failed activating %(name)s") % {"name": pluginname}) - if self.core.debug: - traceback.print_exc() + self.log.warning(_("Failed activating %(name)s") % {"name": pluginname}, exc_info=self.core.debug) self.log.info(_("Activated plugins: %s") % ", ".join(sorted(active))) self.log.info(_("Deactivate plugins: %s") % ", ".join(sorted(deactive))) @@ -311,7 +306,6 @@ def dispatchEvent(self, event, *args): f(*args) except Exception, e: self.log.warning("Error calling event handler %s: %s, %s, %s" - % (event, f, args, str(e))) - if self.core.debug: - traceback.print_exc() - + % (event, f, args, str(e)), + exc_info=self.core.debug) + diff --git a/module/InitHomeDir.py b/module/InitHomeDir.py index 156c9f9325..9d38b8719e 100644 --- a/module/InitHomeDir.py +++ b/module/InitHomeDir.py @@ -52,27 +52,22 @@ __builtin__.homedir = homedir -args = " ".join(argv[1:]) - # dirty method to set configdir from commandline arguments -if "--configdir=" in args: - pos = args.find("--configdir=") - end = args.find("-", pos + 12) - - if end == -1: - configdir = args[pos + 12:].strip() - else: - configdir = args[pos + 12:end].strip() -elif path.exists(path.join(pypath, "module", "config", "configdir")): - f = open(path.join(pypath, "module", "config", "configdir"), "rb") - c = f.read().strip() - f.close() - configdir = path.join(pypath, c) +for arg in argv[1:]: + if arg.startswith("--configdir="): + configdir=arg[12:].strip() + break else: - if platform in ("posix", "linux2"): - configdir = path.join(homedir, ".pyload") + if path.exists(path.join(pypath, "module", "config", "configdir")): + f = open(path.join(pypath, "module", "config", "configdir"), "rb") + c = f.read().strip() + f.close() + configdir = path.join(pypath, c) else: - configdir = path.join(homedir, "pyload") + if platform in ("posix", "linux2"): + configdir = path.join(homedir, ".pyload") + else: + configdir = path.join(homedir, "pyload") if not path.exists(configdir): makedirs(configdir, 0700) diff --git a/module/PluginThread.py b/module/PluginThread.py index 56c36c778a..beaf136e33 100644 --- a/module/PluginThread.py +++ b/module/PluginThread.py @@ -21,9 +21,9 @@ from Queue import Queue from threading import Thread from os import listdir, stat -from os.path import join +from os.path import join, isdir from time import sleep, time, strftime, gmtime -from traceback import print_exc, format_exc +from traceback import format_exc from pprint import pformat from sys import exc_info, exc_clear from copy import copy @@ -61,12 +61,14 @@ def writeDebugReport(self, pyfile): zip = zipfile.ZipFile(dump_name, "w") - for f in listdir(join("tmp", pyfile.pluginname)): - try: - # avoid encoding errors - zip.write(join("tmp", pyfile.pluginname, f), save_join(pyfile.pluginname, f)) - except: - pass + dumps_dir = join("tmp", pyfile.pluginname) + if isdir(dumps_dir): + for f in listdir(dumps_dir): + try: + # avoid encoding errors + zip.write(join("tmp", pyfile.pluginname, f), save_join(pyfile.pluginname, f)) + except: + pass info = zipfile.ZipInfo(save_join(pyfile.pluginname, "debug_Report.txt"), gmtime()) info.external_attr = 0644 << 16L # change permissions @@ -273,9 +275,8 @@ def run(self): else: pyfile.setStatus("failed") - self.m.log.error("pycurl error %s: %s" % (code, msg)) + self.m.log.error("pycurl error %s: %s" % (code, msg), exc_info=self.m.core.debug) if self.m.core.debug: - print_exc() self.writeDebugReport(pyfile) self.m.core.hookManager.downloadFailed(pyfile) @@ -287,7 +288,7 @@ def run(self): pyfile.setStatus("skipped") self.m.log.info( - _("Download skipped: %(name)s due to %(plugin)s") % {"name": pyfile.name, "plugin": e.message}) + _("Download skipped: %(name)s due to %(plugin)s") % {"name": pyfile.name, "plugin": e.args[0]}) self.clean(pyfile) @@ -301,11 +302,10 @@ def run(self): except Exception, e: pyfile.setStatus("failed") - self.m.log.warning(_("Download failed: %(name)s | %(msg)s") % {"name": pyfile.name, "msg": str(e)}) + self.m.log.warning(_("Download failed: %(name)s | %(msg)s") % {"name": pyfile.name, "msg": str(e)}, exc_info=self.m.core.debug) pyfile.error = str(e) if self.m.core.debug: - print_exc() self.writeDebugReport(pyfile) self.m.core.hookManager.downloadFailed(pyfile) @@ -392,11 +392,10 @@ def run(self): except Exception, e: self.active.setStatus("failed") - self.m.log.error(_("Decrypting failed: %(name)s | %(msg)s") % {"name": self.active.name, "msg": str(e)}) + self.m.log.error(_("Decrypting failed: %(name)s | %(msg)s") % {"name": self.active.name, "msg": str(e)}, exc_info=self.m.core.debug) self.active.error = str(e) if self.m.core.debug: - print_exc() self.writeDebugReport(pyfile) return @@ -546,8 +545,7 @@ def run(self): try: data = self.decryptContainer(name, url) except: - print_exc() - self.m.log.error("Could not decrypt container.") + self.m.log.error("Could not decrypt container.", exc_info=self.m.core.debug) data = [] for url, plugin in data: @@ -634,9 +632,7 @@ def fetchForPlugin(self, pluginname, plugin, urls, cb, err=None): self.m.log.debug("Finished Info Fetching for %s" % pluginname) except Exception, e: self.m.log.warning(_("Info Fetching for %(name)s failed | %(err)s") % - {"name": pluginname, "err": str(e)}) - if self.m.core.debug: - print_exc() + {"name": pluginname, "err": str(e)}, exc_info=self.m.core.debug) # generate default results if err: diff --git a/module/PyFile.py b/module/PyFile.py index 3dede93600..6c989e415e 100644 --- a/module/PyFile.py +++ b/module/PyFile.py @@ -283,3 +283,8 @@ def setProgress(self, value): if not value == self.progress: self.progress = value self.notifyChange() + + def setName(self, value): + if not value == self.name: + self.name = value + self.notifyChange() diff --git a/module/ThreadManager.py b/module/ThreadManager.py index 8937f4a293..1691c73d5f 100644 --- a/module/ThreadManager.py +++ b/module/ThreadManager.py @@ -23,7 +23,6 @@ from subprocess import Popen from threading import Event, Lock from time import sleep, time -from traceback import print_exc from random import choice import pycurl @@ -69,12 +68,11 @@ def __init__(self, core): pycurl.global_init(pycurl.GLOBAL_DEFAULT) for i in range(0, self.core.config.get("download", "max_downloads")): - self.createThread() + self.createDownloadThread() - def createThread(self): + def createDownloadThread(self): """create a download thread""" - thread = PluginThread.DownloadThread(self) self.threads.append(thread) @@ -134,19 +132,15 @@ def work(self): try: self.tryReconnect() except Exception, e: - self.log.error(_("Reconnect Failed: %s") % str(e) ) + self.log.error(_("Reconnect Failed: %s") % str(e), exc_info=self.core.debug) self.reconnecting.clear() - if self.core.debug: - print_exc() self.checkThreadCount() try: self.assignJob() except Exception, e: - self.log.warning("Assign job error", e) - if self.core.debug: - print_exc() - + self.log.warning(_("Assign job error: %s") % str(e), exc_info=self.core.debug) + sleep(0.5) self.assignJob() #it may be failed non critical so we try it again @@ -193,11 +187,9 @@ def tryReconnect(self): try: reconn = Popen(self.core.config['reconnect']['method'], bufsize=-1, shell=True)#, stdout=subprocess.PIPE) except: - self.log.warning(_("Failed executing reconnect script!")) + self.log.warning(_("Failed executing reconnect script!"), exc_info=self.core.debug) self.core.config["reconnect"]["activated"] = False self.reconnecting.clear() - if self.core.debug: - print_exc() return reconn.wait() @@ -234,7 +226,7 @@ def checkThreadCount(self): if len(self.threads) == self.core.config.get("download", "max_downloads"): return True elif len(self.threads) < self.core.config.get("download", "max_downloads"): - self.createThread() + self.createDownloadThread() else: free = [x for x in self.threads if not x.active] if free: @@ -262,7 +254,7 @@ def assignJob(self): free = [x for x in self.threads if not x.active] - inuse = set([(x.active.pluginname,self.getLimit(x)) for x in self.threads if x.active and x.active.hasPlugin() and x.active.plugin.account]) + inuse = set([(x.active.pluginname,self.getLimit(x)) for x in self.threads if x.active and x.active.hasPlugin()]) inuse = map(lambda x : (x[0], x[1], len([y for y in self.threads if y.active and y.active.pluginname == x[0]])) ,inuse) onlimit = [x[0] for x in inuse if x[1] > 0 and x[2] >= x[1]] @@ -275,8 +267,7 @@ def assignJob(self): try: job.initPlugin() except Exception, e: - self.log.critical(str(e)) - print_exc() + self.log.critical(str(e), exc_info=True) job.setStatus("failed") job.error = str(e) job.release() @@ -292,6 +283,7 @@ def assignJob(self): thread = free[0] #self.downloaded += 1 + job.setStatus("starting") thread.put(job) else: #put job back @@ -299,7 +291,7 @@ def assignJob(self): self.core.files.jobCache[occ] = [] self.core.files.jobCache[occ].append(job.id) - #check for decrypt jobs + # check for decrypt jobs job = self.core.files.getDecryptJob() if job: job.initPlugin() @@ -310,8 +302,29 @@ def assignJob(self): thread = PluginThread.DecrypterThread(self, job) def getLimit(self, thread): - limit = thread.active.plugin.account.getAccountData(thread.active.plugin.user)["options"].get("limitDL",["0"])[0] - return int(limit) + if thread.active.plugin.account: + account_limit = max( + int( + thread.active.plugin.account.getAccountData( + thread.active.plugin.account.user + )["options"].get("limitDL", ["0"])[0] + ), + 0, + ) + else: + account_limit = 0 + + plugin_limit = ( + max(thread.active.plugin.limitDL, 0) + if hasattr(thread.active.plugin, "limitDL") + else 0 + ) + if account_limit > 0 and plugin_limit > 0: + limit = min(account_limit, plugin_limit) + else: + limit = account_limit or plugin_limit + + return limit def cleanup(self): """do global cleanup, should be called when finished with pycurl""" diff --git a/module/common/JsEngine.py b/module/common/JsEngine.py index 0acdef04d8..061460eb88 100644 --- a/module/common/JsEngine.py +++ b/module/common/JsEngine.py @@ -17,10 +17,12 @@ @author: RaNaN """ -from imp import find_module -from os.path import join, exists -from urllib import quote +from __future__ import with_statement +import os +import tempfile +import urllib +from imp import find_module ENGINE = "" @@ -36,9 +38,23 @@ import js2py out = js2py.eval_js("(23+19).toString()") - #integrity check + # integrity check if out.strip() == "42": ENGINE = "js2py" + + def monkey_patch(): + """Patching js2py for CVE-2024-28397""" + from js2py.constructors.jsobject import Object + fn = Object.own["getOwnPropertyNames"]["value"].code + + def wraps(*args, **kwargs): + result = fn(*args, **kwargs) + return list(result) + Object.own["getOwnPropertyNames"]["value"].code = wraps + + monkey_patch() + js2py.disable_pyimport() + JS2PY = True except: pass @@ -82,12 +98,12 @@ try: path = "" #path where to find rhino - if exists("/usr/share/java/js.jar"): + if os.path.exists("/usr/share/java/js.jar"): path = "/usr/share/java/js.jar" - elif exists("js.jar"): + elif os.path.exists("js.jar"): path = "js.jar" - elif exists(join(pypath, "js.jar")): #may raises an exception, but js.jar wasnt found anyway - path = join(pypath, "js.jar") + elif os.path.exists(os.path.join(pypath, "js.jar")): #may raises an exception, but js.jar wasnt found anyway + path = os.path.join(pypath, "js.jar") if not path: raise Exception @@ -171,34 +187,62 @@ def eval(self, script): return results[0] def eval_pyv8(self, script): - rt = PyV8.JSContext() - rt.enter() - return rt.eval(script) + with PyV8.JSLocker(): + with PyV8.JSContext() as rt: + return rt.eval(script) def eval_js(self, script): - script = "print(eval(unescape('%s')))" % quote(script) - p = subprocess.Popen(["js", "-e", script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) + script = "print(eval(unescape('%s')))" % urllib.quote(script) + if len(script) <= 2000: + script_file = None + p = subprocess.Popen(["js", "-e", script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) + else: + fd, script_file = tempfile.mkstemp(prefix='script_file_', suffix='.js', dir="tmp") + os.write(fd, script) + os.close(fd) + p = subprocess.Popen(["js", "-f", script_file], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) out, err = p.communicate() + if script_file and os.path.exists(script_file): + os.unlink(script_file) res = out.strip() return res def eval_js2py(self, script): - script = "(eval(unescape('%s'))).toString()" % quote(script) + script = "(eval(unescape('%s'))).toString()" % urllib.quote(script) res = js2py.eval_js(script).strip() return res def eval_node(self, script): - script = "console.log(eval(unescape('%s')))" % quote(script) - p = subprocess.Popen(["node", "-e", script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) + script = "console.log(eval(unescape('%s')))" % urllib.quote(script) + if len(script) <= 2000: + script_file = None + p = subprocess.Popen(["node", "-e", script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) + else: + fd, script_file = tempfile.mkstemp(prefix='script_file_', suffix='.js', dir="tmp") + os.write(fd, script) + os.close(fd) + p = subprocess.Popen(["node",script_file], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) out, err = p.communicate() + if script_file and os.path.exists(script_file): + os.unlink(script_file) res = out.strip() return res def eval_rhino(self, script): - script = "print(eval(unescape('%s')))" % quote(script) - p = subprocess.Popen(["java", "-cp", path, "org.mozilla.javascript.tools.shell.Main", "-e", script], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) + script = "print(eval(unescape('%s')))" % urllib.quote(script) + if len(script) <= 1800: + script_file = None + p = subprocess.Popen(["java", "-cp", path, "org.mozilla.javascript.tools.shell.Main", "-e", script], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) + else: + fd, script_file = tempfile.mkstemp(prefix='script_file_', suffix='.js', dir="tmp") + os.write(fd, script) + os.close(fd) + p = subprocess.Popen(["java", "-cp", path, "org.mozilla.javascript.tools.shell.Main", "-f", script_file], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1) out, err = p.communicate() + if script_file and os.path.exists(script_file): + os.unlink(script_file) res = out.strip() return res.decode("utf8").encode("ISO-8859-1") @@ -209,4 +253,4 @@ def error(self): js = JsEngine() test = u'"ü"+"ä"' - js.eval(test) \ No newline at end of file + js.eval(test) diff --git a/module/config/default.conf b/module/config/default.conf index 8727cb4359..a4b661655f 100644 --- a/module/config/default.conf +++ b/module/config/default.conf @@ -1,65 +1,69 @@ version: 1 remote - "Remote": - int port : "Port" = 7227 - ip listenaddr : "Adress" = 0.0.0.0 - bool nolocalauth : "No authentication on local connections" = True - bool activated : "Activated" = True + int port : "Port" = 7227 + ip listenaddr : "Adress" = 0.0.0.0 + bool nolocalauth : "No authentication on local connections" = True + bool activated : "Activated" = True ssl - "SSL": - bool activated : "Activated"= False - file cert : "SSL Certificate" = ssl.crt - file key : "SSL Key" = ssl.key + bool activated : "Activated"= False + file cert : "SSL certificate" = ssl.crt + file key : "SSL key" = ssl.key + file cert_chain : "CA's intermediate certificate bundle (optional)" = webinterface - "Webinterface": - bool activated : "Activated" = True - builtin;threaded;fastcgi;lightweight server : "Server" = builtin - bool https : "Use HTTPS" = False - ip host : "IP" = 0.0.0.0 - int port : "Port" = 8001 - str template : "Template" = classic - str prefix: "Path Prefix" = + bool activated : "Activated" = True + builtin;threaded;fastcgi;lightweight server : "Server" = builtin + bool https : "Use HTTPS" = False + bool dualstack : "Listen on both IPv4 and IPv6 (IP must be set to 0.0.0.0)" = True + ip host : "IP" = 0.0.0.0 + int port : "Port" = 8000 + classic;modern;pyplex template : "Template" = modern + str prefix: "Path prefix" = + bool basicauth : "Use basic auth" = False log - "Log": - bool file_log : "File Log" = True - folder log_folder : "Folder" = Logs - int log_count : "Count" = 5 - int log_size : "Size in kb" = 100 - bool log_rotate : "Log Rotate" = True + bool file_log : "File log" = True + folder log_folder : "Folder" = Logs + int log_count : "Count" = 5 + int log_size : "Size in kb" = 100 + bool log_rotate : "Log rotate" = True general - "General": - en;de;fr;it;es;nl;sv;ru;pl;cs;sr;pt_BR language : "Language" = en - folder download_folder : "Download Folder" = Downloads - bool debug_mode : "Debug Mode" = False - bool checksum : "Use Checksum" = False - int min_free_space : "Min Free Space (MB)" = 200 - bool folder_per_package : "Create folder for each package" = True - int renice : "CPU Priority" = 0 + en;de;fr;it;es;el_GR;nl;sv;ru;pl;cs;sr;pt_BR language : "Language" = en + folder download_folder : "Download folder" = Downloads + bool debug_mode : "Debug mode" = False + bool checksum : "Use checksum" = False + int min_free_space : "Minimum free Space (MB)" = 200 + bool folder_per_package : "Create folder for each package" = True + int renice : "CPU Priority" = 0 download - "Download": - int chunks : "Max connections for one download" = 3 - int max_downloads : "Max Parallel Downloads" = 3 - int max_speed : "Max Download Speed in kb/s" = -1 - bool limit_speed : "Limit Download Speed" = False + int chunks : "Maximum connections for one download" = 3 + int max_downloads : "Maximum parallel downloads" = 3 + int max_speed : "Maximum download speed in KiB/s" = -1 + bool limit_speed : "Limit download speed" = False str interface : "Download interface to bind (ip or Name)" = None bool ipv6 : "Allow IPv6" = False bool skip_existing : "Skip already existing files" = False permission - "Permissions": bool change_user : "Change user of running process" = False - str user : "Username" = user - str folder : "Folder Permission mode" = 0755 - bool change_file : "Change file mode of downloads" = False - str file : "Filemode for Downloads" = 0644 + str user : "Username for ownership" = user + str folder : "Permission mode for created folders" = 0755 + bool change_file : "Change permissions of downloads" = False + str file : "Permission mode for downloaded files" = 0644 bool change_group : "Change group of running process" = False - str group : "Groupname" = users - bool change_dl : "Change Group and User of Downloads" = False + str group : "Groupname for ownership" = users + bool change_dl : "Change ownership of downloads" = False reconnect - "Reconnect": - bool activated : "Use Reconnect" = False - str method : "Method" = None - time startTime : "Start" = 0:00 - time endTime : "End" = 0:00 -downloadTime - "Download Time": - time start : "Start" = 0:00 - time end : "End" = 0:00 + bool activated : "Use reconnect" = False + str method : "Method" = None + time startTime : "Start" = 0:00 + time endTime : "End" = 0:00 +downloadTime - "Download time": + time start : "Start" = 0:00 + time end : "End" = 0:00 proxy - "Proxy": - str address : "Address" = "localhost" - int port : "Port" = 7070 - http;socks4;socks5 type : "Protocol" = http - str username : "Username" = None - password password : "Password" = None - bool proxy : "Use Proxy" = False + str address : "Address" = "localhost" + int port : "Port" = 7070 + http;https;socks4;socks5 type : "Protocol" = http + bool socksResolveDns : "Enable DNS resolution through SOCKS proxy" = False + str username : "Username" = + password password : "Password" = + bool proxy : "Use proxy" = False diff --git a/module/database/DatabaseBackend.py b/module/database/DatabaseBackend.py index 9530390c30..5c42da0054 100644 --- a/module/database/DatabaseBackend.py +++ b/module/database/DatabaseBackend.py @@ -190,7 +190,7 @@ def _convertDB(self, v): except: print "Filedatabase could NOT be converted." - #--convert scripts start + # --convert scripts start def _convertV2(self): self.c.execute('CREATE TABLE IF NOT EXISTS "storage" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "identifier" TEXT NOT NULL, "key" TEXT NOT NULL, "value" TEXT DEFAULT "")') @@ -207,7 +207,7 @@ def _convertV3(self): except: print "Database was converted from v3 to v4." - #--convert scripts end + # --convert scripts end def _createTables(self): """create tables for database""" @@ -225,7 +225,7 @@ def _createTables(self): FROM packages p JOIN links l ON p.id = l.package AND l.status in (0,4,13) GROUP BY p.id) s ON s.id = p.id \ GROUP BY p.id') - #try to lower ids + # try to lower ids self.c.execute('SELECT max(id) FROM LINKS') fid = self.c.fetchone()[0] if fid: @@ -243,6 +243,9 @@ def _createTables(self): pid = 0 self.c.execute('UPDATE SQLITE_SEQUENCE SET seq=? WHERE name=?', (pid, "packages")) + # set unfinished links as aborted + self.c.execute("UPDATE links SET status=9 WHERE status NOT IN (0, 1, 4, 6, 8, 9)") + self.c.execute('VACUUM') diff --git a/module/gui/connector.py b/module/gui/Connector.py similarity index 99% rename from module/gui/connector.py rename to module/gui/Connector.py index c16ccd08e9..adc3f345eb 100644 --- a/module/gui/connector.py +++ b/module/gui/Connector.py @@ -16,7 +16,7 @@ @author: mkaay """ -SERVER_VERSION = "0.4.9" +SERVER_VERSION = "0.4.20" from time import sleep from uuid import uuid4 as uuid diff --git a/module/lib/BeautifulSoup.py b/module/lib/BeautifulSoup.py index 55567f588b..4b17b853d0 100644 --- a/module/lib/BeautifulSoup.py +++ b/module/lib/BeautifulSoup.py @@ -79,7 +79,7 @@ from __future__ import generators __author__ = "Leonard Richardson (leonardr@segfault.org)" -__version__ = "3.0.8.1" +__version__ = "3.2.0" __copyright__ = "Copyright (c) 2004-2010 Leonard Richardson" __license__ = "New-style BSD" @@ -531,6 +531,8 @@ def __init__(self, parser, name, attrs=None, parent=None, self.name = name if attrs is None: attrs = [] + elif isinstance(attrs, dict): + attrs = attrs.items() self.attrs = attrs self.contents = [] self.setup(parent, previous) @@ -1295,7 +1297,7 @@ def _smartPop(self, name): """ nestingResetTriggers = self.NESTABLE_TAGS.get(name) - isNestable = nestingResetTriggers is not None + isNestable = nestingResetTriggers != None isResetNesting = self.RESET_NESTING_TAGS.has_key(name) popTo = None inclusive = True diff --git a/module/lib/SafeEval.py b/module/lib/SafeEval.py index 8fc57f261f..d2a3206d9a 100644 --- a/module/lib/SafeEval.py +++ b/module/lib/SafeEval.py @@ -30,7 +30,7 @@ def test_expr(expr, allowed_codes): try: c = compile(expr, "", "eval") except: - raise ValueError, "%s is not a valid expression" % expr + raise ValueError, "'%s' is not a valid expression" % expr codes, names = _get_opcodes(c) for code in codes: if code not in allowed_codes: diff --git a/module/lib/beaker/session.py b/module/lib/beaker/session.py index 7d465530bd..f782748883 100644 --- a/module/lib/beaker/session.py +++ b/module/lib/beaker/session.py @@ -183,7 +183,7 @@ def _delete_cookie(self): if self.secure: self.cookie[self.key]['secure'] = True self.cookie[self.key]['path'] = '/' - expires = datetime.today().replace(year=2003) + expires = datetime.today().replace(day=1, year=2003) self.cookie[self.key]['expires'] = \ expires.strftime("%a, %d-%b-%Y %H:%M:%S GMT" ) self.request['cookie_out'] = self.cookie[self.key].output(header='') diff --git a/module/lib/bottle.py b/module/lib/bottle.py index 2c243278e2..96ca3e4a6b 100644 --- a/module/lib/bottle.py +++ b/module/lib/bottle.py @@ -9,14 +9,14 @@ Homepage and documentation: http://bottlepy.org/ -Copyright (c) 2011, Marcel Hellkamp. -License: MIT (see LICENSE.txt for details) +Copyright (c) 2016, Marcel Hellkamp. +License: MIT (see LICENSE for details) """ from __future__ import with_statement __author__ = 'Marcel Hellkamp' -__version__ = '0.10.2' +__version__ = '0.12.18' __license__ = 'MIT' # The gevent server adapter needs to patch some modules before they are imported @@ -35,102 +35,122 @@ if _cmd_options.server and _cmd_options.server.startswith('gevent'): import gevent.monkey; gevent.monkey.patch_all() -import sys -import base64 -import cgi -import email.utils -import functools -import hmac -import httplib -import imp -import itertools -import mimetypes -import os -import re -import subprocess -import tempfile -import thread -import threading -import time -import warnings - -from Cookie import SimpleCookie +import base64, cgi, email.utils, functools, hmac, itertools, mimetypes,\ + os, re, subprocess, sys, tempfile, threading, time, warnings, hashlib + from datetime import date as datedate, datetime, timedelta from tempfile import TemporaryFile from traceback import format_exc, print_exc -from urlparse import urljoin, SplitResult as UrlSplitResult - -# Workaround for a bug in some versions of lib2to3 (fixed on CPython 2.7 and 3.2) -import urllib -urlencode = urllib.urlencode -urlquote = urllib.quote -urlunquote = urllib.unquote - -try: from collections import MutableMapping as DictMixin -except ImportError: # pragma: no cover - from UserDict import DictMixin +from inspect import getargspec +from unicodedata import normalize -try: from urlparse import parse_qs -except ImportError: # pragma: no cover - from cgi import parse_qs -try: import cPickle as pickle +try: from simplejson import dumps as json_dumps, loads as json_lds except ImportError: # pragma: no cover - import pickle - -try: from json import dumps as json_dumps, loads as json_lds -except ImportError: # pragma: no cover - try: from simplejson import dumps as json_dumps, loads as json_lds - except ImportError: # pragma: no cover + try: from json import dumps as json_dumps, loads as json_lds + except ImportError: try: from django.utils.simplejson import dumps as json_dumps, loads as json_lds - except ImportError: # pragma: no cover + except ImportError: def json_dumps(data): raise ImportError("JSON support requires Python 2.6 or simplejson.") json_lds = json_dumps -py3k = sys.version_info >= (3,0,0) -NCTextIOWrapper = None -if py3k: # pragma: no cover - json_loads = lambda s: json_lds(touni(s)) - # See Request.POST + +# We now try to fix 2.5/2.6/3.1/3.2 incompatibilities. +# It ain't pretty but it works... Sorry for the mess. + +py = sys.version_info +py3k = py >= (3, 0, 0) +py25 = py < (2, 6, 0) +py31 = (3, 1, 0) <= py < (3, 2, 0) + +# Workaround for the missing "as" keyword in py3k. +def _e(): return sys.exc_info()[1] + +# Workaround for the "print is a keyword/function" Python 2/3 dilemma +# and a fallback for mod_wsgi (resticts stdout/err attribute access) +try: + _stdout, _stderr = sys.stdout.write, sys.stderr.write +except IOError: + _stdout = lambda x: sys.stdout.write(x) + _stderr = lambda x: sys.stderr.write(x) + +# Lots of stdlib and builtin differences. +if py3k: + import http.client as httplib + import _thread as thread + from urllib.parse import urljoin, SplitResult as UrlSplitResult + from urllib.parse import urlencode, quote as urlquote, unquote as urlunquote + urlunquote = functools.partial(urlunquote, encoding='latin1') + from http.cookies import SimpleCookie + if py >= (3, 3, 0): + from collections.abc import MutableMapping as DictMixin + from types import ModuleType as new_module + else: + from collections import MutableMapping as DictMixin + from imp import new_module + import pickle from io import BytesIO - def touni(x, enc='utf8', err='strict'): - """ Convert anything to unicode """ - return str(x, enc, err) if isinstance(x, bytes) else str(x) - if sys.version_info < (3,2,0): - from io import TextIOWrapper - class NCTextIOWrapper(TextIOWrapper): - ''' Garbage collecting an io.TextIOWrapper(buffer) instance closes - the wrapped buffer. This subclass keeps it open. ''' - def close(self): pass -else: - json_loads = json_lds + from configparser import ConfigParser + basestring = str + unicode = str + json_loads = lambda s: json_lds(touni(s)) + callable = lambda x: hasattr(x, '__call__') + imap = map + def _raise(*a): raise a[0](a[1]).with_traceback(a[2]) +else: # 2.x + import httplib + import thread + from urlparse import urljoin, SplitResult as UrlSplitResult + from urllib import urlencode, quote as urlquote, unquote as urlunquote + from Cookie import SimpleCookie + from itertools import imap + import cPickle as pickle + from imp import new_module from StringIO import StringIO as BytesIO - bytes = str - def touni(x, enc='utf8', err='strict'): - """ Convert anything to unicode """ - return x if isinstance(x, unicode) else unicode(str(x), enc, err) - -def tob(data, enc='utf8'): - """ Convert anything to bytes """ - return data.encode(enc) if isinstance(data, unicode) else bytes(data) + from ConfigParser import SafeConfigParser as ConfigParser + if py25: + msg = "Python 2.5 support may be dropped in future versions of Bottle." + warnings.warn(msg, DeprecationWarning) + from UserDict import DictMixin + def next(it): return it.next() + bytes = str + else: # 2.6, 2.7 + from collections import MutableMapping as DictMixin + unicode = unicode + json_loads = json_lds + eval(compile('def _raise(*a): raise a[0], a[1], a[2]', '', 'exec')) +# Some helpers for string/byte handling +def tob(s, enc='utf8'): + return s.encode(enc) if isinstance(s, unicode) else bytes(s) +def touni(s, enc='utf8', err='strict'): + return s.decode(enc, err) if isinstance(s, bytes) else unicode(s) tonat = touni if py3k else tob -tonat.__doc__ = """ Convert anything to native strings """ -def try_update_wrapper(wrapper, wrapped, *a, **ka): - try: # Bug: functools breaks if wrapper is an instane method - functools.update_wrapper(wrapper, wrapped, *a, **ka) +# 3.2 fixes cgi.FieldStorage to accept bytes (which makes a lot of sense). +# 3.1 needs a workaround. +if py31: + from io import TextIOWrapper + class NCTextIOWrapper(TextIOWrapper): + def close(self): pass # Keep wrapped buffer open. + + +# A bug in functools causes it to break if the wrapper is an instance method +def update_wrapper(wrapper, wrapped, *a, **ka): + try: functools.update_wrapper(wrapper, wrapped, *a, **ka) except AttributeError: pass -# Backward compatibility -def depr(message): - warnings.warn(message, DeprecationWarning, stacklevel=3) -# Small helpers -def makelist(data): +# These helpers are used at module level and need to be defined first. +# And yes, I know PEP-8, but sometimes a lower-case classname makes more sense. + +def depr(message, hard=False): + warnings.warn(message, DeprecationWarning, stacklevel=3) + +def makelist(data): # This is just to handy if isinstance(data, (tuple, list, set, dict)): return list(data) elif data: return [data] else: return [] @@ -161,12 +181,13 @@ def __delete__(self, obj): del getattr(obj, self.attr)[self.key] -class CachedProperty(object): +class cached_property(object): ''' A property that is only computed once per instance and then replaces itself with an ordinary attribute. Deleting the attribute resets the property. ''' def __init__(self, func): + self.__doc__ = getattr(func, '__doc__') self.func = func def __get__(self, obj, cls): @@ -174,10 +195,8 @@ def __get__(self, obj, cls): value = obj.__dict__[self.func.__name__] = self.func(obj) return value -cached_property = CachedProperty - -class lazy_attribute(object): # Does not need configuration -> lower-case name +class lazy_attribute(object): ''' A property that caches itself to the class object. ''' def __init__(self, func): functools.update_wrapper(self, func, updated=[]) @@ -203,35 +222,6 @@ class BottleException(Exception): pass -#TODO: These should subclass BaseRequest - -class HTTPResponse(BottleException): - """ Used to break execution and immediately finish the response """ - def __init__(self, output='', status=200, header=None): - super(BottleException, self).__init__("HTTP Response %d" % status) - self.status = int(status) - self.output = output - self.headers = HeaderDict(header) if header else None - - def apply(self, response): - if self.headers: - for key, value in self.headers.iterallitems(): - response.headers[key] = value - response.status = self.status - - -class HTTPError(HTTPResponse): - """ Used to generate an error page """ - def __init__(self, code=500, output='Unknown Error', exception=None, - traceback=None, header=None): - super(HTTPError, self).__init__(output, code, header) - self.exception = exception - self.traceback = traceback - - def __repr__(self): - return template(ERROR_PAGE_TEMPLATE, e=self) - - @@ -251,11 +241,22 @@ class RouteReset(BottleException): class RouterUnknownModeError(RouteError): pass + class RouteSyntaxError(RouteError): - """ The route parser found something not supported by this router """ + """ The route parser found something not supported by this router. """ + class RouteBuildError(RouteError): - """ The route could not been built """ + """ The route could not be built. """ + + +def _re_flatten(p): + ''' Turn all capturing groups in a regular expression pattern into + non-capturing groups. ''' + if '(' not in p: return p + return re.sub(r'(\\*)(\(\?P<[^>]+>|\((?!\?))', + lambda m: m.group(0) if len(m.group(1)) % 2 else m.group(1) + '(?:', p) + class Router(object): ''' A Router is an ordered collection of route->target pairs. It is used to @@ -270,44 +271,40 @@ class Router(object): ''' default_pattern = '[^/]+' - default_filter = 're' - #: Sorry for the mess. It works. Trust me. - rule_syntax = re.compile('(\\\\*)'\ - '(?:(?::([a-zA-Z_][a-zA-Z_0-9]*)?()(?:#(.*?)#)?)'\ - '|(?:<([a-zA-Z_][a-zA-Z_0-9]*)?(?::([a-zA-Z_]*)'\ - '(?::((?:\\\\.|[^\\\\>]+)+)?)?)?>))') + default_filter = 're' + + #: The current CPython regexp implementation does not allow more + #: than 99 matching groups per regular expression. + _MAX_GROUPS_PER_PATTERN = 99 def __init__(self, strict=False): - self.rules = {} # A {rule: Rule} mapping - self.builder = {} # A rule/name->build_info mapping - self.static = {} # Cache for static routes: {path: {method: target}} - self.dynamic = [] # Cache for dynamic routes. See _compile() + self.rules = [] # All rules in order + self._groups = {} # index of regexes to find them in dyna_routes + self.builder = {} # Data structure for the url builder + self.static = {} # Search structure for static routes + self.dyna_routes = {} + self.dyna_regexes = {} # Search structure for dynamic routes #: If true, static routes are no longer checked first. self.strict_order = strict - self.filters = {'re': self.re_filter, 'int': self.int_filter, - 'float': self.re_filter, 'path': self.path_filter} - - def re_filter(self, conf): - return conf or self.default_pattern, None, None - - def int_filter(self, conf): - return r'-?\d+', int, lambda x: str(int(x)) + self.filters = { + 're': lambda conf: + (_re_flatten(conf or self.default_pattern), None, None), + 'int': lambda conf: (r'-?\d+', int, lambda x: str(int(x))), + 'float': lambda conf: (r'-?[\d.]+', float, lambda x: str(float(x))), + 'path': lambda conf: (r'.+?', None, None)} - def float_filter(self, conf): - return r'-?\d*\.\d+', float, lambda x: str(float(x)) - - def path_filter(self, conf): - return r'.*?', None, None - def add_filter(self, name, func): ''' Add a filter. The provided function is called with the configuration string as parameter and must return a (regexp, to_python, to_url) tuple. The first element is a string, the last two are callables or None. ''' self.filters[name] = func - - def parse_rule(self, rule): - ''' Parses a rule into a (name, filter, conf) token stream. If mode is - None, name contains a static rule part. ''' + + rule_syntax = re.compile('(\\\\*)'\ + '(?:(?::([a-zA-Z_][a-zA-Z_0-9]*)?()(?:#(.*?)#)?)'\ + '|(?:<([a-zA-Z_][a-zA-Z_0-9]*)?(?::([a-zA-Z_]*)'\ + '(?::((?:\\\\.|[^\\\\>]+)+)?)?)?>))') + + def _itertokens(self, rule): offset, prefix = 0, '' for match in self.rule_syntax.finditer(rule): prefix += rule[offset:match.start()] @@ -316,77 +313,95 @@ def parse_rule(self, rule): prefix += match.group(0)[len(g[0]):] offset = match.end() continue - if prefix: yield prefix, None, None - name, filtr, conf = g[1:4] if not g[2] is None else g[4:7] - if not filtr: filtr = self.default_filter - yield name, filtr, conf or None + if prefix: + yield prefix, None, None + name, filtr, conf = g[4:7] if g[2] is None else g[1:4] + yield name, filtr or 'default', conf or None offset, prefix = match.end(), '' if offset <= len(rule) or prefix: yield prefix+rule[offset:], None, None def add(self, rule, method, target, name=None): - ''' Add a new route or replace the target for an existing route. ''' - if rule in self.rules: - self.rules[rule][method] = target - if name: self.builder[name] = self.builder[rule] - return - - target = self.rules[rule] = {method: target} - - # Build pattern and other structures for dynamic routes - anons = 0 # Number of anonymous wildcards - pattern = '' # Regular expression pattern - filters = [] # Lists of wildcard input filters - builder = [] # Data structure for the URL builder + ''' Add a new rule or replace the target for an existing rule. ''' + anons = 0 # Number of anonymous wildcards found + keys = [] # Names of keys + pattern = '' # Regular expression pattern with named groups + filters = [] # Lists of wildcard input filters + builder = [] # Data structure for the URL builder is_static = True - for key, mode, conf in self.parse_rule(rule): + + for key, mode, conf in self._itertokens(rule): if mode: is_static = False + if mode == 'default': mode = self.default_filter mask, in_filter, out_filter = self.filters[mode](conf) - if key: - pattern += '(?P<%s>%s)' % (key, mask) - else: + if not key: pattern += '(?:%s)' % mask - key = 'anon%d' % anons; anons += 1 + key = 'anon%d' % anons + anons += 1 + else: + pattern += '(?P<%s>%s)' % (key, mask) + keys.append(key) if in_filter: filters.append((key, in_filter)) builder.append((key, out_filter or str)) elif key: pattern += re.escape(key) builder.append((None, key)) + self.builder[rule] = builder if name: self.builder[name] = builder if is_static and not self.strict_order: - self.static[self.build(rule)] = target + self.static.setdefault(method, {}) + self.static[method][self.build(rule)] = (target, None) return - def fpat_sub(m): - return m.group(0) if len(m.group(1)) % 2 else m.group(1) + '(?:' - flat_pattern = re.sub(r'(\\*)(\(\?P<[^>]*>|\((?!\?))', fpat_sub, pattern) - try: - re_match = re.compile('^(%s)$' % pattern).match - except re.error, e: - raise RouteSyntaxError("Could not add Route: %s (%s)" % (rule, e)) - - def match(path): - """ Return an url-argument dictionary. """ - url_args = re_match(path).groupdict() - for name, wildcard_filter in filters: - try: - url_args[name] = wildcard_filter(url_args[name]) - except ValueError: - raise HTTPError(400, 'Path has wrong format.') - return url_args + re_pattern = re.compile('^(%s)$' % pattern) + re_match = re_pattern.match + except re.error: + raise RouteSyntaxError("Could not add Route: %s (%s)" % (rule, _e())) + + if filters: + def getargs(path): + url_args = re_match(path).groupdict() + for name, wildcard_filter in filters: + try: + url_args[name] = wildcard_filter(url_args[name]) + except ValueError: + raise HTTPError(400, 'Path has wrong format.') + return url_args + elif re_pattern.groupindex: + def getargs(path): + return re_match(path).groupdict() + else: + getargs = None - try: - combined = '%s|(^%s$)' % (self.dynamic[-1][0].pattern, flat_pattern) - self.dynamic[-1] = (re.compile(combined), self.dynamic[-1][1]) - self.dynamic[-1][1].append((match, target)) - except (AssertionError, IndexError), e: # AssertionError: Too many groups - self.dynamic.append((re.compile('(^%s$)' % flat_pattern), - [(match, target)])) - return match + flatpat = _re_flatten(pattern) + whole_rule = (rule, flatpat, target, getargs) + + if (flatpat, method) in self._groups: + if DEBUG: + msg = 'Route <%s %s> overwrites a previously defined route' + warnings.warn(msg % (method, rule), RuntimeWarning) + self.dyna_routes[method][self._groups[flatpat, method]] = whole_rule + else: + self.dyna_routes.setdefault(method, []).append(whole_rule) + self._groups[flatpat, method] = len(self.dyna_routes[method]) - 1 + + self._compile(method) + + def _compile(self, method): + all_rules = self.dyna_routes[method] + comborules = self.dyna_regexes[method] = [] + maxgroups = self._MAX_GROUPS_PER_PATTERN + for x in range(0, len(all_rules), maxgroups): + some = all_rules[x:x+maxgroups] + combined = (flatpat for (_, flatpat, _, _) in some) + combined = '|'.join('(^%s$)' % flatpat for flatpat in combined) + combined = re.compile(combined).match + rules = [(target, getargs) for (_, _, target, getargs) in some] + comborules.append((combined, rules)) def build(self, _name, *anons, **query): ''' Build an URL by filling the wildcards in a rule. ''' @@ -396,36 +411,50 @@ def build(self, _name, *anons, **query): for i, value in enumerate(anons): query['anon%d'%i] = value url = ''.join([f(query.pop(n)) if n else f for (n,f) in builder]) return url if not query else url+'?'+urlencode(query) - except KeyError, e: - raise RouteBuildError('Missing URL argument: %r' % e.args[0]) + except KeyError: + raise RouteBuildError('Missing URL argument: %r' % _e().args[0]) def match(self, environ): ''' Return a (target, url_agrs) tuple or raise HTTPError(400/404/405). ''' - path, targets, urlargs = environ['PATH_INFO'] or '/', None, {} - if path in self.static: - targets = self.static[path] + verb = environ['REQUEST_METHOD'].upper() + path = environ['PATH_INFO'] or '/' + target = None + if verb == 'HEAD': + methods = ['PROXY', verb, 'GET', 'ANY'] else: - for combined, rules in self.dynamic: - match = combined.match(path) - if not match: continue - getargs, targets = rules[match.lastindex - 1] - urlargs = getargs(path) if getargs else {} - break - - if not targets: - raise HTTPError(404, "Not found: " + repr(environ['PATH_INFO'])) - method = environ['REQUEST_METHOD'].upper() - if method in targets: - return targets[method], urlargs - if method == 'HEAD' and 'GET' in targets: - return targets['GET'], urlargs - if 'ANY' in targets: - return targets['ANY'], urlargs - allowed = [verb for verb in targets if verb != 'ANY'] - if 'GET' in allowed and 'HEAD' not in allowed: - allowed.append('HEAD') - raise HTTPError(405, "Method not allowed.", - header=[('Allow',",".join(allowed))]) + methods = ['PROXY', verb, 'ANY'] + + for method in methods: + if method in self.static and path in self.static[method]: + target, getargs = self.static[method][path] + return target, getargs(path) if getargs else {} + elif method in self.dyna_regexes: + for combined, rules in self.dyna_regexes[method]: + match = combined(path) + if match: + target, getargs = rules[match.lastindex - 1] + return target, getargs(path) if getargs else {} + + # No matching route found. Collect alternative methods for 405 response + allowed = set([]) + nocheck = set(methods) + for method in set(self.static) - nocheck: + if path in self.static[method]: + allowed.add(verb) + for method in set(self.dyna_regexes) - allowed - nocheck: + for combined, rules in self.dyna_regexes[method]: + match = combined(path) + if match: + allowed.add(method) + if allowed: + allow_header = ",".join(sorted(allowed)) + raise HTTPError(405, "Method not allowed.", Allow=allow_header) + + # No matching route and no alternative method found. We give up + raise HTTPError(404, "Not found: " + repr(path)) + + + @@ -435,7 +464,6 @@ class Route(object): turing an URL path rule into a regular expression usable by the Router. ''' - def __init__(self, app, rule, method, callback, name=None, plugins=None, skiplist=None, **config): #: The application this route is installed to. @@ -455,12 +483,12 @@ def __init__(self, app, rule, method, callback, name=None, #: Additional keyword arguments passed to the :meth:`Bottle.route` #: decorator are stored in this dictionary. Used for route-specific #: plugin configuration and meta-data. - self.config = ConfigDict(config) + self.config = ConfigDict().load_dict(config, make_namespaces=True) def __call__(self, *a, **ka): depr("Some APIs changed to return Route() instances instead of"\ " callables. Make sure to use the Route.call method and not to"\ - " call Route instances directly.") + " call Route instances directly.") #0.12 return self.call(*a, **ka) @cached_property @@ -480,7 +508,7 @@ def prepare(self): @property def _context(self): - depr('Switch to Plugin API v2 and access the Route object directly.') + depr('Switch to Plugin API v2 and access the Route object directly.') #0.12 return dict(rule=self.rule, method=self.method, callback=self.callback, name=self.name, app=self.app, config=self.config, apply=self.plugins, skip=self.skiplist) @@ -509,9 +537,36 @@ def _make_callback(self): except RouteReset: # Try again with changed configuration. return self._make_callback() if not callback is self.callback: - try_update_wrapper(callback, self.callback) + update_wrapper(callback, self.callback) return callback + def get_undecorated_callback(self): + ''' Return the callback. If the callback is a decorated function, try to + recover the original function. ''' + func = self.callback + func = getattr(func, '__func__' if py3k else 'im_func', func) + closure_attr = '__closure__' if py3k else 'func_closure' + while hasattr(func, closure_attr) and getattr(func, closure_attr): + func = getattr(func, closure_attr)[0].cell_contents + return func + + def get_callback_args(self): + ''' Return a list of argument names the callback (most likely) accepts + as keyword arguments. If the callback is a decorated function, try + to recover the original function before inspection. ''' + return getargspec(self.get_undecorated_callback())[0] + + def get_config(self, key, default=None): + ''' Lookup a config field and return its value, first checking the + route.config, then route.app.config.''' + for conf in (self.config, self.app.conifg): + if key in conf: return conf[key] + return default + + def __repr__(self): + cb = self.get_undecorated_callback() + return '<%s %r %r>' % (self.method, self.rule, cb) + @@ -523,27 +578,81 @@ def _make_callback(self): class Bottle(object): - """ WSGI application """ + """ Each Bottle object represents a single, distinct web application and + consists of routes, callbacks, plugins, resources and configuration. + Instances are callable WSGI applications. + + :param catchall: If true (default), handle all exceptions. Turn off to + let debugging middleware handle exceptions. + """ + + def __init__(self, catchall=True, autojson=True): + + #: A :class:`ConfigDict` for app specific configuration. + self.config = ConfigDict() + self.config._on_change = functools.partial(self.trigger_hook, 'config') + self.config.meta_set('autojson', 'validate', bool) + self.config.meta_set('catchall', 'validate', bool) + self.config['catchall'] = catchall + self.config['autojson'] = autojson + + #: A :class:`ResourceManager` for application files + self.resources = ResourceManager() - def __init__(self, catchall=True, autojson=True, config=None): - """ Create a new bottle instance. - You usually don't do that. Use `bottle.app.push()` instead. - """ self.routes = [] # List of installed :class:`Route` instances. self.router = Router() # Maps requests to :class:`Route` instances. - self.plugins = [] # List of installed plugins. - self.error_handler = {} - #: If true, most exceptions are catched and returned as :exc:`HTTPError` - self.config = ConfigDict(config or {}) - self.catchall = catchall - #: An instance of :class:`HooksPlugin`. Empty by default. - self.hooks = HooksPlugin() - self.install(self.hooks) - if autojson: + + # Core plugins + self.plugins = [] # List of installed plugins. + if self.config['autojson']: self.install(JSONPlugin()) self.install(TemplatePlugin()) + #: If true, most exceptions are caught and returned as :exc:`HTTPError` + catchall = DictProperty('config', 'catchall') + + __hook_names = 'before_request', 'after_request', 'app_reset', 'config' + __hook_reversed = 'after_request' + + @cached_property + def _hooks(self): + return dict((name, []) for name in self.__hook_names) + + def add_hook(self, name, func): + ''' Attach a callback to a hook. Three hooks are currently implemented: + + before_request + Executed once before each request. The request context is + available, but no routing has happened yet. + after_request + Executed once after each request regardless of its outcome. + app_reset + Called whenever :meth:`Bottle.reset` is called. + ''' + if name in self.__hook_reversed: + self._hooks[name].insert(0, func) + else: + self._hooks[name].append(func) + + def remove_hook(self, name, func): + ''' Remove a callback from a hook. ''' + if name in self._hooks and func in self._hooks[name]: + self._hooks[name].remove(func) + return True + + def trigger_hook(self, __name, *args, **kwargs): + ''' Trigger a hook and return a list of results. ''' + return [hook(*args, **kwargs) for hook in self._hooks[__name][:]] + + def hook(self, name): + """ Return a decorator that attaches a callback to a hook. See + :meth:`add_hook` for details.""" + def decorator(func): + self.add_hook(name, func) + return func + return decorator + def mount(self, prefix, app, **options): ''' Mount an application (:class:`Bottle` or plain WSGI) to a specific URL prefix. Example:: @@ -557,31 +666,50 @@ def mount(self, prefix, app, **options): All other parameters are passed to the underlying :meth:`route` call. ''' if isinstance(app, basestring): - prefix, app = app, prefix - depr('Parameter order of Bottle.mount() changed.') # 0.10 + depr('Parameter order of Bottle.mount() changed.', True) # 0.10 - parts = filter(None, prefix.split('/')) - if not parts: raise ValueError('Empty path prefix.') - path_depth = len(parts) - options.setdefault('skip', True) - options.setdefault('method', 'ANY') + segments = [p for p in prefix.split('/') if p] + if not segments: raise ValueError('Empty path prefix.') + path_depth = len(segments) - @self.route('/%s/:#.*#' % '/'.join(parts), **options) - def mountpoint(): + def mountpoint_wrapper(): try: request.path_shift(path_depth) - rs = BaseResponse([], 200) - def start_response(status, header): + rs = HTTPResponse([]) + def start_response(status, headerlist, exc_info=None): + if exc_info: + try: + _raise(*exc_info) + finally: + exc_info = None rs.status = status - for name, value in header: rs.add_header(name, value) + for name, value in headerlist: rs.add_header(name, value) return rs.body.append - rs.body = itertools.chain(rs.body, app(request.environ, start_response)) - return HTTPResponse(rs.body, rs.status, rs.headers) + body = app(request.environ, start_response) + if body and rs.body: body = itertools.chain(rs.body, body) + rs.body = body or rs.body + return rs finally: request.path_shift(-path_depth) + options.setdefault('skip', True) + options.setdefault('method', 'PROXY') + options.setdefault('mountpoint', {'prefix': prefix, 'target': app}) + options['callback'] = mountpoint_wrapper + + self.route('/%s/<:re:.*>' % '/'.join(segments), **options) if not prefix.endswith('/'): - self.route('/' + '/'.join(parts), callback=mountpoint, **options) + self.route('/' + '/'.join(segments), **options) + + def merge(self, routes): + ''' Merge the routes of another :class:`Bottle` application or a list of + :class:`Route` objects into this application. The routes keep their + 'owner', meaning that the :data:`Route.app` attribute is not + changed. ''' + if isinstance(routes, Bottle): + routes = routes.routes + for route in routes: + self.add_route(route) def install(self, plugin): ''' Add a plugin to the list of plugins and prepare it for being @@ -620,7 +748,7 @@ def reset(self, route=None): for route in routes: route.reset() if DEBUG: for route in routes: route.prepare() - self.hooks.trigger('app_reset') + self.trigger_hook('app_reset') def close(self): ''' Close the application and all installed plugins. ''' @@ -628,6 +756,10 @@ def close(self): if hasattr(plugin, 'close'): plugin.close() self.stopped = True + def run(self, **kwargs): + ''' Calls :func:`run` with the same parameters. ''' + run(self, **kwargs) + def match(self, environ): """ Search for a matching route and return a (:class:`Route` , urlargs) tuple. The second value is a dictionary with parameters extracted @@ -640,6 +772,13 @@ def get_url(self, routename, **kargs): location = self.router.build(routename, **kargs).lstrip('/') return urljoin(urljoin('/', scriptname), location) + def add_route(self, route): + ''' Add a route object, but do not change the :data:`Route.app` + attribute.''' + self.routes.append(route) + self.router.add(route.rule, route.method, route, name=route.name) + if DEBUG: route.prepare() + def route(self, path=None, method='GET', callback=None, name=None, apply=None, skip=None, **config): """ A decorator to bind a function to a request URL. Example:: @@ -678,9 +817,7 @@ def decorator(callback): verb = verb.upper() route = Route(self, rule, verb, callback, name=name, plugins=plugins, skiplist=skiplist, **config) - self.routes.append(route) - self.router.add(rule, verb, route, name=name) - if DEBUG: route.prepare() + self.add_route(route) return callback return decorator(callback) if callback else decorator @@ -707,44 +844,45 @@ def wrapper(handler): return handler return wrapper - def hook(self, name): - """ Return a decorator that attaches a callback to a hook. """ - def wrapper(func): - self.hooks.add(name, func) - return func - return wrapper - - def handle(self, path, method='GET'): - """ (deprecated) Execute the first matching route callback and return - the result. :exc:`HTTPResponse` exceptions are catched and returned. - If :attr:`Bottle.catchall` is true, other exceptions are catched as - well and returned as :exc:`HTTPError` instances (500). - """ - depr("This method will change semantics in 0.10. Try to avoid it.") - if isinstance(path, dict): - return self._handle(path) - return self._handle({'PATH_INFO': path, 'REQUEST_METHOD': method.upper()}) + def default_error_handler(self, res): + return tob(template(ERROR_PAGE_TEMPLATE, e=res)) def _handle(self, environ): + path = environ['bottle.raw_path'] = environ['PATH_INFO'] + if py3k: + try: + environ['PATH_INFO'] = path.encode('latin1').decode('utf8') + except UnicodeError: + return HTTPError(400, 'Invalid path string. Expected UTF-8') + try: - route, args = self.router.match(environ) - environ['route.handle'] = environ['bottle.route'] = route - environ['route.url_args'] = args - return route.call(**args) - except HTTPResponse, r: - return r + environ['bottle.app'] = self + request.bind(environ) + response.bind() + try: + self.trigger_hook('before_request') + route, args = self.router.match(environ) + environ['route.handle'] = route + environ['bottle.route'] = route + environ['route.url_args'] = args + return route.call(**args) + finally: + self.trigger_hook('after_request') + + except HTTPResponse: + return _e() except RouteReset: route.reset() return self._handle(environ) except (KeyboardInterrupt, SystemExit, MemoryError): raise - except Exception, e: + except Exception: if not self.catchall: raise - stacktrace = format_exc(10) + stacktrace = format_exc() environ['wsgi.errors'].write(stacktrace) - return HTTPError(500, "Internal Server Error", e, stacktrace) + return HTTPError(500, "Internal Server Error", _e(), stacktrace) - def _cast(self, out, request, response, peek=None): + def _cast(self, out, peek=None): """ Try to convert the parameter into something WSGI compatible and set correct HTTP headers when possible. Support: False, str, unicode, dict, HTTPResponse, HTTPError, file-like, @@ -753,7 +891,8 @@ def _cast(self, out, request, response, peek=None): # Empty output is done here if not out: - response['Content-Length'] = 0 + if 'Content-Length' not in response: + response['Content-Length'] = 0 return [] # Join lists of byte or unicode strings. Mixed lists are NOT supported if isinstance(out, (tuple, list))\ @@ -764,19 +903,18 @@ def _cast(self, out, request, response, peek=None): out = out.encode(response.charset) # Byte Strings are just returned if isinstance(out, bytes): - response['Content-Length'] = len(out) + if 'Content-Length' not in response: + response['Content-Length'] = len(out) return [out] # HTTPError or HTTPException (recursive, because they may wrap anything) # TODO: Handle these explicitly in handle() or make them iterable. if isinstance(out, HTTPError): out.apply(response) - out = self.error_handler.get(out.status, repr)(out) - if isinstance(out, HTTPResponse): - depr('Error handlers must not return :exc:`HTTPResponse`.') #0.9 - return self._cast(out, request, response) + out = self.error_handler.get(out.status_code, self.default_error_handler)(out) + return self._cast(out) if isinstance(out, HTTPResponse): out.apply(response) - return self._cast(out.output, request, response) + return self._cast(out.body) # File-like objects. if hasattr(out, 'read'): @@ -787,55 +925,59 @@ def _cast(self, out, request, response, peek=None): # Handle Iterables. We peek into them to detect their inner type. try: - out = iter(out) - first = out.next() + iout = iter(out) + first = next(iout) while not first: - first = out.next() + first = next(iout) except StopIteration: - return self._cast('', request, response) - except HTTPResponse, e: - first = e - except Exception, e: - first = HTTPError(500, 'Unhandled exception', e, format_exc(10)) - if isinstance(e, (KeyboardInterrupt, SystemExit, MemoryError))\ - or not self.catchall: - raise + return self._cast('') + except HTTPResponse: + first = _e() + except (KeyboardInterrupt, SystemExit, MemoryError): + raise + except Exception: + if not self.catchall: raise + first = HTTPError(500, 'Unhandled exception', _e(), format_exc()) + # These are the inner types allowed in iterator or generator objects. if isinstance(first, HTTPResponse): - return self._cast(first, request, response) - if isinstance(first, bytes): - return itertools.chain([first], out) - if isinstance(first, unicode): - return itertools.imap(lambda x: x.encode(response.charset), - itertools.chain([first], out)) - return self._cast(HTTPError(500, 'Unsupported response type: %s'\ - % type(first)), request, response) + return self._cast(first) + elif isinstance(first, bytes): + new_iter = itertools.chain([first], iout) + elif isinstance(first, unicode): + encoder = lambda x: x.encode(response.charset) + new_iter = imap(encoder, itertools.chain([first], iout)) + else: + msg = 'Unsupported response type: %s' % type(first) + return self._cast(HTTPError(500, msg)) + if hasattr(out, 'close'): + new_iter = _closeiter(new_iter, out.close) + return new_iter def wsgi(self, environ, start_response): """ The bottle WSGI-interface. """ try: - environ['bottle.app'] = self - request.bind(environ) - response.bind() - out = self._cast(self._handle(environ), request, response) + out = self._cast(self._handle(environ)) # rfc2616 section 4.3 if response._status_code in (100, 101, 204, 304)\ - or request.method == 'HEAD': + or environ['REQUEST_METHOD'] == 'HEAD': if hasattr(out, 'close'): out.close() out = [] - start_response(response._status_line, list(response.iter_headers())) + start_response(response._status_line, response.headerlist) return out except (KeyboardInterrupt, SystemExit, MemoryError): raise - except Exception, e: + except Exception: if not self.catchall: raise err = '

Critical error while processing request: %s

' \ - % environ.get('PATH_INFO', '/') + % html_escape(environ.get('PATH_INFO', '/')) if DEBUG: - err += '

Error:

\n
%s
\n' % repr(e) - err += '

Traceback:

\n
%s
\n' % format_exc(10) - environ['wsgi.errors'].write(err) #TODO: wsgi.error should not get html - start_response('500 INTERNAL SERVER ERROR', [('Content-Type', 'text/html')]) + err += '

Error:

\n
\n%s\n
\n' \ + '

Traceback:

\n
\n%s\n
\n' \ + % (html_escape(repr(_e())), html_escape(format_exc())) + environ['wsgi.errors'].write(err) + headers = [('Content-Type', 'text/html; charset=UTF-8')] + start_response('500 INTERNAL SERVER ERROR', headers, sys.exc_info()) return [tob(err)] def __call__(self, environ, start_response): @@ -851,20 +993,41 @@ def __call__(self, environ, start_response): # HTTP and WSGI Tools ########################################################## ############################################################################### - -class BaseRequest(DictMixin): +class BaseRequest(object): """ A wrapper for WSGI environment dictionaries that adds a lot of - convenient access methods and properties. Most of them are read-only.""" + convenient access methods and properties. Most of them are read-only. + + Adding new attributes to a request actually adds them to the environ + dictionary (as 'bottle.request.ext.'). This is the recommended + way to store and access request-specific data. + """ + + __slots__ = ('environ') #: Maximum size of memory buffer for :attr:`body` in bytes. MEMFILE_MAX = 102400 - def __init__(self, environ): + def __init__(self, environ=None): """ Wrap a WSGI environ dictionary. """ #: The wrapped WSGI environ dictionary. This is the only real attribute. #: All other attributes actually are read-only properties. - self.environ = environ - environ['bottle.request'] = self + self.environ = {} if environ is None else environ + self.environ['bottle.request'] = self + + @DictProperty('environ', 'bottle.app', read_only=True) + def app(self): + ''' Bottle application handling this request. ''' + raise RuntimeError('This request is not connected to an application.') + + @DictProperty('environ', 'bottle.route', read_only=True) + def route(self): + """ The bottle :class:`Route` object that matches this request. """ + raise RuntimeError('This request is not connected to a route.') + + @DictProperty('environ', 'route.url_args', read_only=True) + def url_args(self): + """ The arguments extracted from the URL. """ + raise RuntimeError('This request is not connected to a route.') @property def path(self): @@ -891,8 +1054,8 @@ def get_header(self, name, default=None): def cookies(self): """ Cookies parsed into a :class:`FormsDict`. Signed cookies are NOT decoded. Use :meth:`get_cookie` if you expect signed cookies. """ - cookies = SimpleCookie(self.environ.get('HTTP_COOKIE','')) - return FormsDict((c.key, c.value) for c in cookies.itervalues()) + cookies = SimpleCookie(self.environ.get('HTTP_COOKIE','')).values() + return FormsDict((c.key, c.value) for c in cookies) def get_cookie(self, key, default=None, secret=None): """ Return the content of a cookie. To read a `Signed Cookie`, the @@ -911,22 +1074,21 @@ def query(self): values are sometimes called "URL arguments" or "GET parameters", but not to be confused with "URL wildcards" as they are provided by the :class:`Router`. ''' - data = parse_qs(self.query_string, keep_blank_values=True) get = self.environ['bottle.get'] = FormsDict() - for key, values in data.iteritems(): - for value in values: - get[key] = value + pairs = _parse_qsl(self.environ.get('QUERY_STRING', '')) + for key, value in pairs: + get[key] = value return get @DictProperty('environ', 'bottle.request.forms', read_only=True) def forms(self): """ Form values parsed from an `url-encoded` or `multipart/form-data` - encoded POST or PUT request body. The result is retuned as a + encoded POST or PUT request body. The result is returned as a :class:`FormsDict`. All keys and values are strings. File uploads are stored separately in :attr:`files`. """ forms = FormsDict() - for name, item in self.POST.iterallitems(): - if not hasattr(item, 'filename'): + for name, item in self.POST.allitems(): + if not isinstance(item, FileUpload): forms[name] = item return forms @@ -935,32 +1097,21 @@ def params(self): """ A :class:`FormsDict` with the combined values of :attr:`query` and :attr:`forms`. File uploads are stored in :attr:`files`. """ params = FormsDict() - for key, value in self.query.iterallitems(): + for key, value in self.query.allitems(): params[key] = value - for key, value in self.forms.iterallitems(): + for key, value in self.forms.allitems(): params[key] = value return params @DictProperty('environ', 'bottle.request.files', read_only=True) def files(self): - """ File uploads parsed from an `url-encoded` or `multipart/form-data` - encoded POST or PUT request body. The values are instances of - :class:`cgi.FieldStorage`. The most important attributes are: - - filename - The filename, if specified; otherwise None; this is the client - side filename, *not* the file name on which it is stored (that's - a temporary file you don't deal with) - file - The file(-like) object from which you can read the data. - value - The value as a *string*; for file uploads, this transparently - reads the file every time you request the value. Do not do this - on big files. + """ File uploads parsed from `multipart/form-data` encoded POST or PUT + request body. The values are instances of :class:`FileUpload`. + """ files = FormsDict() - for name, item in self.POST.iterallitems(): - if hasattr(item, 'filename'): + for name, item in self.POST.allitems(): + if isinstance(item, FileUpload): files[name] = item return files @@ -970,25 +1121,78 @@ def json(self): property holds the parsed content of the request body. Only requests smaller than :attr:`MEMFILE_MAX` are processed to avoid memory exhaustion. ''' - if 'application/json' in self.environ.get('CONTENT_TYPE', '') \ - and 0 < self.content_length < self.MEMFILE_MAX: - return json_loads(self.body.read(self.MEMFILE_MAX)) + ctype = self.environ.get('CONTENT_TYPE', '').lower().split(';')[0] + if ctype == 'application/json': + b = self._get_body_string() + if not b: + return None + return json_loads(b) return None - @DictProperty('environ', 'bottle.request.body', read_only=True) - def _body(self): + def _iter_body(self, read, bufsize): maxread = max(0, self.content_length) - stream = self.environ['wsgi.input'] - body = BytesIO() if maxread < self.MEMFILE_MAX else TemporaryFile(mode='w+b') - while maxread > 0: - part = stream.read(min(maxread, self.MEMFILE_MAX)) + while maxread: + part = read(min(maxread, bufsize)) if not part: break - body.write(part) + yield part maxread -= len(part) + + def _iter_chunked(self, read, bufsize): + err = HTTPError(400, 'Error while parsing chunked transfer body.') + rn, sem, bs = tob('\r\n'), tob(';'), tob('') + while True: + header = read(1) + while header[-2:] != rn: + c = read(1) + header += c + if not c: raise err + if len(header) > bufsize: raise err + size, _, _ = header.partition(sem) + try: + maxread = int(tonat(size.strip()), 16) + except ValueError: + raise err + if maxread == 0: break + buff = bs + while maxread > 0: + if not buff: + buff = read(min(maxread, bufsize)) + part, buff = buff[:maxread], buff[maxread:] + if not part: raise err + yield part + maxread -= len(part) + if read(2) != rn: + raise err + + @DictProperty('environ', 'bottle.request.body', read_only=True) + def _body(self): + body_iter = self._iter_chunked if self.chunked else self._iter_body + read_func = self.environ['wsgi.input'].read + body, body_size, is_temp_file = BytesIO(), 0, False + for part in body_iter(read_func, self.MEMFILE_MAX): + body.write(part) + body_size += len(part) + if not is_temp_file and body_size > self.MEMFILE_MAX: + body, tmp = TemporaryFile(mode='w+b'), body + body.write(tmp.getvalue()) + del tmp + is_temp_file = True self.environ['wsgi.input'] = body body.seek(0) return body + def _get_body_string(self): + ''' read body until content-length or MEMFILE_MAX into a string. Raise + HTTPError(413) on requests that are to large. ''' + clen = self.content_length + if clen > self.MEMFILE_MAX: + raise HTTPError(413, 'Request to large') + if clen < 0: clen = self.MEMFILE_MAX + 1 + data = self.body.read(clen) + if len(data) > self.MEMFILE_MAX: # Fail fast + raise HTTPError(413, 'Request to large') + return data + @property def body(self): """ The HTTP request body as a seek-able file-like object. Depending on @@ -999,6 +1203,11 @@ def body(self): self._body.seek(0) return self._body + @property + def chunked(self): + ''' True if Chunked transfer encoding was. ''' + return 'chunked' in self.environ.get('HTTP_TRANSFER_ENCODING', '').lower() + #: An alias for :attr:`query`. GET = query @@ -1009,24 +1218,34 @@ def POST(self): instances of :class:`cgi.FieldStorage` (file uploads). """ post = FormsDict() + # We default to application/x-www-form-urlencoded for everything that + # is not multipart and take the fast path (also: 3.1 workaround) + if not self.content_type.startswith('multipart/'): + pairs = _parse_qsl(tonat(self._get_body_string(), 'latin1')) + for key, value in pairs: + post[key] = value + return post + safe_env = {'QUERY_STRING':''} # Build a safe environment for cgi for key in ('REQUEST_METHOD', 'CONTENT_TYPE', 'CONTENT_LENGTH'): if key in self.environ: safe_env[key] = self.environ[key] - if NCTextIOWrapper: - fb = NCTextIOWrapper(self.body, encoding='ISO-8859-1', newline='\n') - else: - fb = self.body - data = cgi.FieldStorage(fp=fb, environ=safe_env, keep_blank_values=True) - for item in data.list or []: - post[item.name] = item if item.filename else item.value + args = dict(fp=self.body, environ=safe_env, keep_blank_values=True) + if py31: + args['fp'] = NCTextIOWrapper(args['fp'], encoding='utf8', + newline='\n') + elif py3k: + args['encoding'] = 'utf8' + data = cgi.FieldStorage(**args) + self['_cgi.FieldStorage'] = data #http://bugs.python.org/issue18394#msg207958 + data = data.list or [] + for item in data: + if item.filename: + post[item.name] = FileUpload(item.file, item.name, + item.filename, item.headers) + else: + post[item.name] = item.value return post - @property - def COOKIES(self): - ''' Alias for :attr:`cookies` (deprecated). ''' - depr('BaseRequest.COOKIES was renamed to BaseRequest.cookies (lowercase).') - return self.cookies - @property def url(self): """ The full request URI including hostname and scheme. If your app @@ -1042,7 +1261,7 @@ def urlparts(self): but the fragment is always empty because it is not visible to the server. ''' env = self.environ - http = env.get('wsgi.url_scheme', 'http') + http = env.get('HTTP_X_FORWARDED_PROTO') or env.get('wsgi.url_scheme', 'http') host = env.get('HTTP_X_FORWARDED_HOST') or env.get('HTTP_HOST') if not host: # HTTP 1.1 requires a Host-header. This is for HTTP/1.0 clients. @@ -1090,6 +1309,11 @@ def content_length(self): and -1 is returned. In this case, :attr:`body` will be empty. ''' return int(self.environ.get('CONTENT_LENGTH') or -1) + @property + def content_type(self): + ''' The Content-Type header as a lowercase-string (default: empty). ''' + return self.environ.get('CONTENT_TYPE', '').lower() + @property def is_xhr(self): ''' True if the request was triggered by a XMLHttpRequest. This only @@ -1139,6 +1363,7 @@ def copy(self): """ Return a new :class:`Request` with a shallow :attr:`environ` copy. """ return Request(self.environ.copy()) + def get(self, value, default=None): return self.environ.get(value, default) def __getitem__(self, key): return self.environ[key] def __delitem__(self, key): self[key] = ""; del(self.environ[key]) def __iter__(self): return iter(self.environ) @@ -1166,27 +1391,49 @@ def __setitem__(self, key, value): def __repr__(self): return '<%s: %s %s>' % (self.__class__.__name__, self.method, self.url) -def _hkey(s): - return s.title().replace('_','-') + def __getattr__(self, name): + ''' Search in self.environ for additional user defined attributes. ''' + try: + var = self.environ['bottle.request.ext.%s'%name] + return var.__get__(self) if hasattr(var, '__get__') else var + except KeyError: + raise AttributeError('Attribute %r not defined.' % name) + + def __setattr__(self, name, value): + if name == 'environ': return object.__setattr__(self, name, value) + self.environ['bottle.request.ext.%s'%name] = value + + +def _hkey(key): + if '\n' in key or '\r' in key or '\0' in key: + raise ValueError("Header names must not contain control characters: %r" % key) + return key.title().replace('_', '-') + + +def _hval(value): + value = tonat(value) + if '\n' in value or '\r' in value or '\0' in value: + raise ValueError("Header value must not contain control characters: %r" % value) + return value + class HeaderProperty(object): - def __init__(self, name, reader=None, writer=str, default=''): - self.name, self.reader, self.writer, self.default = name, reader, writer, default + def __init__(self, name, reader=None, writer=None, default=''): + self.name, self.default = name, default + self.reader, self.writer = reader, writer self.__doc__ = 'Current value of the %r header.' % name.title() def __get__(self, obj, cls): if obj is None: return self - value = obj.headers.get(self.name) - return self.reader(value) if (value and self.reader) else (value or self.default) + value = obj.get_header(self.name, self.default) + return self.reader(value) if self.reader else value def __set__(self, obj, value): - if self.writer: value = self.writer(value) - obj.headers[self.name] = value + obj[self.name] = self.writer(value) if self.writer else value def __delete__(self, obj): - if self.name in obj.headers: - del obj.headers[self.name] + del obj[self.name] class BaseResponse(object): @@ -1195,6 +1442,14 @@ class BaseResponse(object): This class does support dict-like case-insensitive item-access to headers, but is NOT a dict. Most notably, iterating over a response yields parts of the body and not the headers. + + :param body: The response body as one of the supported types. + :param status: Either an HTTP status code (e.g. 200) or a status line + including the reason phrase (e.g. '200 OK'). + :param headers: A dictionary or a list of name-value pairs. + + Additional keyword arguments are added to the list of headers. + Underscores in the header name are replaced with dashes. """ default_status = 200 @@ -1208,22 +1463,30 @@ class BaseResponse(object): 'Content-Length', 'Content-Range', 'Content-Type', 'Content-Md5', 'Last-Modified'))} - def __init__(self, body='', status=None, **headers): - self._status_line = None - self._status_code = None - self.body = body + def __init__(self, body='', status=None, headers=None, **more_headers): self._cookies = None - self._headers = {'Content-Type': [self.default_content_type]} + self._headers = {} + self.body = body self.status = status or self.default_status if headers: - for name, value in headers.items(): - self[name] = value - - def copy(self): + if isinstance(headers, dict): + headers = headers.items() + for name, value in headers: + self.add_header(name, value) + if more_headers: + for name, value in more_headers.items(): + self.add_header(name, value) + + def copy(self, cls=None): ''' Returns a copy of self. ''' - copy = Response() + cls = cls or BaseResponse + assert issubclass(cls, BaseResponse) + copy = cls() copy.status = self.status copy._headers = dict((k, v[:]) for (k, v) in self._headers.items()) + if self._cookies: + copy._cookies = SimpleCookie() + copy._cookies.load(self._cookies.output(header='')) return copy def __iter__(self): @@ -1253,92 +1516,81 @@ def _set_status(self, status): raise ValueError('String status line without a reason phrase.') if not 100 <= code <= 999: raise ValueError('Status code out of range.') self._status_code = code - self._status_line = status or ('%d Unknown' % code) + self._status_line = str(status or ('%d Unknown' % code)) def _get_status(self): - depr('BaseReuqest.status will change to return a string in 0.11. Use'\ - ' status_line and status_code to make sure.') #0.10 - return self._status_code + return self._status_line status = property(_get_status, _set_status, None, ''' A writeable property to change the HTTP response status. It accepts either a numeric code (100-999) or a string with a custom reason phrase (e.g. "404 Brain not found"). Both :data:`status_line` and - :data:`status_code` are updates accordingly. The return value is - always a numeric code. ''') + :data:`status_code` are updated accordingly. The return value is + always a status string. ''') del _get_status, _set_status @property def headers(self): ''' An instance of :class:`HeaderDict`, a case-insensitive dict-like view on the response headers. ''' - self.__dict__['headers'] = hdict = HeaderDict() + hdict = HeaderDict() hdict.dict = self._headers return hdict def __contains__(self, name): return _hkey(name) in self._headers def __delitem__(self, name): del self._headers[_hkey(name)] def __getitem__(self, name): return self._headers[_hkey(name)][-1] - def __setitem__(self, name, value): self._headers[_hkey(name)] = [str(value)] + def __setitem__(self, name, value): self._headers[_hkey(name)] = [_hval(value)] def get_header(self, name, default=None): ''' Return the value of a previously defined header. If there is no header with that name, return a default value. ''' return self._headers.get(_hkey(name), [default])[-1] - def set_header(self, name, value, append=False): + def set_header(self, name, value): ''' Create a new response header, replacing any previously defined headers with the same name. ''' - if append: - self.add_header(name, value) - else: - self._headers[_hkey(name)] = [str(value)] + self._headers[_hkey(name)] = [_hval(value)] def add_header(self, name, value): ''' Add an additional response header, not removing duplicates. ''' - self._headers.setdefault(_hkey(name), []).append(str(value)) + self._headers.setdefault(_hkey(name), []).append(_hval(value)) def iter_headers(self): ''' Yield (header, value) tuples, skipping headers that are not allowed with the current response status code. ''' - headers = self._headers.iteritems() - bad_headers = self.bad_headers.get(self.status_code) - if bad_headers: - headers = [h for h in headers if h[0] not in bad_headers] - for name, values in headers: - for value in values: - yield name, value - if self._cookies: - for c in self._cookies.values(): - yield 'Set-Cookie', c.OutputString() - - def wsgiheader(self): - depr('The wsgiheader method is deprecated. See headerlist.') #0.10 return self.headerlist @property def headerlist(self): - ''' WSGI conform list of (header, value) tuples. ''' - return list(self.iter_headers()) + """ WSGI conform list of (header, value) tuples. """ + out = [] + headers = list(self._headers.items()) + if 'Content-Type' not in self._headers: + headers.append(('Content-Type', [self.default_content_type])) + if self._status_code in self.bad_headers: + bad_headers = self.bad_headers[self._status_code] + headers = [h for h in headers if h[0] not in bad_headers] + out += [(name, val) for (name, vals) in headers for val in vals] + if self._cookies: + for c in self._cookies.values(): + out.append(('Set-Cookie', _hval(c.OutputString()))) + if py3k: + out = [(k, v.encode('utf8').decode('latin1')) for (k, v) in out] + return out content_type = HeaderProperty('Content-Type') content_length = HeaderProperty('Content-Length', reader=int) + expires = HeaderProperty('Expires', + reader=lambda x: datetime.utcfromtimestamp(parse_date(x)), + writer=lambda x: http_date(x)) @property - def charset(self): + def charset(self, default='UTF-8'): """ Return the charset specified in the content-type header (default: utf8). """ if 'charset=' in self.content_type: return self.content_type.split('charset=')[-1].split(';')[0].strip() - return 'UTF-8' - - @property - def COOKIES(self): - """ A dict-like SimpleCookie instance. This should not be used directly. - See :meth:`set_cookie`. """ - depr('The COOKIES dict is deprecated. Use `set_cookie()` instead.') # 0.10 - if not self._cookies: - self._cookies = SimpleCookie() - return self._cookies + return default def set_cookie(self, name, value, secret=None, **options): ''' Create a new cookie or replace an old one. If the `secret` parameter is @@ -1384,7 +1636,7 @@ def set_cookie(self, name, value, secret=None, **options): if len(value) > 4096: raise ValueError('Cookie value to long.') self._cookies[name] = value - for key, value in options.iteritems(): + for key, value in options.items(): if key == 'max_age': if isinstance(value, timedelta): value = value.seconds + value.days * 24 * 3600 @@ -1410,20 +1662,66 @@ def __repr__(self): return out -class LocalRequest(BaseRequest, threading.local): - ''' A thread-local subclass of :class:`BaseRequest`. ''' - def __init__(self): pass +def local_property(name=None): + if name: depr('local_property() is deprecated and will be removed.') #0.12 + ls = threading.local() + def fget(self): + try: return ls.var + except AttributeError: + raise RuntimeError("Request context not initialized.") + def fset(self, value): ls.var = value + def fdel(self): del ls.var + return property(fget, fset, fdel, 'Thread-local property') + + +class LocalRequest(BaseRequest): + ''' A thread-local subclass of :class:`BaseRequest` with a different + set of attributes for each thread. There is usually only one global + instance of this class (:data:`request`). If accessed during a + request/response cycle, this instance always refers to the *current* + request (even on a multithreaded server). ''' bind = BaseRequest.__init__ + environ = local_property() -class LocalResponse(BaseResponse, threading.local): - ''' A thread-local subclass of :class:`BaseResponse`. ''' +class LocalResponse(BaseResponse): + ''' A thread-local subclass of :class:`BaseResponse` with a different + set of attributes for each thread. There is usually only one global + instance of this class (:data:`response`). Its attributes are used + to build the HTTP response at the end of the request/response cycle. + ''' bind = BaseResponse.__init__ + _status_line = local_property() + _status_code = local_property() + _cookies = local_property() + _headers = local_property() + body = local_property() + -Response = LocalResponse # BC 0.9 -Request = LocalRequest # BC 0.9 +Request = BaseRequest +Response = BaseResponse +class HTTPResponse(Response, BottleException): + def __init__(self, body='', status=None, headers=None, **more_headers): + super(HTTPResponse, self).__init__(body, status, headers, **more_headers) + + def apply(self, response): + response._status_code = self._status_code + response._status_line = self._status_line + response._headers = self._headers + response._cookies = self._cookies + response.body = self.body + + +class HTTPError(HTTPResponse): + default_status = 500 + def __init__(self, status=None, body=None, exception=None, traceback=None, + **options): + self.exception = exception + self.traceback = traceback + super(HTTPError, self).__init__(body, status, **options) + @@ -1434,6 +1732,7 @@ class LocalResponse(BaseResponse, threading.local): class PluginError(BottleException): pass + class JSONPlugin(object): name = 'json' api = 2 @@ -1441,63 +1740,26 @@ class JSONPlugin(object): def __init__(self, json_dumps=json_dumps): self.json_dumps = json_dumps - def apply(self, callback, context): + def apply(self, callback, route): dumps = self.json_dumps if not dumps: return callback def wrapper(*a, **ka): - rv = callback(*a, **ka) + try: + rv = callback(*a, **ka) + except HTTPError: + rv = _e() + if isinstance(rv, dict): #Attempt to serialize, raises exception on failure json_response = dumps(rv) #Set content type only if serialization succesful response.content_type = 'application/json' return json_response + elif isinstance(rv, HTTPResponse) and isinstance(rv.body, dict): + rv.body = dumps(rv.body) + rv.content_type = 'application/json' return rv - return wrapper - - -class HooksPlugin(object): - name = 'hooks' - api = 2 - - _names = 'before_request', 'after_request', 'app_reset' - - def __init__(self): - self.hooks = dict((name, []) for name in self._names) - self.app = None - - def _empty(self): - return not (self.hooks['before_request'] or self.hooks['after_request']) - def setup(self, app): - self.app = app - - def add(self, name, func): - ''' Attach a callback to a hook. ''' - was_empty = self._empty() - self.hooks.setdefault(name, []).append(func) - if self.app and was_empty and not self._empty(): self.app.reset() - - def remove(self, name, func): - ''' Remove a callback from a hook. ''' - was_empty = self._empty() - if name in self.hooks and func in self.hooks[name]: - self.hooks[name].remove(func) - if self.app and not was_empty and self._empty(): self.app.reset() - - def trigger(self, name, *a, **ka): - ''' Trigger a hook and return a list of results. ''' - hooks = self.hooks[name] - if ka.pop('reversed', False): hooks = hooks[::-1] - return [hook(*a, **ka) for hook in hooks] - - def apply(self, callback, context): - if self._empty(): return callback - def wrapper(*a, **ka): - self.trigger('before_request') - rv = callback(*a, **ka) - self.trigger('after_request', reversed=True) - return rv return wrapper @@ -1513,9 +1775,6 @@ def apply(self, callback, route): conf = route.config.get('template') if isinstance(conf, (tuple, list)) and len(conf) == 2: return view(conf[0], **conf[1])(callback) - elif isinstance(conf, str) and 'template_opts' in route.config: - depr('The `template_opts` parameter is deprecated.') #0.9 - return view(conf, **route.config['template_opts'])(callback) elif isinstance(conf, str): return view(conf)(callback) else: @@ -1528,20 +1787,20 @@ def __init__(self, name, impmask): ''' Create a virtual package that redirects imports (see PEP 302). ''' self.name = name self.impmask = impmask - self.module = sys.modules.setdefault(name, imp.new_module(name)) + self.module = sys.modules.setdefault(name, new_module(name)) self.module.__dict__.update({'__file__': __file__, '__path__': [], '__all__': [], '__loader__': self}) sys.meta_path.append(self) def find_module(self, fullname, path=None): if '.' not in fullname: return - packname, modname = fullname.rsplit('.', 1) + packname = fullname.rsplit('.', 1)[0] if packname != self.name: return return self def load_module(self, fullname): if fullname in sys.modules: return sys.modules[fullname] - packname, modname = fullname.rsplit('.', 1) + modname = fullname.rsplit('.', 1)[1] realname = self.impmask % modname __import__(realname) module = sys.modules[fullname] = sys.modules[realname] @@ -1566,26 +1825,37 @@ class MultiDict(DictMixin): """ def __init__(self, *a, **k): - self.dict = dict((k, [v]) for k, v in dict(*a, **k).iteritems()) + self.dict = dict((k, [v]) for (k, v) in dict(*a, **k).items()) + def __len__(self): return len(self.dict) def __iter__(self): return iter(self.dict) def __contains__(self, key): return key in self.dict def __delitem__(self, key): del self.dict[key] def __getitem__(self, key): return self.dict[key][-1] def __setitem__(self, key, value): self.append(key, value) - def iterkeys(self): return self.dict.iterkeys() - def itervalues(self): return (v[-1] for v in self.dict.itervalues()) - def iteritems(self): return ((k, v[-1]) for (k, v) in self.dict.iteritems()) - def iterallitems(self): - for key, values in self.dict.iteritems(): - for value in values: - yield key, value - - # 2to3 is not able to fix these automatically. - keys = iterkeys if py3k else lambda self: list(self.iterkeys()) - values = itervalues if py3k else lambda self: list(self.itervalues()) - items = iteritems if py3k else lambda self: list(self.iteritems()) - allitems = iterallitems if py3k else lambda self: list(self.iterallitems()) + def keys(self): return self.dict.keys() + + if py3k: + def values(self): return (v[-1] for v in self.dict.values()) + def items(self): return ((k, v[-1]) for k, v in self.dict.items()) + def allitems(self): + return ((k, v) for k, vl in self.dict.items() for v in vl) + iterkeys = keys + itervalues = values + iteritems = items + iterallitems = allitems + + else: + def values(self): return [v[-1] for v in self.dict.values()] + def items(self): return [(k, v[-1]) for k, v in self.dict.items()] + def iterkeys(self): return self.dict.iterkeys() + def itervalues(self): return (v[-1] for v in self.dict.itervalues()) + def iteritems(self): + return ((k, v[-1]) for k, v in self.dict.iteritems()) + def iterallitems(self): + return ((k, v) for k, vl in self.dict.iteritems() for v in vl) + def allitems(self): + return [(k, v) for k, vl in self.dict.iteritems() for v in vl] def get(self, key, default=None, index=-1, type=None): ''' Return the most recent value for a key. @@ -1600,7 +1870,7 @@ def get(self, key, default=None, index=-1, type=None): try: val = self.dict[key][index] return type(val) if type else val - except Exception, e: + except Exception: pass return default @@ -1621,31 +1891,51 @@ def getall(self, key): getlist = getall - class FormsDict(MultiDict): ''' This :class:`MultiDict` subclass is used to store request form data. Additionally to the normal dict-like item access methods (which return unmodified data as native strings), this container also supports - attribute-like access to its values. Attribues are automatiically de- or - recoded to match :attr:`input_encoding` (default: 'utf8'). Missing + attribute-like access to its values. Attributes are automatically de- + or recoded to match :attr:`input_encoding` (default: 'utf8'). Missing attributes default to an empty string. ''' #: Encoding used for attribute values. input_encoding = 'utf8' + #: If true (default), unicode strings are first encoded with `latin1` + #: and then decoded to match :attr:`input_encoding`. + recode_unicode = True + + def _fix(self, s, encoding=None): + if isinstance(s, unicode) and self.recode_unicode: # Python 3 WSGI + return s.encode('latin1').decode(encoding or self.input_encoding) + elif isinstance(s, bytes): # Python 2 WSGI + return s.decode(encoding or self.input_encoding) + else: + return s + + def decode(self, encoding=None): + ''' Returns a copy with all keys and values de- or recoded to match + :attr:`input_encoding`. Some libraries (e.g. WTForms) want a + unicode dictionary. ''' + copy = FormsDict() + enc = copy.input_encoding = encoding or self.input_encoding + copy.recode_unicode = False + for key, value in self.allitems(): + copy.append(self._fix(key, enc), self._fix(value, enc)) + return copy def getunicode(self, name, default=None, encoding=None): - value, enc = self.get(name, default), encoding or self.input_encoding + ''' Return the value as a unicode string, or the default. ''' try: - if isinstance(value, bytes): # Python 2 WSGI - return value.decode(enc) - elif isinstance(value, unicode): # Python 3 WSGI - return value.encode('latin1').decode(enc) - return value - except UnicodeError, e: + return self._fix(self[name], encoding) + except (UnicodeError, KeyError): return default - def __getattr__(self, name): return self.getunicode(name, default=u'') - + def __getattr__(self, name, default=unicode()): + # Without this guard, pickle generates a cryptic TypeError: + if name.startswith('__') and name.endswith('__'): + return super(FormsDict, self).__getattr__(name) + return self.getunicode(name, default=default) class HeaderDict(MultiDict): """ A case-insensitive version of :class:`MultiDict` that defaults to @@ -1658,15 +1948,14 @@ def __init__(self, *a, **ka): def __contains__(self, key): return _hkey(key) in self.dict def __delitem__(self, key): del self.dict[_hkey(key)] def __getitem__(self, key): return self.dict[_hkey(key)][-1] - def __setitem__(self, key, value): self.dict[_hkey(key)] = [str(value)] - def append(self, key, value): - self.dict.setdefault(_hkey(key), []).append(str(value)) - def replace(self, key, value): self.dict[_hkey(key)] = [str(value)] + def __setitem__(self, key, value): self.dict[_hkey(key)] = [_hval(value)] + def append(self, key, value): self.dict.setdefault(_hkey(key), []).append(_hval(value)) + def replace(self, key, value): self.dict[_hkey(key)] = [_hval(value)] def getall(self, key): return self.dict.get(_hkey(key)) or [] def get(self, key, default=None, index=-1): return MultiDict.get(self, _hkey(key), default, index) def filter(self, names): - for name in map(_hkey, names): + for name in (_hkey(n) for n in names): if name in self.dict: del self.dict[name] @@ -1682,7 +1971,7 @@ class WSGIHeaderDict(DictMixin): Currently PEP 333, 444 and 3333 are supported. (PEP 444 is the only one that uses non-native strings.) ''' - #: List of keys that do not have a 'HTTP_' prefix. + #: List of keys that do not have a ``HTTP_`` prefix. cgikeys = ('CONTENT_TYPE', 'CONTENT_LENGTH') def __init__(self, environ): @@ -1720,39 +2009,212 @@ def __len__(self): return len(self.keys()) def __contains__(self, key): return self._ekey(key) in self.environ + class ConfigDict(dict): - ''' A dict-subclass with some extras: You can access keys like attributes. - Uppercase attributes create new ConfigDicts and act as name-spaces. - Other missing attributes return None. Calling a ConfigDict updates its - values and returns itself. - - >>> cfg = ConfigDict() - >>> cfg.Namespace.value = 5 - >>> cfg.OtherNamespace(a=1, b=2) - >>> cfg - {'Namespace': {'value': 5}, 'OtherNamespace': {'a': 1, 'b': 2}} + ''' A dict-like configuration storage with additional support for + namespaces, validators, meta-data, on_change listeners and more. + + This storage is optimized for fast read access. Retrieving a key + or using non-altering dict methods (e.g. `dict.get()`) has no overhead + compared to a native dict. ''' + __slots__ = ('_meta', '_on_change') + + class Namespace(DictMixin): + + def __init__(self, config, namespace): + self._config = config + self._prefix = namespace + + def __getitem__(self, key): + depr('Accessing namespaces as dicts is discouraged. ' + 'Only use flat item access: ' + 'cfg["names"]["pace"]["key"] -> cfg["name.space.key"]') #0.12 + return self._config[self._prefix + '.' + key] + + def __setitem__(self, key, value): + self._config[self._prefix + '.' + key] = value + + def __delitem__(self, key): + del self._config[self._prefix + '.' + key] + + def __iter__(self): + ns_prefix = self._prefix + '.' + for key in self._config: + ns, dot, name = key.rpartition('.') + if ns == self._prefix and name: + yield name + + def keys(self): return [x for x in self] + def __len__(self): return len(self.keys()) + def __contains__(self, key): return self._prefix + '.' + key in self._config + def __repr__(self): return '' % self._prefix + def __str__(self): return '' % self._prefix + + # Deprecated ConfigDict features + def __getattr__(self, key): + depr('Attribute access is deprecated.') #0.12 + if key not in self and key[0].isupper(): + self[key] = ConfigDict.Namespace(self._config, self._prefix + '.' + key) + if key not in self and key.startswith('__'): + raise AttributeError(key) + return self.get(key) + + def __setattr__(self, key, value): + if key in ('_config', '_prefix'): + self.__dict__[key] = value + return + depr('Attribute assignment is deprecated.') #0.12 + if hasattr(DictMixin, key): + raise AttributeError('Read-only attribute.') + if key in self and self[key] and isinstance(self[key], self.__class__): + raise AttributeError('Non-empty namespace attribute.') + self[key] = value + + def __delattr__(self, key): + if key in self: + val = self.pop(key) + if isinstance(val, self.__class__): + prefix = key + '.' + for key in self: + if key.startswith(prefix): + del self[prefix+key] + + def __call__(self, *a, **ka): + depr('Calling ConfDict is deprecated. Use the update() method.') #0.12 + self.update(*a, **ka) + return self + + def __init__(self, *a, **ka): + self._meta = {} + self._on_change = lambda name, value: None + if a or ka: + depr('Constructor does no longer accept parameters.') #0.12 + self.update(*a, **ka) + + def load_config(self, filename): + ''' Load values from an *.ini style config file. + + If the config file contains sections, their names are used as + namespaces for the values within. The two special sections + ``DEFAULT`` and ``bottle`` refer to the root namespace (no prefix). + ''' + conf = ConfigParser() + conf.read(filename) + for section in conf.sections(): + for key, value in conf.items(section): + if section not in ('DEFAULT', 'bottle'): + key = section + '.' + key + self[key] = value + return self + + def load_dict(self, source, namespace='', make_namespaces=False): + ''' Import values from a dictionary structure. Nesting can be used to + represent namespaces. + + >>> ConfigDict().load_dict({'name': {'space': {'key': 'value'}}}) + {'name.space.key': 'value'} + ''' + stack = [(namespace, source)] + while stack: + prefix, source = stack.pop() + if not isinstance(source, dict): + raise TypeError('Source is not a dict (r)' % type(key)) + for key, value in source.items(): + if not isinstance(key, basestring): + raise TypeError('Key is not a string (%r)' % type(key)) + full_key = prefix + '.' + key if prefix else key + if isinstance(value, dict): + stack.append((full_key, value)) + if make_namespaces: + self[full_key] = self.Namespace(self, full_key) + else: + self[full_key] = value + return self + + def update(self, *a, **ka): + ''' If the first parameter is a string, all keys are prefixed with this + namespace. Apart from that it works just as the usual dict.update(). + Example: ``update('some.namespace', key='value')`` ''' + prefix = '' + if a and isinstance(a[0], basestring): + prefix = a[0].strip('.') + '.' + a = a[1:] + for key, value in dict(*a, **ka).items(): + self[prefix+key] = value + + def setdefault(self, key, value): + if key not in self: + self[key] = value + return self[key] + + def __setitem__(self, key, value): + if not isinstance(key, basestring): + raise TypeError('Key has type %r (not a string)' % type(key)) + + value = self.meta_get(key, 'filter', lambda x: x)(value) + if key in self and self[key] is value: + return + self._on_change(key, value) + dict.__setitem__(self, key, value) + def __delitem__(self, key): + dict.__delitem__(self, key) + + def clear(self): + for key in self: + del self[key] + + def meta_get(self, key, metafield, default=None): + ''' Return the value of a meta field for a key. ''' + return self._meta.get(key, {}).get(metafield, default) + + def meta_set(self, key, metafield, value): + ''' Set the meta field for a key to a new value. This triggers the + on-change handler for existing keys. ''' + self._meta.setdefault(key, {})[metafield] = value + if key in self: + self[key] = self[key] + + def meta_list(self, key): + ''' Return an iterable of meta field names defined for a key. ''' + return self._meta.get(key, {}).keys() + + # Deprecated ConfigDict features def __getattr__(self, key): + depr('Attribute access is deprecated.') #0.12 if key not in self and key[0].isupper(): - self[key] = ConfigDict() + self[key] = self.Namespace(self, key) + if key not in self and key.startswith('__'): + raise AttributeError(key) return self.get(key) def __setattr__(self, key, value): + if key in self.__slots__: + return dict.__setattr__(self, key, value) + depr('Attribute assignment is deprecated.') #0.12 if hasattr(dict, key): raise AttributeError('Read-only attribute.') - if key in self and self[key] and isinstance(self[key], ConfigDict): + if key in self and self[key] and isinstance(self[key], self.Namespace): raise AttributeError('Non-empty namespace attribute.') self[key] = value def __delattr__(self, key): - if key in self: del self[key] + if key in self: + val = self.pop(key) + if isinstance(val, self.Namespace): + prefix = key + '.' + for key in self: + if key.startswith(prefix): + del self[prefix+key] def __call__(self, *a, **ka): - for key, value in dict(*a, **ka).iteritems(): setattr(self, key, value) + depr('Calling ConfDict is deprecated. Use the update() method.') #0.12 + self.update(*a, **ka) return self + class AppStack(list): """ A stack-like list. Calling it returns the head of the stack. """ @@ -1770,17 +2232,186 @@ def push(self, value=None): class WSGIFileWrapper(object): - def __init__(self, fp, buffer_size=1024*64): - self.fp, self.buffer_size = fp, buffer_size - for attr in ('fileno', 'close', 'read', 'readlines'): - if hasattr(fp, attr): setattr(self, attr, getattr(fp, attr)) + def __init__(self, fp, buffer_size=1024*64): + self.fp, self.buffer_size = fp, buffer_size + for attr in ('fileno', 'close', 'read', 'readlines', 'tell', 'seek'): + if hasattr(fp, attr): setattr(self, attr, getattr(fp, attr)) + + def __iter__(self): + buff, read = self.buffer_size, self.read + while True: + part = read(buff) + if not part: return + yield part + + +class _closeiter(object): + ''' This only exists to be able to attach a .close method to iterators that + do not support attribute assignment (most of itertools). ''' - def __iter__(self): - read, buff = self.fp.read, self.buffer_size - while True: - part = read(buff) - if not part: break - yield part + def __init__(self, iterator, close=None): + self.iterator = iterator + self.close_callbacks = makelist(close) + + def __iter__(self): + return iter(self.iterator) + + def close(self): + for func in self.close_callbacks: + func() + + +class ResourceManager(object): + ''' This class manages a list of search paths and helps to find and open + application-bound resources (files). + + :param base: default value for :meth:`add_path` calls. + :param opener: callable used to open resources. + :param cachemode: controls which lookups are cached. One of 'all', + 'found' or 'none'. + ''' + + def __init__(self, base='./', opener=open, cachemode='all'): + self.opener = open + self.base = base + self.cachemode = cachemode + + #: A list of search paths. See :meth:`add_path` for details. + self.path = [] + #: A cache for resolved paths. ``res.cache.clear()`` clears the cache. + self.cache = {} + + def add_path(self, path, base=None, index=None, create=False): + ''' Add a new path to the list of search paths. Return False if the + path does not exist. + + :param path: The new search path. Relative paths are turned into + an absolute and normalized form. If the path looks like a file + (not ending in `/`), the filename is stripped off. + :param base: Path used to absolutize relative search paths. + Defaults to :attr:`base` which defaults to ``os.getcwd()``. + :param index: Position within the list of search paths. Defaults + to last index (appends to the list). + + The `base` parameter makes it easy to reference files installed + along with a python module or package:: + + res.add_path('./resources/', __file__) + ''' + base = os.path.abspath(os.path.dirname(base or self.base)) + path = os.path.abspath(os.path.join(base, os.path.dirname(path))) + path += os.sep + if path in self.path: + self.path.remove(path) + if create and not os.path.isdir(path): + os.makedirs(path) + if index is None: + self.path.append(path) + else: + self.path.insert(index, path) + self.cache.clear() + return os.path.exists(path) + + def __iter__(self): + ''' Iterate over all existing files in all registered paths. ''' + search = self.path[:] + while search: + path = search.pop() + if not os.path.isdir(path): continue + for name in os.listdir(path): + full = os.path.join(path, name) + if os.path.isdir(full): search.append(full) + else: yield full + + def lookup(self, name): + ''' Search for a resource and return an absolute file path, or `None`. + + The :attr:`path` list is searched in order. The first match is + returend. Symlinks are followed. The result is cached to speed up + future lookups. ''' + if name not in self.cache or DEBUG: + for path in self.path: + fpath = os.path.join(path, name) + if os.path.isfile(fpath): + if self.cachemode in ('all', 'found'): + self.cache[name] = fpath + return fpath + if self.cachemode == 'all': + self.cache[name] = None + return self.cache[name] + + def open(self, name, mode='r', *args, **kwargs): + ''' Find a resource and return a file object, or raise IOError. ''' + fname = self.lookup(name) + if not fname: raise IOError("Resource %r not found." % name) + return self.opener(fname, mode=mode, *args, **kwargs) + + +class FileUpload(object): + + def __init__(self, fileobj, name, filename, headers=None): + ''' Wrapper for file uploads. ''' + #: Open file(-like) object (BytesIO buffer or temporary file) + self.file = fileobj + #: Name of the upload form field + self.name = name + #: Raw filename as sent by the client (may contain unsafe characters) + self.raw_filename = filename + #: A :class:`HeaderDict` with additional headers (e.g. content-type) + self.headers = HeaderDict(headers) if headers else HeaderDict() + + content_type = HeaderProperty('Content-Type') + content_length = HeaderProperty('Content-Length', reader=int, default=-1) + + def get_header(self, name, default=None): + """ Return the value of a header within the mulripart part. """ + return self.headers.get(name, default) + + @cached_property + def filename(self): + ''' Name of the file on the client file system, but normalized to ensure + file system compatibility. An empty filename is returned as 'empty'. + + Only ASCII letters, digits, dashes, underscores and dots are + allowed in the final filename. Accents are removed, if possible. + Whitespace is replaced by a single dash. Leading or tailing dots + or dashes are removed. The filename is limited to 255 characters. + ''' + fname = self.raw_filename + if not isinstance(fname, unicode): + fname = fname.decode('utf8', 'ignore') + fname = normalize('NFKD', fname).encode('ASCII', 'ignore').decode('ASCII') + fname = os.path.basename(fname.replace('\\', os.path.sep)) + fname = re.sub(r'[^a-zA-Z0-9-_.\s]', '', fname).strip() + fname = re.sub(r'[-\s]+', '-', fname).strip('.-') + return fname[:255] or 'empty' + + def _copy_file(self, fp, chunk_size=2**16): + read, write, offset = self.file.read, fp.write, self.file.tell() + while 1: + buf = read(chunk_size) + if not buf: break + write(buf) + self.file.seek(offset) + + def save(self, destination, overwrite=False, chunk_size=2**16): + ''' Save file to disk or copy its content to an open file(-like) object. + If *destination* is a directory, :attr:`filename` is added to the + path. Existing files are not overwritten by default (IOError). + + :param destination: File path, directory or file(-like) object. + :param overwrite: If True, replace existing files. (default: False) + :param chunk_size: Bytes to read at a time. (default: 64kb) + ''' + if isinstance(destination, basestring): # Except file-likes here + if os.path.isdir(destination): + destination = os.path.join(destination, self.filename) + if not overwrite and os.path.exists(destination): + raise IOError('File exists.') + with open(destination, 'wb') as fp: + self._copy_file(fp, chunk_size) + else: + self._copy_file(destination, chunk_size) @@ -1792,7 +2423,7 @@ def __iter__(self): ############################################################################### -def abort(code=500, text='Unknown Error: Application stopped.'): +def abort(code=500, text='Unknown Error.'): """ Aborts execution and causes a HTTP error. """ raise HTTPError(code, text) @@ -1800,21 +2431,48 @@ def abort(code=500, text='Unknown Error: Application stopped.'): def redirect(url, code=None): """ Aborts execution and causes a 303 or 302 redirect, depending on the HTTP protocol version. """ - if code is None: + if not code: code = 303 if request.get('SERVER_PROTOCOL') == "HTTP/1.1" else 302 - location = urljoin(request.url, url) - raise HTTPResponse("", status=code, header=dict(Location=location)) + res = response.copy(cls=HTTPResponse) + res.status = code + res.body = "" + res.set_header('Location', urljoin(request.url, url)) + raise res + +def _file_iter_range(fp, offset, bytes, maxread=1024*1024): + ''' Yield chunks from a range in a file. No chunk is bigger than maxread.''' + fp.seek(offset) + while bytes > 0: + part = fp.read(min(bytes, maxread)) + if not part: break + bytes -= len(part) + yield part -def static_file(filename, root, mimetype='auto', download=False): + +def static_file(filename, root, mimetype='auto', download=False, charset='UTF-8'): """ Open a file in a safe way and return :exc:`HTTPResponse` with status - code 200, 305, 401 or 404. Set Content-Type, Content-Encoding, - Content-Length and Last-Modified header. Obey If-Modified-Since header - and HEAD requests. + code 200, 305, 403 or 404. The ``Content-Type``, ``Content-Encoding``, + ``Content-Length`` and ``Last-Modified`` headers are set if possible. + Special support for ``If-Modified-Since``, ``Range`` and ``HEAD`` + requests. + + :param filename: Name or path of the file to send. + :param root: Root path for file lookups. Should be an absolute directory + path. + :param mimetype: Defines the content-type header (default: guess from + file extension) + :param download: If True, ask the browser to open a `Save as...` dialog + instead of opening the file with the associated program. You can + specify a custom filename as a string. If not specified, the + original filename is used (default: False). + :param charset: The charset to use for files with a ``text/*`` + mime-type. (default: UTF-8) """ + root = os.path.abspath(root) + os.sep filename = os.path.abspath(os.path.join(root, filename.strip('/\\'))) - header = dict() + headers = dict() if not filename.startswith(root): return HTTPError(403, "Access denied.") @@ -1825,29 +2483,43 @@ def static_file(filename, root, mimetype='auto', download=False): if mimetype == 'auto': mimetype, encoding = mimetypes.guess_type(filename) - if mimetype: header['Content-Type'] = mimetype - if encoding: header['Content-Encoding'] = encoding - elif mimetype: - header['Content-Type'] = mimetype + if encoding: headers['Content-Encoding'] = encoding + + if mimetype: + if mimetype[:5] == 'text/' and charset and 'charset' not in mimetype: + mimetype += '; charset=%s' % charset + headers['Content-Type'] = mimetype if download: download = os.path.basename(filename if download == True else download) - header['Content-Disposition'] = 'attachment; filename="%s"' % download + headers['Content-Disposition'] = 'attachment; filename="%s"' % download stats = os.stat(filename) - header['Content-Length'] = stats.st_size + headers['Content-Length'] = clen = stats.st_size lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime)) - header['Last-Modified'] = lm + headers['Last-Modified'] = lm ims = request.environ.get('HTTP_IF_MODIFIED_SINCE') if ims: ims = parse_date(ims.split(";")[0].strip()) if ims is not None and ims >= int(stats.st_mtime): - header['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) - return HTTPResponse(status=304, header=header) + headers['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) + return HTTPResponse(status=304, **headers) body = '' if request.method == 'HEAD' else open(filename, 'rb') - return HTTPResponse(body, header=header) + + headers["Accept-Ranges"] = "bytes" + ranges = request.environ.get('HTTP_RANGE') + if 'HTTP_RANGE' in request.environ: + ranges = list(parse_range_header(request.environ['HTTP_RANGE'], clen)) + if not ranges: + return HTTPError(416, "Requested Range Not Satisfiable") + offset, end = ranges[0] + headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end-1, clen) + headers["Content-Length"] = str(end-offset) + if body: body = _file_iter_range(body, offset, end-offset) + return HTTPResponse(body, status=206, **headers) + return HTTPResponse(body, **headers) @@ -1863,8 +2535,17 @@ def debug(mode=True): """ Change the debug level. There is only one debug level supported at the moment.""" global DEBUG + if mode: warnings.simplefilter('default') DEBUG = bool(mode) +def http_date(value): + if isinstance(value, (datedate, datetime)): + value = value.utctimetuple() + elif isinstance(value, (int, float)): + value = time.gmtime(value) + if not isinstance(value, basestring): + value = time.strftime("%a, %d %b %Y %H:%M:%S GMT", value) + return value def parse_date(ims): """ Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. """ @@ -1874,21 +2555,47 @@ def parse_date(ims): except (TypeError, ValueError, IndexError, OverflowError): return None - def parse_auth(header): """ Parse rfc2617 HTTP authentication header string (basic) and return (user,pass) tuple or None""" try: method, data = header.split(None, 1) if method.lower() == 'basic': - #TODO: Add 2to3 save base64[encode/decode] functions. user, pwd = touni(base64.b64decode(tob(data))).split(':',1) return user, pwd except (KeyError, ValueError): return None +def parse_range_header(header, maxlen=0): + ''' Yield (start, end) ranges parsed from a HTTP Range header. Skip + unsatisfiable ranges. The end index is non-inclusive.''' + if not header or header[:6] != 'bytes=': return + ranges = [r.split('-', 1) for r in header[6:].split(',') if '-' in r] + for start, end in ranges: + try: + if not start: # bytes=-100 -> last 100 bytes + start, end = max(0, maxlen-int(end)), maxlen + elif not end: # bytes=100- -> all but the first 99 bytes + start, end = int(start), maxlen + else: # bytes=100-200 -> bytes 100-200 (inclusive) + start, end = int(start), min(int(end)+1, maxlen) + if 0 <= start < end <= maxlen: + yield start, end + except ValueError: + pass + +def _parse_qsl(qs): + r = [] + for pair in qs.replace(';','&').split('&'): + if not pair: continue + nv = pair.split('=', 1) + if len(nv) != 2: nv.append('') + key = urlunquote(nv[0].replace('+', ' ')) + value = urlunquote(nv[1].replace('+', ' ')) + r.append((key, value)) + return r def _lscmp(a, b): - ''' Compares two strings in a cryptographically save way: + ''' Compares two strings in a cryptographically safe way: Runtime is not affected by length of common prefix. ''' return not sum(0 if x==y else 1 for x, y in zip(a, b)) and len(a) == len(b) @@ -1896,7 +2603,7 @@ def _lscmp(a, b): def cookie_encode(data, key): ''' Encode and sign a pickle-able object. Return a (byte) string ''' msg = base64.b64encode(pickle.dumps(data, -1)) - sig = base64.b64encode(hmac.new(tob(key), msg).digest()) + sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest()) return tob('!') + sig + tob('?') + msg @@ -1905,7 +2612,7 @@ def cookie_decode(data, key): data = tob(data) if cookie_is_encoded(data): sig, msg = data.split(tob('?'), 1) - if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg).digest())): + if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())): return pickle.loads(base64.b64decode(msg)) return None @@ -1923,7 +2630,7 @@ def html_escape(string): def html_quote(string): ''' Escape and quote a string to be used as an HTTP attribute.''' - return '"%s"' % html_escape(string).replace('\n','%#10;')\ + return '"%s"' % html_escape(string).replace('\n',' ')\ .replace('\r',' ').replace('\t',' ') @@ -1933,18 +2640,17 @@ def yieldroutes(func): takes optional keyword arguments. The output is best described by example:: a() -> '/a' - b(x, y) -> '/b/:x/:y' - c(x, y=5) -> '/c/:x' and '/c/:x/:y' - d(x=5, y=6) -> '/d' and '/d/:x' and '/d/:x/:y' + b(x, y) -> '/b//' + c(x, y=5) -> '/c/' and '/c//' + d(x=5, y=6) -> '/d' and '/d/' and '/d//' """ - import inspect # Expensive module. Only import if necessary. path = '/' + func.__name__.replace('__','/').lstrip('/') - spec = inspect.getargspec(func) + spec = getargspec(func) argc = len(spec[0]) - len(spec[3] or []) - path += ('/:%s' * argc) % tuple(spec[0][:argc]) + path += ('/<%s>' * argc) % tuple(spec[0][:argc]) yield path for arg in spec[0][argc:]: - path += '/:%s' % arg + path += '/<%s>' % arg yield path @@ -1979,41 +2685,24 @@ def path_shift(script_name, path_info, shift=1): return new_script_name, new_path_info -def validate(**vkargs): - """ - Validates and manipulates keyword arguments by user defined callables. - Handles ValueError and missing arguments by raising HTTPError(403). - """ - depr('Use route wildcard filters instead.') - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kargs): - for key, value in vkargs.iteritems(): - if key not in kargs: - abort(403, 'Missing parameter: %s' % key) - try: - kargs[key] = value(kargs[key]) - except ValueError: - abort(403, 'Wrong parameter format for: %s' % key) - return func(*args, **kargs) - return wrapper - return decorator - - def auth_basic(check, realm="private", text="Access denied"): ''' Callback decorator to require HTTP auth (basic). TODO: Add route(check_auth=...) parameter. ''' def decorator(func): - def wrapper(*a, **ka): - user, password = request.auth or (None, None) - if user is None or not check(user, password): - response.headers['WWW-Authenticate'] = 'Basic realm="%s"' % realm - return HTTPError(401, text) - return func(*a, **ka) - return wrapper + def wrapper(*a, **ka): + user, password = request.auth or (None, None) + if user is None or not check(user, password): + err = HTTPError(401, text) + err.add_header('WWW-Authenticate', 'Basic realm="%s"' % realm) + return err + return func(*a, **ka) + return wrapper return decorator +# Shortcuts for common Bottle methods. +# They all refer to the current default application. + def make_default_app_wrapper(name): ''' Return a callable that relays calls to the current default app. ''' @functools.wraps(getattr(Bottle, name)) @@ -2021,12 +2710,18 @@ def wrapper(*a, **ka): return getattr(app(), name)(*a, **ka) return wrapper +route = make_default_app_wrapper('route') +get = make_default_app_wrapper('get') +post = make_default_app_wrapper('post') +put = make_default_app_wrapper('put') +delete = make_default_app_wrapper('delete') +error = make_default_app_wrapper('error') +mount = make_default_app_wrapper('mount') +hook = make_default_app_wrapper('hook') +install = make_default_app_wrapper('install') +uninstall = make_default_app_wrapper('uninstall') +url = make_default_app_wrapper('get_url') -for name in '''route get post put delete error mount - hook install uninstall'''.split(): - globals()[name] = make_default_app_wrapper(name) -url = make_default_app_wrapper('get_url') -del name @@ -2040,8 +2735,8 @@ def wrapper(*a, **ka): class ServerAdapter(object): quiet = False - def __init__(self, host='127.0.0.1', port=8080, **config): - self.options = config + def __init__(self, host='127.0.0.1', port=8080, **options): + self.options = options self.host = host self.port = int(port) @@ -2071,32 +2766,66 @@ def run(self, handler): # pragma: no cover class WSGIRefServer(ServerAdapter): - def run(self, handler): # pragma: no cover - from wsgiref.simple_server import make_server, WSGIRequestHandler - if self.quiet: - class QuietHandler(WSGIRequestHandler): - def log_request(*args, **kw): pass - self.options['handler_class'] = QuietHandler - srv = make_server(self.host, self.port, handler, **self.options) + def run(self, app): # pragma: no cover + from wsgiref.simple_server import WSGIRequestHandler, WSGIServer + from wsgiref.simple_server import make_server + import socket + + class FixedHandler(WSGIRequestHandler): + def address_string(self): # Prevent reverse DNS lookups please. + return self.client_address[0] + def log_request(*args, **kw): + if not self.quiet: + return WSGIRequestHandler.log_request(*args, **kw) + + handler_cls = self.options.get('handler_class', FixedHandler) + server_cls = self.options.get('server_class', WSGIServer) + + if ':' in self.host: # Fix wsgiref for IPv6 addresses. + if getattr(server_cls, 'address_family') == socket.AF_INET: + class server_cls(server_cls): + address_family = socket.AF_INET6 + + srv = make_server(self.host, self.port, app, server_cls, handler_cls) srv.serve_forever() class CherryPyServer(ServerAdapter): def run(self, handler): # pragma: no cover from cherrypy import wsgiserver - server = wsgiserver.CherryPyWSGIServer((self.host, self.port), handler) + self.options['bind_addr'] = (self.host, self.port) + self.options['wsgi_app'] = handler + + certfile = self.options.get('certfile') + if certfile: + del self.options['certfile'] + keyfile = self.options.get('keyfile') + if keyfile: + del self.options['keyfile'] + + server = wsgiserver.CherryPyWSGIServer(**self.options) + if certfile: + server.ssl_certificate = certfile + if keyfile: + server.ssl_private_key = keyfile + try: server.start() finally: server.stop() +class WaitressServer(ServerAdapter): + def run(self, handler): + from waitress import serve + serve(handler, host=self.host, port=self.port) + + class PasteServer(ServerAdapter): def run(self, handler): # pragma: no cover from paste import httpserver - if not self.quiet: - from paste.translogger import TransLogger - handler = TransLogger(handler) + from paste.translogger import TransLogger + handler = TransLogger(handler, setup_console_handler=(not self.quiet)) httpserver.serve(handler, host=self.host, port=str(self.port), **self.options) @@ -2120,8 +2849,8 @@ def run(self, handler): # pragma: no cover evwsgi.start(self.host, port) # fapws3 never releases the GIL. Complain upstream. I tried. No luck. if 'BOTTLE_CHILD' in os.environ and not self.quiet: - print "WARNING: Auto-reloading does not work with Fapws3." - print " (Fapws3 breaks python thread support)" + _stderr("WARNING: Auto-reloading does not work with Fapws3.\n") + _stderr(" (Fapws3 breaks python thread support)\n") evwsgi.set_base_module(base) def app(environ, start_response): environ['wsgi.multiprocess'] = False @@ -2136,7 +2865,7 @@ def run(self, handler): # pragma: no cover import tornado.wsgi, tornado.httpserver, tornado.ioloop container = tornado.wsgi.WSGIContainer(handler) server = tornado.httpserver.HTTPServer(container) - server.listen(port=self.port) + server.listen(port=self.port,address=self.host) tornado.ioloop.IOLoop.instance().start() @@ -2178,16 +2907,32 @@ def run(self, handler): class GeventServer(ServerAdapter): """ Untested. Options: - * `monkey` (default: True) fixes the stdlib to use greenthreads. * `fast` (default: False) uses libevent's http server, but has some issues: No streaming, no pipelining, no SSL. + * See gevent.wsgi.WSGIServer() documentation for more options. """ def run(self, handler): - from gevent import wsgi as wsgi_fast, pywsgi, monkey, local - if self.options.get('monkey', True): - if not threading.local is local.local: monkey.patch_all() - wsgi = wsgi_fast if self.options.get('fast') else pywsgi - wsgi.WSGIServer((self.host, self.port), handler).serve_forever() + from gevent import pywsgi, local + if not isinstance(threading.local(), local.local): + msg = "Bottle requires gevent.monkey.patch_all() (before import)" + raise RuntimeError(msg) + if self.options.pop('fast', None): + depr('The "fast" option has been deprecated and removed by Gevent.') + if self.quiet: + self.options['log'] = None + address = (self.host, self.port) + server = pywsgi.WSGIServer(address, handler, **self.options) + if 'BOTTLE_CHILD' in os.environ: + import signal + signal.signal(signal.SIGINT, lambda s, f: server.stop()) + server.serve_forever() + + +class GeventSocketIOServer(ServerAdapter): + def run(self,handler): + from socketio import server + address = (self.host, self.port) + server.SocketIOServer(address, handler, **self.options).serve_forever() class GunicornServer(ServerAdapter): @@ -2212,7 +2957,12 @@ class EventletServer(ServerAdapter): """ Untested """ def run(self, handler): from eventlet import wsgi, listen - wsgi.server(listen((self.host, self.port)), handler) + try: + wsgi.server(listen((self.host, self.port)), handler, + log_output=(not self.quiet)) + except TypeError: + # Fallback, if we have old version of eventlet + wsgi.server(listen((self.host, self.port)), handler) class RocketServer(ServerAdapter): @@ -2232,7 +2982,7 @@ def run(self, handler): class AutoServer(ServerAdapter): """ Untested. """ - adapters = [PasteServer, CherryPyServer, TwistedServer, WSGIRefServer] + adapters = [WaitressServer, PasteServer, TwistedServer, CherryPyServer, WSGIRefServer] def run(self, handler): for sa in self.adapters: try: @@ -2244,6 +2994,7 @@ def run(self, handler): 'cgi': CGIServer, 'flup': FlupFCGIServer, 'wsgiref': WSGIRefServer, + 'waitress': WaitressServer, 'cherrypy': CherryPyServer, 'paste': PasteServer, 'fapws3': FapwsServer, @@ -2255,6 +3006,7 @@ def run(self, handler): 'gunicorn': GunicornServer, 'eventlet': EventletServer, 'gevent': GeventServer, + 'geventSocketIO':GeventSocketIOServer, 'rocket': RocketServer, 'bjoern' : BjoernServer, 'auto': AutoServer, @@ -2303,8 +3055,10 @@ def load_app(target): default_app.remove(tmp) # Remove the temporary added default application NORUN = nr_old +_debug = debug def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, - interval=1, reloader=False, quiet=False, plugins=None, **kargs): + interval=1, reloader=False, quiet=False, plugins=None, + debug=None, **kargs): """ Start a server instance. This method blocks until the server terminates. :param app: WSGI application or target string supported by @@ -2324,6 +3078,7 @@ def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, if NORUN: return if reloader and not os.environ.get('BOTTLE_CHILD'): try: + lockfile = None fd, lockfile = tempfile.mkstemp(prefix='bottle.', suffix='.lock') os.close(fd) # We only need this file to exist. We never write to it while os.path.exists(lockfile): @@ -2345,9 +3100,8 @@ def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, os.unlink(lockfile) return - stderr = sys.stderr.write - try: + if debug is not None: _debug(debug) app = app or default_app() if isinstance(app, basestring): app = load_app(app) @@ -2368,9 +3122,9 @@ def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, server.quiet = server.quiet or quiet if not server.quiet: - stderr("Bottle server starting up (using %s)...\n" % repr(server)) - stderr("Listening on http://%s:%d/\n" % (server.host, server.port)) - stderr("Hit Ctrl-C to quit.\n\n") + _stderr("Bottle v%s server starting up (using %s)...\n" % (__version__, repr(server))) + _stderr("Listening on http://%s:%d/\n" % (server.host, server.port)) + _stderr("Hit Ctrl-C to quit.\n\n") if reloader: lockfile = os.environ.get('BOTTLE_LOCKFILE') @@ -2383,12 +3137,15 @@ def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, server.run(app) except KeyboardInterrupt: pass - except (SyntaxError, ImportError): + except (SystemExit, MemoryError): + raise + except: if not reloader: raise - if not getattr(server, 'quiet', False): print_exc() + if not getattr(server, 'quiet', quiet): + print_exc() + time.sleep(interval) sys.exit(3) - finally: - if not getattr(server, 'quiet', False): stderr('Shutdown...\n') + class FileCheckerThread(threading.Thread): @@ -2406,8 +3163,8 @@ def run(self): mtime = lambda path: os.stat(path).st_mtime files = dict() - for module in sys.modules.values(): - path = getattr(module, '__file__', '') + for module in list(sys.modules.values()): + path = getattr(module, '__file__', '') or '' if path[-4:] in ('.pyo', '.pyc'): path = path[:-1] if path and exists(path): files[path] = mtime(path) @@ -2416,20 +3173,20 @@ def run(self): or mtime(self.lockfile) < time.time() - self.interval - 5: self.status = 'error' thread.interrupt_main() - for path, lmtime in files.iteritems(): + for path, lmtime in list(files.items()): if not exists(path) or mtime(path) > lmtime: self.status = 'reload' thread.interrupt_main() break time.sleep(self.interval) - + def __enter__(self): self.start() - + def __exit__(self, exc_type, exc_val, exc_tb): if not self.status: self.status = 'exit' # silent exit self.join() - return issubclass(exc_type, KeyboardInterrupt) + return exc_type is not None and issubclass(exc_type, KeyboardInterrupt) @@ -2465,7 +3222,7 @@ def __init__(self, source=None, name=None, lookup=[], encoding='utf8', **setting self.name = name self.source = source.read() if hasattr(source, 'read') else source self.filename = source.filename if hasattr(source, 'filename') else None - self.lookup = map(os.path.abspath, lookup) + self.lookup = [os.path.abspath(x) for x in lookup] self.encoding = encoding self.settings = self.settings.copy() # Copy from class variable self.settings.update(settings) # Apply @@ -2481,11 +3238,19 @@ def __init__(self, source=None, name=None, lookup=[], encoding='utf8', **setting def search(cls, name, lookup=[]): """ Search name in all directories specified in lookup. First without, then with common extensions. Return first hit. """ - if os.path.isfile(name): return name + if not lookup: + depr('The template lookup path list should not be empty.') #0.12 + lookup = ['.'] + + if os.path.isabs(name) and os.path.isfile(name): + depr('Absolute template path names are deprecated.') #0.12 + return os.path.abspath(name) + for spath in lookup: - fname = os.path.join(spath, name) - if os.path.isfile(fname): - return fname + spath = os.path.abspath(spath) + os.sep + fname = os.path.abspath(os.path.join(spath, name)) + if not fname.startswith(spath): continue + if os.path.isfile(fname): return fname for ext in cls.extensions: if os.path.isfile('%s.%s' % (fname, ext)): return '%s.%s' % (fname, ext) @@ -2510,8 +3275,8 @@ def render(self, *args, **kwargs): """ Render the template with the specified local variables and return a single byte or unicode string. If it is a byte string, the encoding must match self.encoding. This method must be thread-safe! - Local variables may be provided in dictionaries (*args) - or directly, as keywords (**kwargs). + Local variables may be provided in dictionaries (args) + or directly, as keywords (kwargs). """ raise NotImplementedError @@ -2556,7 +3321,7 @@ def render(self, *args, **kwargs): class Jinja2Template(BaseTemplate): - def prepare(self, filters=None, tests=None, **kwargs): + def prepare(self, filters=None, tests=None, globals={}, **kwargs): from jinja2 import Environment, FunctionLoader if 'prefix' in kwargs: # TODO: to be removed after a while raise RuntimeError('The keyword argument `prefix` has been removed. ' @@ -2564,6 +3329,7 @@ def prepare(self, filters=None, tests=None, **kwargs): self.env = Environment(loader=FunctionLoader(self.loader), **kwargs) if filters: self.env.filters.update(filters) if tests: self.env.tests.update(tests) + if globals: self.env.globals.update(globals) if self.source: self.tpl = self.env.from_string(self.source) else: @@ -2577,189 +3343,267 @@ def render(self, *args, **kwargs): def loader(self, name): fname = self.search(name, self.lookup) - if fname: - with open(fname, "rb") as f: - return f.read().decode(self.encoding) - - -class SimpleTALTemplate(BaseTemplate): - ''' Untested! ''' - def prepare(self, **options): - from simpletal import simpleTAL - # TODO: add option to load METAL files during render - if self.source: - self.tpl = simpleTAL.compileHTMLTemplate(self.source) - else: - with open(self.filename, 'rb') as fp: - self.tpl = simpleTAL.compileHTMLTemplate(tonat(fp.read())) - - def render(self, *args, **kwargs): - from simpletal import simpleTALES - for dictarg in args: kwargs.update(dictarg) - # TODO: maybe reuse a context instead of always creating one - context = simpleTALES.Context() - for k,v in self.defaults.items(): - context.addGlobal(k, v) - for k,v in kwargs.items(): - context.addGlobal(k, v) - output = StringIO() - self.tpl.expand(context, output) - return output.getvalue() + if not fname: return + with open(fname, "rb") as f: + return f.read().decode(self.encoding) class SimpleTemplate(BaseTemplate): - blocks = ('if', 'elif', 'else', 'try', 'except', 'finally', 'for', 'while', - 'with', 'def', 'class') - dedent_blocks = ('elif', 'else', 'except', 'finally') - - @lazy_attribute - def re_pytokens(cls): - ''' This matches comments and all kinds of quoted strings but does - NOT match comments (#...) within quoted strings. (trust me) ''' - return re.compile(r''' - (''(?!')|""(?!")|'{6}|"{6} # Empty strings (all 4 types) - |'(?:[^\\']|\\.)+?' # Single quotes (') - |"(?:[^\\"]|\\.)+?" # Double quotes (") - |'{3}(?:[^\\]|\\.|\n)+?'{3} # Triple-quoted strings (') - |"{3}(?:[^\\]|\\.|\n)+?"{3} # Triple-quoted strings (") - |\#.* # Comments - )''', re.VERBOSE) - - def prepare(self, escape_func=html_escape, noescape=False, **kwargs): + + def prepare(self, escape_func=html_escape, noescape=False, syntax=None, **ka): self.cache = {} enc = self.encoding self._str = lambda x: touni(x, enc) self._escape = lambda x: escape_func(touni(x, enc)) + self.syntax = syntax if noescape: self._str, self._escape = self._escape, self._str - @classmethod - def split_comment(cls, code): - """ Removes comments (#...) from python code. """ - if '#' not in code: return code - #: Remove comments only (leave quoted strings as they are) - subf = lambda m: '' if m.group(0)[0]=='#' else m.group(0) - return re.sub(cls.re_pytokens, subf, code) - @cached_property def co(self): return compile(self.code, self.filename or '', 'exec') @cached_property def code(self): - stack = [] # Current Code indentation - lineno = 0 # Current line of code - ptrbuffer = [] # Buffer for printable strings and token tuple instances - codebuffer = [] # Buffer for generated python code - multiline = dedent = oneline = False - template = self.source or open(self.filename, 'rb').read() - - def yield_tokens(line): - for i, part in enumerate(re.split(r'\{\{(.*?)\}\}', line)): - if i % 2: - if part.startswith('!'): yield 'RAW', part[1:] - else: yield 'CMD', part - else: yield 'TXT', part - - def flush(): # Flush the ptrbuffer - if not ptrbuffer: return - cline = '' - for line in ptrbuffer: - for token, value in line: - if token == 'TXT': cline += repr(value) - elif token == 'RAW': cline += '_str(%s)' % value - elif token == 'CMD': cline += '_escape(%s)' % value - cline += ', ' - cline = cline[:-2] + '\\\n' - cline = cline[:-2] - if cline[:-1].endswith('\\\\\\\\\\n'): - cline = cline[:-7] + cline[-1] # 'nobr\\\\\n' --> 'nobr' - cline = '_printlist([' + cline + '])' - del ptrbuffer[:] # Do this before calling code() again - code(cline) - - def code(stmt): - for line in stmt.splitlines(): - codebuffer.append(' ' * len(stack) + line.strip()) - - for line in template.splitlines(True): - lineno += 1 - line = line if isinstance(line, unicode)\ - else unicode(line, encoding=self.encoding) - if lineno <= 2: - m = re.search(r"%.*coding[:=]\s*([-\w\.]+)", line) - if m: self.encoding = m.group(1) - if m: line = line.replace('coding','coding (removed)') - if line.strip()[:2].count('%') == 1: - line = line.split('%',1)[1].lstrip() # Full line following the % - cline = self.split_comment(line).strip() - cmd = re.split(r'[^a-zA-Z0-9_]', cline)[0] - flush() # You are actually reading this? Good luck, it's a mess :) - if cmd in self.blocks or multiline: - cmd = multiline or cmd - dedent = cmd in self.dedent_blocks # "else:" - if dedent and not oneline and not multiline: - cmd = stack.pop() - code(line) - oneline = not cline.endswith(':') # "if 1: pass" - multiline = cmd if cline.endswith('\\') else False - if not oneline and not multiline: - stack.append(cmd) - elif cmd == 'end' and stack: - code('#end(%s) %s' % (stack.pop(), line.strip()[3:])) - elif cmd == 'include': - p = cline.split(None, 2)[1:] - if len(p) == 2: - code("_=_include(%s, _stdout, %s)" % (repr(p[0]), p[1])) - elif p: - code("_=_include(%s, _stdout)" % repr(p[0])) - else: # Empty %include -> reverse of %rebase - code("_printlist(_base)") - elif cmd == 'rebase': - p = cline.split(None, 2)[1:] - if len(p) == 2: - code("globals()['_rebase']=(%s, dict(%s))" % (repr(p[0]), p[1])) - elif p: - code("globals()['_rebase']=(%s, {})" % repr(p[0])) - else: - code(line) - else: # Line starting with text (not '%') or '%%' (escaped) - if line.strip().startswith('%%'): - line = line.replace('%%', '%', 1) - ptrbuffer.append(yield_tokens(line)) - flush() - return '\n'.join(codebuffer) + '\n' - - def subtemplate(self, _name, _stdout, *args, **kwargs): - for dictarg in args: kwargs.update(dictarg) + source = self.source + if not source: + with open(self.filename, 'rb') as f: + source = f.read() + try: + source, encoding = touni(source), 'utf8' + except UnicodeError: + depr('Template encodings other than utf8 are no longer supported.') #0.11 + source, encoding = touni(source, 'latin1'), 'latin1' + parser = StplParser(source, encoding=encoding, syntax=self.syntax) + code = parser.translate() + self.encoding = parser.encoding + return code + + def _rebase(self, _env, _name=None, **kwargs): + if _name is None: + depr('Rebase function called without arguments.' + ' You were probably looking for {{base}}?', True) #0.12 + _env['_rebase'] = (_name, kwargs) + + def _include(self, _env, _name=None, **kwargs): + if _name is None: + depr('Rebase function called without arguments.' + ' You were probably looking for {{base}}?', True) #0.12 + env = _env.copy() + env.update(kwargs) if _name not in self.cache: self.cache[_name] = self.__class__(name=_name, lookup=self.lookup) - return self.cache[_name].execute(_stdout, kwargs) + return self.cache[_name].execute(env['_stdout'], env) - def execute(self, _stdout, *args, **kwargs): - for dictarg in args: kwargs.update(dictarg) + def execute(self, _stdout, kwargs): env = self.defaults.copy() - env.update({'_stdout': _stdout, '_printlist': _stdout.extend, - '_include': self.subtemplate, '_str': self._str, - '_escape': self._escape, 'get': env.get, - 'setdefault': env.setdefault, 'defined': env.__contains__}) env.update(kwargs) + env.update({'_stdout': _stdout, '_printlist': _stdout.extend, + 'include': functools.partial(self._include, env), + 'rebase': functools.partial(self._rebase, env), '_rebase': None, + '_str': self._str, '_escape': self._escape, 'get': env.get, + 'setdefault': env.setdefault, 'defined': env.__contains__ }) eval(self.co, env) - if '_rebase' in env: - subtpl, rargs = env['_rebase'] - rargs['_base'] = _stdout[:] #copy stdout + if env.get('_rebase'): + subtpl, rargs = env.pop('_rebase') + rargs['base'] = ''.join(_stdout) #copy stdout del _stdout[:] # clear stdout - return self.subtemplate(subtpl,_stdout,rargs) + return self._include(env, subtpl, **rargs) return env def render(self, *args, **kwargs): """ Render the template using keyword arguments as local variables. """ - for dictarg in args: kwargs.update(dictarg) - stdout = [] - self.execute(stdout, kwargs) + env = {}; stdout = [] + for dictarg in args: env.update(dictarg) + env.update(kwargs) + self.execute(stdout, env) return ''.join(stdout) +class StplSyntaxError(TemplateError): pass + + +class StplParser(object): + ''' Parser for stpl templates. ''' + _re_cache = {} #: Cache for compiled re patterns + # This huge pile of voodoo magic splits python code into 8 different tokens. + # 1: All kinds of python strings (trust me, it works) + _re_tok = '([urbURB]?(?:\'\'(?!\')|""(?!")|\'{6}|"{6}' \ + '|\'(?:[^\\\\\']|\\\\.)+?\'|"(?:[^\\\\"]|\\\\.)+?"' \ + '|\'{3}(?:[^\\\\]|\\\\.|\\n)+?\'{3}' \ + '|"{3}(?:[^\\\\]|\\\\.|\\n)+?"{3}))' + _re_inl = _re_tok.replace('|\\n','') # We re-use this string pattern later + # 2: Comments (until end of line, but not the newline itself) + _re_tok += '|(#.*)' + # 3,4: Open and close grouping tokens + _re_tok += '|([\\[\\{\\(])' + _re_tok += '|([\\]\\}\\)])' + # 5,6: Keywords that start or continue a python block (only start of line) + _re_tok += '|^([ \\t]*(?:if|for|while|with|try|def|class)\\b)' \ + '|^([ \\t]*(?:elif|else|except|finally)\\b)' + # 7: Our special 'end' keyword (but only if it stands alone) + _re_tok += '|((?:^|;)[ \\t]*end[ \\t]*(?=(?:%(block_close)s[ \\t]*)?\\r?$|;|#))' + # 8: A customizable end-of-code-block template token (only end of line) + _re_tok += '|(%(block_close)s[ \\t]*(?=\\r?$))' + # 9: And finally, a single newline. The 10th token is 'everything else' + _re_tok += '|(\\r?\\n)' + + # Match the start tokens of code areas in a template + _re_split = '(?m)^[ \t]*(\\\\?)((%(line_start)s)|(%(block_start)s))(%%?)' + # Match inline statements (may contain python strings) + _re_inl = '(?m)%%(inline_start)s((?:%s|[^\'"\n]*?)+)%%(inline_end)s' % _re_inl + _re_tok = '(?m)' + _re_tok + + default_syntax = '<% %> % {{ }}' + + def __init__(self, source, syntax=None, encoding='utf8'): + self.source, self.encoding = touni(source, encoding), encoding + self.set_syntax(syntax or self.default_syntax) + self.code_buffer, self.text_buffer = [], [] + self.lineno, self.offset = 1, 0 + self.indent, self.indent_mod = 0, 0 + self.paren_depth = 0 + + def get_syntax(self): + ''' Tokens as a space separated string (default: <% %> % {{ }}) ''' + return self._syntax + + def set_syntax(self, syntax): + self._syntax = syntax + self._tokens = syntax.split() + if not syntax in self._re_cache: + names = 'block_start block_close line_start inline_start inline_end' + etokens = map(re.escape, self._tokens) + pattern_vars = dict(zip(names.split(), etokens)) + patterns = (self._re_split, self._re_tok, self._re_inl) + patterns = [re.compile(p%pattern_vars) for p in patterns] + self._re_cache[syntax] = patterns + self.re_split, self.re_tok, self.re_inl = self._re_cache[syntax] + + syntax = property(get_syntax, set_syntax) + + def translate(self): + if self.offset: raise RuntimeError('Parser is a one time instance.') + while True: + m = self.re_split.search(self.source[self.offset:]) + if m: + text = self.source[self.offset:self.offset+m.start()] + self.text_buffer.append(text) + self.offset += m.end() + if m.group(1): # New escape syntax + line, sep, _ = self.source[self.offset:].partition('\n') + self.text_buffer.append(m.group(2)+m.group(5)+line+sep) + self.offset += len(line+sep)+1 + continue + elif m.group(5): # Old escape syntax + depr('Escape code lines with a backslash.') #0.12 + line, sep, _ = self.source[self.offset:].partition('\n') + self.text_buffer.append(m.group(2)+line+sep) + self.offset += len(line+sep)+1 + continue + self.flush_text() + self.read_code(multiline=bool(m.group(4))) + else: break + self.text_buffer.append(self.source[self.offset:]) + self.flush_text() + return ''.join(self.code_buffer) + + def read_code(self, multiline): + code_line, comment = '', '' + while True: + m = self.re_tok.search(self.source[self.offset:]) + if not m: + code_line += self.source[self.offset:] + self.offset = len(self.source) + self.write_code(code_line.strip(), comment) + return + code_line += self.source[self.offset:self.offset+m.start()] + self.offset += m.end() + _str, _com, _po, _pc, _blk1, _blk2, _end, _cend, _nl = m.groups() + if (code_line or self.paren_depth > 0) and (_blk1 or _blk2): # a if b else c + code_line += _blk1 or _blk2 + continue + if _str: # Python string + code_line += _str + elif _com: # Python comment (up to EOL) + comment = _com + if multiline and _com.strip().endswith(self._tokens[1]): + multiline = False # Allow end-of-block in comments + elif _po: # open parenthesis + self.paren_depth += 1 + code_line += _po + elif _pc: # close parenthesis + if self.paren_depth > 0: + # we could check for matching parentheses here, but it's + # easier to leave that to python - just check counts + self.paren_depth -= 1 + code_line += _pc + elif _blk1: # Start-block keyword (if/for/while/def/try/...) + code_line, self.indent_mod = _blk1, -1 + self.indent += 1 + elif _blk2: # Continue-block keyword (else/elif/except/...) + code_line, self.indent_mod = _blk2, -1 + elif _end: # The non-standard 'end'-keyword (ends a block) + self.indent -= 1 + elif _cend: # The end-code-block template token (usually '%>') + if multiline: multiline = False + else: code_line += _cend + else: # \n + self.write_code(code_line.strip(), comment) + self.lineno += 1 + code_line, comment, self.indent_mod = '', '', 0 + if not multiline: + break + + def flush_text(self): + text = ''.join(self.text_buffer) + del self.text_buffer[:] + if not text: return + parts, pos, nl = [], 0, '\\\n'+' '*self.indent + for m in self.re_inl.finditer(text): + prefix, pos = text[pos:m.start()], m.end() + if prefix: + parts.append(nl.join(map(repr, prefix.splitlines(True)))) + if prefix.endswith('\n'): parts[-1] += nl + parts.append(self.process_inline(m.group(1).strip())) + if pos < len(text): + prefix = text[pos:] + lines = prefix.splitlines(True) + if lines[-1].endswith('\\\\\n'): lines[-1] = lines[-1][:-3] + elif lines[-1].endswith('\\\\\r\n'): lines[-1] = lines[-1][:-4] + parts.append(nl.join(map(repr, lines))) + code = '_printlist((%s,))' % ', '.join(parts) + self.lineno += code.count('\n')+1 + self.write_code(code) + + def process_inline(self, chunk): + if chunk[0] == '!': return '_str(%s)' % chunk[1:] + return '_escape(%s)' % chunk + + def write_code(self, line, comment=''): + line, comment = self.fix_backward_compatibility(line, comment) + code = ' ' * (self.indent+self.indent_mod) + code += line.lstrip() + comment + '\n' + self.code_buffer.append(code) + + def fix_backward_compatibility(self, line, comment): + parts = line.strip().split(None, 2) + if parts and parts[0] in ('include', 'rebase'): + depr('The include and rebase keywords are functions now.') #0.12 + if len(parts) == 1: return "_printlist([base])", comment + elif len(parts) == 2: return "_=%s(%r)" % tuple(parts), comment + else: return "_=%s(%r, %s)" % tuple(parts), comment + if self.lineno <= 2 and not line.strip() and 'coding' in comment: + m = re.match(r"#.*coding[:=]\s*([-\w.]+)", comment) + if m: + depr('PEP263 encoding strings in templates are deprecated.') #0.12 + enc = m.group(1) + self.source = self.source.encode(self.encoding).decode(enc) + self.encoding = enc + return line, comment.replace('coding','coding*') + return line, comment + + def template(*args, **kwargs): ''' Get a rendered template as a string iterator. @@ -2768,26 +3612,26 @@ def template(*args, **kwargs): or directly (as keyword arguments). ''' tpl = args[0] if args else None - template_adapter = kwargs.pop('template_adapter', SimpleTemplate) - if tpl not in TEMPLATES or DEBUG: + adapter = kwargs.pop('template_adapter', SimpleTemplate) + lookup = kwargs.pop('template_lookup', TEMPLATE_PATH) + tplid = (id(lookup), tpl) + if tplid not in TEMPLATES or DEBUG: settings = kwargs.pop('template_settings', {}) - lookup = kwargs.pop('template_lookup', TEMPLATE_PATH) - if isinstance(tpl, template_adapter): - TEMPLATES[tpl] = tpl - if settings: TEMPLATES[tpl].prepare(**settings) + if isinstance(tpl, adapter): + TEMPLATES[tplid] = tpl + if settings: TEMPLATES[tplid].prepare(**settings) elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl: - TEMPLATES[tpl] = template_adapter(source=tpl, lookup=lookup, **settings) + TEMPLATES[tplid] = adapter(source=tpl, lookup=lookup, **settings) else: - TEMPLATES[tpl] = template_adapter(name=tpl, lookup=lookup, **settings) - if not TEMPLATES[tpl]: + TEMPLATES[tplid] = adapter(name=tpl, lookup=lookup, **settings) + if not TEMPLATES[tplid]: abort(500, 'Template (%s) not found' % tpl) for dictarg in args[1:]: kwargs.update(dictarg) - return TEMPLATES[tpl].render(kwargs) + return TEMPLATES[tplid].render(kwargs) mako_template = functools.partial(template, template_adapter=MakoTemplate) cheetah_template = functools.partial(template, template_adapter=CheetahTemplate) jinja2_template = functools.partial(template, template_adapter=Jinja2Template) -simpletal_template = functools.partial(template, template_adapter=SimpleTALTemplate) def view(tpl_name, **defaults): @@ -2808,6 +3652,8 @@ def wrapper(*args, **kwargs): tplvars = defaults.copy() tplvars.update(result) return template(tpl_name, **tplvars) + elif result is None: + return template(tpl_name, defaults) return result return wrapper return decorator @@ -2815,7 +3661,6 @@ def wrapper(*args, **kwargs): mako_view = functools.partial(view, template_adapter=MakoTemplate) cheetah_view = functools.partial(view, template_adapter=CheetahTemplate) jinja2_view = functools.partial(view, template_adapter=Jinja2Template) -simpletal_view = functools.partial(view, template_adapter=SimpleTALTemplate) @@ -2835,21 +3680,21 @@ def wrapper(*args, **kwargs): #: A dict to map HTTP status codes (e.g. 404) to phrases (e.g. 'Not Found') HTTP_CODES = httplib.responses HTTP_CODES[418] = "I'm a teapot" # RFC 2324 +HTTP_CODES[422] = "Unprocessable Entity" # RFC 4918 HTTP_CODES[428] = "Precondition Required" HTTP_CODES[429] = "Too Many Requests" HTTP_CODES[431] = "Request Header Fields Too Large" HTTP_CODES[511] = "Network Authentication Required" -_HTTP_STATUS_LINES = dict((k, '%d %s'%(k,v)) for (k,v) in HTTP_CODES.iteritems()) +_HTTP_STATUS_LINES = dict((k, '%d %s'%(k,v)) for (k,v) in HTTP_CODES.items()) #: The default template used for error pages. Override with @error() ERROR_PAGE_TEMPLATE = """ -%try: - %from bottle import DEBUG, HTTP_CODES, request, touni - %status_name = HTTP_CODES.get(e.status, 'Unknown').title() +%%try: + %%from %s import DEBUG, HTTP_CODES, request, touni - Error {{e.status}}: {{status_name}} + Error: {{e.status}} -

Error {{e.status}}: {{status_name}}

+

Error: {{e.status}}

Sorry, the requested URL {{repr(request.url)}} caused an error:

-
{{e.output}}
- %if DEBUG and e.exception: +
{{e.body}}
+ %%if DEBUG and e.exception:

Exception:

{{repr(e.exception)}}
- %end - %if DEBUG and e.traceback: + %%end + %%if DEBUG and e.traceback:

Traceback:

{{e.traceback}}
- %end + %%end -%except ImportError: +%%except ImportError: ImportError: Could not generate the error page. Please add bottle to the import path. -%end -""" +%%end +""" % __name__ -#: A thread-safe instance of :class:`Request` representing the `current` request. -request = Request() +#: A thread-safe instance of :class:`LocalRequest`. If accessed from within a +#: request callback, this instance always refers to the *current* request +#: (even on a multithreaded server). +request = LocalRequest() -#: A thread-safe instance of :class:`Response` used to build the HTTP response. -response = Response() +#: A thread-safe instance of :class:`LocalResponse`. It is used to change the +#: HTTP response for the *current* request. +response = LocalResponse() #: A thread-safe namespace. Not used by Bottle. local = threading.local() @@ -2894,29 +3742,30 @@ def wrapper(*args, **kwargs): #: A virtual package that redirects import statements. #: Example: ``import bottle.ext.sqlite`` actually imports `bottle_sqlite`. -ext = _ImportRedirect(__name__+'.ext', 'bottle_%s').module +ext = _ImportRedirect('bottle.ext' if __name__ == '__main__' else __name__+".ext", 'bottle_%s').module if __name__ == '__main__': opt, args, parser = _cmd_options, _cmd_args, _cmd_parser if opt.version: - print 'Bottle', __version__; sys.exit(0) + _stdout('Bottle %s\n'%__version__) + sys.exit(0) if not args: parser.print_help() - print '\nError: No application specified.\n' + _stderr('\nError: No application specified.\n') sys.exit(1) - try: - sys.path.insert(0, '.') - sys.modules.setdefault('bottle', sys.modules['__main__']) - except (AttributeError, ImportError), e: - parser.error(e.args[0]) + sys.path.insert(0, '.') + sys.modules.setdefault('bottle', sys.modules['__main__']) + + host, port = (opt.bind or 'localhost'), 8080 + if ':' in host and host.rfind(']') < host.rfind(':'): + host, port = host.rsplit(':', 1) + host = host.strip('[]') + + run(args[0], host=host, port=int(port), server=opt.server, + reloader=opt.reload, plugins=opt.plugin, debug=opt.debug) + - if opt.bind and ':' in opt.bind: - host, port = opt.bind.rsplit(':', 1) - else: - host, port = (opt.bind or 'localhost'), 8080 - debug(opt.debug) - run(args[0], host=host, port=port, server=opt.server, reloader=opt.reload, plugins=opt.plugin) # THE END diff --git a/module/lib/wsgiserver/LICENSE.txt b/module/lib/wsgiserver/LICENSE.txt index a15165ee26..23c7d09635 100644 --- a/module/lib/wsgiserver/LICENSE.txt +++ b/module/lib/wsgiserver/LICENSE.txt @@ -1,22 +1,27 @@ -Copyright (c) 2004-2007, CherryPy Team (team@cherrypy.org) -All rights reserved. +**Copyright © 2004-2017, CherryPy Team (team@cherrypy.org)** -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +**All rights reserved.** - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of the CherryPy Team nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. +* * * -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of CherryPy nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER diff --git a/module/lib/wsgiserver/__init__.py b/module/lib/wsgiserver/__init__.py index c380e18b05..e6e48a6c1f 100644 --- a/module/lib/wsgiserver/__init__.py +++ b/module/lib/wsgiserver/__init__.py @@ -1,1794 +1,13 @@ -"""A high-speed, production ready, thread pooled, generic WSGI server. - -Simplest example on how to use this module directly -(without using CherryPy's application machinery): - - from cherrypy import wsgiserver - - def my_crazy_app(environ, start_response): - status = '200 OK' - response_headers = [('Content-type','text/plain')] - start_response(status, response_headers) - return ['Hello world!\n'] - - server = wsgiserver.CherryPyWSGIServer( - ('0.0.0.0', 8070), my_crazy_app, - server_name='www.cherrypy.example') - -The CherryPy WSGI server can serve as many WSGI applications -as you want in one instance by using a WSGIPathInfoDispatcher: - - d = WSGIPathInfoDispatcher({'/': my_crazy_app, '/blog': my_blog_app}) - server = wsgiserver.CherryPyWSGIServer(('0.0.0.0', 80), d) - -Want SSL support? Just set these attributes: - - server.ssl_certificate = - server.ssl_private_key = - - if __name__ == '__main__': - try: - server.start() - except KeyboardInterrupt: - server.stop() - -This won't call the CherryPy engine (application side) at all, only the -WSGI server, which is independant from the rest of CherryPy. Don't -let the name "CherryPyWSGIServer" throw you; the name merely reflects -its origin, not its coupling. - -For those of you wanting to understand internals of this module, here's the -basic call flow. The server's listening thread runs a very tight loop, -sticking incoming connections onto a Queue: - - server = CherryPyWSGIServer(...) - server.start() - while True: - tick() - # This blocks until a request comes in: - child = socket.accept() - conn = HTTPConnection(child, ...) - server.requests.put(conn) - -Worker threads are kept in a pool and poll the Queue, popping off and then -handling each connection in turn. Each connection can consist of an arbitrary -number of requests and their responses, so we run a nested loop: - - while True: - conn = server.requests.get() - conn.communicate() - -> while True: - req = HTTPRequest(...) - req.parse_request() - -> # Read the Request-Line, e.g. "GET /page HTTP/1.1" - req.rfile.readline() - req.read_headers() - req.respond() - -> response = wsgi_app(...) - try: - for chunk in response: - if chunk: - req.write(chunk) - finally: - if hasattr(response, "close"): - response.close() - if req.close_connection: - return -""" - - -import base64 -import os -import Queue -import re -quoted_slash = re.compile("(?i)%2F") -import rfc822 -import socket -try: - import cStringIO as StringIO -except ImportError: - import StringIO - -_fileobject_uses_str_type = isinstance(socket._fileobject(None)._rbuf, basestring) - -import sys -import threading -import time -import traceback -from urllib import unquote -from urlparse import urlparse -import warnings +"""High-performance, pure-Python HTTP server used by CherryPy.""" try: - from OpenSSL import SSL - from OpenSSL import crypto + import pkg_resources except ImportError: - SSL = None - -import errno - -def plat_specific_errors(*errnames): - """Return error numbers for all errors in errnames on this platform. - - The 'errno' module contains different global constants depending on - the specific platform (OS). This function will return the list of - numeric values for a given list of potential names. - """ - errno_names = dir(errno) - nums = [getattr(errno, k) for k in errnames if k in errno_names] - # de-dupe the list - return dict.fromkeys(nums).keys() - -socket_error_eintr = plat_specific_errors("EINTR", "WSAEINTR") - -socket_errors_to_ignore = plat_specific_errors( - "EPIPE", - "EBADF", "WSAEBADF", - "ENOTSOCK", "WSAENOTSOCK", - "ETIMEDOUT", "WSAETIMEDOUT", - "ECONNREFUSED", "WSAECONNREFUSED", - "ECONNRESET", "WSAECONNRESET", - "ECONNABORTED", "WSAECONNABORTED", - "ENETRESET", "WSAENETRESET", - "EHOSTDOWN", "EHOSTUNREACH", - ) -socket_errors_to_ignore.append("timed out") - -socket_errors_nonblocking = plat_specific_errors( - 'EAGAIN', 'EWOULDBLOCK', 'WSAEWOULDBLOCK') - -comma_separated_headers = ['ACCEPT', 'ACCEPT-CHARSET', 'ACCEPT-ENCODING', - 'ACCEPT-LANGUAGE', 'ACCEPT-RANGES', 'ALLOW', 'CACHE-CONTROL', - 'CONNECTION', 'CONTENT-ENCODING', 'CONTENT-LANGUAGE', 'EXPECT', - 'IF-MATCH', 'IF-NONE-MATCH', 'PRAGMA', 'PROXY-AUTHENTICATE', 'TE', - 'TRAILER', 'TRANSFER-ENCODING', 'UPGRADE', 'VARY', 'VIA', 'WARNING', - 'WWW-AUTHENTICATE'] - - -class WSGIPathInfoDispatcher(object): - """A WSGI dispatcher for dispatch based on the PATH_INFO. - - apps: a dict or list of (path_prefix, app) pairs. - """ - - def __init__(self, apps): - try: - apps = apps.items() - except AttributeError: - pass - - # Sort the apps by len(path), descending - apps.sort() - apps.reverse() - - # The path_prefix strings must start, but not end, with a slash. - # Use "" instead of "/". - self.apps = [(p.rstrip("/"), a) for p, a in apps] - - def __call__(self, environ, start_response): - path = environ["PATH_INFO"] or "/" - for p, app in self.apps: - # The apps list should be sorted by length, descending. - if path.startswith(p + "/") or path == p: - environ = environ.copy() - environ["SCRIPT_NAME"] = environ["SCRIPT_NAME"] + p - environ["PATH_INFO"] = path[len(p):] - return app(environ, start_response) - - start_response('404 Not Found', [('Content-Type', 'text/plain'), - ('Content-Length', '0')]) - return [''] - - -class MaxSizeExceeded(Exception): pass -class SizeCheckWrapper(object): - """Wraps a file-like object, raising MaxSizeExceeded if too large.""" - - def __init__(self, rfile, maxlen): - self.rfile = rfile - self.maxlen = maxlen - self.bytes_read = 0 - - def _check_length(self): - if self.maxlen and self.bytes_read > self.maxlen: - raise MaxSizeExceeded() - - def read(self, size=None): - data = self.rfile.read(size) - self.bytes_read += len(data) - self._check_length() - return data - - def readline(self, size=None): - if size is not None: - data = self.rfile.readline(size) - self.bytes_read += len(data) - self._check_length() - return data - - # User didn't specify a size ... - # We read the line in chunks to make sure it's not a 100MB line ! - res = [] - while True: - data = self.rfile.readline(256) - self.bytes_read += len(data) - self._check_length() - res.append(data) - # See http://www.cherrypy.org/ticket/421 - if len(data) < 256 or data[-1:] == "\n": - return ''.join(res) - - def readlines(self, sizehint=0): - # Shamelessly stolen from StringIO - total = 0 - lines = [] - line = self.readline() - while line: - lines.append(line) - total += len(line) - if 0 < sizehint <= total: - break - line = self.readline() - return lines - - def close(self): - self.rfile.close() - - def __iter__(self): - return self - - def next(self): - data = self.rfile.next() - self.bytes_read += len(data) - self._check_length() - return data - - -class HTTPRequest(object): - """An HTTP Request (and response). - - A single HTTP connection may consist of multiple request/response pairs. - - send: the 'send' method from the connection's socket object. - wsgi_app: the WSGI application to call. - environ: a partial WSGI environ (server and connection entries). - The caller MUST set the following entries: - * All wsgi.* entries, including .input - * SERVER_NAME and SERVER_PORT - * Any SSL_* entries - * Any custom entries like REMOTE_ADDR and REMOTE_PORT - * SERVER_SOFTWARE: the value to write in the "Server" response header. - * ACTUAL_SERVER_PROTOCOL: the value to write in the Status-Line of - the response. From RFC 2145: "An HTTP server SHOULD send a - response version equal to the highest version for which the - server is at least conditionally compliant, and whose major - version is less than or equal to the one received in the - request. An HTTP server MUST NOT send a version for which - it is not at least conditionally compliant." - - outheaders: a list of header tuples to write in the response. - ready: when True, the request has been parsed and is ready to begin - generating the response. When False, signals the calling Connection - that the response should not be generated and the connection should - close. - close_connection: signals the calling Connection that the request - should close. This does not imply an error! The client and/or - server may each request that the connection be closed. - chunked_write: if True, output will be encoded with the "chunked" - transfer-coding. This value is set automatically inside - send_headers. - """ - - max_request_header_size = 0 - max_request_body_size = 0 - - def __init__(self, wfile, environ, wsgi_app): - self.rfile = environ['wsgi.input'] - self.wfile = wfile - self.environ = environ.copy() - self.wsgi_app = wsgi_app - - self.ready = False - self.started_response = False - self.status = "" - self.outheaders = [] - self.sent_headers = False - self.close_connection = False - self.chunked_write = False - - def parse_request(self): - """Parse the next HTTP request start-line and message-headers.""" - self.rfile.maxlen = self.max_request_header_size - self.rfile.bytes_read = 0 - - try: - self._parse_request() - except MaxSizeExceeded: - self.simple_response("413 Request Entity Too Large") - return - - def _parse_request(self): - # HTTP/1.1 connections are persistent by default. If a client - # requests a page, then idles (leaves the connection open), - # then rfile.readline() will raise socket.error("timed out"). - # Note that it does this based on the value given to settimeout(), - # and doesn't need the client to request or acknowledge the close - # (although your TCP stack might suffer for it: cf Apache's history - # with FIN_WAIT_2). - request_line = self.rfile.readline() - if not request_line: - # Force self.ready = False so the connection will close. - self.ready = False - return - - if request_line == "\r\n": - # RFC 2616 sec 4.1: "...if the server is reading the protocol - # stream at the beginning of a message and receives a CRLF - # first, it should ignore the CRLF." - # But only ignore one leading line! else we enable a DoS. - request_line = self.rfile.readline() - if not request_line: - self.ready = False - return - - environ = self.environ - - try: - method, path, req_protocol = request_line.strip().split(" ", 2) - except ValueError: - self.simple_response(400, "Malformed Request-Line") - return - - environ["REQUEST_METHOD"] = method - - # path may be an abs_path (including "http://host.domain.tld"); - scheme, location, path, params, qs, frag = urlparse(path) - - if frag: - self.simple_response("400 Bad Request", - "Illegal #fragment in Request-URI.") - return - - if scheme: - environ["wsgi.url_scheme"] = scheme - if params: - path = path + ";" + params - - environ["SCRIPT_NAME"] = "" - - # Unquote the path+params (e.g. "/this%20path" -> "this path"). - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.2 - # - # But note that "...a URI must be separated into its components - # before the escaped characters within those components can be - # safely decoded." http://www.ietf.org/rfc/rfc2396.txt, sec 2.4.2 - atoms = [unquote(x) for x in quoted_slash.split(path)] - path = "%2F".join(atoms) - environ["PATH_INFO"] = path - - # Note that, like wsgiref and most other WSGI servers, - # we unquote the path but not the query string. - environ["QUERY_STRING"] = qs - - # Compare request and server HTTP protocol versions, in case our - # server does not support the requested protocol. Limit our output - # to min(req, server). We want the following output: - # request server actual written supported response - # protocol protocol response protocol feature set - # a 1.0 1.0 1.0 1.0 - # b 1.0 1.1 1.1 1.0 - # c 1.1 1.0 1.0 1.0 - # d 1.1 1.1 1.1 1.1 - # Notice that, in (b), the response will be "HTTP/1.1" even though - # the client only understands 1.0. RFC 2616 10.5.6 says we should - # only return 505 if the _major_ version is different. - rp = int(req_protocol[5]), int(req_protocol[7]) - server_protocol = environ["ACTUAL_SERVER_PROTOCOL"] - sp = int(server_protocol[5]), int(server_protocol[7]) - if sp[0] != rp[0]: - self.simple_response("505 HTTP Version Not Supported") - return - # Bah. "SERVER_PROTOCOL" is actually the REQUEST protocol. - environ["SERVER_PROTOCOL"] = req_protocol - self.response_protocol = "HTTP/%s.%s" % min(rp, sp) - - # If the Request-URI was an absoluteURI, use its location atom. - if location: - environ["SERVER_NAME"] = location - - # then all the http headers - try: - self.read_headers() - except ValueError, ex: - self.simple_response("400 Bad Request", repr(ex.args)) - return - - mrbs = self.max_request_body_size - if mrbs and int(environ.get("CONTENT_LENGTH", 0)) > mrbs: - self.simple_response("413 Request Entity Too Large") - return - - # Persistent connection support - if self.response_protocol == "HTTP/1.1": - # Both server and client are HTTP/1.1 - if environ.get("HTTP_CONNECTION", "") == "close": - self.close_connection = True - else: - # Either the server or client (or both) are HTTP/1.0 - if environ.get("HTTP_CONNECTION", "") != "Keep-Alive": - self.close_connection = True - - # Transfer-Encoding support - te = None - if self.response_protocol == "HTTP/1.1": - te = environ.get("HTTP_TRANSFER_ENCODING") - if te: - te = [x.strip().lower() for x in te.split(",") if x.strip()] - - self.chunked_read = False - - if te: - for enc in te: - if enc == "chunked": - self.chunked_read = True - else: - # Note that, even if we see "chunked", we must reject - # if there is an extension we don't recognize. - self.simple_response("501 Unimplemented") - self.close_connection = True - return - - # From PEP 333: - # "Servers and gateways that implement HTTP 1.1 must provide - # transparent support for HTTP 1.1's "expect/continue" mechanism. - # This may be done in any of several ways: - # 1. Respond to requests containing an Expect: 100-continue request - # with an immediate "100 Continue" response, and proceed normally. - # 2. Proceed with the request normally, but provide the application - # with a wsgi.input stream that will send the "100 Continue" - # response if/when the application first attempts to read from - # the input stream. The read request must then remain blocked - # until the client responds. - # 3. Wait until the client decides that the server does not support - # expect/continue, and sends the request body on its own. - # (This is suboptimal, and is not recommended.) - # - # We used to do 3, but are now doing 1. Maybe we'll do 2 someday, - # but it seems like it would be a big slowdown for such a rare case. - if environ.get("HTTP_EXPECT", "") == "100-continue": - self.simple_response(100) - - self.ready = True - - def read_headers(self): - """Read header lines from the incoming stream.""" - environ = self.environ - - while True: - line = self.rfile.readline() - if not line: - # No more data--illegal end of headers - raise ValueError("Illegal end of headers.") - - if line == '\r\n': - # Normal end of headers - break - - if line[0] in ' \t': - # It's a continuation line. - v = line.strip() - else: - k, v = line.split(":", 1) - k, v = k.strip().upper(), v.strip() - envname = "HTTP_" + k.replace("-", "_") - - if k in comma_separated_headers: - existing = environ.get(envname) - if existing: - v = ", ".join((existing, v)) - environ[envname] = v - - ct = environ.pop("HTTP_CONTENT_TYPE", None) - if ct is not None: - environ["CONTENT_TYPE"] = ct - cl = environ.pop("HTTP_CONTENT_LENGTH", None) - if cl is not None: - environ["CONTENT_LENGTH"] = cl - - def decode_chunked(self): - """Decode the 'chunked' transfer coding.""" - cl = 0 - data = StringIO.StringIO() - while True: - line = self.rfile.readline().strip().split(";", 1) - chunk_size = int(line.pop(0), 16) - if chunk_size <= 0: - break -## if line: chunk_extension = line[0] - cl += chunk_size - data.write(self.rfile.read(chunk_size)) - crlf = self.rfile.read(2) - if crlf != "\r\n": - self.simple_response("400 Bad Request", - "Bad chunked transfer coding " - "(expected '\\r\\n', got %r)" % crlf) - return - - # Grab any trailer headers - self.read_headers() - - data.seek(0) - self.environ["wsgi.input"] = data - self.environ["CONTENT_LENGTH"] = str(cl) or "" - return True - - def respond(self): - """Call the appropriate WSGI app and write its iterable output.""" - # Set rfile.maxlen to ensure we don't read past Content-Length. - # This will also be used to read the entire request body if errors - # are raised before the app can read the body. - if self.chunked_read: - # If chunked, Content-Length will be 0. - self.rfile.maxlen = self.max_request_body_size - else: - cl = int(self.environ.get("CONTENT_LENGTH", 0)) - if self.max_request_body_size: - self.rfile.maxlen = min(cl, self.max_request_body_size) - else: - self.rfile.maxlen = cl - self.rfile.bytes_read = 0 - - try: - self._respond() - except MaxSizeExceeded: - if not self.sent_headers: - self.simple_response("413 Request Entity Too Large") - return - - def _respond(self): - if self.chunked_read: - if not self.decode_chunked(): - self.close_connection = True - return - - response = self.wsgi_app(self.environ, self.start_response) - try: - for chunk in response: - # "The start_response callable must not actually transmit - # the response headers. Instead, it must store them for the - # server or gateway to transmit only after the first - # iteration of the application return value that yields - # a NON-EMPTY string, or upon the application's first - # invocation of the write() callable." (PEP 333) - if chunk: - self.write(chunk) - finally: - if hasattr(response, "close"): - response.close() - - if (self.ready and not self.sent_headers): - self.sent_headers = True - self.send_headers() - if self.chunked_write: - self.wfile.sendall("0\r\n\r\n") - - def simple_response(self, status, msg=""): - """Write a simple response back to the client.""" - status = str(status) - buf = ["%s %s\r\n" % (self.environ['ACTUAL_SERVER_PROTOCOL'], status), - "Content-Length: %s\r\n" % len(msg), - "Content-Type: text/plain\r\n"] - - if status[:3] == "413" and self.response_protocol == 'HTTP/1.1': - # Request Entity Too Large - self.close_connection = True - buf.append("Connection: close\r\n") - - buf.append("\r\n") - if msg: - buf.append(msg) - - try: - self.wfile.sendall("".join(buf)) - except socket.error, x: - if x.args[0] not in socket_errors_to_ignore: - raise - - def start_response(self, status, headers, exc_info = None): - """WSGI callable to begin the HTTP response.""" - # "The application may call start_response more than once, - # if and only if the exc_info argument is provided." - if self.started_response and not exc_info: - raise AssertionError("WSGI start_response called a second " - "time with no exc_info.") - - # "if exc_info is provided, and the HTTP headers have already been - # sent, start_response must raise an error, and should raise the - # exc_info tuple." - if self.sent_headers: - try: - raise exc_info[0], exc_info[1], exc_info[2] - finally: - exc_info = None - - self.started_response = True - self.status = status - self.outheaders.extend(headers) - return self.write - - def write(self, chunk): - """WSGI callable to write unbuffered data to the client. - - This method is also used internally by start_response (to write - data from the iterable returned by the WSGI application). - """ - if not self.started_response: - raise AssertionError("WSGI write called before start_response.") - - if not self.sent_headers: - self.sent_headers = True - self.send_headers() - - if self.chunked_write and chunk: - buf = [hex(len(chunk))[2:], "\r\n", chunk, "\r\n"] - self.wfile.sendall("".join(buf)) - else: - self.wfile.sendall(chunk) - - def send_headers(self): - """Assert, process, and send the HTTP response message-headers.""" - hkeys = [key.lower() for key, value in self.outheaders] - status = int(self.status[:3]) - - if status == 413: - # Request Entity Too Large. Close conn to avoid garbage. - self.close_connection = True - elif "content-length" not in hkeys: - # "All 1xx (informational), 204 (no content), - # and 304 (not modified) responses MUST NOT - # include a message-body." So no point chunking. - if status < 200 or status in (204, 205, 304): - pass - else: - if (self.response_protocol == 'HTTP/1.1' - and self.environ["REQUEST_METHOD"] != 'HEAD'): - # Use the chunked transfer-coding - self.chunked_write = True - self.outheaders.append(("Transfer-Encoding", "chunked")) - else: - # Closing the conn is the only way to determine len. - self.close_connection = True - - if "connection" not in hkeys: - if self.response_protocol == 'HTTP/1.1': - # Both server and client are HTTP/1.1 or better - if self.close_connection: - self.outheaders.append(("Connection", "close")) - else: - # Server and/or client are HTTP/1.0 - if not self.close_connection: - self.outheaders.append(("Connection", "Keep-Alive")) - - if (not self.close_connection) and (not self.chunked_read): - # Read any remaining request body data on the socket. - # "If an origin server receives a request that does not include an - # Expect request-header field with the "100-continue" expectation, - # the request includes a request body, and the server responds - # with a final status code before reading the entire request body - # from the transport connection, then the server SHOULD NOT close - # the transport connection until it has read the entire request, - # or until the client closes the connection. Otherwise, the client - # might not reliably receive the response message. However, this - # requirement is not be construed as preventing a server from - # defending itself against denial-of-service attacks, or from - # badly broken client implementations." - size = self.rfile.maxlen - self.rfile.bytes_read - if size > 0: - self.rfile.read(size) - - if "date" not in hkeys: - self.outheaders.append(("Date", rfc822.formatdate())) - - if "server" not in hkeys: - self.outheaders.append(("Server", self.environ['SERVER_SOFTWARE'])) - - buf = [self.environ['ACTUAL_SERVER_PROTOCOL'], " ", self.status, "\r\n"] - try: - buf += [k + ": " + v + "\r\n" for k, v in self.outheaders] - except TypeError: - if not isinstance(k, str): - raise TypeError("WSGI response header key %r is not a string.") - if not isinstance(v, str): - raise TypeError("WSGI response header value %r is not a string.") - else: - raise - buf.append("\r\n") - self.wfile.sendall("".join(buf)) - - -class NoSSLError(Exception): - """Exception raised when a client speaks HTTP to an HTTPS socket.""" - pass - - -class FatalSSLAlert(Exception): - """Exception raised when the SSL implementation signals a fatal alert.""" - pass - - -if not _fileobject_uses_str_type: - class CP_fileobject(socket._fileobject): - """Faux file object attached to a socket object.""" - - def sendall(self, data): - """Sendall for non-blocking sockets.""" - while data: - try: - bytes_sent = self.send(data) - data = data[bytes_sent:] - except socket.error, e: - if e.args[0] not in socket_errors_nonblocking: - raise - - def send(self, data): - return self._sock.send(data) - - def flush(self): - if self._wbuf: - buffer = "".join(self._wbuf) - self._wbuf = [] - self.sendall(buffer) - - def recv(self, size): - while True: - try: - return self._sock.recv(size) - except socket.error, e: - if (e.args[0] not in socket_errors_nonblocking - and e.args[0] not in socket_error_eintr): - raise - - def read(self, size=-1): - # Use max, disallow tiny reads in a loop as they are very inefficient. - # We never leave read() with any leftover data from a new recv() call - # in our internal buffer. - rbufsize = max(self._rbufsize, self.default_bufsize) - # Our use of StringIO rather than lists of string objects returned by - # recv() minimizes memory usage and fragmentation that occurs when - # rbufsize is large compared to the typical return value of recv(). - buf = self._rbuf - buf.seek(0, 2) # seek end - if size < 0: - # Read until EOF - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - data = self.recv(rbufsize) - if not data: - break - buf.write(data) - return buf.getvalue() - else: - # Read until size bytes or EOF seen, whichever comes first - buf_len = buf.tell() - if buf_len >= size: - # Already have size bytes in our buffer? Extract and return. - buf.seek(0) - rv = buf.read(size) - self._rbuf = StringIO.StringIO() - self._rbuf.write(buf.read()) - return rv - - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - left = size - buf_len - # recv() will malloc the amount of memory given as its - # parameter even though it often returns much less data - # than that. The returned data string is short lived - # as we copy it into a StringIO and free it. This avoids - # fragmentation issues on many platforms. - data = self.recv(left) - if not data: - break - n = len(data) - if n == size and not buf_len: - # Shortcut. Avoid buffer data copies when: - # - We have no data in our buffer. - # AND - # - Our call to recv returned exactly the - # number of bytes we were asked to read. - return data - if n == left: - buf.write(data) - del data # explicit free - break - assert n <= left, "recv(%d) returned %d bytes" % (left, n) - buf.write(data) - buf_len += n - del data # explicit free - #assert buf_len == buf.tell() - return buf.getvalue() - - def readline(self, size=-1): - buf = self._rbuf - buf.seek(0, 2) # seek end - if buf.tell() > 0: - # check if we already have it in our buffer - buf.seek(0) - bline = buf.readline(size) - if bline.endswith('\n') or len(bline) == size: - self._rbuf = StringIO.StringIO() - self._rbuf.write(buf.read()) - return bline - del bline - if size < 0: - # Read until \n or EOF, whichever comes first - if self._rbufsize <= 1: - # Speed up unbuffered case - buf.seek(0) - buffers = [buf.read()] - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - data = None - recv = self.recv - while data != "\n": - data = recv(1) - if not data: - break - buffers.append(data) - return "".join(buffers) - - buf.seek(0, 2) # seek end - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - data = self.recv(self._rbufsize) - if not data: - break - nl = data.find('\n') - if nl >= 0: - nl += 1 - buf.write(data[:nl]) - self._rbuf.write(data[nl:]) - del data - break - buf.write(data) - return buf.getvalue() - else: - # Read until size bytes or \n or EOF seen, whichever comes first - buf.seek(0, 2) # seek end - buf_len = buf.tell() - if buf_len >= size: - buf.seek(0) - rv = buf.read(size) - self._rbuf = StringIO.StringIO() - self._rbuf.write(buf.read()) - return rv - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - data = self.recv(self._rbufsize) - if not data: - break - left = size - buf_len - # did we just receive a newline? - nl = data.find('\n', 0, left) - if nl >= 0: - nl += 1 - # save the excess data to _rbuf - self._rbuf.write(data[nl:]) - if buf_len: - buf.write(data[:nl]) - break - else: - # Shortcut. Avoid data copy through buf when returning - # a substring of our first recv(). - return data[:nl] - n = len(data) - if n == size and not buf_len: - # Shortcut. Avoid data copy through buf when - # returning exactly all of our first recv(). - return data - if n >= left: - buf.write(data[:left]) - self._rbuf.write(data[left:]) - break - buf.write(data) - buf_len += n - #assert buf_len == buf.tell() - return buf.getvalue() - -else: - class CP_fileobject(socket._fileobject): - """Faux file object attached to a socket object.""" - - def sendall(self, data): - """Sendall for non-blocking sockets.""" - while data: - try: - bytes_sent = self.send(data) - data = data[bytes_sent:] - except socket.error, e: - if e.args[0] not in socket_errors_nonblocking: - raise - - def send(self, data): - return self._sock.send(data) - - def flush(self): - if self._wbuf: - buffer = "".join(self._wbuf) - self._wbuf = [] - self.sendall(buffer) - - def recv(self, size): - while True: - try: - return self._sock.recv(size) - except socket.error, e: - if (e.args[0] not in socket_errors_nonblocking - and e.args[0] not in socket_error_eintr): - raise - - def read(self, size=-1): - if size < 0: - # Read until EOF - buffers = [self._rbuf] - self._rbuf = "" - if self._rbufsize <= 1: - recv_size = self.default_bufsize - else: - recv_size = self._rbufsize - - while True: - data = self.recv(recv_size) - if not data: - break - buffers.append(data) - return "".join(buffers) - else: - # Read until size bytes or EOF seen, whichever comes first - data = self._rbuf - buf_len = len(data) - if buf_len >= size: - self._rbuf = data[size:] - return data[:size] - buffers = [] - if data: - buffers.append(data) - self._rbuf = "" - while True: - left = size - buf_len - recv_size = max(self._rbufsize, left) - data = self.recv(recv_size) - if not data: - break - buffers.append(data) - n = len(data) - if n >= left: - self._rbuf = data[left:] - buffers[-1] = data[:left] - break - buf_len += n - return "".join(buffers) - - def readline(self, size=-1): - data = self._rbuf - if size < 0: - # Read until \n or EOF, whichever comes first - if self._rbufsize <= 1: - # Speed up unbuffered case - assert data == "" - buffers = [] - while data != "\n": - data = self.recv(1) - if not data: - break - buffers.append(data) - return "".join(buffers) - nl = data.find('\n') - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - return data[:nl] - buffers = [] - if data: - buffers.append(data) - self._rbuf = "" - while True: - data = self.recv(self._rbufsize) - if not data: - break - buffers.append(data) - nl = data.find('\n') - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - buffers[-1] = data[:nl] - break - return "".join(buffers) - else: - # Read until size bytes or \n or EOF seen, whichever comes first - nl = data.find('\n', 0, size) - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - return data[:nl] - buf_len = len(data) - if buf_len >= size: - self._rbuf = data[size:] - return data[:size] - buffers = [] - if data: - buffers.append(data) - self._rbuf = "" - while True: - data = self.recv(self._rbufsize) - if not data: - break - buffers.append(data) - left = size - buf_len - nl = data.find('\n', 0, left) - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - buffers[-1] = data[:nl] - break - n = len(data) - if n >= left: - self._rbuf = data[left:] - buffers[-1] = data[:left] - break - buf_len += n - return "".join(buffers) - - -class SSL_fileobject(CP_fileobject): - """SSL file object attached to a socket object.""" - - ssl_timeout = 3 - ssl_retry = .01 - - def _safe_call(self, is_reader, call, *args, **kwargs): - """Wrap the given call with SSL error-trapping. - - is_reader: if False EOF errors will be raised. If True, EOF errors - will return "" (to emulate normal sockets). - """ - start = time.time() - while True: - try: - return call(*args, **kwargs) - except SSL.WantReadError: - # Sleep and try again. This is dangerous, because it means - # the rest of the stack has no way of differentiating - # between a "new handshake" error and "client dropped". - # Note this isn't an endless loop: there's a timeout below. - time.sleep(self.ssl_retry) - except SSL.WantWriteError: - time.sleep(self.ssl_retry) - except SSL.SysCallError, e: - if is_reader and e.args == (-1, 'Unexpected EOF'): - return "" - - errnum = e.args[0] - if is_reader and errnum in socket_errors_to_ignore: - return "" - raise socket.error(errnum) - except SSL.Error, e: - if is_reader and e.args == (-1, 'Unexpected EOF'): - return "" - - thirdarg = None - try: - thirdarg = e.args[0][0][2] - except IndexError: - pass - - if thirdarg == 'http request': - # The client is talking HTTP to an HTTPS server. - raise NoSSLError() - raise FatalSSLAlert(*e.args) - except: - raise - - if time.time() - start > self.ssl_timeout: - raise socket.timeout("timed out") - - def recv(self, *args, **kwargs): - buf = [] - r = super(SSL_fileobject, self).recv - while True: - data = self._safe_call(True, r, *args, **kwargs) - buf.append(data) - p = self._sock.pending() - if not p: - return "".join(buf) - - def sendall(self, *args, **kwargs): - return self._safe_call(False, super(SSL_fileobject, self).sendall, *args, **kwargs) - - def send(self, *args, **kwargs): - return self._safe_call(False, super(SSL_fileobject, self).send, *args, **kwargs) - - -class HTTPConnection(object): - """An HTTP connection (active socket). - - socket: the raw socket object (usually TCP) for this connection. - wsgi_app: the WSGI application for this server/connection. - environ: a WSGI environ template. This will be copied for each request. - - rfile: a fileobject for reading from the socket. - send: a function for writing (+ flush) to the socket. - """ - - rbufsize = -1 - RequestHandlerClass = HTTPRequest - environ = {"wsgi.version": (1, 0), - "wsgi.url_scheme": "http", - "wsgi.multithread": True, - "wsgi.multiprocess": False, - "wsgi.run_once": False, - "wsgi.errors": sys.stderr, - } - - def __init__(self, sock, wsgi_app, environ): - self.socket = sock - self.wsgi_app = wsgi_app - - # Copy the class environ into self. - self.environ = self.environ.copy() - self.environ.update(environ) - - if SSL and isinstance(sock, SSL.ConnectionType): - timeout = sock.gettimeout() - self.rfile = SSL_fileobject(sock, "rb", self.rbufsize) - self.rfile.ssl_timeout = timeout - self.wfile = SSL_fileobject(sock, "wb", -1) - self.wfile.ssl_timeout = timeout - else: - self.rfile = CP_fileobject(sock, "rb", self.rbufsize) - self.wfile = CP_fileobject(sock, "wb", -1) - - # Wrap wsgi.input but not HTTPConnection.rfile itself. - # We're also not setting maxlen yet; we'll do that separately - # for headers and body for each iteration of self.communicate - # (if maxlen is 0 the wrapper doesn't check length). - self.environ["wsgi.input"] = SizeCheckWrapper(self.rfile, 0) - - def communicate(self): - """Read each request and respond appropriately.""" - try: - while True: - # (re)set req to None so that if something goes wrong in - # the RequestHandlerClass constructor, the error doesn't - # get written to the previous request. - req = None - req = self.RequestHandlerClass(self.wfile, self.environ, - self.wsgi_app) - - # This order of operations should guarantee correct pipelining. - req.parse_request() - if not req.ready: - return - - req.respond() - if req.close_connection: - return - - except socket.error, e: - errnum = e.args[0] - if errnum == 'timed out': - if req and not req.sent_headers: - req.simple_response("408 Request Timeout") - elif errnum not in socket_errors_to_ignore: - if req and not req.sent_headers: - req.simple_response("500 Internal Server Error", - format_exc()) - return - except (KeyboardInterrupt, SystemExit): - raise - except FatalSSLAlert, e: - # Close the connection. - return - except NoSSLError: - if req and not req.sent_headers: - # Unwrap our wfile - req.wfile = CP_fileobject(self.socket._sock, "wb", -1) - req.simple_response("400 Bad Request", - "The client sent a plain HTTP request, but " - "this server only speaks HTTPS on this port.") - self.linger = True - except Exception, e: - if req and not req.sent_headers: - req.simple_response("500 Internal Server Error", format_exc()) - - linger = False - - def close(self): - """Close the socket underlying this connection.""" - self.rfile.close() - - if not self.linger: - # Python's socket module does NOT call close on the kernel socket - # when you call socket.close(). We do so manually here because we - # want this server to send a FIN TCP segment immediately. Note this - # must be called *before* calling socket.close(), because the latter - # drops its reference to the kernel socket. - self.socket._sock.close() - self.socket.close() - else: - # On the other hand, sometimes we want to hang around for a bit - # to make sure the client has a chance to read our entire - # response. Skipping the close() calls here delays the FIN - # packet until the socket object is garbage-collected later. - # Someday, perhaps, we'll do the full lingering_close that - # Apache does, but not today. - pass - - -def format_exc(limit=None): - """Like print_exc() but return a string. Backport for Python 2.3.""" - try: - etype, value, tb = sys.exc_info() - return ''.join(traceback.format_exception(etype, value, tb, limit)) - finally: - etype = value = tb = None - - -_SHUTDOWNREQUEST = None - -class WorkerThread(threading.Thread): - """Thread which continuously polls a Queue for Connection objects. - - server: the HTTP Server which spawned this thread, and which owns the - Queue and is placing active connections into it. - ready: a simple flag for the calling server to know when this thread - has begun polling the Queue. - - Due to the timing issues of polling a Queue, a WorkerThread does not - check its own 'ready' flag after it has started. To stop the thread, - it is necessary to stick a _SHUTDOWNREQUEST object onto the Queue - (one for each running WorkerThread). - """ - - conn = None - - def __init__(self, server): - self.ready = False - self.server = server - threading.Thread.__init__(self) - - def run(self): - try: - self.ready = True - while True: - conn = self.server.requests.get() - if conn is _SHUTDOWNREQUEST: - return - - self.conn = conn - try: - conn.communicate() - finally: - conn.close() - self.conn = None - except (KeyboardInterrupt, SystemExit), exc: - self.server.interrupt = exc - - -class ThreadPool(object): - """A Request Queue for the CherryPyWSGIServer which pools threads. - - ThreadPool objects must provide min, get(), put(obj), start() - and stop(timeout) attributes. - """ - - def __init__(self, server, min=10, max=-1): - self.server = server - self.min = min - self.max = max - self._threads = [] - self._queue = Queue.Queue() - self.get = self._queue.get - - def start(self): - """Start the pool of threads.""" - for i in xrange(self.min): - self._threads.append(WorkerThread(self.server)) - for worker in self._threads: - worker.setName("CP WSGIServer " + worker.getName()) - worker.start() - for worker in self._threads: - while not worker.ready: - time.sleep(.1) - - def _get_idle(self): - """Number of worker threads which are idle. Read-only.""" - return len([t for t in self._threads if t.conn is None]) - idle = property(_get_idle, doc=_get_idle.__doc__) - - def put(self, obj): - self._queue.put(obj) - if obj is _SHUTDOWNREQUEST: - return - - def grow(self, amount): - """Spawn new worker threads (not above self.max).""" - for i in xrange(amount): - if self.max > 0 and len(self._threads) >= self.max: - break - worker = WorkerThread(self.server) - worker.setName("CP WSGIServer " + worker.getName()) - self._threads.append(worker) - worker.start() - - def shrink(self, amount): - """Kill off worker threads (not below self.min).""" - # Grow/shrink the pool if necessary. - # Remove any dead threads from our list - for t in self._threads: - if not t.isAlive(): - self._threads.remove(t) - amount -= 1 - - if amount > 0: - for i in xrange(min(amount, len(self._threads) - self.min)): - # Put a number of shutdown requests on the queue equal - # to 'amount'. Once each of those is processed by a worker, - # that worker will terminate and be culled from our list - # in self.put. - self._queue.put(_SHUTDOWNREQUEST) - - def stop(self, timeout=5): - # Must shut down threads here so the code that calls - # this method can know when all threads are stopped. - for worker in self._threads: - self._queue.put(_SHUTDOWNREQUEST) - - # Don't join currentThread (when stop is called inside a request). - current = threading.currentThread() - while self._threads: - worker = self._threads.pop() - if worker is not current and worker.isAlive(): - try: - if timeout is None or timeout < 0: - worker.join() - else: - worker.join(timeout) - if worker.isAlive(): - # We exhausted the timeout. - # Forcibly shut down the socket. - c = worker.conn - if c and not c.rfile.closed: - if SSL and isinstance(c.socket, SSL.ConnectionType): - # pyOpenSSL.socket.shutdown takes no args - c.socket.shutdown() - else: - c.socket.shutdown(socket.SHUT_RD) - worker.join() - except (AssertionError, - # Ignore repeated Ctrl-C. - # See http://www.cherrypy.org/ticket/691. - KeyboardInterrupt), exc1: - pass - - - -class SSLConnection: - """A thread-safe wrapper for an SSL.Connection. - - *args: the arguments to create the wrapped SSL.Connection(*args). - """ - - def __init__(self, *args): - self._ssl_conn = SSL.Connection(*args) - self._lock = threading.RLock() - - for f in ('get_context', 'pending', 'send', 'write', 'recv', 'read', - 'renegotiate', 'bind', 'listen', 'connect', 'accept', - 'setblocking', 'fileno', 'shutdown', 'close', 'get_cipher_list', - 'getpeername', 'getsockname', 'getsockopt', 'setsockopt', - 'makefile', 'get_app_data', 'set_app_data', 'state_string', - 'sock_shutdown', 'get_peer_certificate', 'want_read', - 'want_write', 'set_connect_state', 'set_accept_state', - 'connect_ex', 'sendall', 'settimeout'): - exec """def %s(self, *args): - self._lock.acquire() - try: - return self._ssl_conn.%s(*args) - finally: - self._lock.release() -""" % (f, f) - try: - import fcntl -except ImportError: - try: - from ctypes import windll, WinError - except ImportError: - def prevent_socket_inheritance(sock): - """Dummy function, since neither fcntl nor ctypes are available.""" - pass - else: - def prevent_socket_inheritance(sock): - """Mark the given socket fd as non-inheritable (Windows).""" - if not windll.kernel32.SetHandleInformation(sock.fileno(), 1, 0): - raise WinError() -else: - def prevent_socket_inheritance(sock): - """Mark the given socket fd as non-inheritable (POSIX).""" - fd = sock.fileno() - old_flags = fcntl.fcntl(fd, fcntl.F_GETFD) - fcntl.fcntl(fd, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC) - - -class CherryPyWSGIServer(object): - """An HTTP server for WSGI. - - bind_addr: The interface on which to listen for connections. - For TCP sockets, a (host, port) tuple. Host values may be any IPv4 - or IPv6 address, or any valid hostname. The string 'localhost' is a - synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). - The string '0.0.0.0' is a special IPv4 entry meaning "any active - interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for - IPv6. The empty string or None are not allowed. - - For UNIX sockets, supply the filename as a string. - wsgi_app: the WSGI 'application callable'; multiple WSGI applications - may be passed as (path_prefix, app) pairs. - numthreads: the number of worker threads to create (default 10). - server_name: the string to set for WSGI's SERVER_NAME environ entry. - Defaults to socket.gethostname(). - max: the maximum number of queued requests (defaults to -1 = no limit). - request_queue_size: the 'backlog' argument to socket.listen(); - specifies the maximum number of queued connections (default 5). - timeout: the timeout in seconds for accepted connections (default 10). - - nodelay: if True (the default since 3.1), sets the TCP_NODELAY socket - option. - - protocol: the version string to write in the Status-Line of all - HTTP responses. For example, "HTTP/1.1" (the default). This - also limits the supported features used in the response. - - - SSL/HTTPS - --------- - The OpenSSL module must be importable for SSL functionality. - You can obtain it from http://pyopenssl.sourceforge.net/ - - ssl_certificate: the filename of the server SSL certificate. - ssl_privatekey: the filename of the server's private key file. - - If either of these is None (both are None by default), this server - will not use SSL. If both are given and are valid, they will be read - on server start and used in the SSL context for the listening socket. - """ - - protocol = "HTTP/1.1" - _bind_addr = "127.0.0.1" - version = "CherryPy/3.1.2" - ready = False - _interrupt = None - - nodelay = True - - ConnectionClass = HTTPConnection - environ = {} - - # Paths to certificate and private key files - ssl_certificate = None - ssl_private_key = None - - def __init__(self, bind_addr, wsgi_app, numthreads=10, server_name=None, - max=-1, request_queue_size=5, timeout=10, shutdown_timeout=5): - self.requests = ThreadPool(self, min=numthreads or 1, max=max) - - if callable(wsgi_app): - # We've been handed a single wsgi_app, in CP-2.1 style. - # Assume it's mounted at "". - self.wsgi_app = wsgi_app - else: - # We've been handed a list of (path_prefix, wsgi_app) tuples, - # so that the server can call different wsgi_apps, and also - # correctly set SCRIPT_NAME. - warnings.warn("The ability to pass multiple apps is deprecated " - "and will be removed in 3.2. You should explicitly " - "include a WSGIPathInfoDispatcher instead.", - DeprecationWarning) - self.wsgi_app = WSGIPathInfoDispatcher(wsgi_app) - - self.bind_addr = bind_addr - if not server_name: - server_name = socket.gethostname() - self.server_name = server_name - self.request_queue_size = request_queue_size - - self.timeout = timeout - self.shutdown_timeout = shutdown_timeout - - def _get_numthreads(self): - return self.requests.min - def _set_numthreads(self, value): - self.requests.min = value - numthreads = property(_get_numthreads, _set_numthreads) - - def __str__(self): - return "%s.%s(%r)" % (self.__module__, self.__class__.__name__, - self.bind_addr) - - def _get_bind_addr(self): - return self._bind_addr - def _set_bind_addr(self, value): - if isinstance(value, tuple) and value[0] in ('', None): - # Despite the socket module docs, using '' does not - # allow AI_PASSIVE to work. Passing None instead - # returns '0.0.0.0' like we want. In other words: - # host AI_PASSIVE result - # '' Y 192.168.x.y - # '' N 192.168.x.y - # None Y 0.0.0.0 - # None N 127.0.0.1 - # But since you can get the same effect with an explicit - # '0.0.0.0', we deny both the empty string and None as values. - raise ValueError("Host values of '' or None are not allowed. " - "Use '0.0.0.0' (IPv4) or '::' (IPv6) instead " - "to listen on all active interfaces.") - self._bind_addr = value - bind_addr = property(_get_bind_addr, _set_bind_addr, - doc="""The interface on which to listen for connections. - - For TCP sockets, a (host, port) tuple. Host values may be any IPv4 - or IPv6 address, or any valid hostname. The string 'localhost' is a - synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). - The string '0.0.0.0' is a special IPv4 entry meaning "any active - interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for - IPv6. The empty string or None are not allowed. - - For UNIX sockets, supply the filename as a string.""") - - def start(self): - """Run the server forever.""" - # We don't have to trap KeyboardInterrupt or SystemExit here, - # because cherrpy.server already does so, calling self.stop() for us. - # If you're using this server with another framework, you should - # trap those exceptions in whatever code block calls start(). - self._interrupt = None - - # Select the appropriate socket - if isinstance(self.bind_addr, basestring): - # AF_UNIX socket - - # So we can reuse the socket... - try: os.unlink(self.bind_addr) - except: pass - - # So everyone can access the socket... - try: os.chmod(self.bind_addr, 0777) - except: pass - - info = [(socket.AF_UNIX, socket.SOCK_STREAM, 0, "", self.bind_addr)] - else: - # AF_INET or AF_INET6 socket - # Get the correct address family for our host (allows IPv6 addresses) - host, port = self.bind_addr - try: - info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, - socket.SOCK_STREAM, 0, socket.AI_PASSIVE) - except socket.gaierror: - # Probably a DNS issue. Assume IPv4. - info = [(socket.AF_INET, socket.SOCK_STREAM, 0, "", self.bind_addr)] - - self.socket = None - msg = "No socket could be created" - for res in info: - af, socktype, proto, canonname, sa = res - try: - self.bind(af, socktype, proto) - except socket.error, msg: - if self.socket: - self.socket.close() - self.socket = None - continue - break - if not self.socket: - raise socket.error, msg - - # Timeout so KeyboardInterrupt can be caught on Win32 - self.socket.settimeout(1) - self.socket.listen(self.request_queue_size) - - # Create worker threads - self.requests.start() - - self.ready = True - while self.ready: - self.tick() - if self.interrupt: - while self.interrupt is True: - # Wait for self.stop() to complete. See _set_interrupt. - time.sleep(0.1) - if self.interrupt: - raise self.interrupt - - def bind(self, family, type, proto=0): - """Create (or recreate) the actual socket object.""" - self.socket = socket.socket(family, type, proto) - prevent_socket_inheritance(self.socket) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if self.nodelay: - self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - if self.ssl_certificate and self.ssl_private_key: - if SSL is None: - raise ImportError("You must install pyOpenSSL to use HTTPS.") - - # See http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/442473 - ctx = SSL.Context(SSL.SSLv23_METHOD) - ctx.use_privatekey_file(self.ssl_private_key) - ctx.use_certificate_file(self.ssl_certificate) - self.socket = SSLConnection(ctx, self.socket) - self.populate_ssl_environ() - - # If listening on the IPV6 any address ('::' = IN6ADDR_ANY), - # activate dual-stack. See http://www.cherrypy.org/ticket/871. - if (not isinstance(self.bind_addr, basestring) - and self.bind_addr[0] == '::' and family == socket.AF_INET6): - try: - self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) - except (AttributeError, socket.error): - # Apparently, the socket option is not available in - # this machine's TCP stack - pass - - self.socket.bind(self.bind_addr) - - def tick(self): - """Accept a new connection and put it on the Queue.""" - try: - s, addr = self.socket.accept() - prevent_socket_inheritance(s) - if not self.ready: - return - if hasattr(s, 'settimeout'): - s.settimeout(self.timeout) - - environ = self.environ.copy() - # SERVER_SOFTWARE is common for IIS. It's also helpful for - # us to pass a default value for the "Server" response header. - if environ.get("SERVER_SOFTWARE") is None: - environ["SERVER_SOFTWARE"] = "%s WSGI Server" % self.version - # set a non-standard environ entry so the WSGI app can know what - # the *real* server protocol is (and what features to support). - # See http://www.faqs.org/rfcs/rfc2145.html. - environ["ACTUAL_SERVER_PROTOCOL"] = self.protocol - environ["SERVER_NAME"] = self.server_name - - if isinstance(self.bind_addr, basestring): - # AF_UNIX. This isn't really allowed by WSGI, which doesn't - # address unix domain sockets. But it's better than nothing. - environ["SERVER_PORT"] = "" - else: - environ["SERVER_PORT"] = str(self.bind_addr[1]) - # optional values - # Until we do DNS lookups, omit REMOTE_HOST - environ["REMOTE_ADDR"] = addr[0] - environ["REMOTE_PORT"] = str(addr[1]) - - conn = self.ConnectionClass(s, self.wsgi_app, environ) - self.requests.put(conn) - except socket.timeout: - # The only reason for the timeout in start() is so we can - # notice keyboard interrupts on Win32, which don't interrupt - # accept() by default - return - except socket.error, x: - if x.args[0] in socket_error_eintr: - # I *think* this is right. EINTR should occur when a signal - # is received during the accept() call; all docs say retry - # the call, and I *think* I'm reading it right that Python - # will then go ahead and poll for and handle the signal - # elsewhere. See http://www.cherrypy.org/ticket/707. - return - if x.args[0] in socket_errors_nonblocking: - # Just try again. See http://www.cherrypy.org/ticket/479. - return - if x.args[0] in socket_errors_to_ignore: - # Our socket was closed. - # See http://www.cherrypy.org/ticket/686. - return - raise - - def _get_interrupt(self): - return self._interrupt - def _set_interrupt(self, interrupt): - self._interrupt = True - self.stop() - self._interrupt = interrupt - interrupt = property(_get_interrupt, _set_interrupt, - doc="Set this to an Exception instance to " - "interrupt the server.") - - def stop(self): - """Gracefully shutdown a server that is serving forever.""" - self.ready = False - - sock = getattr(self, "socket", None) - if sock: - if not isinstance(self.bind_addr, basestring): - # Touch our own socket to make accept() return immediately. - try: - host, port = sock.getsockname()[:2] - except socket.error, x: - if x.args[0] not in socket_errors_to_ignore: - raise - else: - # Note that we're explicitly NOT using AI_PASSIVE, - # here, because we want an actual IP to touch. - # localhost won't work if we've bound to a public IP, - # but it will if we bound to '0.0.0.0' (INADDR_ANY). - for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, - socket.SOCK_STREAM): - af, socktype, proto, canonname, sa = res - s = None - try: - s = socket.socket(af, socktype, proto) - # See http://groups.google.com/group/cherrypy-users/ - # browse_frm/thread/bbfe5eb39c904fe0 - s.settimeout(1.0) - s.connect((host, port)) - s.close() - except socket.error: - if s: - s.close() - if hasattr(sock, "close"): - sock.close() - self.socket = None - - self.requests.stop(self.shutdown_timeout) - - def populate_ssl_environ(self): - """Create WSGI environ entries to be merged into each request.""" - cert = open(self.ssl_certificate, 'rb').read() - cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert) - ssl_environ = { - "wsgi.url_scheme": "https", - "HTTPS": "on", - # pyOpenSSL doesn't provide access to any of these AFAICT -## 'SSL_PROTOCOL': 'SSLv2', -## SSL_CIPHER string The cipher specification name -## SSL_VERSION_INTERFACE string The mod_ssl program version -## SSL_VERSION_LIBRARY string The OpenSSL program version - } - - # Server certificate attributes - ssl_environ.update({ - 'SSL_SERVER_M_VERSION': cert.get_version(), - 'SSL_SERVER_M_SERIAL': cert.get_serial_number(), -## 'SSL_SERVER_V_START': Validity of server's certificate (start time), -## 'SSL_SERVER_V_END': Validity of server's certificate (end time), - }) - - for prefix, dn in [("I", cert.get_issuer()), - ("S", cert.get_subject())]: - # X509Name objects don't seem to have a way to get the - # complete DN string. Use str() and slice it instead, - # because str(dn) == "" - dnstr = str(dn)[18:-2] - - wsgikey = 'SSL_SERVER_%s_DN' % prefix - ssl_environ[wsgikey] = dnstr - - # The DN should be of the form: /k1=v1/k2=v2, but we must allow - # for any value to contain slashes itself (in a URL). - while dnstr: - pos = dnstr.rfind("=") - dnstr, value = dnstr[:pos], dnstr[pos + 1:] - pos = dnstr.rfind("/") - dnstr, key = dnstr[:pos], dnstr[pos + 1:] - if key and value: - wsgikey = 'SSL_SERVER_%s_DN_%s' % (prefix, key) - ssl_environ[wsgikey] = value - - self.environ.update(ssl_environ) - + #__version__ = pkg_resources.get_distribution('cheroot').version + __version__ = 'cheroot/5.11.0' +except Exception: + __version__ = 'unknown' diff --git a/module/lib/wsgiserver/_compat.py b/module/lib/wsgiserver/_compat.py new file mode 100644 index 0000000000..e9de9a1169 --- /dev/null +++ b/module/lib/wsgiserver/_compat.py @@ -0,0 +1,62 @@ +"""Compatibility code for using Cheroot with various versions of Python.""" + +import re + +import six + +if six.PY3: + def ntob(n, encoding='ISO-8859-1'): + """Return the native string as bytes in the given encoding.""" + assert_native(n) + # In Python 3, the native string type is unicode + return n.encode(encoding) + + def ntou(n, encoding='ISO-8859-1'): + """Return the native string as unicode with the given encoding.""" + assert_native(n) + # In Python 3, the native string type is unicode + return n + + def bton(b, encoding='ISO-8859-1'): + """Return the byte string as native string in the given encoding.""" + return b.decode(encoding) +else: + # Python 2 + def ntob(n, encoding='ISO-8859-1'): + """Return the native string as bytes in the given encoding.""" + assert_native(n) + # In Python 2, the native string type is bytes. Assume it's already + # in the given encoding, which for ISO-8859-1 is almost always what + # was intended. + return n + + def ntou(n, encoding='ISO-8859-1'): + """Return the native string as unicode with the given encoding.""" + assert_native(n) + # In Python 2, the native string type is bytes. + # First, check for the special encoding 'escape'. The test suite uses + # this to signal that it wants to pass a string with embedded \uXXXX + # escapes, but without having to prefix it with u'' for Python 2, + # but no prefix for Python 3. + if encoding == 'escape': + return six.u( + re.sub(r'\\u([0-9a-zA-Z]{4})', + lambda m: six.unichr(int(m.group(1), 16)), + n.decode('ISO-8859-1'))) + # Assume it's already in the given encoding, which for ISO-8859-1 + # is almost always what was intended. + return n.decode(encoding) + + def bton(b, encoding='ISO-8859-1'): + """Return the byte string as native string in the given encoding.""" + return b + + +def assert_native(n): + """Check whether the input is of nativ ``str`` type. + + Raises: + TypeError: in case of failed check + """ + if not isinstance(n, str): + raise TypeError('n must be a native str (got %s)' % type(n).__name__) diff --git a/module/lib/wsgiserver/errors.py b/module/lib/wsgiserver/errors.py new file mode 100644 index 0000000000..974580c7d5 --- /dev/null +++ b/module/lib/wsgiserver/errors.py @@ -0,0 +1,55 @@ +"""Collection of exceptions raised and/or processed by Cheroot.""" + +import errno +import sys + + +class MaxSizeExceeded(Exception): + """Exception raised when a client sends more data then acceptable within limit. + + Depends on ``request.body.maxbytes`` config option if used within CherryPy + """ + + +class NoSSLError(Exception): + """Exception raised when a client speaks HTTP to an HTTPS socket.""" + + +class FatalSSLAlert(Exception): + """Exception raised when the SSL implementation signals a fatal alert.""" + + +def plat_specific_errors(*errnames): + """Return error numbers for all errors in errnames on this platform. + + The 'errno' module contains different global constants depending on + the specific platform (OS). This function will return the list of + numeric values for a given list of potential names. + """ + errno_names = dir(errno) + nums = [getattr(errno, k) for k in errnames if k in errno_names] + # de-dupe the list + return list(dict.fromkeys(nums).keys()) + + +socket_error_eintr = plat_specific_errors('EINTR', 'WSAEINTR') + +socket_errors_to_ignore = plat_specific_errors( + 'EPIPE', + 'EBADF', 'WSAEBADF', + 'ENOTSOCK', 'WSAENOTSOCK', + 'ETIMEDOUT', 'WSAETIMEDOUT', + 'ECONNREFUSED', 'WSAECONNREFUSED', + 'ECONNRESET', 'WSAECONNRESET', + 'ECONNABORTED', 'WSAECONNABORTED', + 'ENETRESET', 'WSAENETRESET', + 'EHOSTDOWN', 'EHOSTUNREACH', +) +socket_errors_to_ignore.append('timed out') +socket_errors_to_ignore.append('The read operation timed out') +socket_errors_nonblocking = plat_specific_errors( + 'EAGAIN', 'EWOULDBLOCK', 'WSAEWOULDBLOCK') + +if sys.platform == 'darwin': + socket_errors_to_ignore.extend(plat_specific_errors('EPROTOTYPE')) + socket_errors_nonblocking.extend(plat_specific_errors('EPROTOTYPE')) diff --git a/module/lib/wsgiserver/makefile.py b/module/lib/wsgiserver/makefile.py new file mode 100644 index 0000000000..145bc7170c --- /dev/null +++ b/module/lib/wsgiserver/makefile.py @@ -0,0 +1,384 @@ +"""Socket file object.""" + +import socket + +try: + # prefer slower Python-based io module + import _pyio as io +except ImportError: + # Python 2.6 + import io + +import six + +from . import errors + + +class BufferedWriter(io.BufferedWriter): + """Faux file object attached to a socket object.""" + + def write(self, b): + """Write bytes to buffer.""" + self._checkClosed() + if isinstance(b, str): + raise TypeError("can't write str to binary stream") + + with self._write_lock: + self._write_buf.extend(b) + self._flush_unlocked() + return len(b) + + def _flush_unlocked(self): + self._checkClosed('flush of closed file') + while self._write_buf: + try: + # ssl sockets only except 'bytes', not bytearrays + # so perhaps we should conditionally wrap this for perf? + n = self.raw.write(bytes(self._write_buf)) + except io.BlockingIOError as e: + n = e.characters_written + del self._write_buf[:n] + + +def MakeFile_PY3(sock, mode='r', bufsize=io.DEFAULT_BUFFER_SIZE): + """File object attached to a socket object.""" + if 'r' in mode: + return io.BufferedReader(socket.SocketIO(sock, mode), bufsize) + else: + return BufferedWriter(socket.SocketIO(sock, mode), bufsize) + + +class MakeFile_PY2(getattr(socket, '_fileobject', object)): + """Faux file object attached to a socket object.""" + + def __init__(self, *args, **kwargs): + """Initialize faux file object.""" + self.bytes_read = 0 + self.bytes_written = 0 + socket._fileobject.__init__(self, *args, **kwargs) + + def write(self, data): + """Sendall for non-blocking sockets.""" + while data: + try: + bytes_sent = self.send(data) + data = data[bytes_sent:] + except socket.error as e: + if e.args[0] not in errors.socket_errors_nonblocking: + raise + + def send(self, data): + """Send some part of message to the socket.""" + bytes_sent = self._sock.send(data) + self.bytes_written += bytes_sent + return bytes_sent + + def flush(self): + """Write all data from buffer to socket and reset write buffer.""" + if self._wbuf: + buffer = ''.join(self._wbuf) + self._wbuf = [] + self.write(buffer) + + def recv(self, size): + """Receive message of a size from the socket.""" + while True: + try: + data = self._sock.recv(size) + self.bytes_read += len(data) + return data + except socket.error as e: + what = ( + e.args[0] not in errors.socket_errors_nonblocking + and e.args[0] not in errors.socket_error_eintr + ) + if what: + raise + + class FauxSocket(object): + """Faux socket with the minimal interface required by pypy.""" + + def _reuse(self): + pass + + _fileobject_uses_str_type = six.PY2 and isinstance( + socket._fileobject(FauxSocket())._rbuf, six.string_types) + + # FauxSocket is no longer needed + del FauxSocket + + if not _fileobject_uses_str_type: + def read(self, size=-1): + """Read data from the socket to buffer.""" + # Use max, disallow tiny reads in a loop as they are very + # inefficient. + # We never leave read() with any leftover data from a new recv() + # call in our internal buffer. + rbufsize = max(self._rbufsize, self.default_bufsize) + # Our use of StringIO rather than lists of string objects returned + # by recv() minimizes memory usage and fragmentation that occurs + # when rbufsize is large compared to the typical return value of + # recv(). + buf = self._rbuf + buf.seek(0, 2) # seek end + if size < 0: + # Read until EOF + # reset _rbuf. we consume it via buf. + self._rbuf = io.BytesIO() + while True: + data = self.recv(rbufsize) + if not data: + break + buf.write(data) + return buf.getvalue() + else: + # Read until size bytes or EOF seen, whichever comes first + buf_len = buf.tell() + if buf_len >= size: + # Already have size bytes in our buffer? Extract and + # return. + buf.seek(0) + rv = buf.read(size) + self._rbuf = io.BytesIO() + self._rbuf.write(buf.read()) + return rv + + # reset _rbuf. we consume it via buf. + self._rbuf = io.BytesIO() + while True: + left = size - buf_len + # recv() will malloc the amount of memory given as its + # parameter even though it often returns much less data + # than that. The returned data string is short lived + # as we copy it into a StringIO and free it. This avoids + # fragmentation issues on many platforms. + data = self.recv(left) + if not data: + break + n = len(data) + if n == size and not buf_len: + # Shortcut. Avoid buffer data copies when: + # - We have no data in our buffer. + # AND + # - Our call to recv returned exactly the + # number of bytes we were asked to read. + return data + if n == left: + buf.write(data) + del data # explicit free + break + assert n <= left, 'recv(%d) returned %d bytes' % (left, n) + buf.write(data) + buf_len += n + del data # explicit free + # assert buf_len == buf.tell() + return buf.getvalue() + + def readline(self, size=-1): + """Read line from the socket to buffer.""" + buf = self._rbuf + buf.seek(0, 2) # seek end + if buf.tell() > 0: + # check if we already have it in our buffer + buf.seek(0) + bline = buf.readline(size) + if bline.endswith('\n') or len(bline) == size: + self._rbuf = io.BytesIO() + self._rbuf.write(buf.read()) + return bline + del bline + if size < 0: + # Read until \n or EOF, whichever comes first + if self._rbufsize <= 1: + # Speed up unbuffered case + buf.seek(0) + buffers = [buf.read()] + # reset _rbuf. we consume it via buf. + self._rbuf = io.BytesIO() + data = None + recv = self.recv + while data != '\n': + data = recv(1) + if not data: + break + buffers.append(data) + return ''.join(buffers) + + buf.seek(0, 2) # seek end + # reset _rbuf. we consume it via buf. + self._rbuf = io.BytesIO() + while True: + data = self.recv(self._rbufsize) + if not data: + break + nl = data.find('\n') + if nl >= 0: + nl += 1 + buf.write(data[:nl]) + self._rbuf.write(data[nl:]) + del data + break + buf.write(data) + return buf.getvalue() + else: + # Read until size bytes or \n or EOF seen, whichever comes + # first + buf.seek(0, 2) # seek end + buf_len = buf.tell() + if buf_len >= size: + buf.seek(0) + rv = buf.read(size) + self._rbuf = io.BytesIO() + self._rbuf.write(buf.read()) + return rv + # reset _rbuf. we consume it via buf. + self._rbuf = io.BytesIO() + while True: + data = self.recv(self._rbufsize) + if not data: + break + left = size - buf_len + # did we just receive a newline? + nl = data.find('\n', 0, left) + if nl >= 0: + nl += 1 + # save the excess data to _rbuf + self._rbuf.write(data[nl:]) + if buf_len: + buf.write(data[:nl]) + break + else: + # Shortcut. Avoid data copy through buf when + # returning a substring of our first recv(). + return data[:nl] + n = len(data) + if n == size and not buf_len: + # Shortcut. Avoid data copy through buf when + # returning exactly all of our first recv(). + return data + if n >= left: + buf.write(data[:left]) + self._rbuf.write(data[left:]) + break + buf.write(data) + buf_len += n + # assert buf_len == buf.tell() + return buf.getvalue() + else: + def read(self, size=-1): + """Read data from the socket to buffer.""" + if size < 0: + # Read until EOF + buffers = [self._rbuf] + self._rbuf = '' + if self._rbufsize <= 1: + recv_size = self.default_bufsize + else: + recv_size = self._rbufsize + + while True: + data = self.recv(recv_size) + if not data: + break + buffers.append(data) + return ''.join(buffers) + else: + # Read until size bytes or EOF seen, whichever comes first + data = self._rbuf + buf_len = len(data) + if buf_len >= size: + self._rbuf = data[size:] + return data[:size] + buffers = [] + if data: + buffers.append(data) + self._rbuf = '' + while True: + left = size - buf_len + recv_size = max(self._rbufsize, left) + data = self.recv(recv_size) + if not data: + break + buffers.append(data) + n = len(data) + if n >= left: + self._rbuf = data[left:] + buffers[-1] = data[:left] + break + buf_len += n + return ''.join(buffers) + + def readline(self, size=-1): + """Read line from the socket to buffer.""" + data = self._rbuf + if size < 0: + # Read until \n or EOF, whichever comes first + if self._rbufsize <= 1: + # Speed up unbuffered case + assert data == '' + buffers = [] + while data != '\n': + data = self.recv(1) + if not data: + break + buffers.append(data) + return ''.join(buffers) + nl = data.find('\n') + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + return data[:nl] + buffers = [] + if data: + buffers.append(data) + self._rbuf = '' + while True: + data = self.recv(self._rbufsize) + if not data: + break + buffers.append(data) + nl = data.find('\n') + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + buffers[-1] = data[:nl] + break + return ''.join(buffers) + else: + # Read until size bytes or \n or EOF seen, whichever comes + # first + nl = data.find('\n', 0, size) + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + return data[:nl] + buf_len = len(data) + if buf_len >= size: + self._rbuf = data[size:] + return data[:size] + buffers = [] + if data: + buffers.append(data) + self._rbuf = '' + while True: + data = self.recv(self._rbufsize) + if not data: + break + buffers.append(data) + left = size - buf_len + nl = data.find('\n', 0, left) + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + buffers[-1] = data[:nl] + break + n = len(data) + if n >= left: + self._rbuf = data[left:] + buffers[-1] = data[:left] + break + buf_len += n + return ''.join(buffers) + + +MakeFile = MakeFile_PY2 if six.PY2 else MakeFile_PY3 diff --git a/module/lib/wsgiserver/server.py b/module/lib/wsgiserver/server.py new file mode 100644 index 0000000000..c68b8bfd8d --- /dev/null +++ b/module/lib/wsgiserver/server.py @@ -0,0 +1,1800 @@ +""" +A high-speed, production ready, thread pooled, generic HTTP server. + +For those of you wanting to understand internals of this module, here's the +basic call flow. The server's listening thread runs a very tight loop, +sticking incoming connections onto a Queue:: + + server = HTTPServer(...) + server.start() + while True: + tick() + # This blocks until a request comes in: + child = socket.accept() + conn = HTTPConnection(child, ...) + server.requests.put(conn) + +Worker threads are kept in a pool and poll the Queue, popping off and then +handling each connection in turn. Each connection can consist of an arbitrary +number of requests and their responses, so we run a nested loop:: + + while True: + conn = server.requests.get() + conn.communicate() + -> while True: + req = HTTPRequest(...) + req.parse_request() + -> # Read the Request-Line, e.g. "GET /page HTTP/1.1" + req.rfile.readline() + read_headers(req.rfile, req.inheaders) + req.respond() + -> response = app(...) + try: + for chunk in response: + if chunk: + req.write(chunk) + finally: + if hasattr(response, "close"): + response.close() + if req.close_connection: + return + +And now for a trivial doctest to exercise the test suite + +>>> 'HTTPServer' in globals() +True + +""" + +import os +import io +import re +import email.utils +import socket +import sys +import time +import traceback as traceback_ +import logging +import platform + +import six +from six.moves import queue +from six.moves import urllib + +from . import errors, __version__ +from ._compat import bton, ntou +from .workers import threadpool +from .makefile import MakeFile + + +__all__ = ('HTTPRequest', 'HTTPConnection', 'HTTPServer', + 'SizeCheckWrapper', 'KnownLengthRFile', 'ChunkedRFile', + 'Gateway', 'get_ssl_adapter_class') + + +if 'win' in sys.platform and hasattr(socket, 'AF_INET6'): + if not hasattr(socket, 'IPPROTO_IPV6'): + socket.IPPROTO_IPV6 = 41 + if not hasattr(socket, 'IPV6_V6ONLY'): + socket.IPV6_V6ONLY = 27 + + +LF = b'\n' +CRLF = b'\r\n' +TAB = b'\t' +SPACE = b' ' +COLON = b':' +SEMICOLON = b';' +EMPTY = b'' +ASTERISK = b'*' +FORWARD_SLASH = b'/' +QUOTED_SLASH = b'%2F' +QUOTED_SLASH_REGEX = re.compile(b'(?i)' + QUOTED_SLASH) + + +comma_separated_headers = [ + b'Accept', b'Accept-Charset', b'Accept-Encoding', + b'Accept-Language', b'Accept-Ranges', b'Allow', b'Cache-Control', + b'Connection', b'Content-Encoding', b'Content-Language', b'Expect', + b'If-Match', b'If-None-Match', b'Pragma', b'Proxy-Authenticate', b'TE', + b'Trailer', b'Transfer-Encoding', b'Upgrade', b'Vary', b'Via', b'Warning', + b'WWW-Authenticate', +] + + +if not hasattr(logging, 'statistics'): + logging.statistics = {} + + +class HeaderReader(object): + """Object for reading headers from an HTTP request. + + Interface and default implementation. + """ + + def __call__(self, rfile, hdict=None): + """ + Read headers from the given stream into the given header dict. + + If hdict is None, a new header dict is created. Returns the populated + header dict. + + Headers which are repeated are folded together using a comma if their + specification so dictates. + + This function raises ValueError when the read bytes violate the HTTP + spec. + You should probably return "400 Bad Request" if this happens. + """ + if hdict is None: + hdict = {} + + while True: + line = rfile.readline() + if not line: + # No more data--illegal end of headers + raise ValueError('Illegal end of headers.') + + if line == CRLF: + # Normal end of headers + break + if not line.endswith(CRLF): + raise ValueError('HTTP requires CRLF terminators') + + if line[0] in (SPACE, TAB): + # It's a continuation line. + v = line.strip() + else: + try: + k, v = line.split(COLON, 1) + except ValueError: + raise ValueError('Illegal header line.') + v = v.strip() + k = self._transform_key(k) + hname = k + + if not self._allow_header(k): + continue + + if k in comma_separated_headers: + existing = hdict.get(hname) + if existing: + v = b', '.join((existing, v)) + hdict[hname] = v + + return hdict + + def _allow_header(self, key_name): + return True + + def _transform_key(self, key_name): + # TODO: what about TE and WWW-Authenticate? + return key_name.strip().title() + + +class DropUnderscoreHeaderReader(HeaderReader): + """Custom HeaderReader to exclude any headers with underscores in them.""" + + def _allow_header(self, key_name): + orig = super(DropUnderscoreHeaderReader, self)._allow_header(key_name) + return orig and '_' not in key_name + + +class SizeCheckWrapper(object): + """Wraps a file-like object, raising MaxSizeExceeded if too large.""" + + def __init__(self, rfile, maxlen): + """Initialize SizeCheckWrapper instance. + + Args: + rfile (file): file of a limited size + maxlen (int): maximum length of the file being read + """ + self.rfile = rfile + self.maxlen = maxlen + self.bytes_read = 0 + + def _check_length(self): + if self.maxlen and self.bytes_read > self.maxlen: + raise errors.MaxSizeExceeded() + + def read(self, size=None): + """Read a chunk from rfile buffer and return it. + + Args: + size (int): amount of data to read + + Returns: + bytes: Chunk from rfile, limited by size if specified. + """ + data = self.rfile.read(size) + self.bytes_read += len(data) + self._check_length() + return data + + def readline(self, size=None): + """Read a single line from rfile buffer and return it. + + Args: + size (int): minimum amount of data to read + + Returns: + bytes: One line from rfile. + """ + if size is not None: + data = self.rfile.readline(size) + self.bytes_read += len(data) + self._check_length() + return data + + # User didn't specify a size ... + # We read the line in chunks to make sure it's not a 100MB line ! + res = [] + while True: + data = self.rfile.readline(256) + self.bytes_read += len(data) + self._check_length() + res.append(data) + # See https://github.com/cherrypy/cherrypy/issues/421 + if len(data) < 256 or data[-1:] == LF: + return EMPTY.join(res) + + def readlines(self, sizehint=0): + """Read all lines from rfile buffer and return them. + + Args: + sizehint (int): hint of minimum amount of data to read + + Returns: + list[bytes]: Lines of bytes read from rfile. + """ + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline(sizehint) + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + return lines + + def close(self): + """Release resources allocated for rfile.""" + self.rfile.close() + + def __iter__(self): + """Return file iterator.""" + return self + + def __next__(self): + """Generate next file chunk.""" + data = next(self.rfile) + self.bytes_read += len(data) + self._check_length() + return data + + def next(self): + """Generate next file chunk.""" + data = self.rfile.next() + self.bytes_read += len(data) + self._check_length() + return data + + +class KnownLengthRFile(object): + """Wraps a file-like object, returning an empty string when exhausted.""" + + def __init__(self, rfile, content_length): + """Initialize KnownLengthRFile instance. + + Args: + rfile (file): file of a known size + content_length (int): length of the file being read + """ + self.rfile = rfile + self.remaining = content_length + + def read(self, size=None): + """Read a chunk from rfile buffer and return it. + + Args: + size (int): amount of data to read + + Returns: + bytes: Chunk from rfile, limited by size if specified. + """ + if self.remaining == 0: + return b'' + if size is None: + size = self.remaining + else: + size = min(size, self.remaining) + + data = self.rfile.read(size) + self.remaining -= len(data) + return data + + def readline(self, size=None): + """Read a single line from rfile buffer and return it. + + Args: + size (int): minimum amount of data to read + + Returns: + bytes: One line from rfile. + """ + if self.remaining == 0: + return b'' + if size is None: + size = self.remaining + else: + size = min(size, self.remaining) + + data = self.rfile.readline(size) + self.remaining -= len(data) + return data + + def readlines(self, sizehint=0): + """Read all lines from rfile buffer and return them. + + Args: + sizehint (int): hint of minimum amount of data to read + + Returns: + list[bytes]: Lines of bytes read from rfile. + """ + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline(sizehint) + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + return lines + + def close(self): + """Release resources allocated for rfile.""" + self.rfile.close() + + def __iter__(self): + """Return file iterator.""" + return self + + def __next__(self): + """Generate next file chunk.""" + data = next(self.rfile) + self.remaining -= len(data) + return data + + +class ChunkedRFile(object): + """Wraps a file-like object, returning an empty string when exhausted. + + This class is intended to provide a conforming wsgi.input value for + request entities that have been encoded with the 'chunked' transfer + encoding. + """ + + def __init__(self, rfile, maxlen, bufsize=8192): + """Initialize ChunkedRFile instance. + + Args: + rfile (file): file encoded with the 'chunked' transfer encoding + maxlen (int): maximum length of the file being read + bufsize (int): size of the buffer used to read the file + """ + self.rfile = rfile + self.maxlen = maxlen + self.bytes_read = 0 + self.buffer = EMPTY + self.bufsize = bufsize + self.closed = False + + def _fetch(self): + if self.closed: + return + + line = self.rfile.readline() + self.bytes_read += len(line) + + if self.maxlen and self.bytes_read > self.maxlen: + raise errors.MaxSizeExceeded( + 'Request Entity Too Large', self.maxlen) + + line = line.strip().split(SEMICOLON, 1) + + try: + chunk_size = line.pop(0) + chunk_size = int(chunk_size, 16) + except ValueError: + raise ValueError('Bad chunked transfer size: ' + repr(chunk_size)) + + if chunk_size <= 0: + self.closed = True + return + +# if line: chunk_extension = line[0] + + if self.maxlen and self.bytes_read + chunk_size > self.maxlen: + raise IOError('Request Entity Too Large') + + chunk = self.rfile.read(chunk_size) + self.bytes_read += len(chunk) + self.buffer += chunk + + crlf = self.rfile.read(2) + if crlf != CRLF: + raise ValueError( + "Bad chunked transfer coding (expected '\\r\\n', " + 'got ' + repr(crlf) + ')') + + def read(self, size=None): + """Read a chunk from rfile buffer and return it. + + Args: + size (int): amount of data to read + + Returns: + bytes: Chunk from rfile, limited by size if specified. + """ + data = EMPTY + + if size == 0: + return data + + while True: + if size and len(data) >= size: + return data + + if not self.buffer: + self._fetch() + if not self.buffer: + # EOF + return data + + if size: + remaining = size - len(data) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + data += self.buffer + self.buffer = EMPTY + + def readline(self, size=None): + """Read a single line from rfile buffer and return it. + + Args: + size (int): minimum amount of data to read + + Returns: + bytes: One line from rfile. + """ + data = EMPTY + + if size == 0: + return data + + while True: + if size and len(data) >= size: + return data + + if not self.buffer: + self._fetch() + if not self.buffer: + # EOF + return data + + newline_pos = self.buffer.find(LF) + if size: + if newline_pos == -1: + remaining = size - len(data) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + remaining = min(size - len(data), newline_pos) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + if newline_pos == -1: + data += self.buffer + self.buffer = EMPTY + else: + data += self.buffer[:newline_pos] + self.buffer = self.buffer[newline_pos:] + + def readlines(self, sizehint=0): + """Read all lines from rfile buffer and return them. + + Args: + sizehint (int): hint of minimum amount of data to read + + Returns: + list[bytes]: Lines of bytes read from rfile. + """ + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline(sizehint) + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + return lines + + def read_trailer_lines(self): + """Read HTTP headers and yield them. + + Returns: + Generator: yields CRLF separated lines. + """ + if not self.closed: + raise ValueError( + 'Cannot read trailers until the request body has been read.') + + while True: + line = self.rfile.readline() + if not line: + # No more data--illegal end of headers + raise ValueError('Illegal end of headers.') + + self.bytes_read += len(line) + if self.maxlen and self.bytes_read > self.maxlen: + raise IOError('Request Entity Too Large') + + if line == CRLF: + # Normal end of headers + break + if not line.endswith(CRLF): + raise ValueError('HTTP requires CRLF terminators') + + yield line + + def close(self): + """Release resources allocated for rfile.""" + self.rfile.close() + + +class HTTPRequest(object): + """An HTTP Request (and response). + + A single HTTP connection may consist of multiple request/response pairs. + """ + + server = None + """The HTTPServer object which is receiving this request.""" + + conn = None + """The HTTPConnection object on which this request connected.""" + + inheaders = {} + """A dict of request headers.""" + + outheaders = [] + """A list of header tuples to write in the response.""" + + ready = False + """When True, the request has been parsed and is ready to begin generating + the response. When False, signals the calling Connection that the response + should not be generated and the connection should close.""" + + close_connection = False + """Signals the calling Connection that the request should close. This does + not imply an error! The client and/or server may each request that the + connection be closed.""" + + chunked_write = False + """If True, output will be encoded with the "chunked" transfer-coding. + + This value is set automatically inside send_headers.""" + + header_reader = HeaderReader() + """ + A HeaderReader instance or compatible reader. + """ + + def __init__(self, server, conn, proxy_mode=False, strict_mode=True): + """Initialize HTTP request container instance. + + Args: + server (HTTPServer): web server object receiving this request + conn (HTTPConnection): HTTP connection object for this request + proxy_mode (bool): whether this HTTPServer should behave as a PROXY + server for certain requests + strict_mode (bool): whether we should return a 400 Bad Request when + we encounter a request that a HTTP compliant client should not be + making + """ + self.server = server + self.conn = conn + + self.ready = False + self.started_request = False + self.scheme = b'http' + if self.server.ssl_adapter is not None: + self.scheme = b'https' + # Use the lowest-common protocol in case read_request_line errors. + self.response_protocol = 'HTTP/1.0' + self.inheaders = {} + + self.status = '' + self.outheaders = [] + self.sent_headers = False + self.close_connection = self.__class__.close_connection + self.chunked_read = False + self.chunked_write = self.__class__.chunked_write + self.proxy_mode = proxy_mode + self.strict_mode = strict_mode + + def parse_request(self): + """Parse the next HTTP request start-line and message-headers.""" + self.rfile = SizeCheckWrapper(self.conn.rfile, + self.server.max_request_header_size) + try: + success = self.read_request_line() + except errors.MaxSizeExceeded: + self.simple_response( + '414 Request-URI Too Long', + 'The Request-URI sent with the request exceeds the maximum ' + 'allowed bytes.') + return + else: + if not success: + return + + try: + success = self.read_request_headers() + except errors.MaxSizeExceeded: + self.simple_response( + '413 Request Entity Too Large', + 'The headers sent with the request exceed the maximum ' + 'allowed bytes.') + return + else: + if not success: + return + + self.ready = True + + def read_request_line(self): + """Read and parse first line of the HTTP request. + + Returns: + bool: True if the request line is valid or False if it's malformed. + """ + # HTTP/1.1 connections are persistent by default. If a client + # requests a page, then idles (leaves the connection open), + # then rfile.readline() will raise socket.error("timed out"). + # Note that it does this based on the value given to settimeout(), + # and doesn't need the client to request or acknowledge the close + # (although your TCP stack might suffer for it: cf Apache's history + # with FIN_WAIT_2). + request_line = self.rfile.readline() + + # Set started_request to True so communicate() knows to send 408 + # from here on out. + self.started_request = True + if not request_line: + return False + + if request_line == CRLF: + # RFC 2616 sec 4.1: "...if the server is reading the protocol + # stream at the beginning of a message and receives a CRLF + # first, it should ignore the CRLF." + # But only ignore one leading line! else we enable a DoS. + request_line = self.rfile.readline() + if not request_line: + return False + + if not request_line.endswith(CRLF): + self.simple_response( + '400 Bad Request', 'HTTP requires CRLF terminators') + return False + + try: + method, uri, req_protocol = request_line.strip().split(SPACE, 2) + if not req_protocol.startswith(b'HTTP/'): + self.simple_response( + '400 Bad Request', 'Malformed Request-Line: bad protocol' + ) + return False + rp = req_protocol[5:].split(b'.', 1) + rp = tuple(map(int, rp)) # Minor.Major must be threat as integers + if rp > (1, 1): + self.simple_response( + '505 HTTP Version Not Supported', 'Cannot fulfill request' + ) + return False + except (ValueError, IndexError): + self.simple_response('400 Bad Request', 'Malformed Request-Line') + return False + + self.uri = uri + self.method = method.upper() + + if self.strict_mode and method != self.method: + resp = ( + 'Malformed method name: According to RFC 2616 ' + '(section 5.1.1) and its successors ' + 'RFC 7230 (section 3.1.1) and RFC 7231 (section 4.1) ' + 'method names are case-sensitive and uppercase.' + ) + self.simple_response('400 Bad Request', resp) + return False + + try: + if six.PY2: # FIXME: Figure out better way to do this + # Ref: https://stackoverflow.com/a/196392/595220 (like this?) + """This is a dummy check for unicode in URI.""" + ntou(bton(uri, 'ascii'), 'ascii') + scheme, authority, path, qs, fragment = urllib.parse.urlsplit(uri) + except UnicodeError: + self.simple_response('400 Bad Request', 'Malformed Request-URI') + return False + + if self.method == b'OPTIONS': + # TODO: cover this branch with tests + path = (uri + # https://tools.ietf.org/html/rfc7230#section-5.3.4 + if self.proxy_mode or uri == ASTERISK + else path) + elif self.method == b'CONNECT': + # TODO: cover this branch with tests + if not self.proxy_mode: + self.simple_response('405 Method Not Allowed') + return False + + # `urlsplit()` above parses "example.com:3128" as path part of URI. + # this is a workaround, which makes it detect netloc correctly + uri_split = urllib.parse.urlsplit(b'//' + uri) + _scheme, _authority, _path, _qs, _fragment = uri_split + _port = EMPTY + try: + _port = uri_split.port + except ValueError: + pass + + # FIXME: use third-party validation to make checks against RFC + # the validation doesn't take into account, that urllib parses + # invalid URIs without raising errors + # https://tools.ietf.org/html/rfc7230#section-5.3.3 + invalid_path = ( + _authority != uri + or not _port + or any((_scheme, _path, _qs, _fragment)) + ) + if invalid_path: + self.simple_response('400 Bad Request', + 'Invalid path in Request-URI: request-' + 'target must match authority-form.') + return False + + authority = path = _authority + scheme = qs = fragment = EMPTY + else: + uri_is_absolute_form = (scheme or authority) + + disallowed_absolute = ( + self.strict_mode + and not self.proxy_mode + and uri_is_absolute_form + ) + if disallowed_absolute: + # https://tools.ietf.org/html/rfc7230#section-5.3.2 + # (absolute form) + """Absolute URI is only allowed within proxies.""" + self.simple_response( + '400 Bad Request', + 'Absolute URI not allowed if server is not a proxy.', + ) + return False + + invalid_path = ( + self.strict_mode + and not uri.startswith(FORWARD_SLASH) + and not uri_is_absolute_form + ) + if invalid_path: + # https://tools.ietf.org/html/rfc7230#section-5.3.1 + # (origin_form) and + """Path should start with a forward slash.""" + resp = ( + 'Invalid path in Request-URI: request-target must contain ' + 'origin-form which starts with absolute-path (URI ' + 'starting with a slash "/").' + ) + self.simple_response('400 Bad Request', resp) + return False + + if fragment: + self.simple_response('400 Bad Request', + 'Illegal #fragment in Request-URI.') + return False + + if path is None: + # FIXME: It looks like this case cannot happen + self.simple_response('400 Bad Request', + 'Invalid path in Request-URI.') + return False + + # Unquote the path+params (e.g. "/this%20path" -> "/this path"). + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.2 + # + # But note that "...a URI must be separated into its components + # before the escaped characters within those components can be + # safely decoded." http://www.ietf.org/rfc/rfc2396.txt, sec 2.4.2 + # Therefore, "/this%2Fpath" becomes "/this%2Fpath", not + # "/this/path". + try: + # TODO: Figure out whether exception can really happen here. + # It looks like it's caught on urlsplit() call above. + atoms = [ + urllib.parse.unquote_to_bytes(x) + for x in QUOTED_SLASH_REGEX.split(path) + ] + except ValueError as ex: + self.simple_response('400 Bad Request', ex.args[0]) + return False + path = QUOTED_SLASH.join(atoms) + + if not path.startswith(FORWARD_SLASH): + path = FORWARD_SLASH + path + + if scheme is not EMPTY: + self.scheme = scheme + self.authority = authority + self.path = path + + # Note that, like wsgiref and most other HTTP servers, + # we "% HEX HEX"-unquote the path but not the query string. + self.qs = qs + + # Compare request and server HTTP protocol versions, in case our + # server does not support the requested protocol. Limit our output + # to min(req, server). We want the following output: + # request server actual written supported response + # protocol protocol response protocol feature set + # a 1.0 1.0 1.0 1.0 + # b 1.0 1.1 1.1 1.0 + # c 1.1 1.0 1.0 1.0 + # d 1.1 1.1 1.1 1.1 + # Notice that, in (b), the response will be "HTTP/1.1" even though + # the client only understands 1.0. RFC 2616 10.5.6 says we should + # only return 505 if the _major_ version is different. + sp = int(self.server.protocol[5]), int(self.server.protocol[7]) + + if sp[0] != rp[0]: + self.simple_response('505 HTTP Version Not Supported') + return False + + self.request_protocol = req_protocol + self.response_protocol = 'HTTP/%s.%s' % min(rp, sp) + + return True + + def read_request_headers(self): + """Read self.rfile into self.inheaders. Return success.""" + # then all the http headers + try: + self.header_reader(self.rfile, self.inheaders) + except ValueError as ex: + self.simple_response('400 Bad Request', ex.args[0]) + return False + + mrbs = self.server.max_request_body_size + if mrbs and int(self.inheaders.get(b'Content-Length', 0)) > mrbs: + self.simple_response( + '413 Request Entity Too Large', + 'The entity sent with the request exceeds the maximum ' + 'allowed bytes.') + return False + + # Persistent connection support + if self.response_protocol == 'HTTP/1.1': + # Both server and client are HTTP/1.1 + if self.inheaders.get(b'Connection', b'') == b'close': + self.close_connection = True + else: + # Either the server or client (or both) are HTTP/1.0 + if self.inheaders.get(b'Connection', b'') != b'Keep-Alive': + self.close_connection = True + + # Transfer-Encoding support + te = None + if self.response_protocol == 'HTTP/1.1': + te = self.inheaders.get(b'Transfer-Encoding') + if te: + te = [x.strip().lower() for x in te.split(b',') if x.strip()] + + self.chunked_read = False + + if te: + for enc in te: + if enc == b'chunked': + self.chunked_read = True + else: + # Note that, even if we see "chunked", we must reject + # if there is an extension we don't recognize. + self.simple_response('501 Unimplemented') + self.close_connection = True + return False + + # From PEP 333: + # "Servers and gateways that implement HTTP 1.1 must provide + # transparent support for HTTP 1.1's "expect/continue" mechanism. + # This may be done in any of several ways: + # 1. Respond to requests containing an Expect: 100-continue request + # with an immediate "100 Continue" response, and proceed normally. + # 2. Proceed with the request normally, but provide the application + # with a wsgi.input stream that will send the "100 Continue" + # response if/when the application first attempts to read from + # the input stream. The read request must then remain blocked + # until the client responds. + # 3. Wait until the client decides that the server does not support + # expect/continue, and sends the request body on its own. + # (This is suboptimal, and is not recommended.) + # + # We used to do 3, but are now doing 1. Maybe we'll do 2 someday, + # but it seems like it would be a big slowdown for such a rare case. + if self.inheaders.get(b'Expect', b'') == b'100-continue': + # Don't use simple_response here, because it emits headers + # we don't want. See + # https://github.com/cherrypy/cherrypy/issues/951 + msg = self.server.protocol.encode('ascii') + msg += b' 100 Continue\r\n\r\n' + try: + self.conn.wfile.write(msg) + except socket.error as ex: + if ex.args[0] not in errors.socket_errors_to_ignore: + raise + return True + + def respond(self): + """Call the gateway and write its iterable output.""" + mrbs = self.server.max_request_body_size + if self.chunked_read: + self.rfile = ChunkedRFile(self.conn.rfile, mrbs) + else: + cl = int(self.inheaders.get(b'Content-Length', 0)) + if mrbs and mrbs < cl: + if not self.sent_headers: + self.simple_response( + '413 Request Entity Too Large', + 'The entity sent with the request exceeds the ' + 'maximum allowed bytes.') + return + self.rfile = KnownLengthRFile(self.conn.rfile, cl) + + self.server.gateway(self).respond() + + if (self.ready and not self.sent_headers): + self.sent_headers = True + self.send_headers() + if self.chunked_write: + self.conn.wfile.write(b'0\r\n\r\n') + + def simple_response(self, status, msg=''): + """Write a simple response back to the client.""" + status = str(status) + proto_status = '%s %s\r\n' % (self.server.protocol, status) + content_length = 'Content-Length: %s\r\n' % len(msg) + content_type = 'Content-Type: text/plain\r\n' + buf = [ + proto_status.encode('ISO-8859-1'), + content_length.encode('ISO-8859-1'), + content_type.encode('ISO-8859-1'), + ] + + if status[:3] in ('413', '414'): + # Request Entity Too Large / Request-URI Too Long + self.close_connection = True + if self.response_protocol == 'HTTP/1.1': + # This will not be true for 414, since read_request_line + # usually raises 414 before reading the whole line, and we + # therefore cannot know the proper response_protocol. + buf.append(b'Connection: close\r\n') + else: + # HTTP/1.0 had no 413/414 status nor Connection header. + # Emit 400 instead and trust the message body is enough. + status = '400 Bad Request' + + buf.append(CRLF) + if msg: + if isinstance(msg, six.text_type): + msg = msg.encode('ISO-8859-1') + buf.append(msg) + + try: + self.conn.wfile.write(EMPTY.join(buf)) + except socket.error as ex: + if ex.args[0] not in errors.socket_errors_to_ignore: + raise + + def write(self, chunk): + """Write unbuffered data to the client.""" + if self.chunked_write and chunk: + chunk_size_hex = hex(len(chunk))[2:].encode('ascii') + buf = [chunk_size_hex, CRLF, chunk, CRLF] + self.conn.wfile.write(EMPTY.join(buf)) + else: + self.conn.wfile.write(chunk) + + def send_headers(self): + """Assert, process, and send the HTTP response message-headers. + + You must set self.status, and self.outheaders before calling this. + """ + hkeys = [key.lower() for key, value in self.outheaders] + status = int(self.status[:3]) + + if status == 413: + # Request Entity Too Large. Close conn to avoid garbage. + self.close_connection = True + elif b'content-length' not in hkeys: + # "All 1xx (informational), 204 (no content), + # and 304 (not modified) responses MUST NOT + # include a message-body." So no point chunking. + if status < 200 or status in (204, 205, 304): + pass + else: + needs_chunked = ( + self.response_protocol == 'HTTP/1.1' + and self.method != b'HEAD' + ) + if needs_chunked: + # Use the chunked transfer-coding + self.chunked_write = True + self.outheaders.append((b'Transfer-Encoding', b'chunked')) + else: + # Closing the conn is the only way to determine len. + self.close_connection = True + + if b'connection' not in hkeys: + if self.response_protocol == 'HTTP/1.1': + # Both server and client are HTTP/1.1 or better + if self.close_connection: + self.outheaders.append((b'Connection', b'close')) + else: + # Server and/or client are HTTP/1.0 + if not self.close_connection: + self.outheaders.append((b'Connection', b'Keep-Alive')) + + if (not self.close_connection) and (not self.chunked_read): + # Read any remaining request body data on the socket. + # "If an origin server receives a request that does not include an + # Expect request-header field with the "100-continue" expectation, + # the request includes a request body, and the server responds + # with a final status code before reading the entire request body + # from the transport connection, then the server SHOULD NOT close + # the transport connection until it has read the entire request, + # or until the client closes the connection. Otherwise, the client + # might not reliably receive the response message. However, this + # requirement is not be construed as preventing a server from + # defending itself against denial-of-service attacks, or from + # badly broken client implementations." + remaining = getattr(self.rfile, 'remaining', 0) + if remaining > 0: + self.rfile.read(remaining) + + if b'date' not in hkeys: + self.outheaders.append(( + b'Date', + email.utils.formatdate(usegmt=True).encode('ISO-8859-1'), + )) + + if b'server' not in hkeys: + self.outheaders.append(( + b'Server', + self.server.server_name.encode('ISO-8859-1'), + )) + + proto = self.server.protocol.encode('ascii') + buf = [proto + SPACE + self.status + CRLF] + for k, v in self.outheaders: + buf.append(k + COLON + SPACE + v + CRLF) + buf.append(CRLF) + self.conn.wfile.write(EMPTY.join(buf)) + + +class HTTPConnection(object): + """An HTTP connection (active socket).""" + + remote_addr = None + remote_port = None + ssl_env = None + rbufsize = io.DEFAULT_BUFFER_SIZE + wbufsize = io.DEFAULT_BUFFER_SIZE + RequestHandlerClass = HTTPRequest + + def __init__(self, server, sock, makefile=MakeFile): + """Initialize HTTPConnection instance. + + Args: + server (HTTPServer): web server object receiving this request + socket (socket._socketobject): the raw socket object (usually + TCP) for this connection + makefile (file): a fileobject class for reading from the socket + """ + self.server = server + self.socket = sock + self.rfile = makefile(sock, 'rb', self.rbufsize) + self.wfile = makefile(sock, 'wb', self.wbufsize) + self.requests_seen = 0 + + def communicate(self): + """Read each request and respond appropriately.""" + request_seen = False + try: + while True: + # (re)set req to None so that if something goes wrong in + # the RequestHandlerClass constructor, the error doesn't + # get written to the previous request. + req = None + req = self.RequestHandlerClass(self.server, self) + + # This order of operations should guarantee correct pipelining. + req.parse_request() + if self.server.stats['Enabled']: + self.requests_seen += 1 + if not req.ready: + # Something went wrong in the parsing (and the server has + # probably already made a simple_response). Return and + # let the conn close. + return + + request_seen = True + req.respond() + if req.close_connection: + return + except socket.error as ex: + errnum = ex.args[0] + # sadly SSL sockets return a different (longer) time out string + timeout_errs = 'timed out', 'The read operation timed out' + if errnum in timeout_errs: + # Don't error if we're between requests; only error + # if 1) no request has been started at all, or 2) we're + # in the middle of a request. + # See https://github.com/cherrypy/cherrypy/issues/853 + if (not request_seen) or (req and req.started_request): + self._conditional_error(req, '408 Request Timeout') + elif errnum not in errors.socket_errors_to_ignore: + self.server.error_log('socket.error %s' % repr(errnum), + level=logging.WARNING, traceback=True) + self._conditional_error(req, '500 Internal Server Error') + except (KeyboardInterrupt, SystemExit): + raise + except errors.FatalSSLAlert: + pass + except errors.NoSSLError: + self._handle_no_ssl(req) + except Exception as ex: + self.server.error_log( + repr(ex), level=logging.ERROR, traceback=True) + self._conditional_error(req, '500 Internal Server Error') + + linger = False + + def _handle_no_ssl(self, req): + if not req or req.sent_headers: + return + # Unwrap wfile + self.wfile = MakeFile(self.socket._sock, 'wb', self.wbufsize) + msg = ( + 'The client sent a plain HTTP request, but ' + 'this server only speaks HTTPS on this port.' + ) + req.simple_response('400 Bad Request', msg) + self.linger = True + + def _conditional_error(self, req, response): + """Respond with an error. + + Don't bother writing if a response + has already started being written. + """ + if not req or req.sent_headers: + return + + try: + req.simple_response('408 Request Timeout') + except errors.FatalSSLAlert: + pass + except errors.NoSSLError: + self._handle_no_ssl(req) + + def close(self): + """Close the socket underlying this connection.""" + self.rfile.close() + + if not self.linger: + self._close_kernel_socket() + self.socket.close() + else: + # On the other hand, sometimes we want to hang around for a bit + # to make sure the client has a chance to read our entire + # response. Skipping the close() calls here delays the FIN + # packet until the socket object is garbage-collected later. + # Someday, perhaps, we'll do the full lingering_close that + # Apache does, but not today. + pass + + def _close_kernel_socket(self): + """Close kernel socket in outdated Python versions. + + On old Python versions, + Python's socket module does NOT call close on the kernel + socket when you call socket.close(). We do so manually here + because we want this server to send a FIN TCP segment + immediately. Note this must be called *before* calling + socket.close(), because the latter drops its reference to + the kernel socket. + """ + if six.PY2 and hasattr(self.socket, '_sock'): + self.socket._sock.close() + + +try: + import fcntl +except ImportError: + try: + from ctypes import windll, WinError + import ctypes.wintypes + _SetHandleInformation = windll.kernel32.SetHandleInformation + _SetHandleInformation.argtypes = [ + ctypes.wintypes.HANDLE, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ] + _SetHandleInformation.restype = ctypes.wintypes.BOOL + except ImportError: + def prevent_socket_inheritance(sock): + """Dummy function, since neither fcntl nor ctypes are available.""" + pass + else: + def prevent_socket_inheritance(sock): + """Mark the given socket fd as non-inheritable (Windows).""" + if not _SetHandleInformation(sock.fileno(), 1, 0): + raise WinError() +else: + def prevent_socket_inheritance(sock): + """Mark the given socket fd as non-inheritable (POSIX).""" + fd = sock.fileno() + old_flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC) + + +class HTTPServer(object): + """An HTTP server.""" + + _bind_addr = '127.0.0.1' + _interrupt = None + + gateway = None + """A Gateway instance.""" + + minthreads = None + """The minimum number of worker threads to create (default 10).""" + + maxthreads = None + """The maximum number of worker threads to create. + + (default -1 = no limit)""" + + server_name = None + """The name of the server; defaults to ``self.version``.""" + + protocol = 'HTTP/1.1' + """The version string to write in the Status-Line of all HTTP responses. + + For example, "HTTP/1.1" is the default. This also limits the supported + features used in the response.""" + + request_queue_size = 5 + """The 'backlog' arg to socket.listen(); max queued connections. + + (default 5).""" + + shutdown_timeout = 5 + """The total time to wait for worker threads to cleanly exit. + + Specified in seconds.""" + + timeout = 10 + """The timeout in seconds for accepted connections (default 10).""" + + version = 'Cheroot/' + __version__ + """A version string for the HTTPServer.""" + + software = None + """The value to set for the SERVER_SOFTWARE entry in the WSGI environ. + + If None, this defaults to ``'%s Server' % self.version``. + """ + + ready = False + """Internal flag which indicating the socket is accepting connections.""" + + max_request_header_size = 0 + """The maximum size, in bytes, for request headers, or 0 for no limit.""" + + max_request_body_size = 0 + """The maximum size, in bytes, for request bodies, or 0 for no limit.""" + + nodelay = True + """If True (the default since 3.1), sets the TCP_NODELAY socket option.""" + + ConnectionClass = HTTPConnection + """The class to use for handling HTTP connections.""" + + ssl_adapter = None + """An instance of ssl.Adapter (or a subclass). + + You must have the corresponding SSL driver library installed. + """ + + def __init__( + self, bind_addr, gateway, minthreads=10, maxthreads=-1, + server_name=None): + """Initialize HTTPServer instance. + + Args: + bind_addr (tuple): network interface to listen to + gateway (Gateway): gateway for processing HTTP requests + minthreads (int): minimum number of threads for HTTP thread pool + maxthreads (int): maximum number of threads for HTTP thread pool + server_name (str): web server name to be advertised via Server + HTTP header + """ + self.bind_addr = bind_addr + self.gateway = gateway + + self.requests = threadpool.ThreadPool( + self, min=minthreads or 1, max=maxthreads) + + if not server_name: + server_name = self.version + self.server_name = server_name + self.clear_stats() + + def clear_stats(self): + """Reset server stat counters..""" + self._start_time = None + self._run_time = 0 + self.stats = { + 'Enabled': False, + 'Bind Address': lambda s: repr(self.bind_addr), + 'Run time': lambda s: (not s['Enabled']) and -1 or self.runtime(), + 'Accepts': 0, + 'Accepts/sec': lambda s: s['Accepts'] / self.runtime(), + 'Queue': lambda s: getattr(self.requests, 'qsize', None), + 'Threads': lambda s: len(getattr(self.requests, '_threads', [])), + 'Threads Idle': lambda s: getattr(self.requests, 'idle', None), + 'Socket Errors': 0, + 'Requests': lambda s: (not s['Enabled']) and -1 or sum( + [w['Requests'](w) for w in s['Worker Threads'].values()], 0), + 'Bytes Read': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Read'](w) for w in s['Worker Threads'].values()], 0), + 'Bytes Written': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Written'](w) for w in s['Worker Threads'].values()], + 0), + 'Work Time': lambda s: (not s['Enabled']) and -1 or sum( + [w['Work Time'](w) for w in s['Worker Threads'].values()], 0), + 'Read Throughput': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Read'](w) / (w['Work Time'](w) or 1e-6) + for w in s['Worker Threads'].values()], 0), + 'Write Throughput': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Written'](w) / (w['Work Time'](w) or 1e-6) + for w in s['Worker Threads'].values()], 0), + 'Worker Threads': {}, + } + logging.statistics['Cheroot HTTPServer %d' % id(self)] = self.stats + + def runtime(self): + """Return server uptime.""" + if self._start_time is None: + return self._run_time + else: + return self._run_time + (time.time() - self._start_time) + + def __str__(self): + """Render Server instance representing bind address.""" + return '%s.%s(%r)' % (self.__module__, self.__class__.__name__, + self.bind_addr) + + def _get_bind_addr(self): + return self._bind_addr + + def _set_bind_addr(self, value): + if isinstance(value, tuple) and value[0] in ('', None): + # Despite the socket module docs, using '' does not + # allow AI_PASSIVE to work. Passing None instead + # returns '0.0.0.0' like we want. In other words: + # host AI_PASSIVE result + # '' Y 192.168.x.y + # '' N 192.168.x.y + # None Y 0.0.0.0 + # None N 127.0.0.1 + # But since you can get the same effect with an explicit + # '0.0.0.0', we deny both the empty string and None as values. + raise ValueError("Host values of '' or None are not allowed. " + "Use '0.0.0.0' (IPv4) or '::' (IPv6) instead " + 'to listen on all active interfaces.') + self._bind_addr = value + bind_addr = property( + _get_bind_addr, + _set_bind_addr, + doc="""The interface on which to listen for connections. + + For TCP sockets, a (host, port) tuple. Host values may be any IPv4 + or IPv6 address, or any valid hostname. The string 'localhost' is a + synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). + The string '0.0.0.0' is a special IPv4 entry meaning "any active + interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for + IPv6. The empty string or None are not allowed. + + For UNIX sockets, supply the filename as a string. + + Systemd socket activation is automatic and doesn't require tempering + with this variable""") + + def safe_start(self): + """Run the server forever, and stop it cleanly on exit.""" + try: + self.start() + except (KeyboardInterrupt, IOError): + # The time.sleep call might raise + # "IOError: [Errno 4] Interrupted function call" on KBInt. + self.error_log('Keyboard Interrupt: shutting down') + self.stop() + raise + except SystemExit: + self.error_log('SystemExit raised: shutting down') + self.stop() + raise + + def start(self): + """Run the server forever.""" + # We don't have to trap KeyboardInterrupt or SystemExit here, + # because cherrpy.server already does so, calling self.stop() for us. + # If you're using this server with another framework, you should + # trap those exceptions in whatever code block calls start(). + self._interrupt = None + + if self.software is None: + self.software = '%s Server' % self.version + + # Select the appropriate socket + self.socket = None + if os.getenv('LISTEN_PID', None): + # systemd socket activation + self.socket = socket.fromfd(3, socket.AF_INET, socket.SOCK_STREAM) + elif isinstance(self.bind_addr, six.string_types): + # AF_UNIX socket + + # So we can reuse the socket... + try: + os.unlink(self.bind_addr) + except Exception: + pass + + # So everyone can access the socket... + try: + os.chmod(self.bind_addr, 0o777) + except Exception: + pass + + info = [ + (socket.AF_UNIX, socket.SOCK_STREAM, 0, '', self.bind_addr)] + else: + # AF_INET or AF_INET6 socket + # Get the correct address family for our host (allows IPv6 + # addresses) + host, port = self.bind_addr + try: + info = socket.getaddrinfo( + host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM, 0, socket.AI_PASSIVE) + except socket.gaierror: + if ':' in self.bind_addr[0]: + info = [(socket.AF_INET6, socket.SOCK_STREAM, + 0, '', self.bind_addr + (0, 0))] + else: + info = [(socket.AF_INET, socket.SOCK_STREAM, + 0, '', self.bind_addr)] + + if not self.socket: + msg = 'No socket could be created' + for res in info: + af, socktype, proto, canonname, sa = res + try: + self.bind(af, socktype, proto) + break + except socket.error as serr: + msg = '%s -- (%s: %s)' % (msg, sa, serr) + if self.socket: + self.socket.close() + self.socket = None + + if not self.socket: + raise socket.error(msg) + + # Timeout so KeyboardInterrupt can be caught on Win32 + self.socket.settimeout(1) + self.socket.listen(self.request_queue_size) + + # Create worker threads + self.requests.start() + + self.ready = True + self._start_time = time.time() + while self.ready: + try: + self.tick() + except (KeyboardInterrupt, SystemExit): + raise + except Exception: + self.error_log('Error in HTTPServer.tick', level=logging.ERROR, + traceback=True) + + if self.interrupt: + while self.interrupt is True: + # Wait for self.stop() to complete. See _set_interrupt. + time.sleep(0.1) + if self.interrupt: + raise self.interrupt + + def error_log(self, msg='', level=20, traceback=False): + """Write error message to log. + + Args: + msg (str): error message + level (int): logging level + traceback (bool): add traceback to output or not + """ + # Override this in subclasses as desired + sys.stderr.write(msg + '\n') + sys.stderr.flush() + if traceback: + tblines = traceback_.format_exc() + sys.stderr.write(tblines) + sys.stderr.flush() + + def bind(self, family, type, proto=0): + """Create (or recreate) the actual socket object.""" + self.socket = socket.socket(family, type, proto) + prevent_socket_inheritance(self.socket) + if platform.system() != 'Windows': + # Windows has different semantics for SO_REUSEADDR, + # so don't set it. + # https://msdn.microsoft.com/en-us/library/ms740621(v=vs.85).aspx + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if self.nodelay and not isinstance(self.bind_addr, str): + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + if self.ssl_adapter is not None: + self.socket = self.ssl_adapter.bind(self.socket) + + host, port = self.bind_addr[:2] + + # If listening on the IPV6 any address ('::' = IN6ADDR_ANY), + # activate dual-stack. See + # https://github.com/cherrypy/cherrypy/issues/871. + listening_ipv6 = ( + hasattr(socket, 'AF_INET6') + and family == socket.AF_INET6 + and host in ('::', '::0', '::0.0.0.0') + ) + if listening_ipv6: + try: + self.socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + except (AttributeError, socket.error): + # Apparently, the socket option is not available in + # this machine's TCP stack + pass + + self.socket.bind(self.bind_addr) + + def tick(self): + """Accept a new connection and put it on the Queue.""" + try: + s, addr = self.socket.accept() + if self.stats['Enabled']: + self.stats['Accepts'] += 1 + if not self.ready: + return + + prevent_socket_inheritance(s) + if hasattr(s, 'settimeout'): + s.settimeout(self.timeout) + + mf = MakeFile + ssl_env = {} + # if ssl cert and key are set, we try to be a secure HTTP server + if self.ssl_adapter is not None: + try: + s, ssl_env = self.ssl_adapter.wrap(s) + except errors.NoSSLError: + msg = ('The client sent a plain HTTP request, but ' + 'this server only speaks HTTPS on this port.') + buf = ['%s 400 Bad Request\r\n' % self.protocol, + 'Content-Length: %s\r\n' % len(msg), + 'Content-Type: text/plain\r\n\r\n', + msg] + + sock_to_make = s if six.PY3 else s._sock + wfile = mf(sock_to_make, 'wb', io.DEFAULT_BUFFER_SIZE) + try: + wfile.write(''.join(buf).encode('ISO-8859-1')) + except socket.error as ex: + if ex.args[0] not in errors.socket_errors_to_ignore: + raise + return + if not s: + return + mf = self.ssl_adapter.makefile + # Re-apply our timeout since we may have a new socket object + if hasattr(s, 'settimeout'): + s.settimeout(self.timeout) + + conn = self.ConnectionClass(self, s, mf) + + if not isinstance(self.bind_addr, six.string_types): + # optional values + # Until we do DNS lookups, omit REMOTE_HOST + if addr is None: # sometimes this can happen + # figure out if AF_INET or AF_INET6. + if len(s.getsockname()) == 2: + # AF_INET + addr = ('0.0.0.0', 0) + else: + # AF_INET6 + addr = ('::', 0) + conn.remote_addr = addr[0] + conn.remote_port = addr[1] + + conn.ssl_env = ssl_env + + try: + self.requests.put(conn) + except queue.Full: + # Just drop the conn. TODO: write 503 back? + conn.close() + return + except socket.timeout: + # The only reason for the timeout in start() is so we can + # notice keyboard interrupts on Win32, which don't interrupt + # accept() by default + return + except socket.error as ex: + if self.stats['Enabled']: + self.stats['Socket Errors'] += 1 + if ex.args[0] in errors.socket_error_eintr: + # I *think* this is right. EINTR should occur when a signal + # is received during the accept() call; all docs say retry + # the call, and I *think* I'm reading it right that Python + # will then go ahead and poll for and handle the signal + # elsewhere. See + # https://github.com/cherrypy/cherrypy/issues/707. + return + if ex.args[0] in errors.socket_errors_nonblocking: + # Just try again. See + # https://github.com/cherrypy/cherrypy/issues/479. + return + if ex.args[0] in errors.socket_errors_to_ignore: + # Our socket was closed. + # See https://github.com/cherrypy/cherrypy/issues/686. + return + raise + + def _get_interrupt(self): + return self._interrupt + + def _set_interrupt(self, interrupt): + self._interrupt = True + self.stop() + self._interrupt = interrupt + interrupt = property(_get_interrupt, _set_interrupt, + doc='Set this to an Exception instance to ' + 'interrupt the server.') + + def stop(self): + """Gracefully shutdown a server that is serving forever.""" + self.ready = False + if self._start_time is not None: + self._run_time += (time.time() - self._start_time) + self._start_time = None + + sock = getattr(self, 'socket', None) + if sock: + if not isinstance(self.bind_addr, six.string_types): + # Touch our own socket to make accept() return immediately. + try: + host, port = sock.getsockname()[:2] + except socket.error as ex: + if ex.args[0] not in errors.socket_errors_to_ignore: + # Changed to use error code and not message + # See + # https://github.com/cherrypy/cherrypy/issues/860. + raise + else: + # Note that we're explicitly NOT using AI_PASSIVE, + # here, because we want an actual IP to touch. + # localhost won't work if we've bound to a public IP, + # but it will if we bound to '0.0.0.0' (INADDR_ANY). + for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + s = None + try: + s = socket.socket(af, socktype, proto) + # See + # http://groups.google.com/group/cherrypy-users/ + # browse_frm/thread/bbfe5eb39c904fe0 + s.settimeout(1.0) + s.connect((host, port)) + s.close() + except socket.error: + if s: + s.close() + if hasattr(sock, 'close'): + sock.close() + self.socket = None + + self.requests.stop(self.shutdown_timeout) + + +class Gateway(object): + """Base class to interface HTTPServer with other systems, such as WSGI.""" + + def __init__(self, req): + """Initialize Gateway instance with request. + + Args: + req (HTTPRequest): current HTTP request + """ + self.req = req + + def respond(self): + """Process the current request. Must be overridden in a subclass.""" + raise NotImplementedError + + +# These may either be ssl.Adapter subclasses or the string names +# of such classes (in which case they will be lazily loaded). +ssl_adapters = { + 'builtin': 'cheroot.ssl.builtin.BuiltinSSLAdapter', + 'pyopenssl': 'cheroot.ssl.pyopenssl.pyOpenSSLAdapter', +} + + +def get_ssl_adapter_class(name='builtin'): + """Return an SSL adapter class for the given name.""" + adapter = ssl_adapters[name.lower()] + if isinstance(adapter, six.string_types): + last_dot = adapter.rfind('.') + attr_name = adapter[last_dot + 1:] + mod_path = adapter[:last_dot] + + try: + mod = sys.modules[mod_path] + if mod is None: + raise KeyError() + except KeyError: + # The last [''] is important. + mod = __import__(mod_path, globals(), locals(), ['']) + + # Let an AttributeError propagate outward. + try: + adapter = getattr(mod, attr_name) + except AttributeError: + raise AttributeError("'%s' object has no attribute '%s'" + % (mod_path, attr_name)) + + return adapter diff --git a/module/lib/wsgiserver/six.py b/module/lib/wsgiserver/six.py new file mode 100644 index 0000000000..5fe9f8e141 --- /dev/null +++ b/module/lib/wsgiserver/six.py @@ -0,0 +1,980 @@ +# Copyright (c) 2010-2020 Benjamin Peterson +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Utilities for writing code that runs on Python 2 and 3""" + +from __future__ import absolute_import + +import functools +import itertools +import operator +import sys +import types + +__author__ = "Benjamin Peterson " +__version__ = "1.14.0" + + +# Useful for very coarse version differentiation. +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 +PY34 = sys.version_info[0:2] >= (3, 4) + +if PY3: + string_types = str, + integer_types = int, + class_types = type, + text_type = str + binary_type = bytes + + MAXSIZE = sys.maxsize +else: + string_types = basestring, + integer_types = (int, long) + class_types = (type, types.ClassType) + text_type = unicode + binary_type = str + + if sys.platform.startswith("java"): + # Jython always uses 32 bits. + MAXSIZE = int((1 << 31) - 1) + else: + # It's possible to have sizeof(long) != sizeof(Py_ssize_t). + class X(object): + + def __len__(self): + return 1 << 31 + try: + len(X()) + except OverflowError: + # 32-bit + MAXSIZE = int((1 << 31) - 1) + else: + # 64-bit + MAXSIZE = int((1 << 63) - 1) + del X + + +def _add_doc(func, doc): + """Add documentation to a function.""" + func.__doc__ = doc + + +def _import_module(name): + """Import module, returning the module after the last dot.""" + __import__(name) + return sys.modules[name] + + +class _LazyDescr(object): + + def __init__(self, name): + self.name = name + + def __get__(self, obj, tp): + result = self._resolve() + setattr(obj, self.name, result) # Invokes __set__. + try: + # This is a bit ugly, but it avoids running this again by + # removing this descriptor. + delattr(obj.__class__, self.name) + except AttributeError: + pass + return result + + +class MovedModule(_LazyDescr): + + def __init__(self, name, old, new=None): + super(MovedModule, self).__init__(name) + if PY3: + if new is None: + new = name + self.mod = new + else: + self.mod = old + + def _resolve(self): + return _import_module(self.mod) + + def __getattr__(self, attr): + _module = self._resolve() + value = getattr(_module, attr) + setattr(self, attr, value) + return value + + +class _LazyModule(types.ModuleType): + + def __init__(self, name): + super(_LazyModule, self).__init__(name) + self.__doc__ = self.__class__.__doc__ + + def __dir__(self): + attrs = ["__doc__", "__name__"] + attrs += [attr.name for attr in self._moved_attributes] + return attrs + + # Subclasses should override this + _moved_attributes = [] + + +class MovedAttribute(_LazyDescr): + + def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): + super(MovedAttribute, self).__init__(name) + if PY3: + if new_mod is None: + new_mod = name + self.mod = new_mod + if new_attr is None: + if old_attr is None: + new_attr = name + else: + new_attr = old_attr + self.attr = new_attr + else: + self.mod = old_mod + if old_attr is None: + old_attr = name + self.attr = old_attr + + def _resolve(self): + module = _import_module(self.mod) + return getattr(module, self.attr) + + +class _SixMetaPathImporter(object): + + """ + A meta path importer to import six.moves and its submodules. + + This class implements a PEP302 finder and loader. It should be compatible + with Python 2.5 and all existing versions of Python3 + """ + + def __init__(self, six_module_name): + self.name = six_module_name + self.known_modules = {} + + def _add_module(self, mod, *fullnames): + for fullname in fullnames: + self.known_modules[self.name + "." + fullname] = mod + + def _get_module(self, fullname): + return self.known_modules[self.name + "." + fullname] + + def find_module(self, fullname, path=None): + if fullname in self.known_modules: + return self + return None + + def __get_module(self, fullname): + try: + return self.known_modules[fullname] + except KeyError: + raise ImportError("This loader does not know module " + fullname) + + def load_module(self, fullname): + try: + # in case of a reload + return sys.modules[fullname] + except KeyError: + pass + mod = self.__get_module(fullname) + if isinstance(mod, MovedModule): + mod = mod._resolve() + else: + mod.__loader__ = self + sys.modules[fullname] = mod + return mod + + def is_package(self, fullname): + """ + Return true, if the named module is a package. + + We need this method to get correct spec objects with + Python 3.4 (see PEP451) + """ + return hasattr(self.__get_module(fullname), "__path__") + + def get_code(self, fullname): + """Return None + + Required, if is_package is implemented""" + self.__get_module(fullname) # eventually raises ImportError + return None + get_source = get_code # same as get_code + +_importer = _SixMetaPathImporter(__name__) + + +class _MovedItems(_LazyModule): + + """Lazy loading of moved objects""" + __path__ = [] # mark as package + + +_moved_attributes = [ + MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), + MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), + MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"), + MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), + MovedAttribute("intern", "__builtin__", "sys"), + MovedAttribute("map", "itertools", "builtins", "imap", "map"), + MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"), + MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"), + MovedAttribute("getoutput", "commands", "subprocess"), + MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute("reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload"), + MovedAttribute("reduce", "__builtin__", "functools"), + MovedAttribute("shlex_quote", "pipes", "shlex", "quote"), + MovedAttribute("StringIO", "StringIO", "io"), + MovedAttribute("UserDict", "UserDict", "collections"), + MovedAttribute("UserList", "UserList", "collections"), + MovedAttribute("UserString", "UserString", "collections"), + MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), + MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"), + MovedModule("builtins", "__builtin__"), + MovedModule("configparser", "ConfigParser"), + MovedModule("collections_abc", "collections", "collections.abc" if sys.version_info >= (3, 3) else "collections"), + MovedModule("copyreg", "copy_reg"), + MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), + MovedModule("dbm_ndbm", "dbm", "dbm.ndbm"), + MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread" if sys.version_info < (3, 9) else "_thread"), + MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), + MovedModule("http_cookies", "Cookie", "http.cookies"), + MovedModule("html_entities", "htmlentitydefs", "html.entities"), + MovedModule("html_parser", "HTMLParser", "html.parser"), + MovedModule("http_client", "httplib", "http.client"), + MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), + MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"), + MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), + MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"), + MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), + MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), + MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), + MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), + MovedModule("cPickle", "cPickle", "pickle"), + MovedModule("queue", "Queue"), + MovedModule("reprlib", "repr"), + MovedModule("socketserver", "SocketServer"), + MovedModule("_thread", "thread", "_thread"), + MovedModule("tkinter", "Tkinter"), + MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), + MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), + MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), + MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), + MovedModule("tkinter_tix", "Tix", "tkinter.tix"), + MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"), + MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), + MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), + MovedModule("tkinter_colorchooser", "tkColorChooser", + "tkinter.colorchooser"), + MovedModule("tkinter_commondialog", "tkCommonDialog", + "tkinter.commondialog"), + MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), + MovedModule("tkinter_font", "tkFont", "tkinter.font"), + MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), + MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", + "tkinter.simpledialog"), + MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"), + MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), + MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), + MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), + MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), + MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"), +] +# Add windows specific modules. +if sys.platform == "win32": + _moved_attributes += [ + MovedModule("winreg", "_winreg"), + ] + +for attr in _moved_attributes: + setattr(_MovedItems, attr.name, attr) + if isinstance(attr, MovedModule): + _importer._add_module(attr, "moves." + attr.name) +del attr + +_MovedItems._moved_attributes = _moved_attributes + +moves = _MovedItems(__name__ + ".moves") +_importer._add_module(moves, "moves") + + +class Module_six_moves_urllib_parse(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_parse""" + + +_urllib_parse_moved_attributes = [ + MovedAttribute("ParseResult", "urlparse", "urllib.parse"), + MovedAttribute("SplitResult", "urlparse", "urllib.parse"), + MovedAttribute("parse_qs", "urlparse", "urllib.parse"), + MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), + MovedAttribute("urldefrag", "urlparse", "urllib.parse"), + MovedAttribute("urljoin", "urlparse", "urllib.parse"), + MovedAttribute("urlparse", "urlparse", "urllib.parse"), + MovedAttribute("urlsplit", "urlparse", "urllib.parse"), + MovedAttribute("urlunparse", "urlparse", "urllib.parse"), + MovedAttribute("urlunsplit", "urlparse", "urllib.parse"), + MovedAttribute("quote", "urllib", "urllib.parse"), + MovedAttribute("quote_plus", "urllib", "urllib.parse"), + MovedAttribute("unquote", "urllib", "urllib.parse"), + MovedAttribute("unquote_plus", "urllib", "urllib.parse"), + MovedAttribute("unquote_to_bytes", "urllib", "urllib.parse", "unquote", "unquote_to_bytes"), + MovedAttribute("urlencode", "urllib", "urllib.parse"), + MovedAttribute("splitquery", "urllib", "urllib.parse"), + MovedAttribute("splittag", "urllib", "urllib.parse"), + MovedAttribute("splituser", "urllib", "urllib.parse"), + MovedAttribute("splitvalue", "urllib", "urllib.parse"), + MovedAttribute("uses_fragment", "urlparse", "urllib.parse"), + MovedAttribute("uses_netloc", "urlparse", "urllib.parse"), + MovedAttribute("uses_params", "urlparse", "urllib.parse"), + MovedAttribute("uses_query", "urlparse", "urllib.parse"), + MovedAttribute("uses_relative", "urlparse", "urllib.parse"), +] +for attr in _urllib_parse_moved_attributes: + setattr(Module_six_moves_urllib_parse, attr.name, attr) +del attr + +Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes + +_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"), + "moves.urllib_parse", "moves.urllib.parse") + + +class Module_six_moves_urllib_error(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_error""" + + +_urllib_error_moved_attributes = [ + MovedAttribute("URLError", "urllib2", "urllib.error"), + MovedAttribute("HTTPError", "urllib2", "urllib.error"), + MovedAttribute("ContentTooShortError", "urllib", "urllib.error"), +] +for attr in _urllib_error_moved_attributes: + setattr(Module_six_moves_urllib_error, attr.name, attr) +del attr + +Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes + +_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"), + "moves.urllib_error", "moves.urllib.error") + + +class Module_six_moves_urllib_request(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_request""" + + +_urllib_request_moved_attributes = [ + MovedAttribute("urlopen", "urllib2", "urllib.request"), + MovedAttribute("install_opener", "urllib2", "urllib.request"), + MovedAttribute("build_opener", "urllib2", "urllib.request"), + MovedAttribute("pathname2url", "urllib", "urllib.request"), + MovedAttribute("url2pathname", "urllib", "urllib.request"), + MovedAttribute("getproxies", "urllib", "urllib.request"), + MovedAttribute("Request", "urllib2", "urllib.request"), + MovedAttribute("OpenerDirector", "urllib2", "urllib.request"), + MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"), + MovedAttribute("ProxyHandler", "urllib2", "urllib.request"), + MovedAttribute("BaseHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"), + MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"), + MovedAttribute("FileHandler", "urllib2", "urllib.request"), + MovedAttribute("FTPHandler", "urllib2", "urllib.request"), + MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"), + MovedAttribute("UnknownHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"), + MovedAttribute("urlretrieve", "urllib", "urllib.request"), + MovedAttribute("urlcleanup", "urllib", "urllib.request"), + MovedAttribute("URLopener", "urllib", "urllib.request"), + MovedAttribute("FancyURLopener", "urllib", "urllib.request"), + MovedAttribute("proxy_bypass", "urllib", "urllib.request"), + MovedAttribute("parse_http_list", "urllib2", "urllib.request"), + MovedAttribute("parse_keqv_list", "urllib2", "urllib.request"), +] +for attr in _urllib_request_moved_attributes: + setattr(Module_six_moves_urllib_request, attr.name, attr) +del attr + +Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes + +_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"), + "moves.urllib_request", "moves.urllib.request") + + +class Module_six_moves_urllib_response(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_response""" + + +_urllib_response_moved_attributes = [ + MovedAttribute("addbase", "urllib", "urllib.response"), + MovedAttribute("addclosehook", "urllib", "urllib.response"), + MovedAttribute("addinfo", "urllib", "urllib.response"), + MovedAttribute("addinfourl", "urllib", "urllib.response"), +] +for attr in _urllib_response_moved_attributes: + setattr(Module_six_moves_urllib_response, attr.name, attr) +del attr + +Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes + +_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"), + "moves.urllib_response", "moves.urllib.response") + + +class Module_six_moves_urllib_robotparser(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_robotparser""" + + +_urllib_robotparser_moved_attributes = [ + MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"), +] +for attr in _urllib_robotparser_moved_attributes: + setattr(Module_six_moves_urllib_robotparser, attr.name, attr) +del attr + +Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes + +_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"), + "moves.urllib_robotparser", "moves.urllib.robotparser") + + +class Module_six_moves_urllib(types.ModuleType): + + """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" + __path__ = [] # mark as package + parse = _importer._get_module("moves.urllib_parse") + error = _importer._get_module("moves.urllib_error") + request = _importer._get_module("moves.urllib_request") + response = _importer._get_module("moves.urllib_response") + robotparser = _importer._get_module("moves.urllib_robotparser") + + def __dir__(self): + return ['parse', 'error', 'request', 'response', 'robotparser'] + +_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"), + "moves.urllib") + + +def add_move(move): + """Add an item to six.moves.""" + setattr(_MovedItems, move.name, move) + + +def remove_move(name): + """Remove item from six.moves.""" + try: + delattr(_MovedItems, name) + except AttributeError: + try: + del moves.__dict__[name] + except KeyError: + raise AttributeError("no such move, %r" % (name,)) + + +if PY3: + _meth_func = "__func__" + _meth_self = "__self__" + + _func_closure = "__closure__" + _func_code = "__code__" + _func_defaults = "__defaults__" + _func_globals = "__globals__" +else: + _meth_func = "im_func" + _meth_self = "im_self" + + _func_closure = "func_closure" + _func_code = "func_code" + _func_defaults = "func_defaults" + _func_globals = "func_globals" + + +try: + advance_iterator = next +except NameError: + def advance_iterator(it): + return it.next() +next = advance_iterator + + +try: + callable = callable +except NameError: + def callable(obj): + return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) + + +if PY3: + def get_unbound_function(unbound): + return unbound + + create_bound_method = types.MethodType + + def create_unbound_method(func, cls): + return func + + Iterator = object +else: + def get_unbound_function(unbound): + return unbound.im_func + + def create_bound_method(func, obj): + return types.MethodType(func, obj, obj.__class__) + + def create_unbound_method(func, cls): + return types.MethodType(func, None, cls) + + class Iterator(object): + + def next(self): + return type(self).__next__(self) + + callable = callable +_add_doc(get_unbound_function, + """Get the function out of a possibly unbound function""") + + +get_method_function = operator.attrgetter(_meth_func) +get_method_self = operator.attrgetter(_meth_self) +get_function_closure = operator.attrgetter(_func_closure) +get_function_code = operator.attrgetter(_func_code) +get_function_defaults = operator.attrgetter(_func_defaults) +get_function_globals = operator.attrgetter(_func_globals) + + +if PY3: + def iterkeys(d, **kw): + return iter(d.keys(**kw)) + + def itervalues(d, **kw): + return iter(d.values(**kw)) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + def iterlists(d, **kw): + return iter(d.lists(**kw)) + + viewkeys = operator.methodcaller("keys") + + viewvalues = operator.methodcaller("values") + + viewitems = operator.methodcaller("items") +else: + def iterkeys(d, **kw): + return d.iterkeys(**kw) + + def itervalues(d, **kw): + return d.itervalues(**kw) + + def iteritems(d, **kw): + return d.iteritems(**kw) + + def iterlists(d, **kw): + return d.iterlists(**kw) + + viewkeys = operator.methodcaller("viewkeys") + + viewvalues = operator.methodcaller("viewvalues") + + viewitems = operator.methodcaller("viewitems") + +_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") +_add_doc(itervalues, "Return an iterator over the values of a dictionary.") +_add_doc(iteritems, + "Return an iterator over the (key, value) pairs of a dictionary.") +_add_doc(iterlists, + "Return an iterator over the (key, [values]) pairs of a dictionary.") + + +if PY3: + def b(s): + return s.encode("latin-1") + + def u(s): + return s + unichr = chr + import struct + int2byte = struct.Struct(">B").pack + del struct + byte2int = operator.itemgetter(0) + indexbytes = operator.getitem + iterbytes = iter + import io + StringIO = io.StringIO + BytesIO = io.BytesIO + del io + _assertCountEqual = "assertCountEqual" + if sys.version_info[1] <= 1: + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" + else: + _assertRaisesRegex = "assertRaisesRegex" + _assertRegex = "assertRegex" + _assertNotRegex = "assertNotRegex" +else: + def b(s): + return s + # Workaround for standalone backslash + + def u(s): + return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape") + unichr = unichr + int2byte = chr + + def byte2int(bs): + return ord(bs[0]) + + def indexbytes(buf, i): + return ord(buf[i]) + iterbytes = functools.partial(itertools.imap, ord) + import StringIO + StringIO = BytesIO = StringIO.StringIO + _assertCountEqual = "assertItemsEqual" + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" +_add_doc(b, """Byte literal""") +_add_doc(u, """Text literal""") + + +def assertCountEqual(self, *args, **kwargs): + return getattr(self, _assertCountEqual)(*args, **kwargs) + + +def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, _assertRaisesRegex)(*args, **kwargs) + + +def assertRegex(self, *args, **kwargs): + return getattr(self, _assertRegex)(*args, **kwargs) + + +def assertNotRegex(self, *args, **kwargs): + return getattr(self, _assertNotRegex)(*args, **kwargs) + + +if PY3: + exec_ = getattr(moves.builtins, "exec") + + def reraise(tp, value, tb=None): + try: + if value is None: + value = tp() + if value.__traceback__ is not tb: + raise value.with_traceback(tb) + raise value + finally: + value = None + tb = None + +else: + def exec_(_code_, _globs_=None, _locs_=None): + """Execute code in a namespace.""" + if _globs_ is None: + frame = sys._getframe(1) + _globs_ = frame.f_globals + if _locs_ is None: + _locs_ = frame.f_locals + del frame + elif _locs_ is None: + _locs_ = _globs_ + exec("""exec _code_ in _globs_, _locs_""") + + exec_("""def reraise(tp, value, tb=None): + try: + raise tp, value, tb + finally: + tb = None +""") + + +if sys.version_info[:2] > (3,): + exec_("""def raise_from(value, from_value): + try: + raise value from from_value + finally: + value = None +""") +else: + def raise_from(value, from_value): + raise value + + +print_ = getattr(moves.builtins, "print", None) +if print_ is None: + def print_(*args, **kwargs): + """The new-style print function for Python 2.4 and 2.5.""" + fp = kwargs.pop("file", sys.stdout) + if fp is None: + return + + def write(data): + if not isinstance(data, basestring): + data = str(data) + # If the file has an encoding, encode unicode with it. + if (isinstance(fp, file) and + isinstance(data, unicode) and + fp.encoding is not None): + errors = getattr(fp, "errors", None) + if errors is None: + errors = "strict" + data = data.encode(fp.encoding, errors) + fp.write(data) + want_unicode = False + sep = kwargs.pop("sep", None) + if sep is not None: + if isinstance(sep, unicode): + want_unicode = True + elif not isinstance(sep, str): + raise TypeError("sep must be None or a string") + end = kwargs.pop("end", None) + if end is not None: + if isinstance(end, unicode): + want_unicode = True + elif not isinstance(end, str): + raise TypeError("end must be None or a string") + if kwargs: + raise TypeError("invalid keyword arguments to print()") + if not want_unicode: + for arg in args: + if isinstance(arg, unicode): + want_unicode = True + break + if want_unicode: + newline = unicode("\n") + space = unicode(" ") + else: + newline = "\n" + space = " " + if sep is None: + sep = space + if end is None: + end = newline + for i, arg in enumerate(args): + if i: + write(sep) + write(arg) + write(end) +if sys.version_info[:2] < (3, 3): + _print = print_ + + def print_(*args, **kwargs): + fp = kwargs.get("file", sys.stdout) + flush = kwargs.pop("flush", False) + _print(*args, **kwargs) + if flush and fp is not None: + fp.flush() + +_add_doc(reraise, """Reraise an exception.""") + +if sys.version_info[0:2] < (3, 4): + # This does exactly the same what the :func:`py3:functools.update_wrapper` + # function does on Python versions after 3.2. It sets the ``__wrapped__`` + # attribute on ``wrapper`` object and it doesn't raise an error if any of + # the attributes mentioned in ``assigned`` and ``updated`` are missing on + # ``wrapped`` object. + def _update_wrapper(wrapper, wrapped, + assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES): + for attr in assigned: + try: + value = getattr(wrapped, attr) + except AttributeError: + continue + else: + setattr(wrapper, attr, value) + for attr in updated: + getattr(wrapper, attr).update(getattr(wrapped, attr, {})) + wrapper.__wrapped__ = wrapped + return wrapper + _update_wrapper.__doc__ = functools.update_wrapper.__doc__ + + def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES): + return functools.partial(_update_wrapper, wrapped=wrapped, + assigned=assigned, updated=updated) + wraps.__doc__ = functools.wraps.__doc__ + +else: + wraps = functools.wraps + + +def with_metaclass(meta, *bases): + """Create a base class with a metaclass.""" + # This requires a bit of explanation: the basic idea is to make a dummy + # metaclass for one level of class instantiation that replaces itself with + # the actual metaclass. + class metaclass(type): + + def __new__(cls, name, this_bases, d): + if sys.version_info[:2] >= (3, 7): + # This version introduced PEP 560 that requires a bit + # of extra care (we mimic what is done by __build_class__). + resolved_bases = types.resolve_bases(bases) + if resolved_bases is not bases: + d['__orig_bases__'] = bases + else: + resolved_bases = bases + return meta(name, resolved_bases, d) + + @classmethod + def __prepare__(cls, name, this_bases): + return meta.__prepare__(name, bases) + return type.__new__(metaclass, 'temporary_class', (), {}) + + +def add_metaclass(metaclass): + """Class decorator for creating a class with a metaclass.""" + def wrapper(cls): + orig_vars = cls.__dict__.copy() + slots = orig_vars.get('__slots__') + if slots is not None: + if isinstance(slots, str): + slots = [slots] + for slots_var in slots: + orig_vars.pop(slots_var) + orig_vars.pop('__dict__', None) + orig_vars.pop('__weakref__', None) + if hasattr(cls, '__qualname__'): + orig_vars['__qualname__'] = cls.__qualname__ + return metaclass(cls.__name__, cls.__bases__, orig_vars) + return wrapper + + +def ensure_binary(s, encoding='utf-8', errors='strict'): + """Coerce **s** to six.binary_type. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> encoded to `bytes` + - `bytes` -> `bytes` + """ + if isinstance(s, text_type): + return s.encode(encoding, errors) + elif isinstance(s, binary_type): + return s + else: + raise TypeError("not expecting type '%s'" % type(s)) + + +def ensure_str(s, encoding='utf-8', errors='strict'): + """Coerce *s* to `str`. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + if not isinstance(s, (text_type, binary_type)): + raise TypeError("not expecting type '%s'" % type(s)) + if PY2 and isinstance(s, text_type): + s = s.encode(encoding, errors) + elif PY3 and isinstance(s, binary_type): + s = s.decode(encoding, errors) + return s + + +def ensure_text(s, encoding='utf-8', errors='strict'): + """Coerce *s* to six.text_type. + + For Python 2: + - `unicode` -> `unicode` + - `str` -> `unicode` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + if isinstance(s, binary_type): + return s.decode(encoding, errors) + elif isinstance(s, text_type): + return s + else: + raise TypeError("not expecting type '%s'" % type(s)) + + +def python_2_unicode_compatible(klass): + """ + A class decorator that defines __unicode__ and __str__ methods under Python 2. + Under Python 3 it does nothing. + + To support Python 2 and 3 with a single code base, define a __str__ method + returning text and apply this decorator to the class. + """ + if PY2: + if '__str__' not in klass.__dict__: + raise ValueError("@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % + klass.__name__) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode('utf-8') + return klass + + +# Complete the moves implementation. +# This code is at the end of this module to speed up module loading. +# Turn this module into a package. +__path__ = [] # required for PEP 302 and PEP 451 +__package__ = __name__ # see PEP 366 @ReservedAssignment +if globals().get("__spec__") is not None: + __spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable +# Remove other six meta path importers, since they cause problems. This can +# happen if six is removed from sys.modules and then reloaded. (Setuptools does +# this for some reason.) +if sys.meta_path: + for i, importer in enumerate(sys.meta_path): + # Here's some real nastiness: Another "instance" of the six module might + # be floating around. Therefore, we can't use isinstance() to check for + # the six meta path importer, since the other six instance will have + # inserted an importer with different class. + if (type(importer).__name__ == "_SixMetaPathImporter" and + importer.name == __name__): + del sys.meta_path[i] + break + del i, importer +# Finally, add the importer to the meta path import hook. +sys.meta_path.append(_importer) diff --git a/module/lib/wsgiserver/ssl/__init__.py b/module/lib/wsgiserver/ssl/__init__.py new file mode 100644 index 0000000000..e581706630 --- /dev/null +++ b/module/lib/wsgiserver/ssl/__init__.py @@ -0,0 +1,48 @@ +"""Implementation of the SSL adapter base interface.""" + +from abc import ABCMeta, abstractmethod + +from six import add_metaclass + + +@add_metaclass(ABCMeta) +class Adapter(object): + """Base class for SSL driver library adapters. + + Required methods: + + * ``wrap(sock) -> (wrapped socket, ssl environ dict)`` + * ``makefile(sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE) -> + socket file object`` + """ + + @abstractmethod + def __init__( + self, certificate, private_key, certificate_chain=None, + ciphers=None): + """Set up certificates, private key ciphers and reset context.""" + self.certificate = certificate + self.private_key = private_key + self.certificate_chain = certificate_chain + self.ciphers = ciphers + self.context = None + + @abstractmethod + def bind(self, sock): + """Wrap and return the given socket.""" + return sock + + @abstractmethod + def wrap(self, sock): + """Wrap and return the given socket, plus WSGI environ entries.""" + raise NotImplementedError + + @abstractmethod + def get_environ(self): + """Return WSGI environ entries to be merged into each request.""" + raise NotImplementedError + + @abstractmethod + def makefile(self, sock, mode='r', bufsize=-1): + """Return socket file object.""" + raise NotImplementedError diff --git a/module/lib/wsgiserver/ssl/builtin.py b/module/lib/wsgiserver/ssl/builtin.py new file mode 100644 index 0000000000..e6209d95e9 --- /dev/null +++ b/module/lib/wsgiserver/ssl/builtin.py @@ -0,0 +1,134 @@ +""" +A library for integrating Python's builtin ``ssl`` library with Cheroot. + +The ssl module must be importable for SSL functionality. + +To use this module, set ``HTTPServer.ssl_adapter`` to an instance of +``BuiltinSSLAdapter``. +""" + +try: + import ssl +except ImportError: + ssl = None + +try: + from _pyio import DEFAULT_BUFFER_SIZE +except ImportError: + try: + from io import DEFAULT_BUFFER_SIZE + except ImportError: + DEFAULT_BUFFER_SIZE = -1 + +from . import Adapter +from .. import errors +from ..makefile import MakeFile + + +class BuiltinSSLAdapter(Adapter): + """A wrapper for integrating Python's builtin ssl module with Cheroot.""" + + certificate = None + """The filename of the server SSL certificate.""" + + private_key = None + """The filename of the server's private key file.""" + + certificate_chain = None + """The filename of the certificate chain file.""" + + context = None + """The ssl.SSLContext that will be used to wrap sockets where available + (on Python > 2.7.9 / 3.3) + """ + + ciphers = None + """The ciphers list of SSL.""" + + def __init__( + self, certificate, private_key, certificate_chain=None, + ciphers=None): + """Set up context in addition to base class properties if available.""" + if ssl is None: + raise ImportError('You must install the ssl module to use HTTPS.') + + super(BuiltinSSLAdapter, self).__init__( + certificate, private_key, certificate_chain, ciphers) + + if hasattr(ssl, 'create_default_context'): + self.context = ssl.create_default_context( + purpose=ssl.Purpose.CLIENT_AUTH, + cafile=certificate_chain + ) + self.context.load_cert_chain(certificate, private_key) + if self.ciphers is not None: + self.context.set_ciphers(ciphers) + + def bind(self, sock): + """Wrap and return the given socket.""" + return super(BuiltinSSLAdapter, self).bind(sock) + + def wrap(self, sock): + """Wrap and return the given socket, plus WSGI environ entries.""" + try: + if self.context is not None: + s = self.context.wrap_socket( + sock, do_handshake_on_connect=True, server_side=True) + else: + s = ssl.wrap_socket(sock, do_handshake_on_connect=True, + server_side=True, + certfile=self.certificate, + keyfile=self.private_key, + ssl_version=ssl.PROTOCOL_SSLv23, + ca_certs=self.certificate_chain) + except ssl.SSLError as ex: + if ex.errno == ssl.SSL_ERROR_EOF: + # This is almost certainly due to the cherrypy engine + # 'pinging' the socket to assert it's connectable; + # the 'ping' isn't SSL. + return None, {} + elif ex.errno == ssl.SSL_ERROR_SSL: + if 'http request' in ex.args[1]: + # The client is speaking HTTP to an HTTPS server. + raise errors.NoSSLError + + # Check if it's one of the known errors + # Errors that are caught by PyOpenSSL, but thrown by + # built-in ssl + _block_errors = ( + 'unknown protocol', 'unknown ca', 'unknown_ca', + 'unknown error', + 'https proxy request', 'inappropriate fallback', + 'wrong version number', + 'no shared cipher', 'certificate unknown', + 'ccs received early', + ) + for error_text in _block_errors: + if error_text in ex.args[1].lower(): + # Accepted error, let's pass + return None, {} + elif 'handshake operation timed out' in ex.args[0]: + # This error is thrown by builtin SSL after a timeout + # when client is speaking HTTP to an HTTPS server. + # The connection can safely be dropped. + return None, {} + raise + return s, self.get_environ(s) + + # TODO: fill this out more with mod ssl env + def get_environ(self, sock): + """Create WSGI environ entries to be merged into each request.""" + cipher = sock.cipher() + ssl_environ = { + 'wsgi.url_scheme': 'https', + 'HTTPS': 'on', + 'SSL_PROTOCOL': cipher[1], + 'SSL_CIPHER': cipher[0] + # SSL_VERSION_INTERFACE string The mod_ssl program version + # SSL_VERSION_LIBRARY string The OpenSSL program version + } + return ssl_environ + + def makefile(self, sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE): + """Return socket file object.""" + return MakeFile(sock, mode, bufsize) diff --git a/module/lib/wsgiserver/ssl/pyopenssl.py b/module/lib/wsgiserver/ssl/pyopenssl.py new file mode 100644 index 0000000000..b32ada281b --- /dev/null +++ b/module/lib/wsgiserver/ssl/pyopenssl.py @@ -0,0 +1,264 @@ +""" +A library for integrating pyOpenSSL with Cheroot. + +The OpenSSL module must be importable for SSL functionality. +You can obtain it from `here `_. + +To use this module, set HTTPServer.ssl_adapter to an instance of +ssl.Adapter. There are two ways to use SSL: + +Method One +---------- + + * ``ssl_adapter.context``: an instance of SSL.Context. + +If this is not None, it is assumed to be an SSL.Context instance, +and will be passed to SSL.Connection on bind(). The developer is +responsible for forming a valid Context object. This approach is +to be preferred for more flexibility, e.g. if the cert and key are +streams instead of files, or need decryption, or SSL.SSLv3_METHOD +is desired instead of the default SSL.SSLv23_METHOD, etc. Consult +the pyOpenSSL documentation for complete options. + +Method Two (shortcut) +--------------------- + + * ``ssl_adapter.certificate``: the filename of the server SSL certificate. + * ``ssl_adapter.private_key``: the filename of the server's private key file. + +Both are None by default. If ssl_adapter.context is None, but .private_key +and .certificate are both given and valid, they will be read, and the +context will be automatically created from them. +""" + +import socket +import threading +import time + +try: + from OpenSSL import SSL + from OpenSSL import crypto +except ImportError: + SSL = None + +from . import Adapter +from .. import errors, server as cheroot_server +from ..makefile import MakeFile + + +class SSL_fileobject(MakeFile): + """SSL file object attached to a socket object.""" + + ssl_timeout = 3 + ssl_retry = .01 + + def _safe_call(self, is_reader, call, *args, **kwargs): + """Wrap the given call with SSL error-trapping. + + is_reader: if False EOF errors will be raised. If True, EOF errors + will return "" (to emulate normal sockets). + """ + start = time.time() + while True: + try: + return call(*args, **kwargs) + except SSL.WantReadError: + # Sleep and try again. This is dangerous, because it means + # the rest of the stack has no way of differentiating + # between a "new handshake" error and "client dropped". + # Note this isn't an endless loop: there's a timeout below. + time.sleep(self.ssl_retry) + except SSL.WantWriteError: + time.sleep(self.ssl_retry) + except SSL.SysCallError as e: + if is_reader and e.args == (-1, 'Unexpected EOF'): + return '' + + errnum = e.args[0] + if is_reader and errnum in errors.socket_errors_to_ignore: + return '' + raise socket.error(errnum) + except SSL.Error as e: + if is_reader and e.args == (-1, 'Unexpected EOF'): + return '' + + thirdarg = None + try: + thirdarg = e.args[0][0][2] + except IndexError: + pass + + if thirdarg == 'http request': + # The client is talking HTTP to an HTTPS server. + raise errors.NoSSLError() + + raise errors.FatalSSLAlert(*e.args) + + if time.time() - start > self.ssl_timeout: + raise socket.timeout('timed out') + + def recv(self, size): + """Receive message of a size from the socket.""" + return self._safe_call(True, super(SSL_fileobject, self).recv, size) + + def sendall(self, *args, **kwargs): + """Send whole message to the socket.""" + return self._safe_call(False, super(SSL_fileobject, self).sendall, + *args, **kwargs) + + def send(self, *args, **kwargs): + """Send some part of message to the socket.""" + return self._safe_call(False, super(SSL_fileobject, self).send, + *args, **kwargs) + + +class SSLConnection: + """A thread-safe wrapper for an SSL.Connection. + + ``*args``: the arguments to create the wrapped ``SSL.Connection(*args)``. + """ + + def __init__(self, *args): + """Initialize SSLConnection instance.""" + self._ssl_conn = SSL.Connection(*args) + self._lock = threading.RLock() + + for f in ('get_context', 'pending', 'send', 'write', 'recv', 'read', + 'renegotiate', 'bind', 'listen', 'connect', 'accept', + 'setblocking', 'fileno', 'close', 'get_cipher_list', + 'getpeername', 'getsockname', 'getsockopt', 'setsockopt', + 'makefile', 'get_app_data', 'set_app_data', 'state_string', + 'sock_shutdown', 'get_peer_certificate', 'want_read', + 'want_write', 'set_connect_state', 'set_accept_state', + 'connect_ex', 'sendall', 'settimeout', 'gettimeout'): + exec("""def %s(self, *args): + self._lock.acquire() + try: + return self._ssl_conn.%s(*args) + finally: + self._lock.release() +""" % (f, f)) + + def shutdown(self, *args): + """Shutdown the SSL connection. + + Ignore all incoming args since pyOpenSSL.socket.shutdown takes no args. + """ + self._lock.acquire() + try: + return self._ssl_conn.shutdown() + finally: + self._lock.release() + + +class pyOpenSSLAdapter(Adapter): + """A wrapper for integrating pyOpenSSL with Cheroot.""" + + certificate = None + """The filename of the server SSL certificate.""" + + private_key = None + """The filename of the server's private key file.""" + + certificate_chain = None + """Optional. The filename of CA's intermediate certificate bundle. + + This is needed for cheaper "chained root" SSL certificates, and should be + left as None if not required.""" + + context = None + """An instance of SSL.Context.""" + + ciphers = None + """The ciphers list of SSL.""" + + def __init__( + self, certificate, private_key, certificate_chain=None, + ciphers=None): + """Initialize OpenSSL Adapter instance.""" + if SSL is None: + raise ImportError('You must install pyOpenSSL to use HTTPS.') + + super(pyOpenSSLAdapter, self).__init__( + certificate, private_key, certificate_chain, ciphers) + + self._environ = None + + def bind(self, sock): + """Wrap and return the given socket.""" + if self.context is None: + self.context = self.get_context() + conn = SSLConnection(self.context, sock) + self._environ = self.get_environ() + return conn + + def wrap(self, sock): + """Wrap and return the given socket, plus WSGI environ entries.""" + return sock, self._environ.copy() + + def get_context(self): + """Return an SSL.Context from self attributes.""" + # See http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/442473 + c = SSL.Context(SSL.SSLv23_METHOD) + c.use_privatekey_file(self.private_key) + if self.certificate_chain: + c.load_verify_locations(self.certificate_chain) + c.use_certificate_file(self.certificate) + return c + + def get_environ(self): + """Return WSGI environ entries to be merged into each request.""" + ssl_environ = { + 'HTTPS': 'on', + # pyOpenSSL doesn't provide access to any of these AFAICT + # 'SSL_PROTOCOL': 'SSLv2', + # SSL_CIPHER string The cipher specification name + # SSL_VERSION_INTERFACE string The mod_ssl program version + # SSL_VERSION_LIBRARY string The OpenSSL program version + } + + if self.certificate: + # Server certificate attributes + cert = open(self.certificate, 'rb').read() + cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert) + ssl_environ.update({ + 'SSL_SERVER_M_VERSION': cert.get_version(), + 'SSL_SERVER_M_SERIAL': cert.get_serial_number(), + # 'SSL_SERVER_V_START': + # Validity of server's certificate (start time), + # 'SSL_SERVER_V_END': + # Validity of server's certificate (end time), + }) + + for prefix, dn in [('I', cert.get_issuer()), + ('S', cert.get_subject())]: + # X509Name objects don't seem to have a way to get the + # complete DN string. Use str() and slice it instead, + # because str(dn) == "" + dnstr = str(dn)[18:-2] + + wsgikey = 'SSL_SERVER_%s_DN' % prefix + ssl_environ[wsgikey] = dnstr + + # The DN should be of the form: /k1=v1/k2=v2, but we must allow + # for any value to contain slashes itself (in a URL). + while dnstr: + pos = dnstr.rfind('=') + dnstr, value = dnstr[:pos], dnstr[pos + 1:] + pos = dnstr.rfind('/') + dnstr, key = dnstr[:pos], dnstr[pos + 1:] + if key and value: + wsgikey = 'SSL_SERVER_%s_DN_%s' % (prefix, key) + ssl_environ[wsgikey] = value + + return ssl_environ + + def makefile(self, sock, mode='r', bufsize=-1): + """Return socket file object.""" + if SSL and isinstance(sock, SSL.ConnectionType): + timeout = sock.gettimeout() + f = SSL_fileobject(sock, mode, bufsize) + f.ssl_timeout = timeout + return f + else: + return cheroot_server.CP_fileobject(sock, mode, bufsize) diff --git a/module/lib/wsgiserver/workers/__init__.py b/module/lib/wsgiserver/workers/__init__.py new file mode 100644 index 0000000000..098b8f25ff --- /dev/null +++ b/module/lib/wsgiserver/workers/__init__.py @@ -0,0 +1 @@ +"""HTTP workers pool.""" diff --git a/module/lib/wsgiserver/workers/threadpool.py b/module/lib/wsgiserver/workers/threadpool.py new file mode 100644 index 0000000000..110fbdaf2d --- /dev/null +++ b/module/lib/wsgiserver/workers/threadpool.py @@ -0,0 +1,267 @@ +"""A thread-based worker pool.""" + + +import threading +import time +import socket + +from six.moves import queue + + +__all__ = ('WorkerThread', 'ThreadPool') + + +class TrueyZero(object): + """Object which equals and does math like the integer 0 but evals True.""" + + def __add__(self, other): + return other + + def __radd__(self, other): + return other + + +trueyzero = TrueyZero() + +_SHUTDOWNREQUEST = None + + +class WorkerThread(threading.Thread): + """Thread which continuously polls a Queue for Connection objects. + + Due to the timing issues of polling a Queue, a WorkerThread does not + check its own 'ready' flag after it has started. To stop the thread, + it is necessary to stick a _SHUTDOWNREQUEST object onto the Queue + (one for each running WorkerThread). + """ + + conn = None + """The current connection pulled off the Queue, or None.""" + + server = None + """The HTTP Server which spawned this thread, and which owns the + Queue and is placing active connections into it.""" + + ready = False + """A simple flag for the calling server to know when this thread + has begun polling the Queue.""" + + def __init__(self, server): + """Initialize WorkerThread instance. + + Args: + server (cheroot.server.HTTPServer): web server object + receiving this request + """ + self.ready = False + self.server = server + + self.requests_seen = 0 + self.bytes_read = 0 + self.bytes_written = 0 + self.start_time = None + self.work_time = 0 + self.stats = { + 'Requests': lambda s: self.requests_seen + ( + (self.start_time is None) and + trueyzero or + self.conn.requests_seen + ), + 'Bytes Read': lambda s: self.bytes_read + ( + (self.start_time is None) and + trueyzero or + self.conn.rfile.bytes_read + ), + 'Bytes Written': lambda s: self.bytes_written + ( + (self.start_time is None) and + trueyzero or + self.conn.wfile.bytes_written + ), + 'Work Time': lambda s: self.work_time + ( + (self.start_time is None) and + trueyzero or + time.time() - self.start_time + ), + 'Read Throughput': lambda s: s['Bytes Read'](s) / ( + s['Work Time'](s) or 1e-6), + 'Write Throughput': lambda s: s['Bytes Written'](s) / ( + s['Work Time'](s) or 1e-6), + } + threading.Thread.__init__(self) + + def run(self): + """Process incoming HTTP connections. + + Retrieves incoming connections from thread pool. + """ + self.server.stats['Worker Threads'][self.getName()] = self.stats + try: + self.ready = True + while True: + conn = self.server.requests.get() + if conn is _SHUTDOWNREQUEST: + return + + self.conn = conn + if self.server.stats['Enabled']: + self.start_time = time.time() + try: + conn.communicate() + finally: + conn.close() + if self.server.stats['Enabled']: + self.requests_seen += self.conn.requests_seen + self.bytes_read += self.conn.rfile.bytes_read + self.bytes_written += self.conn.wfile.bytes_written + self.work_time += time.time() - self.start_time + self.start_time = None + self.conn = None + except (KeyboardInterrupt, SystemExit) as ex: + self.server.interrupt = ex + + +class ThreadPool(object): + """A Request Queue for an HTTPServer which pools threads. + + ThreadPool objects must provide min, get(), put(obj), start() + and stop(timeout) attributes. + """ + + def __init__( + self, server, min=10, max=-1, accepted_queue_size=-1, + accepted_queue_timeout=10): + """Initialize HTTP requests queue instance. + + Args: + server (cheroot.server.HTTPServer): web server object + receiving this request + min (int): minimum number of worker threads + max (int): maximum number of worker threads + accepted_queue_size (int): maximum number of active + requests in queue + accepted_queue_timeout (int): timeout for putting request + into queue + """ + self.server = server + self.min = min + self.max = max + self._threads = [] + self._queue = queue.Queue(maxsize=accepted_queue_size) + self._queue_put_timeout = accepted_queue_timeout + self.get = self._queue.get + + def start(self): + """Start the pool of threads.""" + for i in range(self.min): + self._threads.append(WorkerThread(self.server)) + for worker in self._threads: + worker.setName('CP Server ' + worker.getName()) + worker.start() + for worker in self._threads: + while not worker.ready: + time.sleep(.1) + + def _get_idle(self): + """Number of worker threads which are idle. Read-only.""" + return len([t for t in self._threads if t.conn is None]) + idle = property(_get_idle, doc=_get_idle.__doc__) + + def put(self, obj): + """Put request into queue. + + Args: + obj (cheroot.server.HTTPConnection): HTTP connection + waiting to be processed + """ + self._queue.put(obj, block=True, timeout=self._queue_put_timeout) + if obj is _SHUTDOWNREQUEST: + return + + def grow(self, amount): + """Spawn new worker threads (not above self.max).""" + if self.max > 0: + budget = max(self.max - len(self._threads), 0) + else: + # self.max <= 0 indicates no maximum + budget = float('inf') + + n_new = min(amount, budget) + + workers = [self._spawn_worker() for i in range(n_new)] + while not all(worker.ready for worker in workers): + time.sleep(.1) + self._threads.extend(workers) + + def _spawn_worker(self): + worker = WorkerThread(self.server) + worker.setName('CP Server ' + worker.getName()) + worker.start() + return worker + + def shrink(self, amount): + """Kill off worker threads (not below self.min).""" + # Grow/shrink the pool if necessary. + # Remove any dead threads from our list + for t in self._threads: + if not t.isAlive(): + self._threads.remove(t) + amount -= 1 + + # calculate the number of threads above the minimum + n_extra = max(len(self._threads) - self.min, 0) + + # don't remove more than amount + n_to_remove = min(amount, n_extra) + + # put shutdown requests on the queue equal to the number of threads + # to remove. As each request is processed by a worker, that worker + # will terminate and be culled from the list. + for n in range(n_to_remove): + self._queue.put(_SHUTDOWNREQUEST) + + def stop(self, timeout=5): + """Terminate all worker threads. + + Args: + timeout (int): time to wait for threads to stop gracefully + """ + # Must shut down threads here so the code that calls + # this method can know when all threads are stopped. + for worker in self._threads: + self._queue.put(_SHUTDOWNREQUEST) + + # Don't join currentThread (when stop is called inside a request). + current = threading.currentThread() + if timeout is not None and timeout >= 0: + endtime = time.time() + timeout + while self._threads: + worker = self._threads.pop() + if worker is not current and worker.isAlive(): + try: + if timeout is None or timeout < 0: + worker.join() + else: + remaining_time = endtime - time.time() + if remaining_time > 0: + worker.join(remaining_time) + if worker.isAlive(): + # We exhausted the timeout. + # Forcibly shut down the socket. + c = worker.conn + if c and not c.rfile.closed: + try: + c.socket.shutdown(socket.SHUT_RD) + except TypeError: + # pyOpenSSL sockets don't take an arg + c.socket.shutdown() + worker.join() + except (AssertionError, + # Ignore repeated Ctrl-C. + # See + # https://github.com/cherrypy/cherrypy/issues/691. + KeyboardInterrupt): + pass + + def _get_qsize(self): + return self._queue.qsize() + qsize = property(_get_qsize) diff --git a/module/lib/wsgiserver/wsgi.py b/module/lib/wsgiserver/wsgi.py new file mode 100644 index 0000000000..3a9f3fe15e --- /dev/null +++ b/module/lib/wsgiserver/wsgi.py @@ -0,0 +1,394 @@ +"""This class holds Cheroot WSGI server implementation. + +Simplest example on how to use this server:: + + from cheroot import wsgi + + def my_crazy_app(environ, start_response): + status = '200 OK' + response_headers = [('Content-type','text/plain')] + start_response(status, response_headers) + return ['Hello world!'] + + addr = '0.0.0.0', 8070 + server = wsgi.Server(addr, my_crazy_app) + server.start() + +The Cheroot WSGI server can serve as many WSGI applications +as you want in one instance by using a PathInfoDispatcher:: + + path_map = { + '/': my_crazy_app, + '/blog': my_blog_app, + } + d = wsgi.PathInfoDispatcher(path_map) + server = wsgi.Server(addr, d) +""" + +import sys + +import six +from six.moves import filter + +from . import server +from .workers import threadpool +from ._compat import ntob, bton + + +class Server(server.HTTPServer): + """A subclass of HTTPServer which calls a WSGI application.""" + + wsgi_version = (1, 0) + """The version of WSGI to produce.""" + + def __init__(self, bind_addr, wsgi_app, numthreads=10, server_name=None, + max=-1, request_queue_size=5, timeout=10, shutdown_timeout=5, + accepted_queue_size=-1, accepted_queue_timeout=10): + """Initialize WSGI Server instance. + + Args: + bind_addr (tuple): network interface to listen to + wsgi_app (callable): WSGI application callable + numthreads (int): number of threads for WSGI thread pool + server_name (str): web server name to be advertised via + Server HTTP header + max (int): maximum number of worker threads + request_queue_size (int): the 'backlog' arg to + socket.listen(); max queued connections + timeout (int): the timeout in seconds for accepted connections + shutdown_timeout (int): the total time, in seconds, to + wait for worker threads to cleanly exit + accepted_queue_size (int): maximum number of active + requests in queue + accepted_queue_timeout (int): timeout for putting request + into queue + """ + super(Server, self).__init__( + bind_addr, + gateway=wsgi_gateways[self.wsgi_version], + server_name=server_name, + ) + self.wsgi_app = wsgi_app + self.request_queue_size = request_queue_size + self.timeout = timeout + self.shutdown_timeout = shutdown_timeout + self.requests = threadpool.ThreadPool( + self, min=numthreads or 1, max=max, + accepted_queue_size=accepted_queue_size, + accepted_queue_timeout=accepted_queue_timeout) + + def _get_numthreads(self): + return self.requests.min + + def _set_numthreads(self, value): + self.requests.min = value + numthreads = property(_get_numthreads, _set_numthreads) + + +class Gateway(server.Gateway): + """A base class to interface HTTPServer with WSGI.""" + + def __init__(self, req): + """Initialize WSGI Gateway instance with request. + + Args: + req (HTTPRequest): current HTTP request + """ + super(Gateway, self).__init__(req) + self.started_response = False + self.env = self.get_environ() + self.remaining_bytes_out = None + + @classmethod + def gateway_map(cls): + """Create a mapping of gateways and their versions. + + Returns: + dict[tuple[int,int],class]: map of gateway version and + corresponding class + """ + return dict( + (gw.version, gw) + for gw in cls.__subclasses__() + ) + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version.""" + raise NotImplementedError + + def respond(self): + """Process the current request. + + From PEP 333: + + The start_response callable must not actually transmit + the response headers. Instead, it must store them for the + server or gateway to transmit only after the first + iteration of the application return value that yields + a NON-EMPTY string, or upon the application's first + invocation of the write() callable. + """ + response = self.req.server.wsgi_app(self.env, self.start_response) + try: + for chunk in filter(None, response): + if not isinstance(chunk, six.binary_type): + raise ValueError('WSGI Applications must yield bytes') + self.write(chunk) + finally: + if hasattr(response, 'close'): + response.close() + + def start_response(self, status, headers, exc_info=None): + """WSGI callable to begin the HTTP response.""" + # "The application may call start_response more than once, + # if and only if the exc_info argument is provided." + if self.started_response and not exc_info: + raise AssertionError('WSGI start_response called a second ' + 'time with no exc_info.') + self.started_response = True + + # "if exc_info is provided, and the HTTP headers have already been + # sent, start_response must raise an error, and should raise the + # exc_info tuple." + if self.req.sent_headers: + try: + six.reraise(*exc_info) + finally: + exc_info = None + + self.req.status = self._encode_status(status) + + for k, v in headers: + if not isinstance(k, str): + raise TypeError( + 'WSGI response header key %r is not of type str.' % k) + if not isinstance(v, str): + raise TypeError( + 'WSGI response header value %r is not of type str.' % v) + if k.lower() == 'content-length': + self.remaining_bytes_out = int(v) + out_header = ntob(k), ntob(v) + self.req.outheaders.append(out_header) + + return self.write + + @staticmethod + def _encode_status(status): + """Cast status to bytes representation of current Python version. + + According to PEP 3333, when using Python 3, the response status + and headers must be bytes masquerading as unicode; that is, they + must be of type "str" but are restricted to code points in the + "latin-1" set. + """ + if six.PY2: + return status + if not isinstance(status, str): + raise TypeError('WSGI response status is not of type str.') + return status.encode('ISO-8859-1') + + def write(self, chunk): + """WSGI callable to write unbuffered data to the client. + + This method is also used internally by start_response (to write + data from the iterable returned by the WSGI application). + """ + if not self.started_response: + raise AssertionError('WSGI write called before start_response.') + + chunklen = len(chunk) + rbo = self.remaining_bytes_out + if rbo is not None and chunklen > rbo: + if not self.req.sent_headers: + # Whew. We can send a 500 to the client. + self.req.simple_response( + '500 Internal Server Error', + 'The requested resource returned more bytes than the ' + 'declared Content-Length.') + else: + # Dang. We have probably already sent data. Truncate the chunk + # to fit (so the client doesn't hang) and raise an error later. + chunk = chunk[:rbo] + + if not self.req.sent_headers: + self.req.sent_headers = True + self.req.send_headers() + + self.req.write(chunk) + + if rbo is not None: + rbo -= chunklen + if rbo < 0: + raise ValueError( + 'Response body exceeds the declared Content-Length.') + + +class Gateway_10(Gateway): + """A Gateway class to interface HTTPServer with WSGI 1.0.x.""" + + version = 1, 0 + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version.""" + req = self.req + env = { + # set a non-standard environ entry so the WSGI app can know what + # the *real* server protocol is (and what features to support). + # See http://www.faqs.org/rfcs/rfc2145.html. + 'ACTUAL_SERVER_PROTOCOL': req.server.protocol, + 'PATH_INFO': bton(req.path), + 'QUERY_STRING': bton(req.qs), + 'REMOTE_ADDR': req.conn.remote_addr or '', + 'REMOTE_PORT': str(req.conn.remote_port or ''), + 'REQUEST_METHOD': bton(req.method), + 'REQUEST_URI': bton(req.uri), + 'SCRIPT_NAME': '', + 'SERVER_NAME': req.server.server_name, + # Bah. "SERVER_PROTOCOL" is actually the REQUEST protocol. + 'SERVER_PROTOCOL': bton(req.request_protocol), + 'SERVER_SOFTWARE': req.server.software, + 'wsgi.errors': sys.stderr, + 'wsgi.input': req.rfile, + 'wsgi.multiprocess': False, + 'wsgi.multithread': True, + 'wsgi.run_once': False, + 'wsgi.url_scheme': bton(req.scheme), + 'wsgi.version': self.version, + } + + if isinstance(req.server.bind_addr, six.string_types): + # AF_UNIX. This isn't really allowed by WSGI, which doesn't + # address unix domain sockets. But it's better than nothing. + env['SERVER_PORT'] = '' + else: + env['SERVER_PORT'] = str(req.server.bind_addr[1]) + + # Request headers + env.update( + ('HTTP_' + bton(k).upper().replace('-', '_'), bton(v)) + for k, v in req.inheaders.items() + ) + + # CONTENT_TYPE/CONTENT_LENGTH + ct = env.pop('HTTP_CONTENT_TYPE', None) + if ct is not None: + env['CONTENT_TYPE'] = ct + cl = env.pop('HTTP_CONTENT_LENGTH', None) + if cl is not None: + env['CONTENT_LENGTH'] = cl + + if req.conn.ssl_env: + env.update(req.conn.ssl_env) + + return env + + +class Gateway_u0(Gateway_10): + """A Gateway class to interface HTTPServer with WSGI u.0. + + WSGI u.0 is an experimental protocol, which uses unicode for keys + and values in both Python 2 and Python 3. + """ + + version = 'u', 0 + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version.""" + req = self.req + env_10 = super(Gateway_u0, self).get_environ() + env = dict(map(self._decode_key, env_10.items())) + + # Request-URI + enc = env.setdefault(six.u('wsgi.url_encoding'), six.u('utf-8')) + try: + env['PATH_INFO'] = req.path.decode(enc) + env['QUERY_STRING'] = req.qs.decode(enc) + except UnicodeDecodeError: + # Fall back to latin 1 so apps can transcode if needed. + env['wsgi.url_encoding'] = 'ISO-8859-1' + env['PATH_INFO'] = env_10['PATH_INFO'] + env['QUERY_STRING'] = env_10['QUERY_STRING'] + + env.update(map(self._decode_value, env.items())) + + return env + + @staticmethod + def _decode_key(item): + k, v = item + if six.PY2: + k = k.decode('ISO-8859-1') + return k, v + + @staticmethod + def _decode_value(item): + k, v = item + skip_keys = 'REQUEST_URI', 'wsgi.input' + if six.PY3 or not isinstance(v, bytes) or k in skip_keys: + return k, v + return k, v.decode('ISO-8859-1') + + +wsgi_gateways = Gateway.gateway_map() + + +class PathInfoDispatcher(object): + """A WSGI dispatcher for dispatch based on the PATH_INFO.""" + + def __init__(self, apps): + """Initialize path info WSGI app dispatcher. + + Args: + apps (dict[str,object]|list[tuple[str,object]]): URI prefix + and WSGI app pairs + """ + try: + apps = list(apps.items()) + except AttributeError: + pass + + # Sort the apps by len(path), descending + def by_path_len(app): + return len(app[0]) + apps.sort(key=by_path_len, reverse=True) + + # The path_prefix strings must start, but not end, with a slash. + # Use "" instead of "/". + self.apps = [(p.rstrip('/'), a) for p, a in apps] + + def __call__(self, environ, start_response): + """Process incoming WSGI request. + + Ref: PEP 3333 + + Args: + environ (Mapping): a dict containing WSGI environment variables + start_response (callable): function, which sets response + status and headers + + Returns: + list[bytes]: iterable containing bytes to be returned in + HTTP response body + """ + path = environ['PATH_INFO'] or '/' + for p, app in self.apps: + # The apps list should be sorted by length, descending. + if path.startswith(p + '/') or path == p: + environ = environ.copy() + environ['SCRIPT_NAME'] = environ['SCRIPT_NAME'] + p + environ['PATH_INFO'] = path[len(p):] + return app(environ, start_response) + + start_response('404 Not Found', [('Content-Type', 'text/plain'), + ('Content-Length', '0')]) + return [''] + + +# compatibility aliases +globals().update( + WSGIServer=Server, + WSGIGateway=Gateway, + WSGIGateway_u0=Gateway_u0, + WSGIGateway_10=Gateway_10, + WSGIPathInfoDispatcher=PathInfoDispatcher, +) diff --git a/module/network/Browser.py b/module/network/Browser.py index d68a236870..5a25308685 100644 --- a/module/network/Browser.py +++ b/module/network/Browser.py @@ -79,13 +79,15 @@ def abortDownloads(self): self._size = self.dl.size self.dl.abort = True - def httpDownload(self, url, filename, get={}, post={}, ref=True, cookies=True, chunks=1, resume=False, - progressNotify=None, disposition=False): + def httpDownload(self, url, filename, size=0, get={}, post={}, ref=True, cookies=True, chunks=1, resume=False, + status_notify=None, disposition=False): """ this can also download ftp """ self._size = 0 - self.dl = HTTPDownload(url, filename, get, post, self.lastEffectiveURL if ref else None, - self.cj if cookies else None, self.bucket, self.options, progressNotify, disposition) + self.dl = HTTPDownload(url, filename, size=size, get=get, post=post, referer=self.lastEffectiveURL if ref else None, + cj=self.cj if cookies else None, bucket=self.bucket, options=self.options, + status_notify=status_notify, disposition=disposition) name = self.dl.download(chunks, resume) + self.http.code = self.dl.code self._size = self.dl.size self.dl = None diff --git a/module/network/HTTPChunk.py b/module/network/HTTPChunk.py index b637aef327..33427086a9 100644 --- a/module/network/HTTPChunk.py +++ b/module/network/HTTPChunk.py @@ -16,15 +16,20 @@ @author: RaNaN """ -from os import remove, stat, fsync -from os.path import exists -from time import sleep -from re import search -from module.utils import fs_encode import codecs -import pycurl +import os +import re +import time +import urllib +from cgi import parse_header +from email.header import decode_header +from ntpath import basename as ntpath_basename +from posixpath import basename as posixpath_basename +import pycurl from HTTPRequest import HTTPRequest +from module.utils import decode, fs_encode, parse_name + class WrongFormat(Exception): pass @@ -32,7 +37,7 @@ class WrongFormat(Exception): class ChunkInfo(): def __init__(self, name): - self.name = unicode(name) + self.name = decode(name) self.size = 0 self.resume = False self.chunks = [] @@ -63,7 +68,6 @@ def createChunks(self, chunks): self.addChunk("%s.chunk%s" % (self.name, i), (current, end)) current += chunk_size + 1 - def save(self): fs_name = fs_encode("%s.chunks" % self.name) fh = codecs.open(fs_name, "w", "utf_8") @@ -78,7 +82,7 @@ def save(self): @staticmethod def load(name): fs_name = fs_encode("%s.chunks" % name) - if not exists(fs_name): + if not os.path.exists(fs_name): raise IOError() fh = codecs.open(fs_name, "r", "utf_8") name = fh.readline()[:-1] @@ -93,7 +97,7 @@ def load(name): ci.loaded = True ci.setSize(size) while True: - if not fh.readline(): #skip line + if not fh.readline(): # skip line break name = fh.readline()[1:-1] range = fh.readline()[1:-1] @@ -103,18 +107,19 @@ def load(name): else: raise WrongFormat() - ci.addChunk(name, (long(range[0]), long(range[1]))) + ci.addChunk(name, (int(range[0]), int(range[1]))) fh.close() return ci def remove(self): fs_name = fs_encode("%s.chunks" % self.name) - if exists(fs_name): remove(fs_name) + if os.path.exists(fs_name): + os.remove(fs_name) def getCount(self): return len(self.chunks) - def getChunkName(self, index): + def getChunkFilename(self, index): return self.chunks[index][0] def getChunkRange(self, index): @@ -124,8 +129,8 @@ def getChunkRange(self, index): class HTTPChunk(HTTPRequest): def __init__(self, id, parent, range=None, resume=False): self.id = id - self.p = parent # HTTPDownload instance - self.range = range # tuple (start, end) + self.p = parent # HTTPDownload instance + self.range = range # tuple (start, end) self.resume = resume self.log = parent.log @@ -133,21 +138,23 @@ def __init__(self, id, parent, range=None, resume=False): self.arrived = 0 self.lastURL = self.p.referer + self.aborted = False # indicates that the chunk aborted gracefully + self.c = pycurl.Curl() self.header = "" - self.headerParsed = False #indicates if the header has been processed + self.headerParsed = False # indicates if the header has been processed - self.fp = None #file handle + self.fp = None # file handle self.initHandle() self.setInterface(self.p.options) - self.BOMChecked = False # check and remove byte order mark + self.BOMChecked = False # check and remove byte order mark self.rep = None - self.sleep = 0.000 + self.sleep = 0.0 self.lastSize = 0 def __repr__(self): @@ -157,8 +164,22 @@ def __repr__(self): def cj(self): return self.p.cj + def formatRange(self): + if self.id == len(self.p.info.chunks) - 1: # as last chunk dont set end range, so we get everything + if self.resume: + range = "%i-" % (self.arrived + self.range[0]) + else: + range = "%i-" % self.range[0] + else: + if self.id == 0 and not self.resume: # special case for first chunk + range = "%i-%i" % (0, min(self.range[1] + 1, self.p.size - 1)) + else: + range = "%i-%i" % (self.arrived + self.range[0], min(self.range[1] + 1, self.p.size - 1)) + + return range + def getHandle(self): - """ returns a Curl handle ready to use for perform/multiperform """ + """ returns a Curl handle ready to use for perform/multi-perform """ self.setRequestContext(self.p.url, self.p.get, self.p.post, self.p.referer, self.p.cj) self.c.setopt(pycurl.WRITEFUNCTION, self.writeBody) @@ -166,23 +187,21 @@ def getHandle(self): # request all bytes, since some servers in russia seems to have a defect arihmetic unit - fs_name = fs_encode(self.p.info.getChunkName(self.id)) + fs_name = fs_encode(self.p.info.getChunkFilename(self.id)) if self.resume: self.fp = open(fs_name, "ab") self.arrived = self.fp.tell() if not self.arrived: - self.arrived = stat(fs_name).st_size + self.arrived = os.stat(fs_name).st_size if self.range: - #do nothing if chunk already finished - if self.arrived + self.range[0] >= self.range[1]: return None + # do nothing if chunk already finished + if self.arrived + self.range[0] >= self.range[1]: + return None - if self.id == len(self.p.info.chunks) - 1: #as last chunk dont set end range, so we get everything - range = "%i-" % (self.arrived + self.range[0]) - else: - range = "%i-%i" % (self.arrived + self.range[0], min(self.range[1] + 1, self.p.size - 1)) + range = self.formatRange() - self.log.debug("Chunked resume with range %s" % range) + self.log.debug("Chunked resume with range %s" % self.formatRange()) self.c.setopt(pycurl.RANGE, range) else: self.log.debug("Resume File from %i" % self.arrived) @@ -190,10 +209,7 @@ def getHandle(self): else: if self.range: - if self.id == len(self.p.info.chunks) - 1: # see above - range = "%i-" % self.range[0] - else: - range = "%i-%i" % (self.range[0], min(self.range[1] + 1, self.p.size - 1)) + range = self.formatRange() self.log.debug("Chunked with range %s" % range) self.c.setopt(pycurl.RANGE, range) @@ -204,22 +220,24 @@ def getHandle(self): def writeHeader(self, buf): self.header += buf - #@TODO forward headers?, this is possibly unneeeded, when we just parse valid 200 headers + # @TODO forward headers?, this is possibly unneeeded, when we just parse valid 200 headers # as first chunk, we will parse the headers if not self.range and self.header.endswith("\r\n\r\n"): self.parseHeader() - elif not self.range and buf.startswith("150") and "data connection" in buf: #ftp file size parsing - size = search(r"(\d+) bytes", buf) + elif not self.range and buf.startswith("150") and "data connection" in buf: # ftp file size parsing + size = re.search(r'(\d+) bytes', buf) if size: self.p.size = int(size.group(1)) self.p.chunkSupport = True - self.headerParsed = True + self.headerParsed = True + + return None #: All is fine def writeBody(self, buf): - #ignore BOM, it confuses unrar + #: Ignore BOM, it confuses unrar if not self.BOMChecked: - if [ord(b) for b in buf[:3]] == [239, 187, 191]: + if buf[:3] == codecs.BOM_UTF8: buf = buf[3:] self.BOMChecked = True @@ -230,7 +248,8 @@ def writeBody(self, buf): self.fp.write(buf) if self.p.bucket: - sleep(self.p.bucket.consumed(size)) + time.sleep(self.p.bucket.consumed(size)) + else: # Avoid small buffers, increasing sleep time slowly if buffer size gets smaller # otherwise reduce sleep time percentual (values are based on tests) @@ -243,33 +262,108 @@ def writeBody(self, buf): self.lastSize = size - sleep(self.sleep) + time.sleep(self.sleep) if self.range and self.arrived > self.size: - return 0 #close if we have enough data + self.aborted = True #: Tell parent to ignore the pycurl Exception + return 0 #: Close if we have enough data + return None #: All is fine def parseHeader(self): """parse data from recieved header""" - for orgline in self.decodeResponse(self.header).splitlines(): + location = None + for orgline in self.header.splitlines(): line = orgline.strip().lower() if line.startswith("accept-ranges") and "bytes" in line: self.p.chunkSupport = True - if line.startswith("content-disposition") and "filename=" in line: - name = orgline.partition("filename=")[2] - name = name.replace('"', "").replace("'", "").replace(";", "").strip() - self.p.nameDisposition = name - self.log.debug("Content-Disposition: %s" % name) - - if not self.resume and line.startswith("content-length"): + elif line.startswith("location"): + location = orgline.split(":", 1)[1].strip() + + elif line.startswith("content-disposition"): + disposition_value = orgline.split(":", 1)[1].strip() + disposition_type, disposition_params = parse_header(disposition_value) + + fname = None + if 'filename*' in disposition_params: + fname = disposition_params['filename*'] + m = re.search(r'=\?([^?]+)\?([QB])\?([^?]*)\?=', fname, re.I) #: rfc2047 + if m is not None: + fname, enc = decode_header(fname)[0] + try: + fname = fname.decode(enc) + except LookupError: + self.log.warning("Content-Disposition: | error: No decoder found for %s" % enc) + fname = None + except UnicodeEncodeError: + self.log.warning("Content-Disposition: | error: Error when decoding string from %s." % enc) + fname = None + + else: + m = re.search(r'(.+?)\'(.*)\'(.+)', fname) + if m is not None: + enc, lang, data = m.groups() + try: + fname = urllib.unquote(data).decode(enc) + except LookupError: + self.log.warning("Content-Disposition: | error: No decoder found for %s" % enc) + fname = None + except UnicodeEncodeError: + self.log.warning("Content-Disposition: | error: Error when decoding string from %s." % enc) + fname = None + + else: + fname = None + + if fname is None: + if 'filename' in disposition_params: + fname = disposition_params['filename'] + m = re.search(r'=\?([^?]+)\?([QB])\?([^?]*)\?=', fname, re.I) #: rfc2047 + if m is not None: + fname, enc = decode_header(m.group(0))[0] + try: + fname = fname.decode(enc) + except LookupError: + self.log.warning("Content-Disposition: | error: No decoder found for %s" % enc) + continue + except UnicodeEncodeError: + self.log.warning("Content-Disposition: | error: Error when decoding string from %s." % enc) + continue + else: + try: + fname = urllib.unquote(fname).decode('iso-8859-1') + except UnicodeEncodeError: + self.log.warning("Content-Disposition: | error: Error when decoding string from iso-8859-1.") + continue + + elif disposition_type.lower() == "attachment": + if location is not None: + fname = parse_name(location) + else: + fname = parse_name(self.p.url) + + else: + continue + + #: Drop unsafe characters + fname = posixpath_basename(fname) + fname = ntpath_basename(fname) + for badc in '<>:"/\\|?*\0': + fname = fname.replace(badc, "") + fname = fname.lstrip('.') + + self.log.debug("Content-Disposition: %s" % fname) + self.p.updateDisposition(fname) + + elif not self.resume and line.startswith("content-length"): self.p.size = int(line.split(":")[1]) self.headerParsed = True def stop(self): """The download will not proceed after next call of writeBody""" - self.range = [0,0] + self.range = [0, 0] self.size = 0 def resetRange(self): @@ -279,15 +373,16 @@ def resetRange(self): def setRange(self, range): self.range = range self.size = range[1] - range[0] + self.log.debug("Chunked with range %s" % self.formatRange()) def flushFile(self): """ flush and close file """ self.fp.flush() - fsync(self.fp.fileno()) #make sure everything was written to disk - self.fp.close() #needs to be closed, or merging chunks will fail + os.fsync(self.fp.fileno()) # make sure everything was written to disk + self.fp.close() # needs to be closed, or merging chunks will fail def close(self): """ closes everything, unusable after this """ if self.fp: self.fp.close() self.c.close() - if hasattr(self, "p"): del self.p \ No newline at end of file + if hasattr(self, "p"): del self.p diff --git a/module/network/HTTPDownload.py b/module/network/HTTPDownload.py index fe80755390..3e5ab5a0bb 100644 --- a/module/network/HTTPDownload.py +++ b/module/network/HTTPDownload.py @@ -31,25 +31,28 @@ from module.plugins.Plugin import Abort from module.utils import save_join, fs_encode + class HTTPDownload(): """ loads a url http + ftp """ - def __init__(self, url, filename, get={}, post={}, referer=None, cj=None, bucket=None, - options={}, progressNotify=None, disposition=False): + def __init__(self, url, filename, size=0, get={}, post={}, referer=None, cj=None, bucket=None, + options={}, status_notify=None, disposition=False): self.url = url - self.filename = filename #complete file destination, not only name + self.filename = filename # complete file destination, not only name self.get = get self.post = post self.referer = referer - self.cj = cj #cookiejar if cookies are needed + self.cj = cj # cookiejar if cookies are needed self.bucket = bucket self.options = options self.disposition = disposition # all arguments + self.code = 0 #: last http code, would be set from the first chunk + self.abort = False - self.size = 0 - self.nameDisposition = None #will be parsed from content disposition + self.size = size + self.nameDisposition = None # will be parsed from content disposition self.chunks = [] @@ -57,7 +60,7 @@ def __init__(self, url, filename, get={}, post={}, referer=None, cj=None, bucket try: self.info = ChunkInfo.load(filename) - self.info.resume = True #resume is only possible with valid info file + self.info.resume = True # resume is only possible with valid info file self.size = self.info.size self.infoSaved = True except IOError: @@ -66,12 +69,13 @@ def __init__(self, url, filename, get={}, post={}, referer=None, cj=None, bucket self.chunkSupport = None self.m = pycurl.CurlMulti() - #needed for speed calculation + # needed for speed calculation self.lastArrived = [] self.speeds = [] self.lastSpeeds = [0, 0] - self.progressNotify = progressNotify + # notifications callback + self.status_notify = status_notify if callable(status_notify) else None @property def speed(self): @@ -88,18 +92,17 @@ def percent(self): return (self.arrived * 100) / self.size def _copyChunks(self): - init = fs_encode(self.info.getChunkName(0)) #initial chunk name + init = fs_encode(self.info.getChunkFilename(0)) # initial chunk name if self.info.getCount() > 1: - fo = open(init, "rb+") #first chunkfile + fo = open(init, "rb+") # first chunkfile for i in range(1, self.info.getCount()): - #input file - fo.seek( - self.info.getChunkRange(i - 1)[1] + 1) #seek to beginning of chunk, to get rid of overlapping chunks + # input file + fo.seek(self.info.getChunkRange(i - 1)[1] + 1) # seek to beginning of chunk, to get rid of overlapping chunks fname = fs_encode("%s.chunk%d" % (self.filename, i)) fi = open(fname, "rb") buf = 32 * 1024 - while True: #copy in chunks, consumes less memory + while True: # copy in chunks, consumes less memory data = fi.read(buf) if not data: break @@ -108,16 +111,16 @@ def _copyChunks(self): if fo.tell() < self.info.getChunkRange(i)[1]: fo.close() remove(init) - self.info.remove() #there are probably invalid chunks + self.info.remove() # there are probably invalid chunks raise Exception("Downloaded content was smaller than expected. Try to reduce download connections.") - remove(fname) #remove chunk + remove(fname) # remove chunk fo.close() if self.nameDisposition and self.disposition: self.filename = save_join(dirname(self.filename), self.nameDisposition) move(init, fs_encode(self.filename)) - self.info.remove() #remove info file + self.info.remove() # remove info file def download(self, chunks=1, resume=False): """ returns new filename or None """ @@ -128,13 +131,13 @@ def download(self, chunks=1, resume=False): try: self._download(chunks, resume) except pycurl.error, e: - #code 33 - no resume + # code 33 - no resume code = e.args[0] if code == 33: # try again without resume self.log.debug("Errno 33 -> Restart without resume") - #remove old handles + # remove old handles for chunk in self.chunks: self.closeChunk(chunk) @@ -144,17 +147,19 @@ def download(self, chunks=1, resume=False): finally: self.close() - if self.nameDisposition and self.disposition: return self.nameDisposition - return None + if self.nameDisposition and self.disposition: + return self.nameDisposition + else: + return None def _download(self, chunks, resume): if not resume: self.info.clear() - self.info.addChunk("%s.chunk0" % self.filename, (0, 0)) #create an initial entry + self.info.addChunk("%s.chunk0" % self.filename, (0, 0)) # create an initial entry self.chunks = [] - init = HTTPChunk(0, self, None, resume) #initial chunk that will load complete file (if needed) + init = HTTPChunk(0, self, None, resume) # initial chunk that will load complete file (if needed) self.chunks.append(init) self.m.add_handle(init.getHandle()) @@ -168,8 +173,8 @@ def _download(self, chunks, resume): self.chunkSupport = True while 1: - #need to create chunks - if not chunksCreated and self.chunkSupport and self.size: #will be setted later by first chunk + # do we need to create chunks? + if not chunksCreated and self.chunkSupport and self.size: # will be set later by first chunk if not resume: self.info.setSize(self.size) @@ -188,7 +193,7 @@ def _download(self, chunks, resume): self.chunks.append(c) self.m.add_handle(handle) else: - #close immediatly + # close immediatly self.log.debug("Invalid curl handle -> closed") c.close() @@ -211,9 +216,10 @@ def _download(self, chunks, resume): for c in ok_list: chunk = self.findChunk(c) try: # check if the header implies success, else add it to failed list - chunk.verifyHeader() + chunk.code = chunk.verifyHeader() except BadHeader, e: self.log.debug("Chunk %d failed: %s" % (chunk.id + 1, str(e))) + chunk.code = e.code failed.append(chunk) ex = e else: @@ -222,36 +228,38 @@ def _download(self, chunks, resume): for c in err_list: curl, errno, msg = c chunk = self.findChunk(curl) - #test if chunk was finished - if errno != 23 or "0 !=" not in msg: + # test if chunk was finished + if errno != pycurl.E_WRITE_ERROR or not chunk.aborted: failed.append(chunk) ex = pycurl.error(errno, msg) self.log.debug("Chunk %d failed: %s" % (chunk.id + 1, str(ex))) continue try: # check if the header implies success, else add it to failed list - chunk.verifyHeader() + chunk.code = chunk.verifyHeader() except BadHeader, e: self.log.debug("Chunk %d failed: %s" % (chunk.id + 1, str(e))) + chunk.code = e.code failed.append(chunk) ex = e else: chunksDone.add(curl) - if not num_q: # no more infos to get + + if num_q == 0: # no more infos to get # check if init is not finished so we reset download connections # note that other chunks are closed and downloaded with init too if failed and init not in failed and init.c not in chunksDone: self.log.error(_("Download chunks failed, fallback to single connection | %s" % (str(ex)))) - #list of chunks to clean and remove + # list of chunks to clean and remove to_clean = filter(lambda x: x is not init, self.chunks) for chunk in to_clean: self.closeChunk(chunk) self.chunks.remove(chunk) - remove(fs_encode(self.info.getChunkName(chunk.id))) + remove(fs_encode(self.info.getChunkFilename(chunk.id))) - #let first chunk load the rest and update the info file + # let first chunk load the rest and update the info file init.resetRange() self.info.clear() self.info.addChunk("%s.chunk0" % self.filename, (0, self.size)) @@ -264,12 +272,12 @@ def _download(self, chunks, resume): if len(chunksDone) >= len(self.chunks): if len(chunksDone) > len(self.chunks): self.log.warning("Finished download chunks size incorrect, please report bug.") - done = True #all chunks loaded + done = True # all chunks loaded break if done: - break #all chunks loaded + break # all chunks loaded # calc speed once per second, averaging over 3 seconds if lastTimeCheck + 1 < t: @@ -286,17 +294,27 @@ def _download(self, chunks, resume): if self.abort: raise Abort() - #sleep(0.003) #supress busy waiting - limits dl speed to (1 / x) * buffersize + #sleep(0.003) # supress busy waiting - limits dl speed to (1 / x) * buffersize self.m.select(1) for chunk in self.chunks: - chunk.flushFile() #make sure downloads are written to disk + chunk.flushFile() # make sure downloads are written to disk self._copyChunks() + self.code = init.code + def updateProgress(self): - if self.progressNotify: - self.progressNotify(self.percent) + if self.status_notify: + self.status_notify({'progress': self.percent}) + + def updateDisposition(self, disposition): + self.nameDisposition = disposition + if self.disposition: + if self.status_notify: + self.status_notify({'disposition': disposition}) + else: + self.log.debug("Ignoring Content-Disposition header") def findChunk(self, handle): """ linear search to find a chunk (should be ok since chunk size is usually low) """ diff --git a/module/network/HTTPRequest.py b/module/network/HTTPRequest.py index dab71e72ba..9a601c6131 100644 --- a/module/network/HTTPRequest.py +++ b/module/network/HTTPRequest.py @@ -17,16 +17,20 @@ @author: RaNaN """ -import cStringIO -import pycurl +from __future__ import with_statement -from codecs import getincrementaldecoder, lookup, BOM_UTF8 -from urllib import quote, urlencode +import cStringIO +import mimetypes +from codecs import BOM_UTF8, getincrementaldecoder, lookup from httplib import responses from logging import getLogger +from os.path import abspath, basename, exists +from urllib import quote, urlencode +import pycurl from module.plugins.Plugin import Abort + def myquote(url): return quote(url.encode('utf_8') if isinstance(url, unicode) else url, safe="%/:=&?~#+!$,;'@()*[]") @@ -62,6 +66,15 @@ def __init__(self, code, header="", content=""): self.content = content +class FormFile(): + def __init__(self, filename, data=None, mimetype=None): + self.filename = abspath(filename) + self.data = data + self.mimetype = mimetype or \ + mimetypes.guess_type(filename)[0] if not data and exists(filename) else None or \ + 'application/octet-stream' + + class HTTPRequest(): def __init__(self, cookies=None, options=None): self.c = pycurl.Curl() @@ -97,13 +110,14 @@ def initHandle(self): if hasattr(pycurl, "AUTOREFERER"): self.c.setopt(pycurl.AUTOREFERER, 1) self.c.setopt(pycurl.SSL_VERIFYPEER, 0) - self.c.setopt(pycurl.LOW_SPEED_TIME, 30) + self.c.setopt(pycurl.LOW_SPEED_TIME, 60) self.c.setopt(pycurl.LOW_SPEED_LIMIT, 5) #self.c.setopt(pycurl.VERBOSE, 1) + #self.c.setopt(pycurl.HTTP_VERSION, pycurl.CURL_HTTP_VERSION_1_1) self.c.setopt(pycurl.USERAGENT, - "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:55.0) Gecko/20100101 Firefox/55.0") + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:75.0) Gecko/20100101 Firefox/75.0") if pycurl.version_info()[7]: self.c.setopt(pycurl.ENCODING, "gzip, deflate") self.c.setopt(pycurl.HTTPHEADER, ["Accept: */*", @@ -114,19 +128,23 @@ def initHandle(self): "Expect:"]) def setInterface(self, options): - interface, proxy, ipv6 = options["interface"], options["proxies"], options["ipv6"] if interface and interface.lower() != "none": self.c.setopt(pycurl.INTERFACE, str(interface)) if proxy: - if proxy["type"] == "socks4": - self.c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_SOCKS4) - elif proxy["type"] == "socks5": - self.c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_SOCKS5) - else: + if proxy["type"] == "http": self.c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_HTTP) + elif proxy["type"] == "https": + self.c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_HTTPS) + self.c.setopt(pycurl.PROXY_SSL_VERIFYPEER, 0) + elif proxy["type"] == "socks4": + self.c.setopt(pycurl.PROXYTYPE, + pycurl.PROXYTYPE_SOCKS4A if proxy["socksResolveDns"] else pycurl.PROXYTYPE_SOCKS4) + elif proxy["type"] == "socks5": + self.c.setopt(pycurl.PROXYTYPE, + pycurl.PROXYTYPE_SOCKS5_HOSTNAME if proxy["socksResolveDns"] else pycurl.PROXYTYPE_SOCKS5) self.c.setopt(pycurl.PROXY, str(proxy["address"])) self.c.setopt(pycurl.PROXYPORT, proxy["port"]) @@ -178,7 +196,9 @@ def setRequestContext(self, url, get, post, referer, cookies, multipart=False): if post: self.c.setopt(pycurl.POST, 1) if not multipart: - if type(post) == unicode: + if post is True: + post = "" + elif type(post) == unicode: post = str(post) #unicode not allowed elif type(post) == str: pass @@ -187,8 +207,27 @@ def setRequestContext(self, url, get, post, referer, cookies, multipart=False): self.c.setopt(pycurl.POSTFIELDS, post) else: - post = [(x, y.encode('utf8') if type(y) == unicode else y ) for x, y in post.iteritems()] - self.c.setopt(pycurl.HTTPPOST, post) + multipart_post = [] + for k, v in post.iteritems(): + if isinstance(v, (basestring, bool, int)): + multipart_post.append((k, v.encode('utf8') if type(v) == unicode else str(v))) + + elif isinstance(v, FormFile): + filename = basename(v.filename) + filename = filename.encode('utf8') if type(filename) == unicode else filename + data = v.data + if data is None: + if not exists(v.filename): + continue + else: + with open(v.filename, "rb") as f: #: workaround for pycurl.FORM_FILE UnicodeEncodeError + data = f.read() + multipart_post.append((k, (pycurl.FORM_BUFFER, filename, + pycurl.FORM_BUFFERPTR, data, + pycurl.FORM_CONTENTTYPE, str(v.mimetype)))) + + self.c.setopt(pycurl.HTTPPOST, multipart_post) + else: self.c.setopt(pycurl.POST, 0) @@ -220,7 +259,13 @@ def load(self, url, get={}, post={}, referer=True, cookies=True, just_header=Fal self.c.setopt(pycurl.NOBODY, 0) else: - self.c.perform() + try: + self.c.perform() + except pycurl.error, e: + if e.args[0] == pycurl.E_WRITE_ERROR and self.abort: #: Ignore write error on abort + pass + else: + raise rep = self.getResponse() self.c.setopt(pycurl.POSTFIELDS, "") @@ -331,7 +376,6 @@ def close(self): del self.c if __name__ == "__main__": - url = "http://pyload.org" + url = "http://pyload.net" c = HTTPRequest() print c.load(url) - diff --git a/module/network/RequestFactory.py b/module/network/RequestFactory.py index 5087c380ad..fc01addf3e 100644 --- a/module/network/RequestFactory.py +++ b/module/network/RequestFactory.py @@ -80,40 +80,36 @@ def getCookieJar(self, pluginName, account=None): return self.cookiejars[(pluginName, account)] cj = CookieJar(pluginName, account) - self.cookiejars[(pluginName, account)] = cj + if account: + self.cookiejars[(pluginName, account)] = cj return cj + def removeCookieJar(self, plugin_name, account): + self.cookiejars.pop((plugin_name, account), None) + def getProxies(self): - """ returns a proxy list for the request classes """ - if not self.core.config["proxy"]["proxy"]: + """ returns proxy related options """ + proxy = self.core.config["proxy"] + if not proxy["proxy"]: return {} else: - type = "http" - setting = self.core.config["proxy"]["type"].lower() - if setting == "socks4": - type = "socks4" - elif setting == "socks5": - type = "socks5" - - username = None - if self.core.config["proxy"]["username"] and self.core.config["proxy"]["username"].lower() != "none": - username = self.core.config["proxy"]["username"] - - pw = None - if self.core.config["proxy"]["password"] and self.core.config["proxy"]["password"].lower() != "none": - pw = self.core.config["proxy"]["password"] + proxy_type = proxy["type"] + socks_resolve_dns = proxy["socksResolveDns"] + proxy_username = proxy["username"] or None + proxy_password = proxy["password"] or None return { - "type" : type, - "address" : self.core.config["proxy"]["address"], - "port" : self.core.config["proxy"]["port"], - "username": username, - "password": pw, + "type" : proxy_type, + "socksResolveDns": socks_resolve_dns, + "address" : proxy["address"], + "port" : proxy["port"], + "username": proxy_username, + "password": proxy_password, } def getOptions(self): - """returns options needed for pycurl""" + """ returns options needed for pycurl """ return {"interface": self.iface(), "proxies" : self.getProxies(), "ipv6" : self.core.config["download"]["ipv6"]} diff --git a/module/network/XDCCRequest.py b/module/network/XDCCRequest.py index 2622e94eaa..7afb4d4188 100644 --- a/module/network/XDCCRequest.py +++ b/module/network/XDCCRequest.py @@ -25,9 +25,10 @@ import time from module.plugins.Plugin import Abort +from module.utils import fs_encode -class XDCCRequest(): +class XDCCRequest: def __init__(self, bucket=None, options={}): self.proxies = options.get('proxies', {}) self.bucket = bucket @@ -45,8 +46,7 @@ def __init__(self, bucket=None, options={}): self.abort = False - self.progressNotify = None - + self.status_notify = None def createSocket(self): # proxytype = None @@ -70,7 +70,6 @@ def createSocket(self): return sock - def _write_func(self, buf): size = len(buf) @@ -95,21 +94,19 @@ def _write_func(self, buf): time.sleep(self.sleep) - def _send_ack(self): - # acknowledge data by sending number of recceived bytes + # acknowledge data by sending number of received bytes try: self.dccsock.send(struct.pack('!Q' if self.send_64bits_ack else '!I', self.received)) except socket.error: pass - - def download(self, ip, port, filename, progressNotify=None, resume=None): - self.progressNotify = progressNotify + def download(self, ip, port, filename, status_notify=None, resume=None): + self.status_notify = status_notify if callable(status_notify) else None self.send_64bits_ack = False if self.filesize < 1 << 32 else True - chunk_name = filename + ".chunk0" + chunk_name = fs_encode(filename + ".chunk0") if resume and os.path.exists(chunk_name): self.fh = open(chunk_name, "ab") @@ -125,7 +122,7 @@ def download(self, ip, port, filename, progressNotify=None, resume=None): self.fh = open(chunk_name, "wb") lastUpdate = time.time() - cumRecvLen = 0 + numRecvLen = 0 self.dccsock = self.createSocket() @@ -156,7 +153,7 @@ def download(self, ip, port, filename, progressNotify=None, resume=None): if data_len == 0 or self.filesize and self.received + data_len > self.filesize: break - cumRecvLen += data_len + numRecvLen += data_len self._write_func(data) self._send_ack() @@ -167,9 +164,9 @@ def download(self, ip, port, filename, progressNotify=None, resume=None): # calc speed once per second, averaging over 3 seconds self.speeds[2] = self.speeds[1] self.speeds[1] = self.speeds[0] - self.speeds[0] = float(cumRecvLen) / timespan + self.speeds[0] = float(numRecvLen) / timespan - cumRecvLen = 0 + numRecvLen = 0 lastUpdate = now self.updateProgress() @@ -177,41 +174,34 @@ def download(self, ip, port, filename, progressNotify=None, resume=None): self.dccsock.close() self.fh.close() - os.rename(chunk_name, filename) + os.rename(chunk_name, fs_encode(filename)) return filename - def abortDownloads(self): self.abort = True - def updateProgress(self): - if self.progressNotify: - self.progressNotify(self.percent) - + if self.status_notify: + self.status_notify({'progress': self.percent}) @property def size(self): return self.filesize - @property def arrived(self): return self.received - @property def speed(self): speeds = [x for x in self.speeds if x] return sum(speeds) / len(speeds) - @property def percent(self): if not self.filesize: return 0 return (self.received * 100) / self.filesize - def close(self): pass diff --git a/module/plugins/AccountManager.py b/module/plugins/AccountManager.py index 094641c76c..3dc85ec4f1 100644 --- a/module/plugins/AccountManager.py +++ b/module/plugins/AccountManager.py @@ -52,7 +52,11 @@ def getAccountPlugin(self, plugin): """get account instance for plugin or None if anonymous""" if plugin in self.accounts: if plugin not in self.plugins: - self.plugins[plugin] = self.core.pluginManager.loadClass("accounts", plugin)(self, self.accounts[plugin]) + klass = self.core.pluginManager.loadClass("accounts", plugin) + if klass: + self.plugins[plugin] = klass(self, self.accounts[plugin]) + else: + return None return self.plugins[plugin] else: @@ -113,6 +117,8 @@ def loadAccounts(self): elif ":" in line: name, sep, pw = line.partition(":") + name = name.replace(r"\x3a", ":") + pw = pw.replace(r"\x3a", ":") self.accounts[plugin][name] = {"password": pw, "options": {}, "valid": True} #---------------------------------------------------------------------- def saveAccounts(self): @@ -126,7 +132,10 @@ def saveAccounts(self): f.write(plugin+":\n") for name, data in accounts.iteritems(): - f.write("\n\t%s:%s\n" % (name, data['password']) ) + pw = data['password'] + name = name.replace(":", r"\x3a") + pw = pw.replace(":", r"\x3a") + f.write("\n\t%s:%s\n" % (name, pw)) if data['options']: for option, values in data['options'].iteritems(): f.write("\t@%s %s\n" % (option, " ".join(values))) @@ -144,7 +153,7 @@ def initAccountPlugins(self): @lock def updateAccount(self, plugin , user, password=None, options={}): """add or update account""" - if plugin in self.accounts: + if plugin in self.accounts and user: p = self.getAccountPlugin(plugin) updated = p.updateAccounts(user, password, options) #since accounts is a ref in plugin self.accounts doesnt need to be updated here @@ -170,12 +179,16 @@ def getAccountInfos(self, force=True, refresh=False): self.core.scheduler.addJob(0, self.core.accountManager.getAccountInfos) force = False - for p in self.accounts.keys(): - if self.accounts[p]: - p = self.getAccountPlugin(p) - data[p.__name__] = p.getAllAccounts(force) + for k in self.accounts.keys(): + if self.accounts[k]: + p = self.getAccountPlugin(k) + if p: + data[p.__name__] = p.getAllAccounts(force) + else: + self.core.log.error(_("Bad or missing plugin: ACCOUNT %s") % k) + data[k] = [] else: - data[p] = [] + data[k] = [] e = AccountUpdateEvent() self.core.pullManager.addEvent(e) return data diff --git a/module/plugins/Plugin.py b/module/plugins/Plugin.py index 15bf3971f2..94cab51013 100644 --- a/module/plugins/Plugin.py +++ b/module/plugins/Plugin.py @@ -148,7 +148,7 @@ class Plugin(Base): __config__ = [("name", "type", "desc", "default")] __description__ = """Base Plugin""" __author_name__ = ("RaNaN", "spoob", "mkaay") - __author_mail__ = ("RaNaN@pyload.org", "spoob@pyload.org", "mkaay@mkaay.de") + __author_mail__ = ("RaNaN@pyload.net", "spoob@pyload.net", "mkaay@mkaay.de") def __init__(self, pyfile): Base.__init__(self, pyfile.m.core) diff --git a/module/plugins/PluginManager.py b/module/plugins/PluginManager.py index 924d4f2035..7ef0831d2a 100644 --- a/module/plugins/PluginManager.py +++ b/module/plugins/PluginManager.py @@ -24,7 +24,6 @@ from os.path import isfile, join, exists, abspath from sys import version_info from itertools import chain -from traceback import print_exc from module.lib.SafeEval import const_eval as literal_eval from module.ConfigParser import IGNORE @@ -192,6 +191,7 @@ def parse(self, folder, pattern=False, home={}): plugins[name]["re"] = re.compile(pattern) except: self.log.error(_("%s has a invalid pattern.") % name) + plugins[name]["re"] = re.compile(r'(?!.*)') # internals have no config if folder == "internal": @@ -302,9 +302,8 @@ def loadModule(self, type, name): plugins[name]["module"] = module #cache import, maybe unneeded return module except Exception, e: - self.log.error(_("Error importing %(name)s: %(msg)s") % {"name": name, "msg": str(e)}) - if self.core.debug: - print_exc() + self.log.error(_("Error importing %(name)s: %(msg)s") % {"name": name, "msg": str(e)}, exc_info=self.core.debug) + else: self.log.debug("Plugin %s not found" % name) self.log.debug("Available plugins : %s" % str(plugins)) @@ -409,6 +408,9 @@ def merge(dst, src, overwrite=False): self.plugins["hoster"] = self.hosterPlugins merge(default_config, config) + temp, config = self.parse("hooks") + merge(default_config, config) + self.captchaPlugins, config = self.parse("captcha") self.plugins["captcha"] = self.captchaPlugins merge(default_config, config) diff --git a/module/plugins/accounts/AccioDebridCom.py b/module/plugins/accounts/AccioDebridCom.py new file mode 100644 index 0000000000..4f8b86e176 --- /dev/null +++ b/module/plugins/accounts/AccioDebridCom.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- + +import pycurl +from module.network.HTTPRequest import BadHeader + +from ..internal.misc import decode, json +from ..internal.MultiAccount import MultiAccount + + +def args(**kwargs): + return kwargs + + +class AccioDebridCom(MultiAccount): + __name__ = "AccioDebridCom" + __type__ = "account" + __version__ = "0.02" + __status__ = "testing" + + __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), + ("mh_list", "str", "Hoster list (comma separated)", ""), + ("mh_interval", "int", "Reload interval in hours", 12)] + + __description__ = """Accio-debrid.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("PlugPlus", "accio.debrid@gmail.com")] + + LOGIN_TIMEOUT = -1 + + API_URL = "https://www.accio-debrid.com/apiv2/" + + def api_response(self, action, get={}, post={}): + get['action'] = action + + # Better use pyLoad User-Agent so we don't get blocked + self.req.http.c.setopt(pycurl.USERAGENT, "pyLoad/%s" % self.pyload.version) + + json_data = self.load(self.API_URL, get=get, post=post) + + return json.loads(json_data) + + def grab_hosters(self, user, password, data): + res = self.api_response("getHostsList") + + if res['success']: + return res['value'] + + else: + return [] + + def grab_info(self, user, password, data): + validuntil = None + trafficleft = None + premium = False + + cache_info = data.get('cache_info', {}) + if user in cache_info: + validuntil = float(cache_info[user]['vip_end']) + premium = validuntil > 0 + trafficleft = -1 + + return {'validuntil': validuntil, + 'trafficleft': trafficleft, + 'premium': premium} + + def signin(self, user, password, data): + cache_info = self.db.retrieve("cache_info", {}) + if user in cache_info: + data['cache_info'] = cache_info + self.skip_login() + + try: + res = self.api_response("login", args(login=user, password=password)) + + except BadHeader, e: + if e.code == 401: + self.fail_login() + + elif e.code == 405: + self.fail(_("Banned IP")) + + else: + raise + + if res['response_code'] != "ok": + cache_info.pop(user, None) + data['cache_info'] = cache_info + self.db.store("cache_info", cache_info) + + if res['response_code'] == "INCORRECT_PASSWORD": + self.fail_login() + + elif res['response_code'] == "UNALLOWED_IP": + self.fail_login(_("Banned IP")) + + else: + self.log_error(res['response_text']) + self.fail_login(res['response_text']) + + else: + cache_info[user] = {'vip_end': res['vip_end'], + 'token': res['token']} + data['cache_info'] = cache_info + + self.db.store("cache_info", cache_info) + + def relogin(self): + if self.req: + cache_info = self.info['data'].get('cache_info', {}) + + cache_info.pop(self.user, None) + self.info['data']['cache_info'] = cache_info + self.db.store("cache_info", cache_info) + + return MultiAccount.relogin(self) \ No newline at end of file diff --git a/module/plugins/accounts/AlldebridCom.py b/module/plugins/accounts/AlldebridCom.py index 4de3803367..c435f04b85 100644 --- a/module/plugins/accounts/AlldebridCom.py +++ b/module/plugins/accounts/AlldebridCom.py @@ -1,18 +1,22 @@ # -*- coding: utf-8 -*- -from ..internal.MultiAccount import MultiAccount +from module.network.HTTPRequest import BadHeader + from ..internal.misc import json +from ..internal.MultiAccount import MultiAccount class AlldebridCom(MultiAccount): __name__ = "AlldebridCom" __type__ = "account" - __version__ = "0.41" + __version__ = "0.47" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), ("mh_list", "str", "Hoster list (comma separated)", ""), - ("mh_interval", "int", "Reload interval in hours", 12)] + ("mh_interval", "int", "Reload interval in hours", 12), + ("ignore_status", "bool", "Treat all hosters as available (ignore status field)", False), + ("streams_also", "bool", "Also download from stream hosters", False)] __description__ = """AllDebrid.com account plugin""" __license__ = "GPLv3" @@ -20,45 +24,61 @@ class AlldebridCom(MultiAccount): ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] # See https://docs.alldebrid.com/ - API_URL = "https://api.alldebrid.com/" - - def api_response(self, method, **kwargs): - kwargs['agent'] = "pyLoad" - kwargs['version'] = self.pyload.version - html = self.load(self.API_URL + method, get=kwargs) - return json.loads(html) + API_URL = "https://api.alldebrid.com/v4.1/" + + def api_response(self, method, get={}, post={}, multipart=False): + get.update({'agent': "pyLoad", + 'version': self.pyload.version}) + json_data = json.loads(self.load(self.API_URL + method, get=get, post=post, multipart=multipart)) + if json_data['status'] == "success": + return json_data['data'] + else: + return json_data def grab_hosters(self, user, password, data): - json_data = self.api_response("user/hosts", token=data['token']) - if json_data.get("error", False): + api_data = self.api_response("user/hosts", + get={'apikey': password}) + if api_data.get("error", False): + self.log_error(api_data['error']['message']) return [] else: + valid_statuses = (True, False) if self.config.get("ignore_status") is True else (True,) + valid_hosters = api_data["hosts"].values() + ( + api_data["streams"].values() + if self.config.get("streams_also") is True + else [] + ) return reduce(lambda x, y: x + y, - [[_h['domain']] + _h.get('altDomains', []) - for _h in json_data['hosts'].values() - if _h['status'] is True]) + [_h['domains'] + for _h in valid_hosters + if _h.get('status', False) in valid_statuses or _h.get('type') == "free"]) def grab_info(self, user, password, data): - json_data = self.api_response("user/login", token=data['token']) + api_data = self.api_response("user", + get={'apikey': password}) - if json_data.get("error", False): + if api_data.get("error", False): + self.log_error(api_data['error']['message']) premium = False validuntil = -1 else: - premium = json_data['user']['isPremium'] - validuntil = json_data['user']['premiumUntil'] or -1 + premium = api_data['user']['isPremium'] + validuntil = api_data['user']['premiumUntil'] or -1 return {'validuntil': validuntil, 'trafficleft': -1, 'premium': premium} def signin(self, user, password, data): - json_data = self.api_response("user/login", username=user, password=password) + api_data = self.api_response("user", + get={'apikey': password}) - if json_data.get("error", False): - self.fail_login(json_data['error']) + if api_data.get("error", False): + self.log_error(_("v4 API token required - use GetAlldebridTokenV4.py to get it: https://github.com/pyload/pyload/files/4489732/GetAlldebridTokenV4.zip")) + self.fail_login(api_data['error']['message']) - else: - data['token'] = json_data['token'] + elif api_data['user']['username'] != user: + self.log_error(_("username for alldebrid.com should be your alldebrid.com username")) + self.fail_login() diff --git a/module/plugins/accounts/ArchiveOrg.py b/module/plugins/accounts/ArchiveOrg.py new file mode 100644 index 0000000000..f6bbbdb25a --- /dev/null +++ b/module/plugins/accounts/ArchiveOrg.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +import json + +from module.network.HTTPRequest import BadHeader + +from ..internal.Account import Account +from ..internal.misc import json + + +class ArchiveOrg(Account): + __name__ = "ArchiveOrg" + __type__ = "account" + __version__ = "0.02" + __status__ = "testing" + + __description__ = """Archive.org account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + LOGIN_URL = "https://archive.org/account/login" + LOGIN_CHECK_URL = "https://archive.org/account/index.php?settings=1" + + def grab_info(self, user, password, data): + return {'validuntil': None, + 'trafficleft': None, + 'premium': False} + + def signin(self, user, password, data): + html = self.load(self.LOGIN_CHECK_URL) + if "You must be logged in to change your settings." not in html: + self.skip_login() + + else: + self.load(self.LOGIN_URL) + try: + html = self.load(self.LOGIN_URL, post={ + "username": user, + "password": password, + "remember": "true", + "referer": "https://archive.org/", + "login": "true", + "submit_by_js": "true" + }) + except BadHeader as exc: + self.fail_login(str(exc)) + + else: + json_data = json.loads(html) + if json_data["status"] != "ok": + self.fail_login() diff --git a/module/plugins/accounts/BigfileTo.py b/module/plugins/accounts/BigfileTo.py deleted file mode 100644 index 0911cc620a..0000000000 --- a/module/plugins/accounts/BigfileTo.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.Account import Account - - -class BigfileTo(Account): - __name__ = "BigfileTo" - __type__ = "account" - __version__ = "0.11" - __status__ = "testing" - - __description__ = """bigfile.to account plugin""" - __license__ = "GPLv3" - __authors__ = [("Sasch", "gsasch@gmail.com"), - ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - - def grab_info(self, user, password, data): - html = self.load("https://www.bigfile.to/login.php") - - premium = '', html) is not None: - self.skip_login() - - html = self.load("http://catshare.net/login", # @TODO: Revert to `https` in 0.4.10 - post={'user_email': user, - 'user_password': password}, - redirect=20) - - if re.search(r'/logout".*>Wyloguj', html) is None: - self.fail_login() diff --git a/module/plugins/accounts/DdownloadCom.py b/module/plugins/accounts/DdownloadCom.py new file mode 100644 index 0000000000..0e22348205 --- /dev/null +++ b/module/plugins/accounts/DdownloadCom.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +import pycurl + +from ..internal.XFSAccount import XFSAccount + + +class DdownloadCom(XFSAccount): + __name__ = "DdownloadCom" + __type__ = "account" + __version__ = "0.09" + __status__ = "testing" + + __description__ = """Ddownload.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + PLUGIN_DOMAIN = "ddownload.com" + PLUGIN_URL = "http://ddownload.com" + + PREMIUM_PATTERN = r">Premium Member<" + TRAFFIC_LEFT_PATTERN = r'available\s*
\s*(?:(?P[^<>]+))?(?P-?\d+|[Uu]nlimited)\s*
' + VALID_UNTIL_PATTERN = r'class="expires">([\w ]+)<' + + def setup(self): + super(DdownloadCom, self).setup() + self.req.http.c.setopt(pycurl.USERAGENT, "pyLoad/%s" % self.pyload.version) diff --git a/module/plugins/accounts/DebridlinkFr.py b/module/plugins/accounts/DebridlinkFr.py index be846c5c68..9b7ae0ca63 100644 --- a/module/plugins/accounts/DebridlinkFr.py +++ b/module/plugins/accounts/DebridlinkFr.py @@ -2,21 +2,18 @@ import time -import Crypto.Hash.SHA import pycurl +from module.network.HTTPRequest import BadHeader +from ..hoster.DebridlinkFr import error_description from ..internal.misc import json from ..internal.MultiAccount import MultiAccount -def args(**kwargs): - return kwargs - - class DebridlinkFr(MultiAccount): __name__ = "DebridlinkFr" __type__ = "account" - __version__ = "0.03" + __version__ = "0.06" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -27,46 +24,57 @@ class DebridlinkFr(MultiAccount): __license__ = "GPLv3" __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - API_URL = "https://debrid-link.fr/api" + TUNE_TIMEOUT = False - def api_request(self, method, data=None, get={}, post={}): + #: See https://debrid-link.fr/api_doc/v2 + API_URL = "https://debrid-link.fr/api/" - session = self.info['data'].get('session', None) - if session: - ts = str(int(time.time() - float(session['tsd']))) + def api_request(self, method, get={}, post={}): + api_token = self.info['data'].get('api_token', None) + if api_token and method != "oauth/token": + self.req.http.c.setopt(pycurl.HTTPHEADER, ["Authorization: Bearer " + api_token]) + self.req.http.c.setopt(pycurl.USERAGENT, "pyLoad/%s" % self.pyload.version) + try: + json_data = self.load(self.API_URL + method, get=get, post=post) + except BadHeader, e: + json_data = e.content - sha1 = Crypto.Hash.SHA.new() - sha1.update(ts + method + session['key']) - sign = sha1.hexdigest() + return json.loads(json_data) - self.req.http.c.setopt(pycurl.HTTPHEADER, ["X-DL-TOKEN: " + session['token'], - "X-DL-SIGN: " + sign, - "X-DL-TS: " + ts]) + def _refresh_token(self, client_id, refresh_token): + api_data = self.api_request("oauth/token", + post={'client_id': client_id, + 'refresh_token': refresh_token, + 'grant_type': "refresh_token"}) - json_data = self.load(self.API_URL + method, get=get, post=post) + if 'error' in api_data: + if api_data['error'] == 'invalid_request': + self.log_error(_("You have to use GetDebridlinkToken.py to authorize pyLoad: https://github.com/pyload/pyload/files/9353788/GetDebridlinkToken.zip")) + else: + self.log_error(api_data.get('error_description', error_description(api_data["error"]))) + self.fail_login() - return json.loads(json_data) + return api_data['access_token'], api_data['expires_in'] def grab_hosters(self, user, password, data): - res = self.api_request("/downloader/hostnames") + api_data = self.api_request("v2/downloader/hostnames") - if res['result'] == "OK": - return res['value'] + if api_data['success']: + return api_data['value'] else: return [] def grab_info(self, user, password, data): - res = self.api_request("/account/infos") + api_data = self.api_request("v2/account/infos") - if res['result'] == "OK": - premium = res['value']['premiumLeft'] > 0 - validuntil = res['value']['premiumLeft'] + time.time() + if api_data['success']: + premium = api_data['value']['premiumLeft'] > 0 + validuntil = api_data['value']['premiumLeft'] + time.time() else: - self.log_error( - _("Unable to retrieve account information"), - res['ERR']) + self.log_error(_("Unable to retrieve account information"), + api_data.get('error_description', error_description(api_data["error"]))) validuntil = None premium = None @@ -75,30 +83,18 @@ def grab_info(self, user, password, data): 'premium': premium} def signin(self, user, password, data): - cache_info = self.db.retrieve("cache_info", {}) - if user in cache_info: - self.info['data']['session'] = cache_info[user] - - res = self.api_request("/account/infos") - if res['result'] == "OK": - self.skip_login() + if 'token' not in data: + api_token, timeout = self._refresh_token(user, password) + data['api_token'] = api_token + self.timeout = timeout - 5 * 60 #: Five minutes less to be on the safe side + + api_data = self.api_request("v2/account/infos") + if 'error' in api_data: + if api_data['error'] == 'badToken': #: Token expired? try to refresh + api_token, timeout = self._refresh_token(user, password) + data['api_token'] = api_token + self.timeout = timeout - 5 * 60 #: Five minutes less to be on the safe side else: - del cache_info[user] - self.db.store("cache_info", cache_info) - - res = self.api_request( - "/account/login", - post=args( - pseudo=user, - password=password)) - - if res['result'] != "OK": - self.fail_login() - - cache_info[user] = {'tsd': time.time() - float(res['ts']), - 'token': res['value']['token'], - 'key': res['value']['key']} - - self.info['data']['session'] = cache_info[user] - self.db.store("cache_info", cache_info) + self.log_error(api_data.get('error_description', error_description(api_data["error"]))) + self.fail_login() diff --git a/module/plugins/accounts/DebridplanetCom.py b/module/plugins/accounts/DebridplanetCom.py new file mode 100644 index 0000000000..10ad91f22b --- /dev/null +++ b/module/plugins/accounts/DebridplanetCom.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +import hashlib +import json +import time + +import pycurl +from ..internal.MultiAccount import MultiAccount + + +class DebridplanetCom(MultiAccount): + __name__ = "DebridplanetCom" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __config__ = [ + ("mh_mode", "all;listed;unlisted", "Filter downloaders to use", "all"), + ("mh_list", "str", "Downloader list (comma separated)", ""), + ("mh_interval", "int", "Reload interval in hours", 12), + ] + + __description__ = """Debridplanet.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + API_URL = "https://debridplanet.com/v1/" + + def api_request(self, method, **kwargs): + token = self.info["data"].get("token") + if token is not None: + self.req.http.c.setopt( + pycurl.HTTPHEADER, ["Authorization: Bearer " + token] + ) + json_data = self.load("%s%s.php" % (self.API_URL, method), + post=json.dumps(kwargs)) + return json.loads(json_data) + + def grab_hosters(self, user, password, data): + api_data = self.api_request("supportedhosts") + hosts = [ + h["host"] + for h in api_data["supportedhosts"] + if h["currently_working"] + ] + return hosts + + def grab_info(self, user, password, data): + validuntil = None + premium = False + + api_data = self.api_request("user-info") + if api_data.get("success", False): + premium = api_data["user"]["account_type"] == "premium" + validuntil = time.mktime(time.strptime(api_data["user"]["expire"], "%Y-%m-%dT%H:%M:%S")) + + return {"validuntil": validuntil, "trafficleft": -1, "premium": premium} + + def signin(self, user, password, data): + api_data = self.api_request("user-info") + if api_data.get("success", False): + self.skip_login() + + data["token"] = None + api_data = self.api_request("login", username=user, password=hashlib.sha256(password).hexdigest()) + if api_data.get("success", False): + data["token"] = api_data["token"] + + else: + self.fail_login() diff --git a/module/plugins/accounts/DownsterNet.py b/module/plugins/accounts/DownsterNet.py new file mode 100644 index 0000000000..13333cf6e7 --- /dev/null +++ b/module/plugins/accounts/DownsterNet.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- + +import string +import random +import time + +import pycurl +from module.network.HTTPRequest import BadHeader + +from ..internal.misc import json +from ..internal.MultiAccount import MultiAccount + + +class DownsterApi(object): + API_URL = "https://downster.net/api/" + + def __init__(self, plugin): + self.plugin = plugin + + if hasattr(self.plugin, 'account'): + self.account_plugin = self.plugin.account + + else: + self.account_plugin = self.plugin + + def request(self, method, get={}, **kwargs): + self.plugin.req.http.c.setopt(pycurl.HTTPHEADER, [ + "Accept: application/json, text/plain, */", + "Content-Type: application/json", + "X-Flow-ID: " + self.flow_id(), + ]) + self.plugin.req.http.c.setopt(pycurl.USERAGENT, + "User-Agent: pyLoad/" + self.plugin.pyload.version + + " DownsterNet/" + self.account_plugin.__version__) + + try: + res = self.plugin.load(self.API_URL + method, + get=get, + post=json.dumps(kwargs)) + except BadHeader, e: + res = e.content + + res = json.loads(res) + + return res + + def rnd(self): + return ''.join([random.choice(string.ascii_lowercase + string.digits) for n in range(5)]) + + def flow_id(self): + user_flow_id = self.account_plugin.info['data'].get('user_flow_id') + self.plugin.log_debug("User flow id: %s" % user_flow_id) + if not user_flow_id: + self.account_plugin.info['data']['user_flow_id'] = self.rnd() + self.plugin.log_info('Created user flow id: %s' % self.account_plugin.info['data']['user_flow_id']) + + return 'PYL_' + self.account_plugin.info['data']['user_flow_id'] + '_' + self.rnd() + + +class DownsterNet(MultiAccount): + __name__ = "DownsterNet" + __type__ = "account" + __version__ = "0.04" + __status__ = "testing" + + __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), + ("mh_list", "str", "Hoster list (comma separated)", ""), + ("mh_interval", "int", "Reload interval in hours", 12)] + + __description__ = """Downster.net account plugin""" + __license__ = "GPLv3" + __authors__ = [(None, None)] + + api = None + + def grab_hosters(self, user, password, data): + api_data = self.api.request("download/usage") + if not api_data['success']: + self.log_error('Could not get hoster info: ' + api_data['error']) + return [] + + else: + return [hoster['hoster'] for hoster in api_data['data']] + + def grab_info(self, user, password, data): + api_data = self.api.request("user/info") + + if not api_data['success']: + validuntil = None + trafficleft = None + premium = False + + self.log_error('Could not get user info: ' + api_data['error']) + + else: + validuntil = time.mktime(time.strptime(api_data['data']['premiumUntil'], "%Y-%m-%dT%H:%M:%S.%f+00:00")) + trafficleft = -1 + premium = validuntil > time.time() + + return {'validuntil': validuntil, + 'trafficleft': trafficleft, + 'premium': premium} + + def signin(self, user, password, data): + if self.api is None: + self.api = DownsterApi(self) + + api_data = self.api.request('user/info') + if api_data['success']: + self.skip_login() + + api_data = self.api.request("user/authenticate", + email=user, + password=password) + + if not api_data['success']: + self.fail_login(api_data['error']) diff --git a/module/plugins/accounts/EasybytezCom.py b/module/plugins/accounts/EasybytezCom.py index 4cf1c64d34..176c0bdb38 100644 --- a/module/plugins/accounts/EasybytezCom.py +++ b/module/plugins/accounts/EasybytezCom.py @@ -1,14 +1,12 @@ # -*- coding: utf-8 -*- -import re - from ..internal.XFSAccount import XFSAccount class EasybytezCom(XFSAccount): __name__ = "EasybytezCom" __type__ = "account" - __version__ = "0.18" + __version__ = "0.20" __status__ = "testing" __description__ = """EasyBytez.com account plugin""" diff --git a/module/plugins/accounts/ExtmatrixCom.py b/module/plugins/accounts/ExtmatrixCom.py new file mode 100644 index 0000000000..460b122f19 --- /dev/null +++ b/module/plugins/accounts/ExtmatrixCom.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- + +import re +import time +import urlparse + +from module.PyFile import PyFile + +from ..internal.Account import Account +from ..internal.Captcha import Captcha + + +class ExtmatrixCom(Account): + __name__ = "ExtmatrixCom" + __type__ = "account" + __version__ = "0.02" + __status__ = "testing" + + __description__ = """Extmatrix.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + VALID_UNTIL_PATTERN = r'>Premium End:\s*([\d-]+)' + + def grab_info(self, user, password, data): + html = self.load("https://www.extmatrix.com") + + premium = '>Premium Member<' in html + + m = re.search(self.VALID_UNTIL_PATTERN, html) + if m is not None: + validuntil = time.mktime(time.strptime(m.group(1) + " 23:59:59", '%Y-%m-%d %H:%M:%S')) + + else: + self.log_error(_("VALID_UNTIL_PATTERN not found")) + validuntil = None + + return {'validuntil': validuntil, + 'trafficleft': None, + 'premium': premium} + + def signin(self, user, password, data): + html = self.load("https://www.extmatrix.com/login.php") + if 'href="./logout.php"' in html: + self.skip_login() + + + # dummy pyfile + pyfile = PyFile(self.pyload.files, -1, "https://www.extmatrix.com", "https://www.extmatrix.com", 0, 0, "", self.classname, -1, -1) + pyfile.plugin = self + + for i in range(5): + m = re.search(r' 0: - account_info = {'validuntil': -1, 'trafficleft': kb} + account_info = {'validuntil': -1, + 'trafficleft': trafficleft} else: account_info = { 'validuntil': None, diff --git a/module/plugins/accounts/FastshareCz.py b/module/plugins/accounts/FastshareCz.py index 550b56a7b1..b0fe8a55b1 100644 --- a/module/plugins/accounts/FastshareCz.py +++ b/module/plugins/accounts/FastshareCz.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import re +import time from ..internal.Account import Account from ..internal.misc import set_cookie @@ -9,43 +10,65 @@ class FastshareCz(Account): __name__ = "FastshareCz" __type__ = "account" - __version__ = "0.17" + __version__ = "0.19" __status__ = "testing" __description__ = """Fastshare.cz account plugin""" __license__ = "GPLv3" __authors__ = [("zoidberg", "zoidberg@mujmail.cz"), ("stickell", "l.stickell@yahoo.it"), - ("ondrej", "git@ondrej.it")] + ("ondrej", "git@ondrej.it"), + ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - CREDIT_PATTERN = r'.+\(([\d\.]+) ([MGT]+B)\)' + TRAFFICLEFT_PATTERN = r'([\d\.]+) ([KMGT]B)\s*' + VALID_UNTILL_PATTERN = r">Active until ([\d.]+)<" def grab_info(self, user, password, data): - validuntil = -1 - trafficleft = None - premium = False + html = self.load("https://fastshare.cz/user") - html = self.load("https://www.fastshare.cz/user") - - m = re.search(self.CREDIT_PATTERN, html) + m = re.search(self.VALID_UNTILL_PATTERN, html) if m is not None: - trafficleft = self.parse_traffic(m.group(1), m.group(2)) + validuntil = time.mktime( + time.strptime(m.group(1) + " 23:59:59", "%d.%m.%Y %H:%M:%S") + ) + premium = True + trafficleft = -1 + + else: + validuntil = -1 + m = re.search(self.TRAFFICLEFT_PATTERN, html) + if m is not None: + trafficleft = self.parse_traffic(m.group(1), m.group(2)) + premium = bool(trafficleft) + if not premium: + trafficleft = None + + elif ">Unlimited downloading<" in html: + premium = True + trafficleft = -1 + validuntil = None - premium = bool(trafficleft) + else: + premium = False + trafficleft = None - return {'validuntil': validuntil, - 'trafficleft': trafficleft, - 'premium': premium} + return { + "validuntil": validuntil, + "trafficleft": trafficleft, + "premium": premium, + } def signin(self, user, password, data): set_cookie(self.req.cj, "fastshare.cz", "lang", "en") - # @NOTE: Do not remove or it will not login - self.load('https://www.fastshare.cz/login') + html = self.load("https://fastshare.cz/user") + if 'href="/logout.php"' in html: + self.skip_login() - html = self.load("https://www.fastshare.cz/sql.php", - post={'login': user, - 'heslo': password}) + html = self.load( + "https://fastshare.cz/sql.php", + post={"login": user, "heslo": password}, + ) - if ">Wrong username or password" in html: + if 'href="/logout.php"' not in html: self.fail_login() diff --git a/module/plugins/accounts/FikperCom.py b/module/plugins/accounts/FikperCom.py new file mode 100644 index 0000000000..934c9b73d3 --- /dev/null +++ b/module/plugins/accounts/FikperCom.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +import pycurl +from module.network.HTTPRequest import BadHeader + +from ..internal.Account import Account +from ..internal.misc import json + +class FikperCom(Account): + __name__ = "FikperCom" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __description__ = """Fikper.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + API_URL = "https://sapi.fikper.com/" + + # See https://sapi.fikper.com/api/reference/ + def api_request(self, method, api_key=None, **kwargs): + if api_key is not None: + self.req.http.c.setopt(pycurl.HTTPHEADER, ["x-api-key: %s" % api_key]) + + try: + json_data = self.load(self.API_URL + method, post=kwargs) + return json.loads(json_data) + + except json.JSONDecodeError: + return json_data + + except BadHeader, exc: + return json.loads(exc.content) + + def grab_info(self, user, password, data): + api_data = self.api_request("api/account/info", api_key=password) + + premium = api_data["accountType"] == "premium" + if premium: + validuntil = api_data["premiumExpire"] / 1000 + + else: + validuntil = -1 + + trafficleft = api_data["totalBandwidth"] - api_data["usedBandwidth"] + + return { + "premium": premium, + "validuntil": validuntil, + "trafficleft": trafficleft, + } + + def signin(self, user, password, data): + api_data = self.api_request("api/account/info", api_key=password) + if api_data.get("code") is not None: + self.log_error(_("Password for fikper.com should be the API token")) + self.log_error(_("API error"), api_data) + self.fail_login() + + elif api_data["email"] != user: + self.log_error( + self._("username for fikper.com should be your fikper.com email") + ) + self.fail_login() diff --git a/module/plugins/accounts/FileStoreTo.py b/module/plugins/accounts/FileStoreTo.py new file mode 100644 index 0000000000..bb23cc94da --- /dev/null +++ b/module/plugins/accounts/FileStoreTo.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +import re +import time + +from ..internal.Account import Account + + +class FileStoreTo(Account): + __name__ = "FileStoreTo" + __type__ = "account" + __version__ = "0.02" + __status__ = "testing" + + __description__ = """Filestore.to account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + LOGIN_URL = "https://filestore.to/login" + VALID_UNTIL_PATTERN = r'Premium-Status
([\d\.]+? - [\d:]+)' + + def grab_info(self, user, password, data): + premium = False + validuntil = None + + html = self.load("https://filestore.to/konto") + m = re.search(self.VALID_UNTIL_PATTERN, html) + if m is not None: + validuntil = time.mktime(time.strptime(m.group(1), "%d.%m.%Y - %H:%M")) + premium = validuntil > time.time() + + return {"validuntil": validuntil, "trafficleft": -1, "premium": premium} + + def signin(self, user, password, data): + html = self.load(self.LOGIN_URL) + if 'href="logout"' in html: + self.skip_login() + + else: + html = self.load( + self.LOGIN_URL, + post={"Email": user, "Password": password, "Aktion": "Login"}, + ) + if 'href="logout"' not in html: + self.fail_login() diff --git a/module/plugins/accounts/FileboomMe.py b/module/plugins/accounts/FileboomMe.py new file mode 100644 index 0000000000..fdd4e76ef5 --- /dev/null +++ b/module/plugins/accounts/FileboomMe.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- + +import re + +from module.network.HTTPRequest import BadHeader +from module.PyFile import PyFile + +from ..captcha.ReCaptcha import ReCaptcha +from ..internal.Account import Account +from ..internal.Captcha import Captcha +from ..internal.misc import json + + +class FileboomMe(Account): + __name__ = "FileboomMe" + __type__ = "account" + __version__ = "0.04" + __status__ = "testing" + + __description__ = """Fileboom.me account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + RECAPTCHA_KEY = "6LcYcN0SAAAAABtMlxKj7X0hRxOY8_2U86kI1vbb" + + API_URL = "https://fileboom.me/api/v2/" + #: Actually this is Keep2ShareCc API, see https://github.com/keep2share/api + + def api_response(self, method, **kwargs): + html = self.load(self.API_URL + method, + post=json.dumps(kwargs)) + return json.loads(html) + + def grab_info(self, user, password, data): + json_data = self.api_response("AccountInfo", auth_token=data['token']) + + return {'validuntil': json_data['account_expires'], + 'trafficleft': json_data['available_traffic'], + 'premium': True if json_data['account_expires'] else False} + + def signin(self, user, password, data): + if 'token' in data: + try: + json_data = self.api_response("test", auth_token=data['token']) + + except BadHeader, e: + if e.code == 403: #: Session expired + pass + + else: + raise + else: + self.skip_login() + + try: + json_data = self.api_response("login", username=user, password=password) + + except BadHeader, e: + if e.code == 406: #: Captcha needed + # dummy pyfile + pyfile = PyFile(self.pyload.files, -1, "https://fileboom.me", "https://fileboom.me", 0, 0, "", self.classname, -1, -1) + pyfile.plugin = self + + errors = [json.loads(m.group(0)).get('errorCode', 0) for m in re.finditer(r'{[^}]+}', e.content)] + if 33 in errors: #: ERROR_RE_CAPTCHA_REQUIRED + #: Recaptcha + self.captcha = ReCaptcha(pyfile) + for i in range(10): + json_data = self.api_response("RequestReCaptcha") + if json_data['code'] != 200: + self.log_error(_("Request reCAPTCHA API failed")) + self.fail_login(_("Request reCAPTCHA API failed")) + + re_captcha_response = self.captcha.challenge(self.RECAPTCHA_KEY, version="2js", secure_token=False) + try: + json_data = self.api_response("login", + username=user, + password=password, + re_captcha_challenge=json_data['challenge'], + re_captcha_response=re_captcha_response) + + except BadHeader, e: + if e.code == 406: + errors = [json.loads(m.group(0)).get('errorCode', 0) for m in re.finditer(r'{[^}]+}', e.content)] + if 31 in errors: #: ERROR_CAPTCHA_INVALID + self.captcha.invalid() + continue + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.captcha.correct() + data['token'] = json_data['auth_token'] + break + + else: + self.log_error(_("Max captcha retries reached")) + self.fail_login(_("Max captcha retries reached")) + + elif 30 in errors: #: ERROR_CAPTCHA_REQUIRED + #: Normal captcha + self.captcha = Captcha(pyfile) + for i in range(10): + json_data = self.api_response("RequestCaptcha") + if json_data['code'] != 200: + self.log_error(_("Request captcha API failed")) + self.fail_login(_("Request captcha API failed")) + + captcha_response = self.captcha.decrypt(json_data['captcha_url']) + try: + json_data = self.api_response("login", + username=user, + password=password, + captcha_challenge=json_data['challenge'], + captcha_response=captcha_response) + + except BadHeader, e: + if e.code == 406: + errors = [json.loads(m.group(0)).get('errorCode', 0) for m in re.finditer(r'{[^}]+}', e.content)] + if 31 in errors: #: ERROR_CAPTCHA_INVALID + self.captcha.invalid() + continue + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.captcha.correct() + data['token'] = json_data['auth_token'] + break + + else: + self.log_error(_("Max captcha retries reached")) + self.fail_login(_("Max captcha retries reached")) + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + #: No captcha + data['token'] = json_data['auth_token'] + + """ + @NOTE: below are methods + necessary for captcha to work with account plugins + """ + def check_status(self): + pass + + def retry_captcha(self, attemps=10, wait=1, msg=_("Max captcha retries reached")): + self.captcha.invalid() + self.fail_login(msg=_("Invalid captcha")) diff --git a/module/plugins/accounts/FilejokerNet.py b/module/plugins/accounts/FilejokerNet.py index 00b7013ae4..cbb35e14f0 100644 --- a/module/plugins/accounts/FilejokerNet.py +++ b/module/plugins/accounts/FilejokerNet.py @@ -1,16 +1,49 @@ # -*- coding: utf-8 -*- -from ..internal.XFSAccount import XFSAccount +import time +from ..internal.Account import Account +from ..internal.misc import json -class FilejokerNet(XFSAccount): + +class FilejokerNet(Account): __name__ = "FilejokerNet" __type__ = "account" - __version__ = "0.02" + __version__ = "0.04" __status__ = "testing" __description__ = """Filejoker.net account plugin""" __license__ = "GPLv3" __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - PLUGIN_DOMAIN = "filejoker.net" + API_URL = "https://filejoker.net/zapi" + + + def api_response(self, op, **kwargs): + args = {'op': op} + args.update(kwargs) + return json.loads(self.load(self.API_URL, get=args)) + + def grab_info(self, user, password, data): + res = self.api_response("my_account", session=data['session']) + premium_expire = res.get('usr_premium_expire') + + validuntil = time.mktime(time.strptime(premium_expire, "%Y-%m-%d %H:%M:%S")) if premium_expire else -1 + trafficleft = int(res['traffic_left']) * 1024 ** 2 if 'traffic_left' in res else None + premium = bool(premium_expire) + + return {'validuntil': validuntil, + 'trafficleft': trafficleft, + 'premium': premium} + + + def signin(self, user, password, data): + session = data.get('session') + if session and 'error' not in self.api_response("my_account", session=session): + self.skip_login() + + res = self.api_response("login", **{'email': user, 'pass': password}) + if 'error'in res: + self.fail_login() + + data['session'] = res['session'] diff --git a/module/plugins/accounts/FilejungleCom.py b/module/plugins/accounts/FilejungleCom.py deleted file mode 100644 index 8ef315041c..0000000000 --- a/module/plugins/accounts/FilejungleCom.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -import time -import urlparse - -from ..internal.Account import Account - - -class FilejungleCom(Account): - __name__ = "FilejungleCom" - __type__ = "account" - __version__ = "0.19" - __status__ = "testing" - - __description__ = """Filejungle.com account plugin""" - __license__ = "GPLv3" - __authors__ = [("zoidberg", "zoidberg@mujmail.cz")] - - login_timeout = 60 - - URL = "http://filejungle.com/" - TRAFFIC_LEFT_PATTERN = r'"/extend_premium\.php">Until (\d+ \w+ \d+)' - - def grab_info(self, user, password, data): - html = self.load(self.URL + "dashboard.php") - m = re.search(self.TRAFFIC_LEFT_PATTERN, html) - if m is not None: - premium = True - validuntil = time.mktime(time.strptime(m.group(1), "%d %b %Y")) - else: - premium = False - validuntil = -1 - - return {'premium': premium, 'trafficleft': - - 1, 'validuntil': validuntil} - - def signin(self, user, password, data): - html = self.load(urlparse.urljoin(self.URL, "login.php"), - post={'loginUserName': user, - 'loginUserPassword': password, - 'loginFormSubmit': "Login", - 'recaptcha_challenge_field': "", - 'recaptcha_response_field': "", - 'recaptcha_shortencode_field': ""}) - - if re.search(self.LOGIN_FAILED_PATTERN, html): - self.fail_login() diff --git a/module/plugins/accounts/FilerNet.py b/module/plugins/accounts/FilerNet.py index e37b25611d..749e17454d 100644 --- a/module/plugins/accounts/FilerNet.py +++ b/module/plugins/accounts/FilerNet.py @@ -1,15 +1,18 @@ # -*- coding: utf-8 -*- -import re +import datetime import time +from module.network.HTTPRequest import BadHeader + from ..internal.Account import Account +from ..internal.misc import json class FilerNet(Account): __name__ = "FilerNet" __type__ = "account" - __version__ = "0.13" + __version__ = "0.16" __status__ = "testing" __description__ = """Filer.net account plugin""" @@ -17,47 +20,47 @@ class FilerNet(Account): __authors__ = [("stickell", "l.stickell@yahoo.it"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - TOKEN_PATTERN = r'name="_csrf_token" value="(.+?)"' - VALID_UNTIL_PATTERN = ur'Der Premium-Zugang ist gültig bis (.+)\.\s*' - TRAFFIC_LEFT_PATTERN = r'Traffic\s*([\d.,]+) (?:([\w^_]+))' - FREE_PATTERN = r'Account Status\s*\s*Free' + # See https://filer.net/api + API_URL = "https://filer.net/api/" + + def api_request(self, method, **kwargs): + try: + json_data = self.load(self.API_URL + method, post=kwargs) + except BadHeader as exc: + json_data = exc.content + + return json.loads(json_data) def grab_info(self, user, password, data): - html = self.load("https://filer.net/profile") + api_data = self.api_request("user/account") + + premium = api_data["status"] == "Premium" #: Free user - if re.search(self.FREE_PATTERN, html): - return {'premium': False, - 'validuntil': None, - 'trafficleft': None} - - until = re.search(self.VALID_UNTIL_PATTERN, html) - traffic = re.search(self.TRAFFIC_LEFT_PATTERN, html) - - if until and traffic: - validuntil = time.mktime(time.strptime(until.group(1), "%d.%m.%Y, %H:%M:%S")) - trafficleft = self.parse_traffic(traffic.group(1), traffic.group(2)) - return {'premium': True, - 'validuntil': validuntil, - 'trafficleft': trafficleft} - - else: - self.log_error(_("Unable to retrieve account information")) - return {'premium': False, - 'validuntil': None, - 'trafficleft': None} + if premium is False: + return {"premium": False, "validuntil": None, "trafficleft": None} - def signin(self, user, password, data): - html = self.load("https://filer.net/login") + dt_part = api_data["premiumUntil"][:19] + tz_part = api_data["premiumUntil"][19:] + local_dt = datetime.datetime.strptime(dt_part, "%Y-%m-%d %H:%M:%S") + + # Parse offset: e.g., "+02:00" -> timedelta(hours=2) + sign = 1 if tz_part[0] == '+' else -1 + hours, minutes = map(int, tz_part[1:].split(':')) + tz_offset = datetime.timedelta(hours=sign * hours, minutes=sign * minutes) + utc_dt = local_dt - tz_offset - token = re.search(self.TOKEN_PATTERN, html).group(1) + validuntil = time.mktime(utc_dt.timetuple()) + trafficleft = self.parse_traffic(api_data["traffic"]) - html = self.load("https://filer.net/login_check", - post={'_username': user, - '_password': password, - '_remember_me': "on", - '_csrf_token': token, - '_target_path': "https://filer.net/"}) + return {"premium": premium, "validuntil": validuntil, "trafficleft": trafficleft} + + def signin(self, user, password, data): + api_data = self.api_request("user/account") + if "message" not in api_data: + self.skip_login() - if 'Logout' not in html: + api_data = self.api_request("user/login", email=user, password=password) + if api_data.get("message", "") != "Login successful": + self.log_error(api_data["message"]) self.fail_login() diff --git a/module/plugins/accounts/FilesMailRu.py b/module/plugins/accounts/FilesMailRu.py index 4d7b0faebe..bd429cdee4 100644 --- a/module/plugins/accounts/FilesMailRu.py +++ b/module/plugins/accounts/FilesMailRu.py @@ -11,7 +11,7 @@ class FilesMailRu(Account): __description__ = """Filesmail.ru account plugin""" __license__ = "GPLv3" - __authors__ = [("RaNaN", "RaNaN@pyload.org")] + __authors__ = [("RaNaN", "RaNaN@pyload.net")] def grab_info(self, user, password, data): return {'validuntil': None, 'trafficleft': None} diff --git a/module/plugins/accounts/FileserveCom.py b/module/plugins/accounts/FileserveCom.py deleted file mode 100644 index c94c2ea273..0000000000 --- a/module/plugins/accounts/FileserveCom.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: utf-8 -*- - -import time - -from ..internal.Account import Account -from ..internal.misc import json - - -class FileserveCom(Account): - __name__ = "FileserveCom" - __type__ = "account" - __version__ = "0.27" - __status__ = "testing" - - __description__ = """Fileserve.com account plugin""" - __license__ = "GPLv3" - __authors__ = [("mkaay", "mkaay@mkaay.de")] - - def grab_info(self, user, password, data): - html = self.load("http://app.fileserve.com/api/login/", - post={'username': user, - 'password': password, - 'submit': "Submit+Query"}) - res = json.loads(html) - - if res['type'] == "premium": - validuntil = time.mktime( - time.strptime( - res['expireTime'], - "%Y-%m-%d %H:%M:%S")) - return {'trafficleft': res['traffic'], 'validuntil': validuntil} - else: - return {'premium': False, 'trafficleft': None, 'validuntil': None} - - def signin(self, user, password, data): - html = self.load("http://app.fileserve.com/api/login/", - post={'username': user, - 'password': password, - 'submit': "Submit+Query"}) - res = json.loads(html) - - if not res['type']: - self.fail_login() - - #: Login at fileserv html - self.load("http://www.fileserve.com/login.php", - post={'loginUserName': user, - 'loginUserPassword': password, - 'autoLogin': "checked", - 'loginFormSubmit': "Login"}) diff --git a/module/plugins/accounts/FiregetCom.py b/module/plugins/accounts/FiregetCom.py new file mode 100644 index 0000000000..d50c5400bd --- /dev/null +++ b/module/plugins/accounts/FiregetCom.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +from ..internal.XFSAccount import XFSAccount + + +class FiregetCom(XFSAccount): + __name__ = "FiregetCom" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __description__ = """fireget.com account plugin""" + __license__ = "GPLv3" + + PLUGIN_DOMAIN = "fireget.com" + PLUGIN_URL = "https://fireget.com/" diff --git a/module/plugins/accounts/FreakshareCom.py b/module/plugins/accounts/FreakshareCom.py deleted file mode 100644 index b464ebd055..0000000000 --- a/module/plugins/accounts/FreakshareCom.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -import time - -from ..internal.Account import Account - - -class FreakshareCom(Account): - __name__ = "FreakshareCom" - __type__ = "account" - __version__ = "0.21" - __status__ = "testing" - - __description__ = """Freakshare.com account plugin""" - __license__ = "GPLv3" - __authors__ = [("RaNaN", "RaNaN@pyload.org")] - - def grab_info(self, user, password, data): - premium = False - validuntil = None - trafficleft = None - - html = self.load("http://freakshare.com/") - - try: - m = re.search( - r'ltig bis:\s*([\d.:\-]+)', - html, - re.M) - validuntil = time.mktime( - time.strptime( - m.group(1), - "%d.%m.%Y - %H:%M")) - - except Exception: - pass - - try: - m = re.search(r'Traffic verbleibend:\s*(.+?)', html, re.M) - trafficleft = self.parse_traffic(m.group(1)) - - except Exception: - pass - - return {'premium': premium, 'validuntil': validuntil, - 'trafficleft': trafficleft} - - def signin(self, user, password, data): - self.load("http://freakshare.com/index.php?language=EN") - - html = self.load("https://freakshare.com/login.html", - post={'submit': "Login", - 'user': user, - 'pass': password}) - - if ">Wrong Username or Password" in html: - self.fail_login() diff --git a/module/plugins/accounts/FshareVn.py b/module/plugins/accounts/FshareVn.py index ffe8a92d08..576f221763 100644 --- a/module/plugins/accounts/FshareVn.py +++ b/module/plugins/accounts/FshareVn.py @@ -1,16 +1,16 @@ # -*- coding: utf-8 -*- -import re -import time +import pycurl +from module.network.HTTPRequest import BadHeader from ..internal.Account import Account -from ..internal.misc import parse_html_form +from ..internal.misc import json class FshareVn(Account): __name__ = "FshareVn" __type__ = "account" - __version__ = "0.22" + __version__ = "0.28" __status__ = "testing" __description__ = """Fshare.vn account plugin""" @@ -19,53 +19,84 @@ class FshareVn(Account): ("stickell", "l.stickell@yahoo.it"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - VALID_UNTIL_PATTERN = ur'>Hạn dùng:.+?>([\d/]+)' - LIFETIME_PATTERN = ur'
Lần đăng nhập trước:
\s*
.+?
' - TRAFFIC_LEFT_PATTERN = ur'>Đã SD: \s*([\d.,]+)(?:([\w^_]+))\s*/\s*([\d.,]+)(?:([\w^_]+))' + API_KEY = "dMnqMMZMUnN5YpvKENaEhdQQ5jxDqddt" + API_USERAGENT = "pyLoad-B1RS5N" + API_URL = "https://api.fshare.vn/api/" - def grab_info(self, user, password, data): - html = self.load("https://www.fshare.vn") + # See https://www.fshare.vn/api-doc + def api_request(self, method, session_id=None, **kwargs): + self.req.http.c.setopt(pycurl.USERAGENT, self.API_USERAGENT) - m = re.search(self.TRAFFIC_LEFT_PATTERN, html) - if m is not None: - trafficleft = (self.parse_traffic(m.group(3), m.group(4)) - self.parse_traffic(m.group(1), m.group(2))) if m else None + if len(kwargs) == 0: + json_data = self.load(self.API_URL + method, + cookies=[("fshare.vn", 'session_id', session_id)] if session_id else True) else: - self.log_error(_("TRAFFIC_LEFT_PATTERN not found")) + self.req.http.c.setopt(pycurl.HTTPHEADER, ["Content-Type: application/json"]) + json_data = self.load(self.API_URL + method, + post=json.dumps(kwargs), + cookies=[("fshare.vn", 'session_id', session_id)] if session_id else True) - if re.search(self.LIFETIME_PATTERN, html): - self.log_debug("Lifetime membership detected") - return {'validuntil': -1, - 'trafficleft': trafficleft, - 'premium': True} + return json.loads(json_data) - m = re.search(self.VALID_UNTIL_PATTERN, html) - if m is not None: - premium = True - validuntil = time.mktime(time.strptime(m.group(1) + " 23:59:59", '%d/%m/%Y %H:%M:%S')) + def grab_info(self, user, password, data): + trafficleft = None + premium = False - else: - premium = False - validuntil = None - trafficleft = None + api_data = self.api_request("user/get", session_id=data['session_id']) + + expire_vip = unicode(api_data.get("expire_vip", "")) #: isnumeric() is only available for unicode strings + validuntil = float(expire_vip) if expire_vip.isnumeric() else None + + if validuntil: + premium = True return {'validuntil': validuntil, 'trafficleft': trafficleft, 'premium': premium} def signin(self, user, password, data): - html = self.load("https://www.fshare.vn/site/login") - if 'href="/site/logout"' in html: - self.skip_login() + user = user.lower() + + fshare_session_cache = self.db.retrieve("fshare_session_cache") or {} + if user in fshare_session_cache: + data['token'] = fshare_session_cache[user]['token'] + data['session_id'] = fshare_session_cache[user]['session_id'] + + try: + api_data = self.api_request("user/get", session_id=data['session_id']) + + except BadHeader, e: + if e.code == 401: + del fshare_session_cache[user] + self.db.store("fshare_session_cache", fshare_session_cache) + + if api_data.get('email', "").lower() == user: + self.skip_login() + + else: + del fshare_session_cache[user] + self.db.store("fshare_session_cache", fshare_session_cache) + + data['token'] = None + data['session_id'] = None + + try: + api_data = self.api_request("user/login", + app_key=self.API_KEY, + user_email=user, + password=password) + except BadHeader, e: + self.log_error(_("Login failed, error code %s") % e.code) + self.fail_login() - url, inputs = parse_html_form('id="form-signup"', html) - if inputs is None: - self.fail_login("Login form not found") + if api_data['code'] != 200: + self.log_error(api_data['msg']) + self.fail_login() - inputs.update({'LoginForm[email]': user, - 'LoginForm[password]': password, - 'LoginForm[rememberMe]': 1}) + fshare_session_cache[user] = {'token': api_data['token'], + 'session_id': api_data['session_id']} + self.db.store("fshare_session_cache", fshare_session_cache) - html = self.load("https://www.fshare.vn/site/login", post=inputs) - if not 'href="/site/logout"' in html: - self.fail_login() + data['token'] = fshare_session_cache[user]['token'] + data['session_id'] = fshare_session_cache[user]['session_id'] diff --git a/module/plugins/accounts/GetTwentyFourOrg.py b/module/plugins/accounts/GetTwentyFourOrg.py new file mode 100644 index 0000000000..cf637827eb --- /dev/null +++ b/module/plugins/accounts/GetTwentyFourOrg.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +import time +import pycurl +from hashlib import sha256 + +from ..internal.misc import json +from ..internal.MultiAccount import MultiAccount + + +class GetTwentyFourOrg(MultiAccount): + __name__ = 'GetTwentyFourOrg' + __type__ = 'account' + __version__ = '0.04' + __status__ = 'testing' + + __description__ = 'GeT24.org account plugin' + __license__ = 'GPLv3' + __authors__ = ['get24', 'contact@get24.org'] + + API_URL = 'https://get24.org/api' + + def grab_hosters(self, user, password, data): + rc = self.load('%s/hosts/enabled' % self.API_URL) + hosts = json.loads(rc) + self.log_debug(hosts) + return hosts + + def grab_info(self, user, password, data): + post = {'email': user, + 'passwd_sha256': self.info['data']['passwd_sha256']} + rc = self.load('%s/login' % self.API_URL, post=post) + rc = json.loads(rc) + self.log_debug(rc) + + validuntil = time.mktime(time.strptime(rc['date_expire'], '%Y-%m-%d %H:%M:%S')) + + return {'validuntil': validuntil, + 'trafficleft': rc['transfer_left'] * 1024 * 1024 * 1024, # gb -> b + 'premium': rc['status'] == 'premium'} + + def signin(self, user, password, data): + data['passwd_sha256'] = sha256(password.encode('ascii')).hexdigest() + self.req.http.c.setopt( + pycurl.USERAGENT, "pyLoad/{}".format(self.pyload.version).encode() + ) diff --git a/module/plugins/accounts/HighWayMe.py b/module/plugins/accounts/HighWayMe.py index f682e25dfb..fb56d5e02e 100644 --- a/module/plugins/accounts/HighWayMe.py +++ b/module/plugins/accounts/HighWayMe.py @@ -5,9 +5,9 @@ class HighWayMe(MultiAccount): - __name__ = "HighWayMe.py" + __name__ = "HighWayMe" __type__ = "account" - __version__ = "0.10" + __version__ = "0.12" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -16,11 +16,21 @@ class HighWayMe(MultiAccount): __description__ = """High-Way.me account plugin""" __license__ = "GPLv3" - __authors__ = [("EvolutionClip", "evolutionclip@live.de")] + __authors__ = [("EvolutionClip", "evolutionclip@live.de"), + ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + API_URL = "https://high-way.me/api.php" + + def api_response(self, method, **kwargs): + post=dict([(k.lstrip('_'), v) for k,v in kwargs.items()]) + json_data = self.load(self.API_URL, + get={method: ''}, + post=post) + return json.loads(json_data) def grab_hosters(self, user, password, data): - html = self.load("https://high-way.me/api.php", get={'hoster': 1}) - json_data = json.loads(html) + json_data = self.api_response("hoster") + return [element['name'] for element in json_data['hoster']] def grab_info(self, user, password, data): @@ -28,33 +38,23 @@ def grab_info(self, user, password, data): validuntil = -1 trafficleft = None - json_data = self.load('https://high-way.me/api.php?user') - - self.log_debug("JSON data: %s" % json_data) - - json_data = json.loads(json_data) + json_data = self.api_response("user") if 'premium' in json_data['user'] and json_data['user']['premium']: premium = True - if 'premium_bis' in json_data[ - 'user'] and json_data['user']['premium_bis']: + if 'premium_bis' in json_data['user'] and json_data['user']['premium_bis']: validuntil = float(json_data['user']['premium_bis']) - if 'premium_traffic' in json_data[ - 'user'] and json_data['user']['premium_traffic']: - # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft = float(json_data['user']['premium_traffic']) / 1024 + if 'premium_traffic' in json_data['user'] and json_data['user']['premium_traffic']: + trafficleft = float(json_data['user']['premium_traffic']) return {'premium': premium, 'validuntil': validuntil, 'trafficleft': trafficleft} def signin(self, user, password, data): - html = self.load("https://high-way.me/api.php?login", - post={'login': '1', - 'user': user, - 'pass': password}) + json_data = self.api_response("login", user=user, _pass=password) - if 'UserOrPassInvalid' in html: + if not json_data.get('loggedin', False): self.fail_login() diff --git a/module/plugins/accounts/KatfileCom.py b/module/plugins/accounts/KatfileCom.py new file mode 100644 index 0000000000..e89bbdc89f --- /dev/null +++ b/module/plugins/accounts/KatfileCom.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from ..internal.XFSAccount import XFSAccount + + +class KatfileCom(XFSAccount): + __name__ = "KatfileCom" + __type__ = "account" + __version__ = "0.02" + __status__ = "testing" + + __description__ = """Katfile.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + PLUGIN_DOMAIN = "katfile.cloud" + PLUGIN_URL = "https://katfile.cloud" + + PREMIUM_PATTERN = r"Extend Premium account" + VALID_UNTIL_PATTERN = r"Premium Pro account expire(.+?)<" + TRAFFIC_LEFT_PATTERN = r"Traffic available today.*?\s*(?P[\d.,]+|[Uu]nlimited)\s*(?:(?P[\w^_]+)\s*)?" diff --git a/module/plugins/accounts/Keep2ShareCc.py b/module/plugins/accounts/Keep2ShareCc.py index f0f7aa3dfc..148fa64215 100644 --- a/module/plugins/accounts/Keep2ShareCc.py +++ b/module/plugins/accounts/Keep2ShareCc.py @@ -1,16 +1,20 @@ # -*- coding: utf-8 -*- +import re + from module.network.HTTPRequest import BadHeader -from module.network.RequestFactory import getURL as get_url +from module.PyFile import PyFile +from ..captcha.ReCaptcha import ReCaptcha from ..internal.Account import Account +from ..internal.Captcha import Captcha from ..internal.misc import json class Keep2ShareCc(Account): __name__ = "Keep2ShareCc" __type__ = "account" - __version__ = "0.15" + __version__ = "0.21" __status__ = "testing" __description__ = """Keep2Share.cc account plugin""" @@ -19,21 +23,22 @@ class Keep2ShareCc(Account): ("Walter Purcaro", "vuolter@gmail.com"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + RECAPTCHA_KEY = "6LcOsNIaAAAAABzCMnQw7u0u8zd1mrqY6ibFtto8" + API_URL = "https://keep2share.cc/api/v2/" - #: See https://github.com/keep2share/api + #: See https://keep2share.github.io/api/ https://github.com/keep2share/api - @classmethod - def api_response(cls, method, **kwargs): - html = get_url(cls.API_URL + method, - post=json.dumps(kwargs)) + def api_response(self, method, **kwargs): + html = self.load(self.API_URL + method, + post=json.dumps(kwargs)) return json.loads(html) def grab_info(self, user, password, data): json_data = self.api_response("AccountInfo", auth_token=data['token']) return {'validuntil': json_data['account_expires'], - 'trafficleft': json_data['available_traffic'] / 1024, # @TODO: Remove `/ 1024` in 0.4.10 - 'premium': True} + 'trafficleft': json_data['available_traffic'], + 'premium': True if json_data['account_expires'] else False} def signin(self, user, password, data): if 'token' in data: @@ -41,7 +46,7 @@ def signin(self, user, password, data): json_data = self.api_response("test", auth_token=data['token']) except BadHeader, e: - if e.code == 403: + if e.code == 403: #: Session expired pass else: @@ -53,11 +58,113 @@ def signin(self, user, password, data): json_data = self.api_response("login", username=user, password=password) except BadHeader, e: - if e.code == 406: - self.fail_login() + if e.code == 406: #: Captcha needed + # dummy pyfile + pyfile = PyFile(self.pyload.files, -1, "https://k2s.cc", "https://k2s.cc", 0, 0, "", self.classname, -1, -1) + pyfile.plugin = self + + errors = [json.loads(m.group(0)).get('errorCode', 0) for m in re.finditer(r'{[^}]+}', e.content)] + if 33 in errors: #: ERROR_RE_CAPTCHA_REQUIRED + #: Recaptcha + self.captcha = ReCaptcha(pyfile) + for i in range(10): + json_data = self.api_response("RequestReCaptcha") + if json_data['code'] != 200: + self.log_error(_("Request reCAPTCHA API failed")) + self.fail_login(_("Request reCAPTCHA API failed")) + + re_captcha_response = self.captcha.challenge(self.RECAPTCHA_KEY, version="2js", secure_token=False) + try: + json_data = self.api_response("login", + username=user, + password=password, + re_captcha_challenge=json_data['challenge'], + re_captcha_response=re_captcha_response) + + except BadHeader, e: + if e.code == 406: + errors = [json.loads(m.group(0)).get('errorCode', 0) for m in re.finditer(r'{[^}]+}', e.content)] + if 31 in errors: #: ERROR_CAPTCHA_INVALID + self.captcha.invalid() + continue + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.captcha.correct() + data['token'] = json_data['auth_token'] + break + + else: + self.log_error(_("Max captcha retries reached")) + self.fail_login(_("Max captcha retries reached")) + + elif 30 in errors: #: ERROR_CAPTCHA_REQUIRED + #: Normal captcha + self.captcha = Captcha(pyfile) + for i in range(10): + json_data = self.api_response("RequestCaptcha") + if json_data['code'] != 200: + self.log_error(_("Request captcha API failed")) + self.fail_login(_("Request captcha API failed")) + + captcha_response = self.captcha.decrypt(json_data['captcha_url']) + try: + json_data = self.api_response("login", + username=user, + password=password, + captcha_challenge=json_data['challenge'], + captcha_response=captcha_response) + + except BadHeader, e: + if e.code == 406: + errors = [json.loads(m.group(0)).get('errorCode', 0) for m in re.finditer(r'{[^}]+}', e.content)] + if 31 in errors: #: ERROR_CAPTCHA_INVALID + self.captcha.invalid() + continue + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.log_error(e.content) + self.fail_login(e.content) + + else: + self.captcha.correct() + data['token'] = json_data['auth_token'] + break + + else: + self.log_error(_("Max captcha retries reached")) + self.fail_login(_("Max captcha retries reached")) + + else: + self.log_error(e.content) + self.fail_login(e.content) else: - raise + self.log_error(e.content) + self.fail_login(e.content) else: + #: No captcha data['token'] = json_data['auth_token'] + + """ + @NOTE: below are methods + necessary for captcha to work with account plugins + """ + def check_status(self): + pass + + def retry_captcha(self, attemps=10, wait=1, msg=_("Max captcha retries reached")): + self.captcha.invalid() + self.fail_login(msg=_("Invalid captcha")) diff --git a/module/plugins/accounts/LeechThreeHundreedSixtyCom.py b/module/plugins/accounts/LeechThreeHundreedSixtyCom.py index 28208e825a..a1c5d139d3 100644 --- a/module/plugins/accounts/LeechThreeHundreedSixtyCom.py +++ b/module/plugins/accounts/LeechThreeHundreedSixtyCom.py @@ -8,7 +8,7 @@ class LeechThreeHundreedSixtyCom(MultiAccount): __name__ = "LeechThreeHundreedSixtyCom" __type__ = "account" - __version__ = "0.01" + __version__ = "0.02" __status__ = "testing" __description__ = """Leech360.com account plugin""" @@ -53,8 +53,7 @@ def grab_info(self, user, password, data): premium = False validuntil = time.mktime(time.strptime(status, "%b d %Y %I:%M %p")) - # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft = (536870912000l - int(api_data['data'].get('total_used', 0))) / 1024 + trafficleft = (536870912000l - int(api_data['data'].get('total_used', 0))) return {'premium': premium, 'validuntil': validuntil , 'trafficleft': trafficleft} diff --git a/module/plugins/accounts/LinkifierCom.py b/module/plugins/accounts/LinkifierCom.py index f0c0f7ee12..050e09759f 100644 --- a/module/plugins/accounts/LinkifierCom.py +++ b/module/plugins/accounts/LinkifierCom.py @@ -10,7 +10,7 @@ class LinkifierCom(MultiAccount): __name__ = "LinkifierCom" __type__ = "account" - __version__ = "0.01" + __version__ = "0.03" __status__ = "testing" __description__ = """Linkifier.com account plugin""" @@ -49,7 +49,7 @@ def grab_info(self, user, password, data): validuntil = float(json_data['expirydate']) / 1000 return {'validuntil': validuntil , - 'trafficleft': -1 if trafficleft.lower() == "unlimited" else int(trafficleft), + 'trafficleft': -1 if trafficleft.lower() == "unlimited" else int(trafficleft) * 1024, 'premium': True} def signin(self, user, password, data): diff --git a/module/plugins/accounts/LinksnappyCom.py b/module/plugins/accounts/LinksnappyCom.py index 0508353a3f..a1aa2c71b1 100644 --- a/module/plugins/accounts/LinksnappyCom.py +++ b/module/plugins/accounts/LinksnappyCom.py @@ -7,7 +7,7 @@ class LinksnappyCom(MultiAccount): __name__ = "LinksnappyCom" __type__ = "account" - __version__ = "0.17" + __version__ = "0.22" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -27,7 +27,7 @@ def api_response(self, method, **kwargs): def grab_hosters(self, user, password, data): json_data = self.api_response("FILEHOSTS") - return json_data['return'].keys() + return [k for k,v in json_data['return'].items() if v['Status'] == "1"] def grab_info(self, user, password, data): premium = True @@ -40,23 +40,22 @@ def grab_info(self, user, password, data): self.log_error(json_data['error']) else: - validuntil = json_data['return']['expire'] + expire = json_data['return']['expire'] - if validuntil == "lifetime": + if expire == "lifetime": validuntil = -1 - elif validuntil == "expired": + elif expire == "expired": premium = False else: - validuntil = float(validuntil) + validuntil = float(expire) - if 'trafficleft' not in json_data['return'] or isinstance(json_data['return']['trafficleft'], basestring): + if isinstance(json_data['return'].get("trafficleft", ""), basestring): trafficleft = -1 else: - # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft = float(json_data['return']['trafficleft']) / 1024 + trafficleft = float(json_data['return']['trafficleft']) * 1024 return {'premium': premium, 'validuntil': validuntil, diff --git a/module/plugins/accounts/MegaCoNz.py b/module/plugins/accounts/MegaCoNz.py index 7af87ea625..2956cfcc2c 100644 --- a/module/plugins/accounts/MegaCoNz.py +++ b/module/plugins/accounts/MegaCoNz.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +import hashlib + import Crypto.PublicKey.RSA from ..hoster.MegaCoNz import MegaClient, MegaCrypto @@ -9,7 +11,7 @@ class MegaCoNz(Account): __name__ = "MegaCoNz" __type__ = "account" - __version__ = "0.06" + __version__ = "0.08" __status__ = "testing" __description__ = """Mega.co.nz account plugin""" @@ -28,7 +30,7 @@ def grab_info(self, user, password, data): premium = res.get('utype', 0) > 0 if premium: validuntil = res.get('suntil', None) - trafficleft = (res.get('mxfer', 0) - res.get('caxfer', 0) - res.get('csxfer', 0)) / 1024 + trafficleft = (res.get('mxfer', 0) - res.get('caxfer', 0) - res.get('csxfer', 0)) # if res['rtt']: # self.log_debug("Tranfare history:%s" % res['tah']) @@ -38,6 +40,7 @@ def grab_info(self, user, password, data): 'premium': premium} def signin(self, user, password, data): + user = user.lower() mega = MegaClient(self, None) mega_session_cache = self.db.retrieve("mega_session_cache") or {} @@ -55,21 +58,31 @@ def signin(self, user, password, data): sid = None data['mega_session_id'] = sid - password_key = self.get_password_key(password) - user_hash = self.get_user_hash(user, password_key) + res = mega.api_response(a="us0", user=user) #: us0 is `prelogin` command + if res['v'] == 1: #: v1 account + password_key = self.get_password_key(password) + user_hash = self.get_user_hash_v1(user, password_key) - res = mega.api_response(a="us", user=user, uh=user_hash) - if isinstance(res, int): + elif res['v'] == 2: #: v2 account + salt = MegaCrypto.base64_decode(res['s']) + pbkdf = hashlib.pbkdf2_hmac("SHA512", password, salt, 100000, 32) + + password_key = MegaCrypto.str_to_a32(pbkdf[:16]) + user_hash = MegaCrypto.base64_encode(pbkdf[16:]).replace('=', '') + + else: + self.log_error(_("Unsupported user account version (%s)") % res['v']) self.fail_login() - elif isinstance(res, dict) and 'e' in res: + + res = mega.api_response(a="us", user=user, uh=user_hash) + if isinstance(res, int) or isinstance(res, dict) and 'e' in res: self.fail_login() master_key = MegaCrypto.decrypt_key(res['k'], password_key) if 'tsid' in res: tsid = MegaCrypto.base64_decode(res['tsid']) - if MegaCrypto.a32_to_str(MegaCrypto.encrypt_key( - MegaCrypto.str_to_a32(tsid[:16]), master_key)) == tsid[-16:]: + if MegaCrypto.a32_to_str(MegaCrypto.encrypt_key(MegaCrypto.str_to_a32(tsid[:16]), master_key)) == tsid[-16:]: sid = res['tsid'] else: @@ -77,22 +90,20 @@ def signin(self, user, password, data): elif 'csid' in res: privk = MegaCrypto.a32_to_str(MegaCrypto.decrypt_key(res['privk'], master_key)) - rsa_private_key = [long(0), long(0), long(0), long(0)] + rsa_private_key = [0L, 0L, 0L, 0L] for i in range(4): l = ((ord(privk[0]) * 256 + ord(privk[1]) + 7) / 8) + 2 + if l > len(privk): + self.fail_login() rsa_private_key[i] = self.mpi_to_int(privk[:l]) privk = privk[l:] + if len(privk) >= 16: + self.fail_login() + encrypted_sid = self.mpi_to_int(MegaCrypto.base64_decode(res['csid'])) - rsa = Crypto.PublicKey.RSA.construct( - (rsa_private_key[0] * - rsa_private_key[1], - long(0), - rsa_private_key[2], - rsa_private_key[0], - rsa_private_key[1])) - sid = "%x" % rsa.key._decrypt(encrypted_sid) + sid = "%x" % pow(encrypted_sid, rsa_private_key[2], rsa_private_key[0] * rsa_private_key[1]) sid = '0' * (-len(sid) % 2) + sid sid = "".join(chr(int(sid[i: i + 2], 16)) for i in range(0, len(sid), 2)) @@ -118,7 +129,7 @@ def get_password_key(self, password): return MegaCrypto.str_to_a32(password_key) - def get_user_hash(self, user, password_key): + def get_user_hash_v1(self, user, password_key): user_a32 = MegaCrypto.str_to_a32(user) user_hash = [0, 0, 0, 0] for i in range(len(user_a32)): diff --git a/module/plugins/accounts/MegaDebridEu.py b/module/plugins/accounts/MegaDebridEu.py index 9e5136e398..a05af506da 100644 --- a/module/plugins/accounts/MegaDebridEu.py +++ b/module/plugins/accounts/MegaDebridEu.py @@ -3,7 +3,7 @@ import pycurl from module.network.HTTPRequest import BadHeader -from ..internal.misc import encode, json, reduce +from ..internal.misc import decode, json, reduce from ..internal.MultiAccount import MultiAccount @@ -35,7 +35,7 @@ def api_response(self, action, get={}, post={}): get['action'] = action # Better use pyLoad User-Agent so we don't get blocked - self.req.http.c.setopt(pycurl.USERAGENT, encode("pyLoad/%s" % self.pyload.version)) + self.req.http.c.setopt(pycurl.USERAGENT, decode("pyLoad/%s" % self.pyload.version)) json_data = self.load(self.API_URL, get=get, post=post) diff --git a/module/plugins/accounts/MegaRapidCz.py b/module/plugins/accounts/MegaRapidCz.py index d061e62e34..291733db68 100644 --- a/module/plugins/accounts/MegaRapidCz.py +++ b/module/plugins/accounts/MegaRapidCz.py @@ -9,7 +9,7 @@ class MegaRapidCz(Account): __name__ = "MegaRapidCz" __type__ = "account" - __version__ = "0.42" + __version__ = "0.43" __status__ = "testing" __description__ = """MegaRapid.cz account plugin""" @@ -41,7 +41,7 @@ def grab_info(self, user, password, data): m = re.search(self.TRAFFIC_LEFT_PATTERN, htmll) if m is not None: - trafficleft = float(m.group(1)) * (1 << 20) + trafficleft = float(m.group(1)) * 1024 ** 3 return {'premium': True, 'trafficleft': trafficleft, 'validuntil': -1} return {'premium': False, 'trafficleft': None, 'validuntil': None} diff --git a/module/plugins/accounts/Mp4uploadCom.py b/module/plugins/accounts/Mp4uploadCom.py new file mode 100644 index 0000000000..26648239a1 --- /dev/null +++ b/module/plugins/accounts/Mp4uploadCom.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from ..internal.XFSAccount import XFSAccount + + +class Mp4uploadCom(XFSAccount): + __name__ = "Mp4uploadCom" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __description__ = """Mp4upload.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + PLUGIN_DOMAIN = "mp4upload.com" + LOGIN_URL = "https://www.mp4upload.com/login" + LOGIN_SKIP_PATTERN = r"https://www\.mp4upload\.com/logout/" + PREMIUM_PATTERN = r">Premium Account" + VALID_UNTIL_PATTERN = r"Premium expiration:.*?>(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + VALID_UNTIL_FORMAT = "%Y-%m-%d %H:%M:%S" diff --git a/module/plugins/accounts/MultishareCz.py b/module/plugins/accounts/MultishareCz.py index 4b6f41a638..2e16713b5d 100644 --- a/module/plugins/accounts/MultishareCz.py +++ b/module/plugins/accounts/MultishareCz.py @@ -1,14 +1,16 @@ # -*- coding: utf-8 -*- -import re +import pycurl +from ..internal.misc import json +from module.network.HTTPRequest import BadHeader from ..internal.MultiAccount import MultiAccount class MultishareCz(MultiAccount): __name__ = "MultishareCz" __type__ = "account" - __version__ = "0.14" + __version__ = "0.15" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -17,35 +19,51 @@ class MultishareCz(MultiAccount): __description__ = """Multishare.cz account plugin""" __license__ = "GPLv3" - __authors__ = [("zoidberg", "zoidberg@mujmail.cz")] + __authors__ = [("zoidberg", "zoidberg@mujmail.cz"), + ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - TRAFFIC_LEFT_PATTERN = r'Kredit:\s*(?P[\d.,]+) (?P[\w^_]+)' - ACCOUNT_INFO_PATTERN = r'' + #: See https://multishare.cz/api/ + API_URL = "https://www.multishare.cz/api/" - PLUGIN_PATTERN = r']*?alt="(.+?)">\s*[^>]*?alt="OK"' + def api_response(self, method, **kwargs): + get = {'sub': method} + get.update(kwargs) + self.req.http.c.setopt(pycurl.USERAGENT, "JDownloader") + json_data = self.load(self.API_URL, + get=get) + + if not json_data.startswith('{'): + if json_data.startswith("ERR:"): + json_data = json_data[4:].strip() + return {'err': json_data} + + else: + return json.loads(json_data) def grab_hosters(self, user, password, data): - html = self.load("http://www.multishare.cz/monitoring/") - return re.findall(self.PLUGIN_PATTERN, html) + api_data = self.api_response("supported-hosters") + return api_data['server'] def grab_info(self, user, password, data): - html = self.load("http://www.multishare.cz/profil/") + api_data = self.api_response("account-details", login=user, password=password) + trafficleft = self.parse_traffic(api_data['credit'], "MB") - m = re.search(self.TRAFFIC_LEFT_PATTERN, html) - trafficleft = self.parse_traffic( - m.group('S'), m.group('U')) if m else 0 - self.premium = True if trafficleft else False + premium = True if trafficleft else False - html = self.load("http://www.multishare.cz/") - mms_info = dict(re.findall(self.ACCOUNT_INFO_PATTERN, html)) + return {'validuntil': -1, + 'trafficleft': trafficleft, + 'premium': premium} - return dict(mms_info, **{'validuntil': -1, 'trafficleft': trafficleft}) def signin(self, user, password, data): - html = self.load('https://www.multishare.cz/html/prihlaseni_process.php', - post={'akce': "Přihlásit", - 'heslo': password, - 'jmeno': user}) + try: + api_data = self.api_response("account-details", login=user, password=password) + + except BadHeader,e: + if e.code == 403: + self.fail_login(_("IP is banned")) + else: + raise - if '
' in html: - self.fail_login() + if 'err' in api_data: + self.fail_login(api_data['err']) diff --git a/module/plugins/accounts/NitroflareCom.py b/module/plugins/accounts/NitroflareCom.py index c0ed1d9b06..784c57e70a 100644 --- a/module/plugins/accounts/NitroflareCom.py +++ b/module/plugins/accounts/NitroflareCom.py @@ -2,6 +2,9 @@ import time +from module.PyFile import PyFile + +from ..captcha.ReCaptcha import ReCaptcha from ..internal.Account import Account from ..internal.misc import json @@ -9,7 +12,7 @@ class NitroflareCom(Account): __name__ = "NitroflareCom" __type__ = "account" - __version__ = "0.20" + __version__ = "0.22" __status__ = "testing" __description__ = """Nitroflare.com account plugin""" @@ -17,40 +20,68 @@ class NitroflareCom(Account): __authors__ = [("Walter Purcaro", "vuolter@gmail.com"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + RECAPTCHA_KEY = "6Lenx_USAAAAAF5L1pmTWvWcH73dipAEzNnmNLgy" + + # See https://nitroflare.com/member?s=general-api + API_URL = "https://nitroflare.com/api/v2/" + + def api_request(self, method, **kwargs): + json_data = self.load(self.API_URL + method, get=kwargs) + return json.loads(json_data) + def grab_info(self, user, password, data): validuntil = -1 trafficleft = None premium = False - data = json.loads(self.load("https://nitroflare.com/api/v2/getKeyInfo", - get={'user': user, - 'premiumKey': password})) + api_data = self.api_request("getKeyInfo", user=user, premiumKey=password) - if data['type'] == 'success': - trafficleft = self.parse_traffic( - data['result']['trafficLeft'], "byte") - premium = data['result']['status'] == "active" + if api_data['type'] == 'success': + trafficleft = self.parse_traffic(api_data['result']['trafficLeft'], "byte") + premium = api_data['result']['status'] == "active" if premium: - validuntil = time.mktime( - time.strptime( - data['result']['expiryDate'], - '%Y-%m-%d %H:%M:%S')) + validuntil = time.mktime(time.strptime(api_data['result']['expiryDate'], '%Y-%m-%d %H:%M:%S')) return {'validuntil': validuntil, 'trafficleft': trafficleft, 'premium': premium} - def signin(self, user, password, data): - data = json.loads(self.load("https://nitroflare.com/api/v2/getKeyInfo", - get={'user': user, - 'premiumKey': password})) + def signin(self, user, password, data1): + api_data = self.api_request("getKeyInfo", user=user, premiumKey=password) + + if api_data["type"] != "success": + error_code = api_data["code"] + + if error_code != 12: + self.log_error(api_data["message"]) + self.fail_login() + + else: + # dummy pyfile + pyfile = PyFile(self.pyload.files, -1, "https://nitroflare.com", "https://nitroflare.com", 0, 0, "", self.classname, -1, -1) + pyfile.plugin = self + + self.captcha = ReCaptcha(pyfile) + response = self.captcha.challenge(self.RECAPTCHA_KEY, version="2js", secure_token=False) + + api_response = self.load( + self.API_URL + "solveCaptcha", + get={"user": user}, + post={"response": response} + ) - if data['type'] != 'success': - self.fail_login() + if api_response != "passed": + self.log_error(_("Recaptcha verification failed")) + self.fail_login(_("Recaptcha verification failed")) - elif data['result'].get("status") == "banned": - self.fail_login(_("Banned")) + """ + @NOTE: below are methods + necessary for captcha to work with account plugins + """ + def check_status(self): + pass - elif 'recaptchaPublic' in data['result']: - self.fail_login(_("Account Login Requires Recaptcha")) + def retry_captcha(self, attempts=10, wait=1, msg="Max captcha retries reached"): + self.captcha.invalid() + self.fail_login(msg=_("Invalid captcha")) diff --git a/module/plugins/accounts/NoPremiumPl.py b/module/plugins/accounts/NoPremiumPl.py index 0757f4a406..11788df41b 100644 --- a/module/plugins/accounts/NoPremiumPl.py +++ b/module/plugins/accounts/NoPremiumPl.py @@ -11,7 +11,7 @@ class NoPremiumPl(MultiAccount): __name__ = "NoPremiumPl" __type__ = "account" - __version__ = "0.11" + __version__ = "0.12" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -56,7 +56,7 @@ def grab_info(self, user, password, data): valid_untill = time.mktime(datetime.datetime.fromtimestamp( int(result['expire'])).timetuple()) - traffic_left = result['balance'] * 1024 + traffic_left = result['balance'] * 1024 ** 2 return {'validuntil': valid_untill, 'trafficleft': traffic_left, diff --git a/module/plugins/accounts/NovafileCom.py b/module/plugins/accounts/NovafileCom.py index 21da744764..0f3901b5e8 100644 --- a/module/plugins/accounts/NovafileCom.py +++ b/module/plugins/accounts/NovafileCom.py @@ -6,11 +6,15 @@ class NovafileCom(XFSAccount): __name__ = "NovafileCom" __type__ = "account" - __version__ = "0.07" + __version__ = "0.08" __status__ = "testing" __description__ = """Novafile.com account plugin""" __license__ = "GPLv3" - __authors__ = [("Walter Purcaro", "vuolter@gmail.com")] + __authors__ = [("Walter Purcaro", "vuolter@gmail.com"), + ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - PLUGIN_DOMAIN = "novafile.com" + PLUGIN_DOMAIN = "novafile.org" + LOGIN_URL = "https://novafile.org/login" + + TRAFFIC_LEFT_PATTERN = r"Traffic Available:.*?\s*(?P[\d.,]+|[Uu]nlimited)\s*(?:(?P[\w^_]+)\s*)?" diff --git a/module/plugins/accounts/OboomCom.py b/module/plugins/accounts/OboomCom.py deleted file mode 100644 index 04f59b114a..0000000000 --- a/module/plugins/accounts/OboomCom.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8 -*- - -try: - from beaker.crypto.pbkdf2 import PBKDF2 - -except ImportError: - from beaker.crypto.pbkdf2 import pbkdf2 - from binascii import b2a_hex - - class PBKDF2(object): - - def __init__(self, passphrase, salt, iterations=1000): - self.passphrase = passphrase - self.salt = salt - self.iterations = iterations - - def hexread(self, octets): - return b2a_hex( - pbkdf2(self.passphrase, self.salt, self.iterations, octets)) - -from ..internal.Account import Account -from ..internal.misc import json - - -class OboomCom(Account): - __name__ = "OboomCom" - __type__ = "account" - __version__ = "0.32" - __status__ = "testing" - - __description__ = """Oboom.com account plugin""" - __license__ = "GPLv3" - __authors__ = [("stanley", "stanley.foerster@gmail.com")] - - def load_account_data(self, user, password): - salt = password[::-1] - pbkdf2 = PBKDF2(password, salt, 1000).hexread(16) - - html = self.load("http://www.oboom.com/1/login", # @TODO: Revert to `https` in 0.4.10 - get={'auth': user, - 'pass': pbkdf2}) - result = json.loads(html) - - if result[0] != 200: - self.log_warning(_("Failed to log in: %s") % result[1]) - self.fail_login() - - return result[1] - - def grab_info(self, user, password, data): - account_data = self.load_account_data(user, password) - - userData = account_data['user'] - - premium = userData['premium'] != "null" - - if userData['premium_unix'] == "null": - validUntil = -1 - else: - validUntil = float(userData['premium_unix']) - - traffic = userData['traffic'] - - # @TODO: Remove `/ 1024` in 0.4.10 - trafficLeft = traffic['current'] / 1024 - maxTraffic = traffic['max'] / 1024 # @TODO: Remove `/ 1024` in 0.4.10 - - session = account_data['session'] - - return {'premium': premium, - 'validuntil': validUntil, - 'trafficleft': trafficLeft, - 'maxtraffic': maxTraffic, - 'session': session} - - def signin(self, user, password, data): - self.load_account_data(user, password) diff --git a/module/plugins/accounts/OboomIo.py b/module/plugins/accounts/OboomIo.py new file mode 100644 index 0000000000..aa39340258 --- /dev/null +++ b/module/plugins/accounts/OboomIo.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- + +import re +import time + +from ..internal.Account import Account +from ..internal.misc import json + + +class OboomIo(Account): + __name__ = "OboomIo" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __description__ = """Oboom.io account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + PREMIUM_PATTERN = r">Plan Elite" + VALID_UNTIL_PATTERN = r'fa-calendar-star">
\s*

([\d\w,\.: ]+)' + + def grab_info(self, user, password, data): + html = self.load("https://oboom.io/dashboard") + + premium = re.search(self.PREMIUM_PATTERN, html) is not None + + validuntil = None + m = re.search(self.VALID_UNTIL_PATTERN, html) + if m is not None: + validuntil = time.mktime(time.strptime(m.group(1).strip(), "%d %b %Y, %H:%M")) + + else: + self.log_error(self._("VALID_UNTIL_PATTERN not found")) + + return {"validuntil": validuntil, "trafficleft": -1, "premium": premium} + + def signin(self, user, password, data): + html = self.load("https://oboom.io/dashboard") + if 'href="/logout"' in html: + self.skip_login() + + html = self.load( + "https://oboom.io/api/1.0/apiGetUserLogin/", + post={ + "email": user, + "pass": password, + "re": "0" + } + ) + json_data = json.loads(html) + if json_data.get("message") != "successUserLogin": + self.fail_login() diff --git a/module/plugins/accounts/OneFichierCom.py b/module/plugins/accounts/OneFichierCom.py index dfc6e40621..5872bb5a23 100644 --- a/module/plugins/accounts/OneFichierCom.py +++ b/module/plugins/accounts/OneFichierCom.py @@ -12,7 +12,7 @@ class OneFichierCom(Account): __name__ = "OneFichierCom" __type__ = "account" - __version__ = "0.24" + __version__ = "0.25" __status__ = "testing" __description__ = """1fichier.com account plugin""" @@ -51,17 +51,21 @@ def grab_info(self, user, password, data): def signin(self, user, password, data): login_url = "https://1fichier.com/login.pl?lg=en" + html = self.load(login_url) + if "/logout.pl" in html: + self.skip_login() + try: html = self.load(login_url, ref=login_url, post={'mail': user, 'pass': password, - 'It': "on", + 'lt': "on", 'purge': "off", - 'valider': "Send"}) + 'valider': "OK"}) if any(_x in html for _x in - ('>Invalid username or Password', '>Invalid email address', '>Invalid password')): + ('>Invalid username or Password', '>Invalid email address', '>Invalid password', '>Invalid username')): self.fail_login() except BadHeader, e: diff --git a/module/plugins/accounts/OpenloadCo.py b/module/plugins/accounts/OpenloadCo.py deleted file mode 100644 index 163766993f..0000000000 --- a/module/plugins/accounts/OpenloadCo.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.XFSAccount import XFSAccount - - -class OpenloadCo(XFSAccount): - __name__ = "OpenloadCo" - __type__ = "account" - __version__ = "0.03" - __status__ = "testing" - - __description__ = """Openload.co account plugin""" - __license__ = "GPLv3" - __authors__ = [("Walter Purcaro", "vuolter@gmail.com")] - - PLUGIN_DOMAIN = "openload.co" diff --git a/module/plugins/accounts/PixeldrainCom.py b/module/plugins/accounts/PixeldrainCom.py new file mode 100644 index 0000000000..b951603043 --- /dev/null +++ b/module/plugins/accounts/PixeldrainCom.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +import pycurl +from module.network.HTTPRequest import BadHeader + +from ..internal.Account import Account +from ..internal.misc import json + + +class PixeldrainCom(Account): + __name__ = "PixeldrainCom" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __description__ = """Pixeldrain.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + #: See https://pixeldrain.com/api/ + API_URL = "https://pixeldrain.com/api/" + + def grab_info(self, user, password, data): + # unfortunately, there is no method for account info, assume premium + return {"validuntil": -1, + "trafficleft": -1, + "premium": True} + + def signin(self, user, password, data): + self.req.http.c.setopt(pycurl.USERPWD, ":%s" % password) + try: + json_data = self.load(self.API_URL + "/user/lists") + except BadHeader as exc: + json_data = exc.content + + api_data = json.loads(json_data) + if not api_data.get("success", True): + self.log_error(api_data["message"]) + self.fail_login() diff --git a/module/plugins/accounts/PorntrexCom.py b/module/plugins/accounts/PorntrexCom.py new file mode 100644 index 0000000000..272f50773e --- /dev/null +++ b/module/plugins/accounts/PorntrexCom.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +from ..internal.Account import Account +from ..internal.misc import parse_html_form + + +class PorntrexCom(Account): + # Actually not needed + __name__ = "PorntrexCom" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __description__ = """Porntrex.com account plugin""" + __license__ = "GPLv3" + __authors__ = [("ondrej", "git@ondrej.it")] + + def grab_info(self, user, password, data): + return { + "validuntil": -1, + "trafficleft": -1, + } + + def signin(self, user, password, data): + html = self.load("https://www.porntrex.com") + if ">Log out<" in html: + self.skip_login() + + url, inputs = parse_html_form('action="https://www.porntrex.com/ajax-login/"', html) + if inputs is None: + self.fail_login("Login form not found") + + inputs["username"] = user + inputs["pass"] = password + + html = self.load(url, post=inputs) + if ">Log out<" not in html: + self.fail_login() diff --git a/module/plugins/accounts/PremiumTo.py b/module/plugins/accounts/PremiumTo.py index 224cbd4be5..dfad2ce67c 100644 --- a/module/plugins/accounts/PremiumTo.py +++ b/module/plugins/accounts/PremiumTo.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- from ..internal.MultiAccount import MultiAccount +from ..internal.misc import json class PremiumTo(MultiAccount): __name__ = "PremiumTo" __type__ = "account" - __version__ = "0.19" + __version__ = "0.21" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -15,38 +16,58 @@ class PremiumTo(MultiAccount): __description__ = """Premium.to account plugin""" __license__ = "GPLv3" - __authors__ = [("RaNaN", "RaNaN@pyload.org"), + __authors__ = [("RaNaN", "RaNaN@pyload.net"), ("zoidberg", "zoidberg@mujmail.cz"), ("stickell", "l.stickell@yahoo.it"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - LOGIN_FAILED_PATTERN = r'wrong username' - API_URL = "http://api.premium.to/api/" + # See https://premium.to/API.html + API_URL = "http://api.premium.to/api/2/" - def api_response(self, method, user, password): - return self.load(self.API_URL + method + ".php", - get={'username': user, - 'password': password}) + def api_response(self, method, **kwargs): + return json.loads(self.load(self.API_URL + method + ".php", + get=kwargs)) def grab_hosters(self, user, password, data): - html = self.api_response("hosters", user, password) - return [x.strip() for x in html.replace("\"", "").split(";") if x] \ - if self.req.code == 200 else [] + json_data = self.api_response("hosts", userid=user, apikey=password) + return json_data['hosts'] if json_data.get('code') == 200 else [] def grab_info(self, user, password, data): - traffic = self.api_response("straffic", user, password) + json_data = self.api_response("traffic", userid=user, apikey=password) - if self.req.code == 200: - # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft = sum(map(float, traffic.split(';'))) / 1024 - return {'premium': True, 'trafficleft': trafficleft, 'validuntil': -1} + if json_data.get('code') == 200: + trafficleft = float(json_data['traffic'] + json_data['specialtraffic']) + return {'premium': True, + 'trafficleft': trafficleft, + 'validuntil': -1} else: - return {'premium': False, 'trafficleft': None, 'validuntil': None} + return {'premium': False, + 'trafficleft': None, + 'validuntil': None} def signin(self, user, password, data): - authcode = self.api_response("getauthcode", user, password) + json_data = self.api_response("traffic", userid=user, apikey=password) + + if json_data['code'] != 200: + self.log_warning(_("Username and password for PremiumTo should be the API userid & apikey"), + _("Trying via username and password")) + + json_data = self.api_response("getapicredentials", username=user, password=password) + if not json_data.get('userid') or not json_data.get('userid'): + self.log_warning(json_data['message']) + self.fail_login() + + else: + # Replace current user & password with the generated userid & apikey + # Hacky hack, but what can we do?! + userid = json_data['userid'] + apikey = json_data['apikey'] + plugin = self.classname + self.pyload.accountManager.accounts[plugin][userid] = self.pyload.accountManager.accounts[plugin].pop(user) + self.pyload.accountManager.accounts[plugin][userid]['password'] = apikey + self.user = userid + self.info['data']['login'] = userid + self.info['login']['password'] = apikey - if self.req.code != 200: - self.fail_login() diff --git a/module/plugins/accounts/PremiumizeMe.py b/module/plugins/accounts/PremiumizeMe.py index 5c89ea69b0..8fdf55df31 100644 --- a/module/plugins/accounts/PremiumizeMe.py +++ b/module/plugins/accounts/PremiumizeMe.py @@ -7,7 +7,7 @@ class PremiumizeMe(MultiAccount): __name__ = "PremiumizeMe" __type__ = "account" - __version__ = "0.30" + __version__ = "0.32" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -19,50 +19,45 @@ class PremiumizeMe(MultiAccount): __authors__ = [("Florian Franzen", "FlorianFranzen@gmail.com"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - # See https://www.premiumize.me/static/api/api.html - API_URL = "https://api.premiumize.me/pm-api/v1.php" + # See https://www.premiumize.me/api + API_URL = "https://www.premiumize.me/api/" - def api_respond(self, method, user, password, **kwargs): - get_params = {'method': method, - 'params[login]': user, - 'params[pass]': password} - for key, val in kwargs.items(): - get_params["params[%s]" % key] = val - - json_data = self.load(self.API_URL, get=get_params) + def api_respond(self, method, **kwargs): + json_data = self.load(self.API_URL + method, get=kwargs) return json.loads(json_data) def grab_hosters(self, user, password, data): - res = self.api_respond("hosterlist", user, password) - - if res['status'] != 200: - return [] - - return res['result']['tldlist'] + res = self.api_respond("services/list", apikey=password) + return res['directdl'] + reduce(lambda x, y: x + y, [res['aliases'][_h] if _h in res['aliases'] else [] + for _h in res['directdl']]) def grab_info(self, user, password, data): validuntil = None trafficleft = None premium = False - res = self.api_respond("accountstatus", user, password) + res = self.api_respond("account/info", apikey=password) - if res['status'] == 200: - validuntil = float(res['result']['expires']) + if res['status'] == "success": + premium = res['premium_until'] is not False - # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft = max(0, res['result']['trafficleft_bytes'] / 1024) + if premium: + validuntil = res['premium_until'] - if res['result']['type'] != 'free': - premium = True + trafficleft = -1 return {'validuntil': validuntil, 'trafficleft': trafficleft, 'premium': premium} def signin(self, user, password, data): - res = self.api_respond("accountstatus", user, password) + res = self.api_respond("account/info", apikey=password) + + if res['status'] != "success": + self.log_error(_("Password for premiumize.me should be the API token - get it from: https://www.premiumize.me/account")) + self.fail_login(res['message']) - if res['status'] != 200: - self.fail_login(res['statusmessage']) + elif res['customer_id'] != user: + self.log_error(_("username for premiumize.me should be the Customer ID - get it from: https://www.premiumize.me/account")) + self.fail_login() diff --git a/module/plugins/accounts/RapidgatorNet.py b/module/plugins/accounts/RapidgatorNet.py index 011faab60a..bce580860e 100644 --- a/module/plugins/accounts/RapidgatorNet.py +++ b/module/plugins/accounts/RapidgatorNet.py @@ -9,7 +9,7 @@ class RapidgatorNet(Account): __name__ = "RapidgatorNet" __type__ = "account" - __version__ = "0.24" + __version__ = "0.25" __status__ = "testing" __description__ = """Rapidgator.net account plugin""" @@ -36,8 +36,7 @@ def grab_info(self, user, password, data): if json_data['response_status'] == 200: validuntil = json_data['response']['expire_date'] - # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft = float(json_data['response']['traffic_left']) / 1024 + trafficleft = float(json_data['response']['traffic_left']) premium = True else: diff --git a/module/plugins/accounts/RapiduNet.py b/module/plugins/accounts/RapiduNet.py index d09236722a..66b798226e 100644 --- a/module/plugins/accounts/RapiduNet.py +++ b/module/plugins/accounts/RapiduNet.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -import re import time from ..internal.Account import Account @@ -10,56 +9,41 @@ class RapiduNet(Account): __name__ = "RapiduNet" __type__ = "account" - __version__ = "0.12" + __version__ = "0.13" __status__ = "testing" __description__ = """Rapidu.net account plugin""" __license__ = "GPLv3" __authors__ = [("prOq", None), - ("Walter Purcaro", "vuolter@gmail.com")] + ("Walter Purcaro", "vuolter@gmail.com"), + ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - PREMIUM_PATTERN = r'>Account: Premium' + # https://rapidu.net/documentation/api/ + API_URL = 'https://rapidu.net/api/' - VALID_UNTIL_PATTERN = r'>Account: \w+ \((\d+)' + def api_request(self, method, **kwargs): + json_data = self.load(self.API_URL + method + "/", post=kwargs) + return json.loads(json_data) - TRAFFIC_LEFT_PATTERN = r'class="tipsyS">([\d.,]+)\s*([\w^_]*)<' def grab_info(self, user, password, data): validuntil = None - trafficleft = -1 - premium = False - html = self.load("https://rapidu.net/") + api_data = self.api_request("getAccountDetails", login=user, password=password) - if re.search(self.PREMIUM_PATTERN, html): - premium = True + premium = True if api_data['userPremium'] == "1" else False + if premium: + validuntil = time.mktime(time.strptime(api_data['userPremiumDateEnd'], '%Y-%m-%d %H:%M:%S')) - m = re.search(self.VALID_UNTIL_PATTERN, html) - if m is not None: - validuntil = time.time() + (86400 * int(m.group(1))) - - m = re.search(self.TRAFFIC_LEFT_PATTERN, html) - if m is not None: - trafficleft = self.parse_traffic(m.group(1), m.group(2)) + trafficleft = api_data['userTraffic'] return {'validuntil': validuntil, - 'trafficleft': trafficleft, 'premium': premium} + 'trafficleft': trafficleft, + 'premium': premium} def signin(self, user, password, data): - self.load("https://rapidu.net/ajax.php", - get={'a': "getChangeLang"}, - post={'_go': "", - 'lang': "en"}) - - html = self.load("https://rapidu.net/ajax.php", - get={'a': "getUserLogin"}, - post={'_go': "", - 'login': user, - 'pass': password, - 'remember': "1"}) - json_data = json.loads(html) - - self.log_debug(json_data) + api_data = self.api_request("getAccountDetails", login=user, password=password) - if json_data['message'] != "success": + if "message" in api_data: + self.log_error(api_data['message']['error']) self.fail_login() diff --git a/module/plugins/accounts/RealdebridCom.py b/module/plugins/accounts/RealdebridCom.py index 114b2207bd..bbc9ab87f0 100644 --- a/module/plugins/accounts/RealdebridCom.py +++ b/module/plugins/accounts/RealdebridCom.py @@ -2,6 +2,7 @@ import time +import pycurl from module.network.HTTPRequest import BadHeader from ..internal.misc import json @@ -15,7 +16,7 @@ def args(**kwargs): class RealdebridCom(MultiAccount): __name__ = "RealdebridCom" __type__ = "account" - __version__ = "0.58" + __version__ = "0.62" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -27,37 +28,82 @@ class RealdebridCom(MultiAccount): __authors__ = [("Devirex Hazzard", "naibaf_11@yahoo.de"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - API_URL = "https://api.real-debrid.com/rest/1.0" + TUNE_TIMEOUT = False - def api_response(self, namespace, get={}, post={}): - json_data = self.load(self.API_URL + namespace, get=get, post=post) + # See https://api.real-debrid.com/ + API_URL = "https://api.real-debrid.com" + + def api_response(self, api_type, method, get={}, post={}): + if api_type == "rest": + endpoint = "/rest/1.0" + elif api_type == "oauth": + endpoint = "/oauth/v2" + else: + raise ValueError("Illegal API call type") + + self.req.http.c.setopt(pycurl.USERAGENT, "pyLoad/%s" % self.pyload.version) + + try: + json_data = self.load(self.API_URL + endpoint + method, get=get, post=post) + + except BadHeader, e: + json_data = e.content return json.loads(json_data) + + def _refresh_token(self, client_id, client_secret, refresh_token): + res = self.api_response("oauth", "/token", + post=args(client_id=client_id, + client_secret=client_secret, + code=refresh_token, + grant_type="http://oauth.net/grant_type/device/1.0")) + + if 'error' in res: + self.log_error(_("You have to use GetRealdebridToken.py to authorize pyLoad: https://github.com/pyload/pyload/files/4406037/GetRealdebridToken.zip")) + self.fail_login() + + return res['access_token'], res['expires_in'] + + def grab_hosters(self, user, password, data): - hosters = self.api_response("/hosts/domains") + api_data = self.api_response("rest", "/hosts/status", args(auth_token=data['api_token'])) + hosters = [x[0] for x in api_data.items() if x[1]['supported'] == 1] return hosters def grab_info(self, user, password, data): - account = self.api_response("/user", args(auth_token=password)) + api_data = self.api_response("rest", "/user", args(auth_token=data['api_token'])) - validuntil = time.time() + account["premium"] + premium_remain = api_data["premium"] + premium = premium_remain > 0 + validuntil = time.time() + premium_remain if premium else -1 return {'validuntil': validuntil, 'trafficleft': -1, - 'premium': True} + 'premium': premium} def signin(self, user, password, data): - try: - account = self.api_response("/user", args(auth_token=password)) + user = user.split('/') + if len(user) != 2: + self.log_error(_("You have to use GetRealdebridToken.py to authorize pyLoad: https://github.com/pyload/pyload/files/4406037/GetRealdebridToken.zip")) + self.fail_login() - except BadHeader, e: - if e.code == 401: - self.log_error(_("Password for Real-debrid should be the API token - get it from: https://real-debrid.com/apitoken")) - self.fail_login() + client_id, client_secret = user + + if 'api_token' not in data: + api_token, timeout = self._refresh_token(client_id, client_secret, password) + data['api_token'] = api_token + self.timeout = timeout - 5 * 60 #: Five minutes less to be on the safe side + + api_token = data['api_token'] + + account = self.api_response("rest", "/user", args(auth_token=api_token)) - else: - raise + if account.get('error_code') == 8: #: Token expired? try to refresh + api_token, timeout = self._refresh_token(client_id, client_secret, password) + data['api_token'] = api_token + self.timeout = timeout - 5 * 60 #: Five minutes less to be on the safe side - if user != account["username"]: + elif 'error' in account: + self.log_error(account['error']) self.fail_login() diff --git a/module/plugins/accounts/RehostTo.py b/module/plugins/accounts/RehostTo.py index 59a86029d3..52b60780b7 100644 --- a/module/plugins/accounts/RehostTo.py +++ b/module/plugins/accounts/RehostTo.py @@ -15,7 +15,7 @@ class RehostTo(MultiAccount): __description__ = """Rehost.to account plugin""" __license__ = "GPLv3" - __authors__ = [("RaNaN", "RaNaN@pyload.org")] + __authors__ = [("RaNaN", "RaNaN@pyload.net")] def grab_hosters(self, user, password, data): html = self.load("http://rehost.to/api.php", diff --git a/module/plugins/accounts/ShareonlineBiz.py b/module/plugins/accounts/ShareonlineBiz.py deleted file mode 100644 index 52da018c27..0000000000 --- a/module/plugins/accounts/ShareonlineBiz.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8 -*- - - -from ..internal.Account import Account -from ..internal.misc import set_cookie - - -class ShareonlineBiz(Account): - __name__ = "ShareonlineBiz" - __type__ = "account" - __version__ = "0.46" - __status__ = "testing" - - __description__ = """Share-online.biz account plugin""" - __license__ = "GPLv3" - __authors__ = [("Walter Purcaro", "vuolter@gmail.com")] - - def api_response(self, user, password): - res = self.load("https://api.share-online.biz/cgi-bin", - get={'q': "userdetails", - 'aux': "traffic", - 'username': user, - 'password': password}, - decode=False) - - api = dict(line.split("=") for line in res.splitlines() if "=" in line) - - if not 'a' in api: - self.fail_login(res.strip('*')) - - return api - - def grab_info(self, user, password, data): - premium = False - validuntil = None - trafficleft = -1 - maxtraffic = 100 * 1024 * 1024 * 1024 #: 100 GB - - api_info = self.api_response(user, password) - - premium = api_info['group'] in ( - "PrePaid", - "Premium", - "Penalty-Premium", - "VIP", - "VIP-Special") - validuntil = float(api_info['expire_date']) - traffic = float(api_info['traffic_1d'].split(";")[0]) - - if maxtraffic > traffic: - trafficleft = maxtraffic - traffic - else: - trafficleft = -1 - - maxtraffic /= 1024 # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft /= 1024 # @TODO: Remove `/ 1024` in 0.4.10 - - return {'premium': premium, - 'validuntil': validuntil, - 'trafficleft': trafficleft, - 'maxtraffic': maxtraffic} - - def signin(self, user, password, data): - api_info = self.api_response(user, password) - set_cookie(self.req.cj, "share-online.biz", 'a', api_info['a']) diff --git a/module/plugins/accounts/SimplyPremiumCom.py b/module/plugins/accounts/SimplyPremiumCom.py index bfac50ba54..203e56e299 100644 --- a/module/plugins/accounts/SimplyPremiumCom.py +++ b/module/plugins/accounts/SimplyPremiumCom.py @@ -7,7 +7,7 @@ class SimplyPremiumCom(MultiAccount): __name__ = "SimplyPremiumCom" __type__ = "account" - __version__ = "0.14" + __version__ = "0.15" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -19,11 +19,9 @@ class SimplyPremiumCom(MultiAccount): __authors__ = [("EvolutionClip", "evolutionclip@live.de")] def grab_hosters(self, user, password, data): - json_data = self.load( - "http://www.simply-premium.com/api/hosts.php", - get={ - 'format': "json", - 'online': 1}) + json_data = self.load("http://www.simply-premium.com/api/hosts.php", + get={'format': "json", + 'online': 1}) json_data = json.loads(json_data) host_list = [element['regex'] for element in json_data['result']] @@ -35,8 +33,7 @@ def grab_info(self, user, password, data): validuntil = -1 trafficleft = None - json_data = self.load( - 'http://www.simply-premium.com/api/user.php?format=json') + json_data = self.load('http://www.simply-premium.com/api/user.php?format=json') self.log_debug("JSON data: %s" % json_data) @@ -48,10 +45,8 @@ def grab_info(self, user, password, data): if 'timeend' in json_data['result'] and json_data['result']['timeend']: validuntil = float(json_data['result']['timeend']) - if 'remain_traffic' in json_data[ - 'result'] and json_data['result']['remain_traffic']: - # @TODO: Remove `/ 1024` in 0.4.10 - trafficleft = float(json_data['result']['remain_traffic']) / 1024 + if 'remain_traffic' in json_data['result'] and json_data['result']['remain_traffic']: + trafficleft = float(json_data['result']['remain_traffic']) return {'premium': premium, 'validuntil': validuntil, 'trafficleft': trafficleft} diff --git a/module/plugins/accounts/SmoozedCom.py b/module/plugins/accounts/SmoozedCom.py index 7c71be938a..045913c88e 100644 --- a/module/plugins/accounts/SmoozedCom.py +++ b/module/plugins/accounts/SmoozedCom.py @@ -21,14 +21,13 @@ def __init__(self, passphrase, salt, iterations=1000): self.iterations = iterations def hexread(self, octets): - return b2a_hex( - pbkdf2(self.passphrase, self.salt, self.iterations, octets)) + return b2a_hex(pbkdf2(self.passphrase, self.salt, self.iterations, octets)) class SmoozedCom(MultiAccount): __name__ = "SmoozedCom" __type__ = "account" - __version__ = "0.13" + __version__ = "0.14" __status__ = "testing" __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), @@ -54,13 +53,12 @@ def grab_info(self, user, password, data): else: #: Parse account info info = {'validuntil': float(status['data']['user']['user_premium']), - 'trafficleft': max(0, status['data']['traffic'][1] - status['data']['traffic'][0]), + 'trafficleft': max(0, status['data']['traffic'][1] - status['data']['traffic'][0]) * 1024, 'session': status['data']['session_key'], 'hosters': [hoster['name'] for hoster in status['data']['hoster']]} if info['validuntil'] < time.time(): - if float(status['data']['user'].get( - 'user_trial', 0)) > time.time(): + if float(status['data']['user'].get('user_trial', 0)) > time.time(): info['premium'] = True else: info['premium'] = False diff --git a/module/plugins/accounts/TbSevenPl.py b/module/plugins/accounts/TbSevenPl.py new file mode 100644 index 0000000000..8f01cdcb5f --- /dev/null +++ b/module/plugins/accounts/TbSevenPl.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +import re +import time + +from ..internal.MultiAccount import MultiAccount + + +class TbSevenPl(MultiAccount): + __name__ = "TbSevenPl" + __type__ = "account" + __version__ = "0.02" + __status__ = "testing" + + __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), + ("mh_list", "str", "Hoster list (comma separated)", ""), + ("mh_interval", "int", "Reload interval in hours", 12)] + + __description__ = "tb7.pl account plugin" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + VALID_UNTIL_PATTERN = ur'Dostęp Premium ważny do ([\d. /:]+?)<' + TRAFFIC_LEFT_PATTERN = ur'Pozostały Limit Premium do wykorzystania: (?P[\d.,]+) (?P[\w^_]+)' + + def grab_hosters(self, user, password, data): + hosts = self.load("https://tb7.pl/jdhostingi.txt") + return hosts.splitlines() + + def grab_info(self, user, password, data): + premium = True + validuntil = None + trafficleft = None + + html = self.load("https://tb7.pl/") + m = re.search(self.VALID_UNTIL_PATTERN, html) + if m is not None: + validuntil = time.mktime(time.strptime(m.group(1), "%d.%m.%Y / %H:%M")) + + else: + self.log_error("VALID_UNTIL_PATTERN not found") + + m = re.search(self.TRAFFIC_LEFT_PATTERN, html) + if m is not None: + trafficleft = self.parse_traffic(m.group('S'), m.group('U')) + + else: + self.log_error("TRAFFIC_LEFT_PATTERN not found") + + return {'validuntil': validuntil, + 'trafficleft': trafficleft, + 'premium': premium} + + def signin(self, user, password, data): + html = self.load("https://tb7.pl/") + if "Wyloguj" in html: + self.skip_login() + + html = self.load("https://tb7.pl/login", + post={'login': user, + 'password': password}) + if "Wyloguj" not in html: + self.fail_login() diff --git a/module/plugins/accounts/TenluaVn.py b/module/plugins/accounts/TenluaVn.py index 579d8160b8..2d0aef14da 100644 --- a/module/plugins/accounts/TenluaVn.py +++ b/module/plugins/accounts/TenluaVn.py @@ -3,7 +3,6 @@ import time from module.network.HTTPRequest import BadHeader -from module.network.RequestFactory import getURL as get_url from ..internal.Account import Account from ..internal.misc import json @@ -11,7 +10,7 @@ class TenluaVn(Account): __name__ = "TenluaVn" __type__ = "account" - __version__ = "0.01" + __version__ = "0.02" __status__ = "testing" __description__ = """TenluaVn account plugin""" @@ -20,13 +19,12 @@ class TenluaVn(Account): API_URL = "https://api2.tenlua.vn/" - @classmethod - def api_response(cls, method, **kwargs): + def api_response(self, method, **kwargs): kwargs['a'] = method sid = kwargs.pop('sid', None) - return json.loads(get_url(cls.API_URL, - get={'sid': sid} if sid is not None else {}, - post=json.dumps([kwargs]))) + return json.loads(self.load(self.API_URL, + get={'sid': sid} if sid is not None else {}, + post=json.dumps([kwargs]))) def grab_info(self, user, password, data): user_info = self.api_response("user_info", sid=data['sid'])[0] diff --git a/module/plugins/accounts/TorboxApp.py b/module/plugins/accounts/TorboxApp.py new file mode 100644 index 0000000000..f65521d5a5 --- /dev/null +++ b/module/plugins/accounts/TorboxApp.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + + +import time + +import pycurl + +from module.network.HTTPRequest import BadHeader + +from ..internal.misc import json, reduce +from ..internal.MultiAccount import MultiAccount + + +class TorboxApp(MultiAccount): + __name__ = "TorboxApp" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __config__ = [("mh_mode", "all;listed;unlisted", "Filter hosters to use", "all"), + ("mh_list", "str", "Hoster list (comma separated)", ""), + ("mh_interval", "int", "Reload interval in hours", 12)] + + __description__ = """Torbox.app account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + # See https://api-docs.torbox.app/ + API_URL = "https://api.torbox.app/v1/api/" + + def api_request(self, method, api_key=None, get={}, post={}): + if api_key is not None: + self.req.http.c.setopt( + pycurl.HTTPHEADER, ["Authorization: Bearer " + api_key] + ) + + try: + json_data = self.load(self.API_URL + method, get=get, post=post) + except BadHeader as exc: + json_data = exc.content + + api_data = json.loads(json_data) + return api_data + + def grab_hosters(self, user, password, data): + api_data = self.api_request("webdl/hosters", api_key=password) + if not api_data.get("success", False): + self.log_error(api_data["detail"]) + return [] + + else: + hosts = reduce( + lambda x, y: x + y, + [ + h["domains"] + for h in api_data["data"] + if h["status"] is True + ], + ) + + return hosts + + def grab_info(self, user, password, data): + validuntil = -1 + premium = False + + api_data = self.api_request("user/me", api_key=password) + if not api_data.get("success", False): + self.log_error(api_data["detail"]) + + else: + premium = api_data["data"]["plan"] > 0 + if premium: + validuntil = time.mktime( + time.strptime(api_data["data"]["premium_expires_at"], "%Y-%m-%dT%H:%M:%SZ") + ) - time.timezone + + return {"validuntil": validuntil, "trafficleft": -1, "premium": premium} + + def signin(self, user, password, data): + api_data = self.api_request("user/me", api_key=password) + if api_data.get("success", False): + if api_data["data"]["email"] != user: + self.log_error(_("Username for TorboxApp should be your torbox.app email")) + self.fail_login() + + else: + self.log_error(_("Password for TorboxApp should be your torbox.app API token")) + self.log_error(api_data["detail"]) + self.fail_login() diff --git a/module/plugins/accounts/TurbobitNet.py b/module/plugins/accounts/TurbobitNet.py index 9121bf308a..cb436dfd1b 100644 --- a/module/plugins/accounts/TurbobitNet.py +++ b/module/plugins/accounts/TurbobitNet.py @@ -10,7 +10,7 @@ class TurbobitNet(Account): __name__ = "TurbobitNet" __type__ = "account" - __version__ = "0.12" + __version__ = "0.14" __status__ = "testing" __description__ = """TurbobitNet account plugin""" @@ -50,6 +50,7 @@ def signin(self, user, password, data): inputs['user[login]'] = user inputs['user[pass]'] = password inputs['user[submit]'] = "Sign in" + inputs["user[memory]"] = "on" if inputs.get('user[captcha_type]'): self.fail_login(_("Logging in with captcha is not supported, please disable catcha in turbobit's account settings")) diff --git a/module/plugins/accounts/UlozTo.py b/module/plugins/accounts/UlozTo.py index 649deb6671..ba98bc0f50 100644 --- a/module/plugins/accounts/UlozTo.py +++ b/module/plugins/accounts/UlozTo.py @@ -1,18 +1,19 @@ # -*- coding: utf-8 -*- -import pycurl import re import time import urlparse +import pycurl + from ..internal.Account import Account -from ..internal.misc import json +from ..internal.misc import json, parse_html_form, timestamp class UlozTo(Account): __name__ = "UlozTo" __type__ = "account" - __version__ = "0.29" + __version__ = "0.36" __status__ = "testing" __description__ = """Uloz.to account plugin""" @@ -22,22 +23,10 @@ class UlozTo(Account): ("ondrej", "git@ondrej.it"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - INFO_PATTERN = r'title="credit in use"><\/span>\s*([\d.,]+) ([\w^_]+)\s*<\/td>\s*([\d.]+)<\/td>' + INFO_PATTERN = r'Closest expiration<\/span>\s*([\d.,]+)\s*([\w^_]+)<\/span>\s*on (\d{2}\.\d{2}.\d{4})<\/span>' def grab_info(self, user, password, data): - current_millis = int(time.time() * 1000) - self.req.http.c.setopt(pycurl.HTTPHEADER, ["X-Requested-With: XMLHttpRequest"]) - try: - html = json.loads(self.load("https://ulozto.net/statistiky", - get={'do': "overviewPaymentsView-ajaxLoad", - '_': current_millis} - ))['snippets']['snippet-overviewPaymentsView-'] - - except (ValueError, KeyError): - self.log_error(_("Unable to retrieve account information, unexpected response")) - return {'validuntil': None, - 'trafficleft': None, - 'premium': False} + html = self.load("https://ulozto.net/platby") if ">You don't have any credit at the moment.<" in html: #: Free account validuntil = -1 @@ -62,19 +51,18 @@ def grab_info(self, user, password, data): 'premium': premium} def signin(self, user, password, data): - login_page = self.load('https://ulozto.net/?do=web-login') - if ">Log out<" in login_page: + html = self.load('https://ulozto.net/login') + if 'Log out' in html: self.skip_login() - action = re.findall('

' in html: + html = self.load(urlparse.urljoin("https://ulozto.net/", url), + post=inputs) + if 'Log out' not in html: self.fail_login() diff --git a/module/plugins/accounts/UploadedTo.py b/module/plugins/accounts/UploadedTo.py deleted file mode 100644 index 7fb88e5acb..0000000000 --- a/module/plugins/accounts/UploadedTo.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -import time - -from ..internal.Account import Account - - -class UploadedTo(Account): - __name__ = "UploadedTo" - __type__ = "account" - __version__ = "0.46" - __status__ = "testing" - - __description__ = """Uploaded.to account plugin""" - __license__ = "GPLv3" - __authors__ = [("Walter Purcaro", "vuolter@gmail.com")] - - COOKIES = False - - PREMIUM_PATTERN = r'Premium' - VALID_UNTIL_PATTERN = r'Duration:\s*\s*(.+?)<' - TRAFFIC_LEFT_PATTERN = r'(?P[\d.,]+) (?P[\w^_]+)' - - def grab_info(self, user, password, data): - html = self.load("http://uploaded.net/me") - - premium = True if re.search(self.PREMIUM_PATTERN, html) else False - if premium: - validuntil = None - trafficleft = None - - m = re.search(self.VALID_UNTIL_PATTERN, html, re.M) - if m is not None: - expiredate = m.group(1).lower().strip() - - if expiredate == "unlimited": - validuntil = -1 - else: - m = re.findall( - r'(\d+) (week|day|hour|minute|second)', expiredate) - if m is not None: - validuntil = time.time() - for n, u in m: - validuntil += float(n) * { - 'week': 3600 * 168, - 'day': 3600 * 24, - 'hour': 3600, - 'minute': 60, - 'second': 1}[u] - - m = re.search(self.TRAFFIC_LEFT_PATTERN, html) - if m is not None: - traffic = m.groupdict() - size = traffic['S'].replace('.', '') - unit = traffic['U'].lower() - trafficleft = self.parse_traffic(size, unit) - - else: - validuntil = -1 - trafficleft = -1 - - return {'validuntil': validuntil, - 'trafficleft': trafficleft, - 'premium': premium} - - def signin(self, user, password, data): - try: - self.load("http://uploaded.net/me") - - html = self.load("http://uploaded.net/io/login", - post={'id': user, - 'pw': password}) - - m = re.search(r'"err":"(.+?)"', html) - if m is not None: - self.fail_login(m.group(1)) - - except Exception, e: - self.log_error(e.message, trace=True) - self.fail_login(e.message) diff --git a/module/plugins/accounts/UploadgigCom.py b/module/plugins/accounts/UploadgigCom.py index 5c4678513e..17a934efa2 100644 --- a/module/plugins/accounts/UploadgigCom.py +++ b/module/plugins/accounts/UploadgigCom.py @@ -3,13 +3,17 @@ import re import time +from module.PyFile import PyFile + +from ..captcha.ReCaptcha import ReCaptcha from ..internal.Account import Account +from ..internal.misc import json, parse_html_form class UploadgigCom(Account): __name__ = "UploadgigCom" __type__ = "account" - __version__ = "0.03" + __version__ = "0.05" __status__ = "testing" __description__ = """UploadgigCom account plugin""" @@ -52,17 +56,35 @@ def signin(self, user, password, data): if self.LOGIN_SKIP_PATTERN in html: self.skip_login() - m = re.search(r'name="csrf_tester" value="(\w+?)"', html) - if m is None: - self.fail_login() + url, inputs = parse_html_form('id="login_form"', html) + if inputs is None: + self.fail_login("Login form not found") + + inputs['email'] = user + inputs['pass'] = password + + if '
' in html: + # dummy pyfile + pyfile = PyFile(self.pyload.files, -1, "https://uploadgig.com", "https://uploadgig.com", 0, 0, "", self.classname, -1, -1) + pyfile.plugin = self + recaptcha = ReCaptcha(pyfile) + captcha_key = recaptcha.detect_key(html) - html = self.load("https://uploadgig.com/login/do_login", - post={'email': user, - 'pass': password, - 'csrf_tester': m.group(1), - 'rememberme': 1}) + if captcha_key: + self.captcha = recaptcha + response = recaptcha.challenge(captcha_key, html) + inputs['g-recaptcha-response'] = response - if not '"state":"1"' in html: + else: + self.log_error(_("ReCaptcha key not found")) + self.fail_login(_("ReCaptcha key not found")) + + html = self.load(url, post=inputs) + + json_data = json.loads(html) + + if json_data.get('state') != "1": + self.log_error(json_data['msg']) self.fail_login() @property @@ -84,3 +106,13 @@ def logged(self): else: return True + """ + @NOTE: below are methods + necessary for captcha to work with account plugins + """ + def check_status(self): + pass + + def retry_captcha(self, attemps=10, wait=1, msg=_("Max captcha retries reached")): + self.captcha.invalid() + self.fail_login(msg=_("Invalid captcha")) diff --git a/module/plugins/accounts/UploadheroCom.py b/module/plugins/accounts/UploadheroCom.py deleted file mode 100644 index d04e14f8eb..0000000000 --- a/module/plugins/accounts/UploadheroCom.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: utf-8 -*- - -import datetime -import re -import time - -from ..internal.Account import Account - - -class UploadheroCom(Account): - __name__ = "UploadheroCom" - __type__ = "account" - __version__ = "0.29" - __status__ = "testing" - - __description__ = """Uploadhero.co account plugin""" - __license__ = "GPLv3" - __authors__ = [("mcmyst", "mcmyst@hotmail.fr")] - - def grab_info(self, user, password, data): - _re_premium = re.compile( - 'Il vous reste (\d+) jours premium') - - html = self.load("http://uploadhero.co/my-account") - - if _re_premium.search(html): - end_date = datetime.date.today() + \ - datetime.timedelta( - days=int( - _re_premium.search(html).group(1))) - end_date = time.mktime(end_date.timetuple()) - account_info = { - 'validuntil': end_date, - 'trafficleft': -1, - 'premium': True} - else: - account_info = { - 'validuntil': -1, - 'trafficleft': -1, - 'premium': False} - - return account_info - - def signin(self, user, password, data): - html = self.load("http://uploadhero.co/lib/connexion.php", - post={'pseudo_login': user, - 'password_login': password}) - - if "mot de passe invalide" in html: - self.fail_login() diff --git a/module/plugins/accounts/UpstoreNet.py b/module/plugins/accounts/UpstoreNet.py new file mode 100644 index 0000000000..66111e3b0d --- /dev/null +++ b/module/plugins/accounts/UpstoreNet.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +import re +import time + +from ..internal.Account import Account + + +class UpstoreNet(Account): + __name__ = "UpstoreNet" + __type__ = "account" + __version__ = "0.01" + __status__ = "testing" + + __description__ = """upstore.net account plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + + def grab_info(self, user, password, data): + validuntil = None + trafficleft = None + premium = True + + html = self.load("https://upstore.net/stat/download", + get={'lang': "en"}) + + m = re.search(r"Downloaded in last 24 hours: ([\d.,]+) of ([\d.,]+) GB", html) + if m is not None: + trafficleft = self.parse_traffic(m.group(2), "GB") - self.parse_traffic(m.group(1), "GB") + + if "eternal premium" in html: + validuntil = -1 + + else: + m = re.search(r'premium till\s*(\d{1,2}/\d{1,2}/\d{2})', html) + if m is not None: + validuntil = time.mktime(time.strptime(m.group(1) + " 23:59:59", '%m/%d/%y %H:%M:%S')) + + else: + m = re.search(r'premium till\s*([a-zA-Z.]+\s*\d{1,2}\s*,\s*(\d{4}|\d{2}))', html) + if m is not None: + validuntil = time.mktime(time.strptime(m.group(1) + " 23:59:59", '%B %d , %y %H:%M:%S')) + + return {'validuntil': validuntil, + 'trafficleft': trafficleft, + 'premium': premium} + + def signin(self, user, password, data): + login_url = "https://upstore.net/account/login" + html = self.load(login_url) + if "/account/logout" in html: + self.skip_login() + + html = self.load(login_url, + post={'url': "https://upstore.net", + 'email': user, + 'password': password, + 'send': "Login"}) + + if "/account/logout" not in html: + self.fail_login() diff --git a/module/plugins/accounts/UptoboxCom.py b/module/plugins/accounts/UptoboxCom.py index 28583cc2e4..cb3bdb557a 100644 --- a/module/plugins/accounts/UptoboxCom.py +++ b/module/plugins/accounts/UptoboxCom.py @@ -1,17 +1,15 @@ # -*- coding: utf-8 -*- -import time import re -import urlparse +import time -from ..internal.misc import json -from ..internal.XFSAccount import XFSAccount +from ..internal.Account import Account -class UptoboxCom(XFSAccount): +class UptoboxCom(Account): __name__ = "UptoboxCom" __type__ = "account" - __version__ = "0.23" + __version__ = "0.29" __status__ = "testing" __description__ = """Uptobox.com account plugin""" @@ -19,25 +17,37 @@ class UptoboxCom(XFSAccount): __authors__ = [("benbox69", "dev@tollet.me"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - PLUGIN_DOMAIN = "uptobox.com" - PLUGIN_URL = "https://uptobox.com/" + LOGIN_URL = "https://uptobox.link/login" + LOGIN_SKIP_PATTERN = r"https://uptobox\.link/logout" PREMIUM_PATTERN = r'Premium member' - VALID_UNTIL_PATTERN = r"class='expiration-date .+?'>(\d{1,2} [\w^_]+ \d{4})" + VALID_UNTIL_PATTERN = r'data-tippy-content="Expires on ([\d\-: ]+)"' + + def grab_info(self, user, password, data): + html = self.load("https://uptobox.link/my_account") + + premium = re.search(self.PREMIUM_PATTERN, html) is not None + + m = re.search(self.VALID_UNTIL_PATTERN, html) + if m is not None: + validuntil = time.mktime(time.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")) + + else: + self.log_error(self._("VALID_UNTIL_PATTERN not found")) + validuntil = None + return {"validuntil": validuntil, "trafficleft": -1, "premium": premium} def signin(self, user, password, data): - html = self.load(self.LOGIN_URL, cookies=self.COOKIES) + html = self.load(self.LOGIN_URL) - if re.search(self.LOGIN_SKIP_PATTERN, html): + if re.search(self.LOGIN_SKIP_PATTERN, html) is not None: self.skip_login() - html = self.load(self.PLUGIN_URL, - get={'op': "login", - 'referer': "homepage"}, + html = self.load(self.LOGIN_URL, post={'login': user, 'password': password}, - cookies=self.COOKIES) + ref=self.LOGIN_URL) if re.search(self.LOGIN_SKIP_PATTERN, html) is None: self.fail_login() diff --git a/module/plugins/accounts/WebshareCz.py b/module/plugins/accounts/WebshareCz.py index f34d202c64..584b0c20ff 100644 --- a/module/plugins/accounts/WebshareCz.py +++ b/module/plugins/accounts/WebshareCz.py @@ -1,24 +1,85 @@ # -*- coding: utf-8 -*- import hashlib +import random import re +import string import time -try: - import passlib.hash -except ImportError: - passlib = None +from ..internal.Account import Account -from module.network.RequestFactory import getURL as get_url +def md5_crypt(password, salt=None): + MAGIC = "$1$" + BASE64_CHARS = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -from ..internal.Account import Account + if salt is None: + salt = "".join(random.choice(string.ascii_letters + string.digits + "./") for _ in range(8)) + else: + salt = salt[:8] + + salt = salt.encode("ascii") + + if isinstance(password, unicode): + password = password.encode("utf-8") + + ctx_a = hashlib.md5() + ctx_a.update(password + MAGIC + salt) + + ctx_b = hashlib.md5() + ctx_b.update(password + salt + password) + intermediate_hash = ctx_b.digest() + + for i in range(len(password) ,0, -16): + ctx_a.update(intermediate_hash[:16 if i > 16 else i]) + + i = len(password) + while i: + if i & 1: + ctx_a.update("\0") + else: + ctx_a.update(password[0:1]) + i >>= 1 + + final_hash = ctx_a.digest() + for i in range(1000): + ctx_c = hashlib.md5() + if i & 1: + ctx_c.update(password) + else: + ctx_c.update(final_hash) + + if i % 3: + ctx_c.update(salt) + + if i % 7: + ctx_c.update(password) + + if i & 1: + ctx_c.update(final_hash) + else: + ctx_c.update(password) + + final_hash = ctx_c.digest() + + encoded_hash = "" + for a, b, c in ((0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)): + t = (ord(final_hash[a]) << 16) | (ord(final_hash[b]) << 8) | ord(final_hash[c]) + for i in range(4): + encoded_hash += BASE64_CHARS[t & 0x3f] + t >>= 6 + t = ord(final_hash[11]) + for i in range(2): + encoded_hash += BASE64_CHARS[t & 0x3f] + t >>= 6 + + return "%s%s$%s" % (MAGIC, salt, encoded_hash) class WebshareCz(Account): __name__ = "WebshareCz" __type__ = "account" - __version__ = "0.16" + __version__ = "0.19" __status__ = "testing" __description__ = """Webshare.cz account plugin""" @@ -32,10 +93,9 @@ class WebshareCz(Account): API_URL = "https://webshare.cz/api/" - @classmethod - def api_response(cls, method, **kwargs): - return get_url(cls.API_URL + method + "/", - post=kwargs) + def api_response(self, method, **kwargs): + return self.load(self.API_URL + method + "/", + post=kwargs) def grab_info(self, user, password, data): user_data = self.api_response("user_data", wst=data['wst']) @@ -56,33 +116,25 @@ def grab_info(self, user, password, data): 'premium': premium} def signin(self, user, password, data): - if passlib: - salt = self.api_response("salt", username_or_email=user, wst="") + salt = self.api_response("salt", username_or_email=user) - if "OK" not in salt: - message = re.search(r'(.+?)', salt).group(1) - self.log_warning(message) - self.fail_login() + if "OK" not in salt: + message = re.search(r'(.+?)', salt).group(1) + self.log_warning(message) + self.fail_login() - salt = re.search('(.+?)', salt).group(1) + salt = re.search('(.*?)', salt).group(1) - password = hashlib.sha1(passlib.hash.md5_crypt.encrypt(password, salt=salt)).hexdigest() - digest = hashlib.md5(user + ":Webshare:" + password).hexdigest() + password = hashlib.sha1(md5_crypt(password, salt=salt)).hexdigest() - login = self.api_response("login", - digest=digest, - keep_logged_in=1, - username_or_email=user, - password=password, - wst="") + login = self.api_response("login", + keep_logged_in=1, + username_or_email=user, + password=password) - if "OK" not in login: - message = re.search(r'(.+?)', salt).group(1) - self.log_warning(message) - self.fail_login() + if "OK" not in login: + message = re.search(r'(.+?)', salt).group(1) + self.log_warning(message) + self.fail_login() - data['wst'] = re.search('(.+?)', login).group(1) - - else: - self.log_warning(_("passlib is not installed")) - self.fail_login(_("passlib is not installed")) + data['wst'] = re.search('(.+?)', login).group(1) diff --git a/module/plugins/accounts/WorldbytezCom.py b/module/plugins/accounts/WorldbytezCom.py index c2362ff194..08eff2468e 100644 --- a/module/plugins/accounts/WorldbytezCom.py +++ b/module/plugins/accounts/WorldbytezCom.py @@ -1,16 +1,24 @@ # -*- coding: utf-8 -*- +import re + from ..internal.XFSAccount import XFSAccount class WorldbytezCom(XFSAccount): __name__ = "WorldbytezCom" __type__ = "account" - __version__ = "0.06" + __version__ = "0.08" __status__ = "testing" __description__ = """Worldbytez.com account plugin""" __license__ = "GPLv3" - __authors__ = [("Walter Purcaro", "vuolter@gmail.com")] + __authors__ = [("Walter Purcaro", "vuolter@gmail.com"), + ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] PLUGIN_DOMAIN = "worldbytez.com" + PLUGIN_URL = "https://worldbytez.com/" + + PREMIUM_PATTERN = r">Premium<" + VALID_UNTIL_PATTERN = r"- Expire (.+)" + TRAFFIC_LEFT_PATTERN = r"Daily Traffic.*

\s*(?P.+?)<", re.S diff --git a/module/plugins/captcha/AdYouLike.py b/module/plugins/captcha/AdYouLike.py deleted file mode 100644 index 8203e9eb66..0000000000 --- a/module/plugins/captcha/AdYouLike.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- - -import re - -from ..internal.CaptchaService import CaptchaService -from ..internal.misc import json - - -class AdYouLike(CaptchaService): - __name__ = "AdYouLike" - __type__ = "captcha" - __version__ = "0.11" - __status__ = "testing" - - __description__ = """AdYouLike captcha service plugin""" - __license__ = "GPLv3" - __authors__ = [("Walter Purcaro", "vuolter@gmail.com")] - - AYL_PATTERN = r'Adyoulike\.create\s*\((.+?)\)' - CALLBACK_PATTERN = r'(Adyoulike\.g\._jsonp_\d+)' - - def detect_key(self, data=None): - html = data or self.retrieve_data() - - m = re.search(self.AYL_PATTERN, html) - n = re.search(self.CALLBACK_PATTERN, html) - if m and n: - self.key = (m.group(1).strip(), n.group(1).strip()) - self.log_debug("Ayl: %s | Callback: %s" % self.key) - return self.key #: Key is the tuple(ayl, callback) - else: - self.log_debug("Ayl or callback pattern not found") - return None - - def challenge(self, key=None, data=None): - ayl, callback = key or self.retrieve_key(data) - - #: {'adyoulike':{'key':"P~zQ~O0zV0WTiAzC-iw0navWQpCLoYEP"}, - #: 'all':{'element_id':"ayl_private_cap_92300",'lang':"fr",'env':"prod"}} - ayl = json.loads(ayl) - - html = self.pyfile.plugin.load("http://api-ayl.appspot.com/challenge", - get={'key': ayl['adyoulike']['key'], - 'env': ayl['all']['env'], - 'callback': callback}) - try: - challenge = json.loads( - re.search( - callback + - r'\s*\((.+?)\)', - html).group(1)) - - except AttributeError: - self.fail(_("AdYouLike challenge pattern not found")) - - self.log_debug("Challenge: %s" % challenge) - - return self.result(ayl, challenge), challenge - - def result(self, server, challenge): - #: Adyoulike.g._jsonp_5579316662423138 - #: ({'translations':{'fr':{'instructions_visual':"Recopiez « Soonnight » ci-dessous :"}}, - #: 'site_under':true,'clickable':true,'pixels':{'VIDEO_050':[],'DISPLAY':[],'VIDEO_000':[],'VIDEO_100':[], - #: 'VIDEO_025':[],'VIDEO_075':[]},'medium_type':"image/adyoulike", - #: 'iframes':{'big':""},'shares':{},'id':256, - #: 'token':"e6QuI4aRSnbIZJg02IsV6cp4JQ9~MjA1",'formats':{'small':{'y':300,'x':0,'w':300,'h':60}, - #: 'big':{'y':0,'x':0,'w':300,'h':250},'hover':{'y':440,'x':0,'w':300,'h':60}}, - #: 'tid':"SqwuAdxT1EZoi4B5q0T63LN2AkiCJBg5"}) - - if isinstance(server, basestring): - server = json.loads(server) - - if isinstance(challenge, basestring): - challenge = json.loads(challenge) - - try: - instructions_visual = challenge['translations'][ - server['all']['lang']]['instructions_visual'] - response = re.search( - u'«(.+?)»', instructions_visual).group(1).strip() - - except AttributeError: - self.fail(_("AdYouLike result not found")) - - result = {'_ayl_captcha_engine': "adyoulike", - '_ayl_env': server['all']['env'], - '_ayl_tid': challenge['tid'], - '_ayl_token_challenge': challenge['token'], - '_ayl_response': response} - - return result diff --git a/module/plugins/captcha/AdsCaptcha.py b/module/plugins/captcha/AdsCaptcha.py deleted file mode 100644 index 1870ded2cd..0000000000 --- a/module/plugins/captcha/AdsCaptcha.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8 -*- - -import random -import re - -from ..internal.CaptchaService import CaptchaService - - -class AdsCaptcha(CaptchaService): - __name__ = "AdsCaptcha" - __type__ = "captcha" - __version__ = "0.14" - __status__ = "testing" - - __description__ = """AdsCaptcha captcha service plugin""" - __license__ = "GPLv3" - __authors__ = [("pyLoad Team", "admin@pyload.org")] - - CAPTCHAID_PATTERN = r'api\.adscaptcha\.com/Get\.aspx\?.*?CaptchaId=(\d+)' - PUBLICKEY_PATTERN = r'api\.adscaptcha\.com/Get\.aspx\?.*?PublicKey=([\w\-]+)' - - def detect_key(self, data=None): - html = data or self.retrieve_data() - - m = re.search(self.PUBLICKEY_PATTERN, html) - n = re.search(self.CAPTCHAID_PATTERN, html) - if m and n: - #: Key is the tuple(PublicKey, CaptchaId) - self.key = (m.group(1).strip(), n.group(1).strip()) - self.log_debug("Key: %s | ID: %s" % self.key) - return self.key - else: - self.log_debug("Key or id pattern not found") - return None - - def challenge(self, key=None, data=None): - PublicKey, CaptchaId = key or self.retrieve_key(data) - - html = self.pyfile.plugin.load("http://api.adscaptcha.com/Get.aspx", - get={'CaptchaId': CaptchaId, - 'PublicKey': PublicKey}) - try: - challenge = re.search("challenge: '(.+?)',", html).group(1) - server = re.search("server: '(.+?)',", html).group(1) - - except AttributeError: - self.fail(_("AdsCaptcha challenge pattern not found")) - - self.log_debug("Challenge: %s" % challenge) - - return self.result(server, challenge), challenge - - def result(self, server, challenge): - result = self.decrypt("%sChallenge.aspx" % server, - get={'cid': challenge, 'dummy': random.random()}, - cookies=True, - input_type="jpg") - return result diff --git a/module/plugins/captcha/GigasizeCom.py b/module/plugins/captcha/GigasizeCom.py deleted file mode 100644 index fd9e830326..0000000000 --- a/module/plugins/captcha/GigasizeCom.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.OCR import OCR - - -class GigasizeCom(OCR): - __name__ = "GigasizeCom" - __type__ = "ocr" - __version__ = "0.17" - __status__ = "testing" - - __description__ = """Gigasize.com ocr plugin""" - __license__ = "GPLv3" - __authors__ = [("pyLoad Team", "admin@pyload.org")] - - def recognize(self, image): - self.load_image(image) - self.threshold(2.8) - self.run_tesser(True, False, False, True) - return self.result_captcha diff --git a/module/plugins/captcha/HCaptcha.py b/module/plugins/captcha/HCaptcha.py new file mode 100644 index 0000000000..025fbeb4cb --- /dev/null +++ b/module/plugins/captcha/HCaptcha.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- + +from __future__ import with_statement + +import re +import urllib + +from ..internal.CaptchaService import CaptchaService + +class HCaptcha(CaptchaService): + __name__ = 'HCaptcha' + __type__ = 'captcha' + __version__ = '0.04' + __status__ = 'testing' + + __description__ = 'hCaptcha captcha service plugin' + __license__ = 'GPLv3' + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + KEY_PATTERN = r'(?:data-sitekey=["\']|["\']sitekey["\']\s*:\s*["\'])((?:[\w\-]|%[0-9a-fA-F]{2})+)' + KEY_FORMAT_PATTERN = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + + HCAPTCHA_INTERACTIVE_SIG = "602d66a1db89d74c9d9d69afee01960fd55e684a969e0788fd1f10cb45a04c6bdc2d3c50b0370d7" + \ + "578795f59239ee9764ef1c6f2c9f56ce6fc2e1d2358de8fd29f2c7138b1c68bb8aacda8e170b032" + \ + "e014c61beca34fc0cdd89ec39cca501e5aeb9c3ac938aeb09de3cc1d11673c812b2c9ea51acbbcf" + \ + "d443f97ae5b5d3b2b031367e19d3213aee12de6717eb0901529c3ebb3ac681fa183c6d14f664568" + \ + "3fc5a5b8655798faa80afcf1a3423451f076cba1d573ccf0b6ab0e7cf4fbf31ade86d7322e7a9f5" + \ + "5077cd4099bed8bc13908f4e0ca1d891b228cbdcf4eabeab14af0d8b45a0b9297ece4270d4cf347" + \ + "71f46d04eb7904d4d4fddccf7eb0dc9301c0cf" + + HCAPTCHA_INTERACTIVE_JS = """ + while(document.children[0].childElementCount > 0) { + document.children[0].removeChild(document.children[0].children[0]); + } + document.children[0].innerHTML = '

'; + + gpyload.data.sitekey = request.params.sitekey; + + gpyload.getFrameSize = function() { + var rectAnchor = {top: 0, right: 0, bottom: 0, left: 0}, + rectPopup = {top: 0, right: 0, bottom: 0, left: 0}, + rect; + var anchor = document.body.querySelector("iframe[src*='/hcaptcha.html#frame=checkbox']"); + if (anchor !== null && gpyload.isVisible(anchor)) { + rect = anchor.getBoundingClientRect(); + rectAnchor = {top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left}; + } + var popup = document.body.querySelector("iframe[src*='/hcaptcha.html'][src*='frame=challenge']"); + if (popup !== null && gpyload.isVisible(popup)) { + rect = popup.getBoundingClientRect(); + rectPopup = {top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left}; + } + var left = Math.round(Math.min(rectAnchor.left, rectAnchor.right, rectPopup.left, rectPopup.right)); + var right = Math.round(Math.max(rectAnchor.left, rectAnchor.right, rectPopup.left, rectPopup.right)); + var top = Math.round(Math.min(rectAnchor.top, rectAnchor.bottom, rectPopup.top, rectPopup.bottom)); + var bottom = Math.round(Math.max(rectAnchor.top, rectAnchor.bottom, rectPopup.top, rectPopup.bottom)); + return {top: top, left: left, bottom: bottom, right: right}; + }; + + // function that is called when the captcha finished loading and is ready to interact + window.pyloadCaptchaOnLoadCallback = function() { + var widgetID = hcaptcha.render ( + "captchadiv", + {size: "compact", + 'sitekey': gpyload.data.sitekey, + 'callback': function() { + var hcaptchaResponse = hcaptcha.getResponse(widgetID); // get captcha response + gpyload.submitResponse(hcaptchaResponse); + }} + ); + gpyload.activated(); + }; + + if(typeof hcaptcha !== 'undefined' && hcaptcha) { + window.pyloadCaptchaOnLoadCallback(); + } else { + var js_script = document.createElement('script'); + js_script.type = "text/javascript"; + js_script.src = "//hcaptcha.com/1/api.js?onload=pyloadCaptchaOnLoadCallback&render=explicit"; + js_script.async = true; + document.getElementsByTagName('head')[0].appendChild(js_script); + }""" + + def detect_key(self, data=None): + html = data or self.retrieve_data() + + m = re.search(self.KEY_PATTERN, html) + if m is not None: + key = urllib.unquote(m.group(1).strip()) + m = re.search(self.KEY_FORMAT_PATTERN, key) + if m is not None: + self.key = key + self.log_debug("Key: %s" % self.key) + return self.key + + else: + self.log_debug(key, "Wrong key format, this probably because is is not a hCaptcha key") + + self.log_warning(_("Key pattern not found")) + return None + + def challenge(self, key=None, data=None): + key = key or self.retrieve_key(data) + + return self._challenge_js(key) + + # solve interactive captcha (javascript required) + def _challenge_js(self, key): + self.log_debug("Challenge hCaptcha interactive") + + params = {'url': self.pyfile.url, + 'sitekey': key, + 'script': {'signature': self.HCAPTCHA_INTERACTIVE_SIG, + 'code': self.HCAPTCHA_INTERACTIVE_JS}} + + result = self.decrypt_interactive(params, timeout=300) + + return result + +if __name__ == "__main__": + # Sign with the command `python -m module.plugins.captcha.HCaptcha pyload.private.pem pem_passphrase` + import sys + from ..internal.misc import sign_string + + if len(sys.argv) > 2: + with open(sys.argv[1], 'r') as f: + pem_private = f.read() + + print sign_string(HCaptcha.HCAPTCHA_INTERACTIVE_JS, pem_private, pem_passphrase=sys.argv[2], sign_algo="SHA384") diff --git a/module/plugins/captcha/LinksaveIn.py b/module/plugins/captcha/LinksaveIn.py deleted file mode 100644 index 924c6635f1..0000000000 --- a/module/plugins/captcha/LinksaveIn.py +++ /dev/null @@ -1,155 +0,0 @@ -# -*- coding: utf-8 -*- - -try: - from PIL import Image - -except ImportError: - import Image - -import glob -import os - -from ..internal.OCR import OCR - - -class LinksaveIn(OCR): - __name__ = "LinksaveIn" - __type__ = "ocr" - __version__ = "0.19" - __status__ = "testing" - - __description__ = """Linksave.in ocr plugin""" - __license__ = "GPLv3" - __authors__ = [("pyLoad Team", "admin@pyload.org")] - - def init(self): - self.data_dir = os.path.dirname(os.path.abspath( - __file__)) + os.sep + "LinksaveIn" + os.sep - - def load_image(self, image): - im = Image.open(image) - frame_nr = 0 - - lut = im.resize((256, 1)) - lut.putdata(range(256)) - lut = list(lut.convert("RGB").getdata()) - - new = Image.new("RGB", im.size) - npix = new.load() - while True: - try: - im.seek(frame_nr) - except EOFError: - break - frame = im.copy() - pix = frame.load() - for x in range(frame.size[0]): - for y in range(frame.size[1]): - if lut[pix[x, y]] != (0, 0, 0): - npix[x, y] = lut[pix[x, y]] - frame_nr += 1 - new.save(self.data_dir + "unblacked.png") - self.img = new.copy() - self.pixels = self.img.load() - self.result_captcha = "" - - def get_bg(self): - stat = {} - cstat = {} - img = self.img.convert("P") - for bgpath in glob.glob(self.data_dir + "bg/*.gif"): - stat[bgpath] = 0 - bg = Image.open(bgpath) - - bglut = bg.resize((256, 1)) - bglut.putdata(range(256)) - bglut = list(bglut.convert("RGB").getdata()) - - lut = img.resize((256, 1)) - lut.putdata(range(256)) - lut = list(lut.convert("RGB").getdata()) - - bgpix = bg.load() - pix = img.load() - for x in range(bg.size[0]): - for y in range(bg.size[1]): - rgb_bg = bglut[bgpix[x, y]] - rgb_c = lut[pix[x, y]] - try: - cstat[rgb_c] += 1 - - except Exception: - cstat[rgb_c] = 1 - - if rgb_bg == rgb_c: - stat[bgpath] += 1 - max_p = 0 - bg = "" - for bgpath, value in stat.items(): - if max_p < value: - bg = bgpath - max_p = value - return bg - - def substract_bg(self, bgpath): - bg = Image.open(bgpath) - img = self.img.convert("P") - - bglut = bg.resize((256, 1)) - bglut.putdata(range(256)) - bglut = list(bglut.convert("RGB").getdata()) - - lut = img.resize((256, 1)) - lut.putdata(range(256)) - lut = list(lut.convert("RGB").getdata()) - - bgpix = bg.load() - pix = img.load() - orgpix = self.img.load() - for x in range(bg.size[0]): - for y in range(bg.size[1]): - rgb_bg = bglut[bgpix[x, y]] - rgb_c = lut[pix[x, y]] - if rgb_c == rgb_bg: - orgpix[x, y] = (255, 255, 255) - - def eval_black_white(self): - new = Image.new("RGB", (140, 75)) - pix = new.load() - orgpix = self.img.load() - thresh = 4 - for x in range(new.size[0]): - for y in range(new.size[1]): - rgb = orgpix[x, y] - r, g, b = rgb - pix[x, y] = (255, 255, 255) - if r > max(b, g) + thresh: - pix[x, y] = (0, 0, 0) - if g < min(r, b): - pix[x, y] = (0, 0, 0) - if g > max(r, b) + thresh: - pix[x, y] = (0, 0, 0) - if b > max(r, g) + thresh: - pix[x, y] = (0, 0, 0) - self.img = new - self.pixels = self.img.load() - - def recognize(self, image): - self.load_image(image) - bg = self.get_bg() - self.substract_bg(bg) - self.eval_black_white() - self.to_greyscale() - self.img.save(self.data_dir + "cleaned_pass1.png") - self.clean(4) - self.clean(4) - self.img.save(self.data_dir + "cleaned_pass2.png") - letters = self.split_captcha_letters() - final = "" - for n, letter in enumerate(letters): - self.img = letter - self.img.save(self.data_dir + "letter%d.png" % n) - self.run_tesser(True, True, False, False) - final += self.result_captcha - - return final diff --git a/module/plugins/captcha/NetloadIn.py b/module/plugins/captcha/NetloadIn.py deleted file mode 100644 index 025095fbbc..0000000000 --- a/module/plugins/captcha/NetloadIn.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.OCR import OCR - - -class NetloadIn(OCR): - __name__ = "NetloadIn" - __type__ = "ocr" - __version__ = "0.17" - __status__ = "testing" - - __description__ = """Netload.in ocr plugin""" - __license__ = "GPLv3" - __authors__ = [("pyLoad Team", "admin@pyload.org")] - - def recognize(self, image): - self.load_image(image) - self.to_greyscale() - self.clean(3) - self.clean(3) - self.run_tesser(True, True, False, False) - - self.result_captcha = self.result_captcha.replace( - " ", "")[:4] # cut to 4 numbers - - return self.result_captcha diff --git a/module/plugins/captcha/ReCaptcha.py b/module/plugins/captcha/ReCaptcha.py index c662e5f590..2fe263e85d 100644 --- a/module/plugins/captcha/ReCaptcha.py +++ b/module/plugins/captcha/ReCaptcha.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import with_statement + import os import re import urllib @@ -27,7 +29,7 @@ class ReCaptcha(CaptchaService): __name__ = 'ReCaptcha' __type__ = 'captcha' - __version__ = '0.37' + __version__ = '0.51' __status__ = 'testing' __description__ = 'ReCaptcha captcha service plugin' @@ -37,26 +39,132 @@ class ReCaptcha(CaptchaService): ("Arno-Nymous", None), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - KEY_V1_PATTERN = r'(?:recaptcha(?:/api|\.net)/(?:challenge|noscript)\?k=|Recaptcha\.create\s*\(\s*["\'])((?:[\w\-]|%[0-9a-fA-F]{2})+)' KEY_V2_PATTERN = r'(?:data-sitekey=["\']|["\']sitekey["\']\s*:\s*["\'])((?:[\w\-]|%[0-9a-fA-F]{2})+)' - + KEY_FORMAT_V2_PATTERN = r'^6L[\w-]{6}AAAAA[\w-]{27}$' + INVISIBLE_V2_PATTERN = r'data-size\s*=\s*(["\'])\s*invisible\s*\1' STOKEN_V2_PATTERN = r'data-stoken=["\']([\w\-]+)' + RECAPTCHA_INTERACTIVE_SIG = "2f33aef5ba02a663108edf83d0e31e991454e6da8ea0fdf8fbc1ba4a3bd70aa3998de5444097c5e9" + \ + "2972b6b3c3bdb318a21b6241e027508b98434deb7ff1b214b8bbb459e2f4dd0bd603efda09498023" + \ + "52eef4d280b3e196a602d4c7f05374bc9b71d9d9d232355610512b6299cfcf243b5b5cc5e632b019" + \ + "6bf62036316c0b335cc69e33d48b6d0d853b3917b681d8ff1a3e3d1b32c97627453345121689ec38" + \ + "8a89e877ec6ff9e2058f6ffa3c037f5fcb5b5a543a8d9fd2d89b9a400b9dc86eefbd441629ef6313" + \ + "a1921bf70555c30f378d165b1fb96d52a0810252365af344a965be9e9ab3ac98646e27228e6e4481" + \ + "8f076d48524a6a4fde517678c4f962ce" + + RECAPTCHA_INTERACTIVE_JS = """ + while(document.children[0].childElementCount > 0) { + document.children[0].removeChild(document.children[0].children[0]); + } + document.children[0].innerHTML = '
'; + + gpyload.data.sitekey = request.params.sitekey; + + gpyload.getFrameSize = function() { + var rectAnchor = {top: 0, right: 0, bottom: 0, left: 0}, + rectPopup = {top: 0, right: 0, bottom: 0, left: 0}, + rect; + var anchor = document.body.querySelector("iframe[src*='/anchor']"); + if (anchor !== null && gpyload.isVisible(anchor)) { + rect = anchor.getBoundingClientRect(); + rectAnchor = {top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left}; + } + var popup = document.body.querySelector("iframe[src*='/bframe']"); + if (popup !== null && gpyload.isVisible(popup)) { + popup.parentNode.style.width = ""; + popup.parentNode.style.height = ""; + rect = popup.getBoundingClientRect(); + rectPopup = {top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left}; + } + var left = Math.round(Math.min(rectAnchor.left, rectAnchor.right, rectPopup.left, rectPopup.right)); + var right = Math.round(Math.max(rectAnchor.left, rectAnchor.right, rectPopup.left, rectPopup.right)); + var top = Math.round(Math.min(rectAnchor.top, rectAnchor.bottom, rectPopup.top, rectPopup.bottom)); + var bottom = Math.round(Math.max(rectAnchor.top, rectAnchor.bottom, rectPopup.top, rectPopup.bottom)); + return {top: top, left: left, bottom: bottom, right: right}; + }; + + // function that is called when the captcha finished loading and is ready to interact + window.pyloadCaptchaOnLoadCallback = function() { + grecaptcha.render ( + "captchadiv", + {size: "compact", + 'sitekey': gpyload.data.sitekey, + 'callback': function() { + var recaptchaResponse = grecaptcha.getResponse(); // get captcha response + gpyload.submitResponse(recaptchaResponse); + }} + ); + gpyload.activated(); + }; + + delete window.grecaptcha; + if(typeof grecaptcha !== 'undefined' && grecaptcha) { + window.pyloadCaptchaOnLoadCallback(); + } else { + var js_script = document.createElement('script'); + js_script.type = "text/javascript"; + js_script.src = "//www.google.com/recaptcha/api.js?onload=pyloadCaptchaOnLoadCallback&render=explicit"; + js_script.async = true; + document.getElementsByTagName('head')[0].appendChild(js_script); + }""" + + RECAPTCHA_INVISIBLE_SIG = "6c6fb9970fdeac68b95809ce45a344f1225d2284e2c08507261f3506dbeebe9f0e1d9040df64191e" + \ + "938f578b52009c31d0da920e1a9616d73ff7b7eb1e964477fac169e412fd1e325992c1783c4664e8" + \ + "ba207986af12939fcc50bed642f8c26136cc8656a22be2fc51651437d1e0d356ae19a8c33d569f4d" + \ + "7d11d3d794a074caf06f58bd2e2e339d31968967ad78c955ea36c707a8524ba3933509525a22e3ba" + \ + "56ad3e71770700e6a1d18158db33f65f47d6558f16d2db5c75ab8b1be8595846a27f4aa514a98d7c" + \ + "b13d6a557a1273ca5a06b1251f7481d787e3eca77523a8733be179318f46baaa1d99112daaf08c4f" + \ + "86e067931f593c0f1941d9a8414fa1a4" + + RECAPTCHA_INVISIBLE_JS = """ + while(document.children[0].childElementCount > 0) { + document.children[0].removeChild(document.children[0].children[0]); + } + document.children[0].innerHTML = '
'; + + gpyload.data.sitekey = request.params.sitekey; + // function that is called when the captcha finished loading and is ready to interact + window.pyloadCaptchaOnLoadCallback = function() { + grecaptcha.render ( + "captchadiv", + {size: "invisible", + 'sitekey': gpyload.data.sitekey, + 'callback': function() { + var recaptchaResponse = grecaptcha.getResponse(); // get captcha response + gpyload.submitResponse(recaptchaResponse); + }} + ); + grecaptcha.execute(); + }; + + delete window.grecaptcha; + if(typeof grecaptcha !== 'undefined' && grecaptcha) { + window.pyloadCaptchaOnLoadCallback(); + } else { + var js_script = document.createElement('script'); + js_script.type = "text/javascript"; + js_script.src = "//www.google.com/recaptcha/api.js?onload=pyloadCaptchaOnLoadCallback&render=explicit"; + js_script.async = true; + document.getElementsByTagName('head')[0].appendChild(js_script); + }""" + def detect_key(self, data=None): html = data or self.retrieve_data() - m = re.search( - self.KEY_V2_PATTERN, - html) or re.search( - self.KEY_V1_PATTERN, - html) + m = re.search(self.KEY_V2_PATTERN, html) if m is not None: - self.key = urllib.unquote(m.group(1).strip()) - self.log_debug("Key: %s" % self.key) - return self.key - else: - self.log_warning(_("Key pattern not found")) - return None + key = urllib.unquote(m.group(1).strip()) + m = re.search(self.KEY_FORMAT_V2_PATTERN, key) + if m is not None: + self.key = key + self.log_debug("Key: %s" % self.key) + return self.key + + else: + self.log_debug(key, "Wrong key format, this probably because is is not a reCAPTCHA key") + + self.log_warning(_("Key pattern not found")) + return None def detect_secure_token(self, data=None): html = data or self.retrieve_data() @@ -73,93 +181,33 @@ def detect_secure_token(self, data=None): def detect_version(self, data=None): data = data or self.retrieve_data() - v1 = re.search(self.KEY_V1_PATTERN, data) is not None v2 = re.search(self.KEY_V2_PATTERN, data) is not None + invisible = re.search(self.INVISIBLE_V2_PATTERN, data) is not None - if v1 is True and v2 is False: - self.log_debug("Detected Recaptcha v1") - return 1 - - elif v1 is False and v2 is True: - self.log_debug("Detected Recaptcha v2") - return 2 + if v2 is True: + if invisible is True: + self.log_debug("Detected reCAPTCHA v2 invisible") + return "2invisible" + else: + self.log_debug("Detected reCAPTCHA v2") + return 2 else: - self.log_warning(_("Could not properly detect ReCaptcha version, defaulting to v1")) - return 1 + self.log_warning(_("Could not properly detect reCAPTCHA version, defaulting to v2")) + return 2 def challenge(self, key=None, data=None, version=None, secure_token=None): key = key or self.retrieve_key(data) - secure_token = secure_token or self.detect_secure_token( - data) if version == 2 else None - - if version in (1, 2): - return getattr(self, "_challenge_v%s" % version)(key, secure_token) + secure_token = secure_token or self.detect_secure_token(data) if secure_token is not False else None + if version in (2, '2js', '2invisible'): + return getattr(self, "_challenge_v%s" % version)(key, secure_token=secure_token) else: return self.challenge(key, data, version=self.detect_version(data=data), secure_token=secure_token) - #: Currently secure_token is supported in ReCaptcha v2 only - def _challenge_v1(self, key, secure_token): - html = self.pyfile.plugin.load("http://www.google.com/recaptcha/api/challenge", - get={'k': key}) - try: - challenge = re.search("challenge : '(.+?)',", html).group(1) - server = re.search("server : '(.+?)',", html).group(1) - - except (AttributeError, IndexError): - self.fail(_("ReCaptcha challenge pattern not found")) - - self.log_debug("Challenge: %s" % challenge) - - return self.result(server, challenge, key) - - def result(self, server, challenge, key): - self.pyfile.plugin.load( - "http://www.google.com/recaptcha/api/js/recaptcha.js") - html = self.pyfile.plugin.load("http://www.google.com/recaptcha/api/reload", - get={'c': challenge, - 'k': key, - 'reason': "i", - 'type': "image"}) - - try: - challenge = re.search('\(\'(.+?)\',', html).group(1) - - except (AttributeError, IndexError): - self.fail(_("ReCaptcha second challenge pattern not found")) - - self.log_debug("Second challenge: %s" % challenge) - result = self.decrypt(urlparse.urljoin(server, "image"), - get={'c': challenge}, - cookies=True, - input_type="jpg") - - return result, challenge - - def _collect_api_info(self): - html = self.pyfile.plugin.load( - "http://www.google.com/recaptcha/api.js") - a = re.search(r'po.src = \'(.*?)\';', html).group(1) - vers = a.split("/")[5] - - self.log_debug("API version: %s" % vers) - - language = a.split("__")[1].split(".")[0] - - self.log_debug("API language: %s" % language) - - html = self.pyfile.plugin.load("https://apis.google.com/js/api.js") - b = re.search(r'"h":"(.*?)","', html).group(1) - jsh = b.decode('unicode-escape') - - self.log_debug("API jsh-string: %s" % jsh) - - return vers, language, jsh - def _prepare_image(self, image, challenge_msg): if no_pil: self.log_error( @@ -212,15 +260,12 @@ def _prepare_image(self, image, challenge_msg): ) index_number = str(y * 3 + x + 1) - text_width, text_height = draw.textsize( - index_number, font=font) + text_width, text_height = draw.textsize(index_number, font=font) draw.text( ( - tile_index_pos[ - 'x'] + (tile_index_size['width'] / 2) - (text_width / 2), - tile_index_pos[ - 'y'] + (tile_index_size['height'] / 2) - (text_height / 2) + tile_index_pos['x'] + (tile_index_size['width'] / 2) - (text_width / 2), + tile_index_pos['y'] + (tile_index_size['height'] / 2) - (text_height / 2) ), index_number, '#000', @@ -258,19 +303,13 @@ def _prepare_image(self, image, challenge_msg): else: lines = message.split('\n') - text_area_height = len( - lines) * draw.textsize(dummy_text, font=font)[1] + text_area_height = len(lines) * draw.textsize(dummy_text, font=font)[1] margin = 5 text_area_height = text_area_height + margin * \ 2 # add some margin on top and bottom of text - img2 = Image.new( - 'RGB', - (img.size[0], - img.size[1] + - text_area_height), - 'white') + img2 = Image.new('RGB', (img.size[0], img.size[1] + text_area_height), 'white') img2.paste(img, (0, text_area_height)) draw = ImageDraw.Draw(img2) @@ -278,16 +317,10 @@ def _prepare_image(self, image, challenge_msg): draw.text((3, margin), message, fill='black', font=font) else: for i in range(len(lines)): - draw.text( - (3, - i * - draw.textsize( - dummy_text, - font=font)[1] + - margin), - lines[i], - fill='black', - font=font) + draw.text((3, i * draw.textsize(dummy_text, font=font)[1] + margin), + lines[i], + fill='black', + font=font) s.truncate(0) img2.save(s, format='JPEG') @@ -297,18 +330,21 @@ def _prepare_image(self, image, challenge_msg): return img def _challenge_v2(self, key, secure_token=None): - fallback_url = "http://www.google.com/recaptcha/api/fallback?k=" + key \ + fallback_url = "https://www.google.com/recaptcha/api/fallback?k=" + key \ + ("&stoken=" + secure_token if secure_token else "") html = self.pyfile.plugin.load(fallback_url, ref=self.pyfile.url) + if re.search(r'href="https://support.google.com/recaptcha.*"', html) is not None: + self.log_warning(_("reCAPTCHA noscript is blocked, trying reCAPTCHA interactive")) + return self._challenge_v2js(key, secure_token=secure_token) + for i in range(10): try: - challenge = re.search( - r'name="c"\s+value=\s*"([^"]+)', html).group(1) + challenge = re.search(r'name="c"\s+value=\s*"([^"]+)', html).group(1) except (AttributeError, IndexError): - self.fail(_("ReCaptcha challenge pattern not found")) + self.fail(_("reCAPTCHA challenge pattern not found")) try: challenge_msg = re.search(r'', @@ -320,15 +356,14 @@ def _challenge_v2(self, key, secure_token=None): html).group(1) except (AttributeError, IndexError): - self.fail(_("ReCaptcha challenge message not found")) + self.fail(_("reCAPTCHA challenge message not found")) challenge_msg = re.sub(r'<.*?>', "", challenge_msg) - image_url = urlparse.urljoin('http://www.google.com', + image_url = urlparse.urljoin('https://www.google.com', re.search(r'"(/recaptcha/api2/payload[^"]+)', html).group(1)) - img = self.pyfile.plugin.load( - image_url, ref=fallback_url, decode=False) + img = self.pyfile.plugin.load(image_url, ref=fallback_url, decode=False) img = self._prepare_image(img, challenge_msg) @@ -337,13 +372,10 @@ def _challenge_v2(self, key, secure_token=None): post_str = "c=" + urllib.quote_plus(challenge) +\ "".join("&response=%s" % str(int(k) - 1) for k in response if k.isdigit()) - html = self.pyfile.plugin.load( - fallback_url, post=post_str, ref=fallback_url) + html = self.pyfile.plugin.load(fallback_url, post=post_str, ref=fallback_url) try: - result = re.search( - r'"this\.select\(\)">(.*?)', - html).group(1) + result = re.search(r'
', html).group(1) self.correct() break @@ -351,6 +383,51 @@ def _challenge_v2(self, key, secure_token=None): self.invalid() else: - self.fail(_("Recaptcha max retries exceeded")) + self.fail(_("reCAPTCHA max retries exceeded")) + + return result + + # solve interactive captcha (javascript required), use when non-JS captcha fallback for v2 is not allowed + def _challenge_v2js(self, key, secure_token=None): + self.log_debug("Challenge reCAPTCHA v2 interactive") + + params = {'url': self.pyfile.url, + 'sitekey': key, + 'securetoken': secure_token, + 'script': {'signature': self.RECAPTCHA_INTERACTIVE_SIG, + 'code': self.RECAPTCHA_INTERACTIVE_JS}} + + result = self.decrypt_interactive(params, timeout=300) + + return result + + # solve invisible captcha (browser only, no user interaction) + def _challenge_v2invisible(self, key, secure_token=None): + self.log_debug("Challenge reCAPTCHA v2 invisible") + + params = { + "url": self.pyfile.url, + "sitekey": key, + "securetoken": secure_token, + "script": { + "signature": self.RECAPTCHA_INVISIBLE_SIG, + "code": self.RECAPTCHA_INVISIBLE_JS, + }, + } + + result = self.decrypt_invisible(params, timeout=300) + + return result + + +if __name__ == "__main__": + # Sign with the command: + # `python -m module.plugins.captcha.ReCaptcha RECAPTCHA_INTERACTIVE_JS pyload.private.pem pem_passphrase` + import sys + from ..internal.misc import sign_string + + if len(sys.argv) > 3: + with open(sys.argv[2], 'r') as f: + pem_private = f.read() - return result, challenge + print sign_string(getattr(ReCaptcha, sys.argv[1]), pem_private, pem_passphrase=sys.argv[3], sign_algo="SHA384") diff --git a/module/plugins/captcha/ShareonlineBiz.py b/module/plugins/captcha/ShareonlineBiz.py deleted file mode 100644 index 024b0ad371..0000000000 --- a/module/plugins/captcha/ShareonlineBiz.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.OCR import OCR - - -class ShareonlineBiz(OCR): - __name__ = "ShareonlineBiz" - __type__ = "ocr" - __version__ = "0.17" - __status__ = "testing" - - __description__ = """Shareonline.biz ocr plugin""" - __license__ = "GPLv3" - __authors__ = [("RaNaN", "RaNaN@pyload.org")] - - #: Tesseract at 60% - def recognize(self, image): - self.load_image(image) - self.to_greyscale() - self.img = self.img.resize((160, 50)) - self.pixels = self.img.load() - self.threshold(1.85) - # self.eval_black_white(240) - # self.derotate_by_average() - - letters = self.split_captcha_letters() - - final = "" - for letter in letters: - self.img = letter - self.run_tesser(True, True, False, False) - final += self.result_captcha - - return final diff --git a/module/plugins/captcha/SolveMedia.py b/module/plugins/captcha/SolveMedia.py index c458aa40ba..17262325c2 100644 --- a/module/plugins/captcha/SolveMedia.py +++ b/module/plugins/captcha/SolveMedia.py @@ -14,7 +14,7 @@ class SolveMedia(CaptchaService): __description__ = """SolveMedia captcha service plugin""" __license__ = "GPLv3" - __authors__ = [("pyLoad Team", "admin@pyload.org")] + __authors__ = [("pyLoad Team", "admin@pyload.net")] KEY_PATTERN = r'api(?:-secure)?\.solvemedia\.com/papi/challenge\.(?:no)?script\?k=(.+?)["\']' diff --git a/module/plugins/captcha/Turnstile.py b/module/plugins/captcha/Turnstile.py new file mode 100644 index 0000000000..17f9e5cf2b --- /dev/null +++ b/module/plugins/captcha/Turnstile.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- + +from __future__ import with_statement + +import re +import urllib + +from ..internal.CaptchaService import CaptchaService + +class Turnstile(CaptchaService): + __name__ = 'Turnstile' + __type__ = 'captcha' + __version__ = '0.02' + __status__ = 'testing' + + __description__ = 'Turnstile captcha service plugin' + __license__ = 'GPLv3' + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + KEY_PATTERN = r'(?:data-sitekey=["\']|["\']sitekey["\']\s*:\s*["\'])((?:[\w\-]|%[0-9a-fA-F]{2})+)' + KEY_FORMAT_PATTERN = r'^0x[0-9a-zA-Z\-_]{22}$' + + TURNSTILE_INTERACTIVE_SIG = "779b06997b45a7e8faa47641544530cace0fa1dd6455c4a079a7c0abd7dd981de159e5f8efe43ba" + \ + "234f49fc3f6c8f3404026c6bceda79a66cb07b75ac256404bc903e9d44574a861ba1153f79f31d4" + \ + "7af6c27c002d403760419e02917addd29573c7fb3f51996051f7378df2a373746ec6ddd7f704817" + \ + "35483ff38308fc036f211d11345d0fa560f04e9cb024d4ea76b0c569f7e3116cd0b3d52b64e8e3e" + \ + "2fe04454c799be5cd3d620d9f00489d43a22b6c621c6de39b20f3ed1f4b9f8bdbf43866d6e90568" + \ + "c2747ca006536e9f913f2c60d8e3a45fa2014f87ffe9202d94da70dbca7e83917ff58f77e11945c" + \ + "f7e63f73facb1de766ddcd811ceffc32829425" + + TURNSTILE_INTERACTIVE_JS = """ + while(document.children[0].childElementCount > 0) { + document.children[0].removeChild(document.children[0].children[0]); + } + document.children[0].innerHTML = '
'; + + gpyload.data.sitekey = request.params.sitekey; + + gpyload.getFrameSize = function() { + const rectAnchor = {top: 0, left: 0, right: 150, bottom: 140}; + return rectAnchor; + }; + + // function that is called when the captcha finished loading and is ready to interact + window.pyloadCaptchaOnLoadCallback = function() { + const widgetID = turnstile.render ( + "#captchadiv", { + size: "compact", + 'sitekey': gpyload.data.sitekey, + 'callback': function(turnstileToken) { + const turnstileResponse = turnstile.getResponse(widgetID); // get captcha response + gpyload.submitResponse(turnstileResponse); + } + } + ); + gpyload.activated(); + }; + + if(typeof turnstile !== 'undefined' && turnstile) { + window.pyloadCaptchaOnLoadCallback(); + } else { + const js_script = document.createElement('script'); + js_script.type = "text/javascript"; + js_script.src = "//challenges.cloudflare.com/turnstile/v0/api.js?onload=pyloadCaptchaOnLoadCallback"; + js_script.defer = true; + document.getElementsByTagName('head')[0].appendChild(js_script); + }""" + + def detect_key(self, data=None): + html = data or self.retrieve_data() + + m = re.search(self.KEY_PATTERN, html) + if m is not None: + key = urllib.unquote(m.group(1).strip()) + m = re.search(self.KEY_FORMAT_PATTERN, key) + if m is not None: + self.key = key + self.log_debug("Key: %s" % self.key) + return self.key + + else: + self.log_debug(key, "Wrong key format, this probably because is is not a Turnstile key") + + self.log_warning(_("Key pattern not found")) + return None + + def challenge(self, key=None, data=None): + key = key or self.retrieve_key(data) + + return self._challenge_js(key) + + # solve interactive captcha (javascript required) + def _challenge_js(self, key): + self.log_debug("Challenge Turnstile interactive") + + params = {'url': self.pyfile.url, + 'sitekey': key, + 'script': {'signature': self.TURNSTILE_INTERACTIVE_SIG, + 'code': self.TURNSTILE_INTERACTIVE_JS}} + + result = self.decrypt_interactive(params, timeout=300) + + return result + +if __name__ == "__main__": + # Sign with the command `python -m module.plugins.captcha.Turnstile pyload.private.pem pem_passphrase` + import sys + from ..internal.misc import sign_string + + if len(sys.argv) > 2: + with open(sys.argv[1], 'r') as f: + pem_private = f.read() + + print sign_string(Turnstile.TURNSTILE_INTERACTIVE_JS, pem_private, pem_passphrase=sys.argv[2], sign_algo="SHA384") diff --git a/module/plugins/container/CCF.py b/module/plugins/container/CCF.py index bdb7ebd76b..de55e92953 100644 --- a/module/plugins/container/CCF.py +++ b/module/plugins/container/CCF.py @@ -8,13 +8,13 @@ import MultipartPostHandler from ..internal.Container import Container -from ..internal.misc import encode, fsjoin +from ..internal.misc import fs_encode, fsjoin class CCF(Container): __name__ = "CCF" __type__ = "container" - __version__ = "0.29" + __version__ = "0.30" __status__ = "testing" __pattern__ = r'.+\.ccf$' @@ -24,13 +24,12 @@ class CCF(Container): __description__ = """CCF container decrypter plugin""" __license__ = "GPLv3" - __authors__ = [("Willnix", "Willnix@pyload.org"), + __authors__ = [("Willnix", "Willnix@pyload.net"), ("Walter Purcaro", "vuolter@gmail.com")] def decrypt(self, pyfile): - fs_filename = encode(pyfile.url) - opener = urllib2.build_opener( - MultipartPostHandler.MultipartPostHandler) + fs_filename = fs_encode(pyfile.url) + opener = urllib2.build_opener(MultipartPostHandler.MultipartPostHandler) dlc_content = opener.open('http://service.jdownloader.net/dlcrypt/getDLC.php', {'src': "ccf", @@ -41,10 +40,7 @@ def decrypt(self, pyfile): dlc_file = fsjoin(dl_folder, "tmp_%s.dlc" % pyfile.name) try: - dlc = re.search( - r'(.+)', - dlc_content, - re.S).group(1).decode('base64') + dlc = re.search(r'(.+)', dlc_content, re.S).group(1).decode('base64') except AttributeError: self.fail(_("Container is corrupted")) diff --git a/module/plugins/container/DLC.py b/module/plugins/container/DLC.py index 386a5e5963..4e5e077f8a 100644 --- a/module/plugins/container/DLC.py +++ b/module/plugins/container/DLC.py @@ -8,13 +8,62 @@ import Crypto.Cipher.AES from ..internal.Container import Container -from ..internal.misc import decode, encode +from ..internal.misc import decode, fs_encode + + +class BadDLC(Exception): + pass + + +class DLCDecrypter(object): + KEY = "cb99b5cbc24db398" + IV = "9bc24cb995cb8db3" + API_URL = "http://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data=%s" + + def __init__(self, plugin): + self.plugin = plugin + + def decrypt(self, data): + data = data.strip() + + data += '=' * (-len(data) % 4) + + dlc_key = data[-88:] + dlc_data = data[:-88].decode('base64') + dlc_content = self.plugin.load(self.API_URL % dlc_key) + + try: + rc = re.search(r'(.+)', dlc_content, re.S).group(1).decode('base64')[:16] + + except AttributeError: + raise BadDLC + + key = iv = Crypto.Cipher.AES.new(self.KEY, Crypto.Cipher.AES.MODE_CBC, self.IV).decrypt(rc) + + xml_data = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC, iv).decrypt(dlc_data).decode('base64') + + root = xml.dom.minidom.parseString(xml_data).documentElement + content_node = root.getElementsByTagName("content")[0] + + packages = DLCDecrypter._parse_packages(content_node) + + return packages + + @staticmethod + def _parse_packages(start_node): + return [(decode(node.getAttribute("name").decode('base64')), DLCDecrypter._parse_links(node)) + for node in start_node.getElementsByTagName("package")] + + @staticmethod + def _parse_links(start_node): + return [node.getElementsByTagName("url")[0].firstChild.data.encode('latin1').decode('base64').decode('latin1') + for node in start_node.getElementsByTagName("file")] class DLC(Container): __name__ = "DLC" __type__ = "container" - __version__ = "0.32" + __version__ = "0.34" __status__ = "testing" __pattern__ = r'(.+\.dlc|[\w\+^_]+==[\w\+^_/]+==)$' @@ -24,8 +73,8 @@ class DLC(Container): __description__ = """DLC container decrypter plugin""" __license__ = "GPLv3" - __authors__ = [("RaNaN", "RaNaN@pyload.org"), - ("spoob", "spoob@pyload.org"), + __authors__ = [("RaNaN", "RaNaN@pyload.net"), + ("spoob", "spoob@pyload.net"), ("mkaay", "mkaay@mkaay.de"), ("Schnusch", "Schnusch@users.noreply.github.com"), ("Walter Purcaro", "vuolter@gmail.com"), @@ -36,38 +85,17 @@ class DLC(Container): API_URL = "http://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data=%s" def decrypt(self, pyfile): - fs_filename = encode(pyfile.url) + fs_filename = fs_encode(pyfile.url) with open(fs_filename) as dlc: - data = dlc.read().strip() + data = dlc.read() - data += '=' * (-len(data) % 4) - - dlc_key = data[-88:] - dlc_data = data[:-88].decode('base64') - dlc_content = self.load(self.API_URL % dlc_key) + decrypter = DLCDecrypter(self) try: - rc = re.search(r'(.+)', dlc_content, re.S).group(1).decode('base64')[:16] + packages = decrypter.decrypt(data) - except AttributeError: + except BadDLC: self.fail(_("Container is corrupted")) - key = iv = Crypto.Cipher.AES.new(self.KEY, Crypto.Cipher.AES.MODE_CBC, self.IV).decrypt(rc) - - self.data = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC, iv).decrypt(dlc_data).decode('base64') - self.packages = [(name or pyfile.name, links, name or pyfile.name) - for name, links in self.get_packages()] - - def get_packages(self): - root = xml.dom.minidom.parseString(self.data).documentElement - content = root.getElementsByTagName("content")[0] - return self.parse_packages(content) - - def parse_packages(self, startNode): - return [(decode(node.getAttribute("name")).decode('base64'), self.parse_links(node)) - for node in startNode.getElementsByTagName("package")] - - def parse_links(self, startNode): - return [node.getElementsByTagName("url")[0].firstChild.data.decode('base64') - for node in startNode.getElementsByTagName("file")] + for name, links in packages] diff --git a/module/plugins/container/RSDF.py b/module/plugins/container/RSDF.py index 5faca45978..42ad26c692 100644 --- a/module/plugins/container/RSDF.py +++ b/module/plugins/container/RSDF.py @@ -8,13 +8,13 @@ import Crypto.Cipher.AES from ..internal.Container import Container -from ..internal.misc import encode +from ..internal.misc import fs_encode class RSDF(Container): __name__ = "RSDF" __type__ = "container" - __version__ = "0.37" + __version__ = "0.38" __status__ = "testing" __pattern__ = r'.+\.rsdf$' @@ -24,8 +24,8 @@ class RSDF(Container): __description__ = """RSDF container decrypter plugin""" __license__ = "GPLv3" - __authors__ = [("RaNaN", "RaNaN@pyload.org"), - ("spoob", "spoob@pyload.org"), + __authors__ = [("RaNaN", "RaNaN@pyload.net"), + ("spoob", "spoob@pyload.net"), ("Walter Purcaro", "vuolter@gmail.com")] KEY = "8C35192D964DC3182C6F84F3252239EB4A320D2500000000" @@ -39,7 +39,7 @@ def decrypt(self, pyfile): cipher = Crypto.Cipher.AES.new(KEY, Crypto.Cipher.AES.MODE_CFB, iv) try: - fs_filename = encode(pyfile.url) + fs_filename = fs_encode(pyfile.url) with open(fs_filename, 'r') as rsdf: data = rsdf.read() @@ -51,8 +51,7 @@ def decrypt(self, pyfile): else: try: - raw_links = binascii.unhexlify( - ''.join(data.split())).splitlines() + raw_links = binascii.unhexlify(''.join(data.split())).splitlines() except TypeError: self.fail(_("Container is corrupted")) @@ -60,7 +59,5 @@ def decrypt(self, pyfile): for link in raw_links: if not link: continue - link = cipher.decrypt( - link.decode('base64')).replace( - 'CCF: ', '') + link = cipher.decrypt(link.decode('base64')).replace('CCF: ', '') self.links.append(link) diff --git a/module/plugins/container/TORRENT.py b/module/plugins/container/TORRENT.py index 311c26339c..d5c06885e2 100644 --- a/module/plugins/container/TORRENT.py +++ b/module/plugins/container/TORRENT.py @@ -1,21 +1,26 @@ # -*- coding: utf-8 -*- - -import os +import base64 import re import time import urllib +import urlparse +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes + +from module.network.RequestFactory import getURL as get_url + from ..internal.Container import Container -from ..internal.misc import encode, safename +from ..internal.misc import decode, fs_encode, fsjoin, safename class TORRENT(Container): __name__ = "TORRENT" __type__ = "container" - __version__ = "0.01" + __version__ = "0.06" __status__ = "testing" - __pattern__ = r'^(?!file://).+\.torrent$' + __pattern__ = r'(?:file|https?)://.+\.torrent|magnet:\?.+|(?!(?:file|https?)://).+\.(?:torrent|magnet)' __config__ = [("activated", "bool", "Activated", True), ("use_premium", "bool", "Use premium account if available", True), ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default")] @@ -24,22 +29,285 @@ class TORRENT(Container): __license__ = "GPLv3" __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + CONTAINER_PATTERN = r'(?!(?:file|https?)://).+\.(?:torrent|magnet)' + CRYPTER_PATTERN = r'(?:file|https?)://.+\.torrent|magnet:\?.+' + + def process(self, pyfile): + if re.match(self.CRYPTER_PATTERN, pyfile.url) is not None: + self.log_error(_("No plugin is associated with torrents / magnets"), + _("Please go to plugin settings -> TORRENT and select your preferred plugin")) + + self.fail(_("No plugin is associated with torrents / magnets")) + + elif re.match(self.CONTAINER_PATTERN, pyfile.url) is not None: + return Container.process(self, pyfile) + def decrypt(self, pyfile): - fs_filename = encode(pyfile.url) + fs_filename = fs_encode(pyfile.url) with open(fs_filename, "rb") as f: torrent_content = f.read() time_ref = ("%.2f" % time.time())[-6:].replace(".", "") + pack_name = "torrent_%s" % time_ref - pack_name = "torrent %s" % time_ref - m = re.search(r'name(\d+):', torrent_content) - if m: - m = re.search(r'name%s:(.{%s})' % (m.group(1), m.group(1)), torrent_content) + if pyfile.url.endswith(".magnet"): + if torrent_content.startswith("magnet:?"): + self.packages.append((pyfile.package().name, [torrent_content], pyfile.package().folder)) + + elif pyfile.url.endswith(".torrent"): + m = re.search(r'name(\d+):', torrent_content) if m: - pack_name = safename(m.group(1)) + m = re.search(r'name%s:(.{%s})' % (m.group(1), m.group(1)), torrent_content) + if m: + pack_name = safename(decode(m.group(1))) + + torrent_filename = fsjoin("tmp", "tmp_%s.torrent" % pack_name) + with open(torrent_filename, "wb") as f: + f.write(torrent_content) + + self.packages.append((pack_name, ["file://%s" % urllib.pathname2url(torrent_filename.encode('utf8'))], pack_name)) + + +def bdecode(data): + """ + Decodes a single bencoded element from the given bytes-like object. + Bencoding is a serialization format used by the BitTorrent protocol. + This function supports decoding integers, byte strings, lists, and dictionaries. + + Args: + data (bytes): The bencoded data to decode. Must start with a valid bencode type indicator. + + Returns: + tuple: A tuple containing the decoded Python object (int, bytes, list, or dict) and the remaining bytes after the decoded element. + + Raises: + ValueError: If the input data is malformed or does not conform to the bencode specification. + + Supported bencode types: + - Integers: b"ie" + - Byte strings: b":" + - Lists: b"le" + - Dictionaries: b"de" + """ + if data[0] == b"i": #: integer + m = re.match(b"i(-?\\d+)e", data) + if m is None: + raise ValueError("Malformed bencoded integer.") + + return int(m.group(1)), data[m.span()[1]:] + + elif data[0] in b"dl": #: list or dictionary + lst = [] + rest = data[1:] + while not rest[0] == b"e": + elem, rest = bdecode(rest) + lst.append(elem) + + rest = rest[1:] + if data[0] == b"l": + #: list + return lst, rest + else: + #: dictionary + return {i: j for i, j in zip(lst[::2], lst[1::2])}, rest + + elif data[0].isdigit(): #: string + m = re.match(b"(\\d+):", data) + if m is None: + raise ValueError("Malformed bencoded string.") + + length = int(m.group(1)) + start = m.span()[1] + end = start + length + return data[start:end], data[end:] + + else: + raise ValueError("Malformed bencoded input.") + + +def bencode(x): + """ + Encodes a Python object using the bencode serialization format. + + Bencode is used primarily in torrent files. The function supports encoding of the following types: + - int: Encoded as b'ie' + - bytes: Encoded as b':' + - str: Encoded as b':' + - list: Encoded as b'l...e' + - dict: Encoded as b'd...e' (keys are sorted) + + Args: + x (int, bytes, str, list, dict): The object to bencode. + + Returns: + bytes: The bencoded representation of the input. + + Raises: + TypeError: If the input type is not supported for bencoding. + """ + if isinstance(x, int): + return b"i%de" % x + elif isinstance(x, bytes): + return b"%d:%s" % (len(x), x) + elif isinstance(x, (str, unicode)): + x = x.encode() + return b"%d:%s" % (len(x), x) + elif isinstance(x, list): + return b"l" + b"".join([bencode(i) for i in x]) + b"e" + elif isinstance(x, dict): + items = sorted(x.items()) + return b"d" + b"".join([bencode(k) + bencode(v) for k, v in items]) + b"e" + else: + raise TypeError("Unsupported type for bencoding: %s" % type(x)) + +def get_info_hash(input_data): + """ + Extracts the info hash from a torrent file, magnet URL, or binary content. + + :param input_data: A magnet URL, torrent URL, file-like object, or binary content. + :return: The info hash as a hexadecimal string. + """ + def _get_info_section_from_torrent(torrent_content): + """ + Identifies the start and end positions of the 'info' section within a torrent file's binary content. + + Args: + torrent_content (bytes): The binary content of a torrent file. + + Returns: + tuple[int, int]: A tuple containing the start and end positions of the 'info' section within the binary content. + + Raises: + TypeError: If the input is not of type 'bytes'. + ValueError: If the 'info' section is not found or is malformed. + """ + if not isinstance(torrent_content, bytes): + raise TypeError("torrent_content must be of type 'bytes'.") + + # Find the start of the 'info' dictionary + INFO_KEY = b'4:info' + + start = torrent_content.find(INFO_KEY) + if start == -1: + raise ValueError("'info' section not found in torrent content.") + + # The info dict starts after the key and should be a bencoded dictionary (starts with 'd') + info_start = start + len(INFO_KEY) + if torrent_content[info_start:info_start+1] != b'd': + raise ValueError("'info' section does not start with a dictionary.") + + # Find the end of the info dictionary by bdecode + def find_dict_end(data, offset): + depth = 0 + i = offset + while i < len(data): + if data[i:i+1] == b'd' or data[i:i+1] == b'l': + depth += 1 + i += 1 + elif data[i:i+1] == b'e': + depth -= 1 + i += 1 + if depth == 0: + return i + elif data[i:i+1] == b'i': + # integer: ie + end_i = data.find(b'e', i) + if end_i == -1: + raise ValueError("Malformed bencoded integer.") + i = end_i + 1 + elif data[i:i+1].isdigit(): + # string: : + colon = data.find(b':', i) + if colon == -1: + raise ValueError("Malformed bencoded string.") + strlen = int(data[i:colon]) + i = colon + 1 + strlen + else: + raise ValueError("Malformed bencoded data.") + raise ValueError("Could not find end of 'info' dictionary.") + + info_end = find_dict_end(torrent_content, info_start) + return info_start, info_end + + def _extract_info_hash_from_torrent(torrent_content): + """ + Extracts the info hash from binary torrent content. + + :param torrent_content: Binary content of a torrent file. + :return: The info hash as a hexadecimal string. + """ + if not isinstance(torrent_content, bytes): + raise TypeError("argument must be of type 'bytes'.") + try: + # Decode the bencoded torrent content + info_start, info_end = _get_info_section_from_torrent(torrent_content) + info_content = torrent_content[info_start:info_end] + + # Hash the "info" dictionary using SHA-1 + digest = hashes.Hash(hashes.SHA1(), backend=default_backend()) + digest.update(info_content) + info_hash = digest.finalize() + return info_hash.encode('hex') + except Exception as e: + raise ValueError("Failed to extract info hash: %s" % e) + + def _extract_info_hash_from_magnet(magnet_url): + """ + Extracts the info hash from a magnet URL. + + :param magnet_url: A magnet URL string. + :return: The info hash as a hexadecimal string. + """ + BTV1_HASH_KEY = "urn:btih:" + BTV2_HASH_KEY = "urn:btmh:" + + up = urlparse.urlparse(magnet_url) + qs = urlparse.parse_qs(up.query) + xts = qs.get("xt", [""]) + info_hash = None + if up.scheme == "magnet": + for xt in xts: + if xt.startswith(BTV1_HASH_KEY): + xt = xt[len(BTV1_HASH_KEY):] + if len(xt) == 40: + info_hash = xt + break + elif len(xt) == 32: + info_hash = base64.b32decode(xt).encode('hex') + break + + elif xt.startswith(BTV2_HASH_KEY): + xt = xt[len(BTV2_HASH_KEY):] + if len(xt) == 40: + info_hash = xt + break + + if info_hash is None: + raise ValueError("Invalid magnet URL: Info hash not found.") + + return info_hash + + if isinstance(input_data, str): + up = urlparse.urlparse(input_data) + # Handle magnet URL + if up.scheme == "magnet": + return _extract_info_hash_from_magnet(input_data) + + else: + # Handle torrent URL + if up.scheme in ("http", "https") and up.path.endswith(".torrent"): + return _extract_info_hash_from_torrent(get_url(input_data)) + + else: + raise ValueError("Invalid input URL: Info hash not found.") + + elif hasattr(input_data, 'read') and callable(getattr(input_data, 'read')): + # Handle file-like object + return _extract_info_hash_from_torrent(input_data.read()) - torrent_filename = os.path.join("tmp", "tmp_%s.torrent" % pack_name) - with open(torrent_filename, "wb") as f: - f.write(torrent_content) + elif isinstance(input_data, bytes): + # Handle binary content + return _extract_info_hash_from_torrent(input_data) - self.packages.append((pack_name, ["file://%s" % urllib.pathname2url(torrent_filename)], pack_name)) + else: + raise TypeError("Unsupported input type. Must be a URL, file-like object, or binary content.") diff --git a/module/plugins/container/TXT.py b/module/plugins/container/TXT.py index da709ddccb..d6e2994de4 100644 --- a/module/plugins/container/TXT.py +++ b/module/plugins/container/TXT.py @@ -3,26 +3,25 @@ import codecs from ..internal.Container import Container -from ..internal.misc import encode +from ..internal.misc import fs_encode class TXT(Container): __name__ = "TXT" __type__ = "container" - __version__ = "0.21" + __version__ = "0.22" __status__ = "testing" __pattern__ = r'.+\.(txt|text)$' __config__ = [("activated", "bool", "Activated", True), ("use_premium", "bool", "Use premium account if available", True), - ("folder_per_package", "Default;Yes;No", - "Create folder for each package", "Default"), + ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default"), ("flush", "bool", "Flush list after adding", False), ("encoding", "str", "File encoding", "utf-8")] __description__ = """Read link lists in plain text formats""" __license__ = "GPLv3" - __authors__ = [("spoob", "spoob@pyload.org"), + __authors__ = [("spoob", "spoob@pyload.net"), ("jeix", "jeix@hasnomail.com")] def decrypt(self, pyfile): @@ -32,7 +31,7 @@ def decrypt(self, pyfile): except Exception: encoding = "utf-8" - fs_filename = encode(pyfile.url) + fs_filename = fs_encode(pyfile.url) txt = codecs.open(fs_filename, 'r', encoding) curPack = "Parsed links from %s" % pyfile.name packages = {curPack: [], } diff --git a/module/plugins/crypter/AlldebridComTorrent.py b/module/plugins/crypter/AlldebridComTorrent.py new file mode 100644 index 0000000000..038337fca3 --- /dev/null +++ b/module/plugins/crypter/AlldebridComTorrent.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- + +import os +import time +import urllib + +from ..internal.Crypter import Crypter +from ..internal.misc import exists, json, safejoin + +try: + from module.network.HTTPRequest import FormFile +except ImportError: + pass + + + +class AlldebridComTorrent(Crypter): + __name__ = "AlldebridComTorrent" + __type__ = "crypter" + __version__ = "0.03" + __status__ = "testing" + + __pattern__ = r'^unmatchable$' + __config__ = [("activated", "bool", "Activated", True), + ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default"), + ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10), + ("del_finished", "bool", "Delete downloaded torrents from the server", True)] + + __description__ = """Alldebrid.com torrents crypter plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT}yahoo[DOT]com")] + + # See https://docs.alldebrid.com/ + API_URL = "https://api.alldebrid.com/v4.1/" + + def api_response(self, method, get={}, post={}, multipart=False): + get.update({'agent': "pyLoad", + 'version': self.pyload.version}) + json_data = json.loads(self.load(self.API_URL + method, get=get, post=post, multipart=multipart)) + if json_data['status'] == "success": + return json_data['data'] + else: + return json_data + + def sleep(self, sec): + for _i in range(sec): + if self.pyfile.abort: + break + time.sleep(1) + + def send_request_to_server(self): + """ Send torrent/magnet to the server """ + + if self.pyfile.url.endswith(".torrent"): + #: torrent URL + if self.pyfile.url.startswith("http"): + #: remote URL, download the torrent to tmp directory + torrent_content = self.load(self.pyfile.url, decode=False) + torrent_filename = safejoin("tmp", "tmp_%s.torrent" % self.pyfile.package().name) #: `tmp_` files are deleted automatically + with open(torrent_filename, "wb") as f: + f.write(torrent_content) + + else: + #: URL is local torrent file (uploaded container) + torrent_filename = urllib.url2pathname(self.pyfile.url[7:]).encode('latin1').decode('utf8') #: trim the starting `file://` + if not exists(torrent_filename): + self.fail(_("Torrent file does not exist")) + + #: Check if the torrent file path is inside pyLoad's config directory + if os.path.abspath(torrent_filename).startswith(os.path.abspath(os.getcwd()) + os.sep): + try: + #: send the torrent content to the server + api_data = self.api_response("magnet/upload/file", + get={'apikey': self.api_token}, + post={'files[]': FormFile(torrent_filename, mimetype="application/x-bittorrent")}, + multipart=True) + except NameError: + self.fail(_("Posting file attachments is not supported by HTTPRequest, please update your pyLoad installation")) + + if api_data.get("error", False): + self.fail("%s (code: %s)" % (api_data['error']['message'], api_data['error']['code'])) + + if api_data['files'][0].get('error', False): + self.fail("%s (code: %s)" % (api_data['files'][0]['error']['message'], api_data['files'][0]['error']['code'])) + + torrent_id = api_data['files'][0]['id'] + + else: + self.fail(_("Illegal URL")) #: We don't allow files outside pyLoad's config directory + + else: + #: magnet URL, send to the server + api_data = self.api_response("magnet/upload", + get={'apikey': self.api_token, + 'magnets[]': self.pyfile.url}) + + if api_data.get("error", False): + self.fail("%s (code: %s)" % (api_data['error']['message'], api_data['error']['code'])) + + if api_data['magnets'][0].get('error', False): + self.fail("%s (code: %s)" % (api_data['magnets'][0]['error']['message'], api_data['magnets'][0]['error']['code'])) + + torrent_id = api_data['magnets'][0]['id'] + + return torrent_id + + def wait_for_server_dl(self, torrent_id): + """ Show progress while the server does the download """ + + self.pyfile.setCustomStatus("torrent") + self.pyfile.setProgress(0) + + prev_status = -1 + while True: + torrent_info = self.api_response("magnet/status", + get={'apikey': self.api_token, + 'id': torrent_id}) + + if torrent_info.get("error", False): + self.fail("%s (code: %s)" % (torrent_info['error']['message'], torrent_info['error']['code'])) + + status_code = torrent_info['magnets']['statusCode'] + torrent_size = torrent_info['magnets']['size'] + if status_code > 4: + self.fail("%s (code: %s)" % (torrent_info["magnets"]['status'], status_code)) + + if status_code != prev_status: + if status_code in (0, 1): + self.pyfile.name = torrent_info['magnets']['filename'] + self.pyfile.size = torrent_size + + elif status_code in (2, 3): + self.pyfile.setProgress(100) + self.pyfile.setCustomStatus("postprocessing") + + if status_code == 1: + if torrent_size > 0: + self.pyfile.size = torrent_size + progress = int(100 * torrent_info['magnets']['downloaded'] / torrent_size) + self.pyfile.setProgress(progress) + + elif status_code == 4: + self.pyfile.setProgress(100) + break + + self.sleep(5) + prev_status = status_code + + return [_l['link'] for _l in torrent_info['magnets']['links']] + + def delete_torrent_from_server(self, torrent_id): + """ Remove the torrent from the server """ + + api_data = self.api_response("magnet/delete", + get={'apikey': self.api_token, + 'id': torrent_id}) + + if api_data.get("error", False): + self.log_warning("%s (code: %s)" % (api_data['error']['message'], api_data['error']['code'])) + + def decrypt(self, pyfile): + if 'AlldebridCom' not in self.pyload.accountManager.plugins: + self.fail(_("This plugin requires an active Alldebrid.com account")) + + account_plugin = self.pyload.accountManager.getAccountPlugin("AlldebridCom") + if len(account_plugin.accounts) == 0: + self.fail(_("This plugin requires an active Alldebrid.com account")) + + self.api_token = account_plugin.accounts[account_plugin.accounts.keys()[0]]["password"] + + torrent_id = self.send_request_to_server() + torrent_urls = self.wait_for_server_dl(torrent_id) + + self.packages = [(pyfile.package().name, torrent_urls, pyfile.package().name)] + + if self.config.get("del_finished"): + self.delete_torrent_from_server(torrent_id) diff --git a/module/plugins/crypter/ArchiveOrgFolder.py b/module/plugins/crypter/ArchiveOrgFolder.py new file mode 100644 index 0000000000..c1daba410b --- /dev/null +++ b/module/plugins/crypter/ArchiveOrgFolder.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +import re + +from ..internal.Crypter import Crypter + + +class ArchiveOrgFolder(Crypter): + __name__ = "ArchiveOrgFolder" + __type__ = "decrypter" + __version__ = "0.01" + __status__ = "testing" + + __pattern__ = r"https?://(?:www\.)?archive\.org/details/.+" + __config__ = [("activated", "bool", "Activated", True), + ("use_premium", "bool", "Use premium account if available", True), + ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default"), + ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10)] + + __description__ = """Archive.org decrypter plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + LINK_PATTERN = r'
(.+?)<' + + OFFLINE_PATTERN = r"Item cannot be found." + TEMP_OFFLINE_PATTERN = r"^unmatchable$" + + def decrypt(self, pyfile): + self.data = self.load(pyfile.url) + + m = re.search(self.NAME_PATTERN, self.data) + if m is not None: + name = m.group(1) + else: + name = pyfile.package().name + + links = re.findall(self.LINK_PATTERN, self.data) + self.packages = [(name, links, name)] diff --git a/module/plugins/crypter/BigfileToFolder.py b/module/plugins/crypter/BigfileToFolder.py deleted file mode 100644 index b4b1682617..0000000000 --- a/module/plugins/crypter/BigfileToFolder.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.SimpleCrypter import SimpleCrypter - - -class BigfileToFolder(SimpleCrypter): - __name__ = "BigfileToFolder" - __type__ = "crypter" - __version__ = "0.11" - __status__ = "testing" - - __pattern__ = r'https?://(?:www\.)?(?:uploadable\.ch|bigfile\.to)/list/\w+' - __config__ = [("activated", "bool", "Activated", True), - ("use_premium", "bool", "Use premium account if available", True), - ("folder_per_package", "Default;Yes;No", - "Create folder for each package", "Default"), - ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10)] - - __description__ = """bigfile.to folder decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("guidobelix", "guidobelix@hotmail.it"), - ("Walter Purcaro", "vuolter@gmail.com"), - ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - - URL_REPLACEMENTS = [("https?://uploadable\.ch", "https://bigfile.to")] - - LINK_PATTERN = r'"(.+?)" class="icon_zipfile">' - NAME_PATTERN = r'
 (?P.+?)
' - OFFLINE_PATTERN = r'We are sorry... The URL you entered cannot be found on the server.' - TEMP_OFFLINE_PATTERN = r'
' diff --git a/module/plugins/crypter/BitshareComFolder.py b/module/plugins/crypter/BitshareComFolder.py deleted file mode 100644 index d1a8ec3c2a..0000000000 --- a/module/plugins/crypter/BitshareComFolder.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.DeadCrypter import DeadCrypter - - -class BitshareComFolder(DeadCrypter): - __name__ = "BitshareComFolder" - __type__ = "crypter" - __version__ = "0.11" - __status__ = "testing" - - __pattern__ = r'http://(?:www\.)?bitshare\.com/\?d=\w+' - __config__ = [("activated", "bool", "Activated", True)] - - __description__ = """Bitshare.com folder decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("stickell", "l.stickell@yahoo.it")] diff --git a/module/plugins/crypter/C1NeonCom.py b/module/plugins/crypter/C1NeonCom.py deleted file mode 100644 index d0896678a2..0000000000 --- a/module/plugins/crypter/C1NeonCom.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.DeadCrypter import DeadCrypter - - -class C1NeonCom(DeadCrypter): - __name__ = "C1NeonCom" - __type__ = "crypter" - __version__ = "0.11" - __status__ = "stable" - - __pattern__ = r'http://(?:www\.)?c1neon\.com/.+' - __config__ = [("activated", "bool", "Activated", True)] - - __description__ = """C1neon.com decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("godofdream", "soilfiction@gmail.com")] diff --git a/module/plugins/crypter/ChipDe.py b/module/plugins/crypter/ChipDe.py index 354dbc96c4..cfc971dc59 100644 --- a/module/plugins/crypter/ChipDe.py +++ b/module/plugins/crypter/ChipDe.py @@ -8,10 +8,10 @@ class ChipDe(Crypter): __name__ = "ChipDe" __type__ = "crypter" - __version__ = "0.16" + __version__ = "0.18" __status__ = "testing" - __pattern__ = r'http://(?:www\.)?chip\.de/video/.+\.html' + __pattern__ = r'https?://(?:www\.)?chip\.de/video/.+\.html' __config__ = [("activated", "bool", "Activated", True), ("use_premium", "bool", "Use premium account if available", True), ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default")] @@ -23,7 +23,7 @@ class ChipDe(Crypter): def decrypt(self, pyfile): self.data = self.load(pyfile.url) try: - f = re.search(r'"(http://video\.chip\.de/.+)"', self.data) + f = re.search(r'"(https?://media-video\.chip\.de/.+/MEDIA/.+)"', self.data) except Exception: self.fail(_("Failed to find the URL")) diff --git a/module/plugins/crypter/CloudMailRuFolder.py b/module/plugins/crypter/CloudMailRuFolder.py index 1d82609f67..039be20f6c 100644 --- a/module/plugins/crypter/CloudMailRuFolder.py +++ b/module/plugins/crypter/CloudMailRuFolder.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import base64 -import re import urllib from ..internal.Crypter import Crypter @@ -11,10 +10,10 @@ class CloudMailRuFolder(Crypter): __name__ = "CloudMailRuFolder" __type__ = "crypter" - __version__ = "0.01" + __version__ = "0.03" __status__ = "testing" - __pattern__ = r'https?://cloud\.mail\.ru/public/.+' + __pattern__ = r'https?://cloud\.mail\.ru/public/(?P.+)' __config__ = [("activated", "bool", "Activated", True), ("use_premium", "bool", "Use premium account if available", True), ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default"), @@ -24,25 +23,36 @@ class CloudMailRuFolder(Crypter): __license__ = "GPLv3" __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + API_URL = "https://cloud.mail.ru/api/v2/" + + def api_request(self, method, **kwargs): + json_data = self.load(self.API_URL + method, + get=kwargs) + return json.loads(json_data) + def decrypt(self, pyfile): - self.data = self.load(pyfile.url) + api_data = self.api_request("dispatcher", api=2) + if api_data['status'] != 200: + self.fail(_("API failure, status code %s") % api_data['status']) + + base_url = api_data['body']['weblink_get'][0]['url'] - m = re.search(r'window\.cloudSettings\s*=\s*(\{.+?\});', self.data, re.S) - if m is None: - self.fail(_("Json pattern not found")) + api_data = self.api_request("folder", + weblink=self.info['pattern']['ID'], + offset=0, + limit=500, + api=2) - json_data = json.loads(m.group(1).replace("\\x3c", "<")) + if api_data['status'] != 200: + self.fail(_("API failure, status code %s") % api_data['status']) + pack_name = api_data['body']['name'] pack_links = ["https://cloud.mail.ru/dl?q=%s" % - base64.b64encode(json.dumps({'u': "%s%s?etag=%s&key=%s" % - (json_data['dispatcher']['weblink_view'][0]['url'], - urllib.quote(_link['weblink']), - _link['hash'], - json_data['params']['tokens']['download']), - 'n': urllib.quote_plus(_link['name']), + base64.b64encode(json.dumps({'u': "%s/%s" % (base_url, _link['weblink']), + 'n': urllib.quote_plus(_link['name'].encode('utf8')), 's': _link['size']})) - for _link in json_data['folders']['folder']['list'] - if _link['kind'] == "file"] + for _link in api_data['body']['list'] + if _link['type'] == "file"] if pack_links: - self.packages.append((pyfile.package().name, pack_links, pyfile.package().folder)) + self.packages.append((pack_name or pyfile.package().name, pack_links, pack_name or pyfile.package().folder)) diff --git a/module/plugins/crypter/CriptTo.py b/module/plugins/crypter/CriptTo.py new file mode 100644 index 0000000000..bf6f20c3da --- /dev/null +++ b/module/plugins/crypter/CriptTo.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- + +import binascii +import re + +import Crypto.Cipher.AES + +from ..captcha.ReCaptcha import ReCaptcha +from ..captcha.SolveMedia import SolveMedia +from ..container.DLC import BadDLC, DLCDecrypter +from ..internal.misc import json, parse_html_form +from ..internal.SimpleCrypter import SimpleCrypter + + +class CriptTo(SimpleCrypter): + __name__ = "CriptTo" + __type__ = "crypter" + __version__ = "0.05" + __status__ = "testing" + + __pattern__ = r'https?://(?:www\.)?cript\.to/folder/(?P\w+)' + __config__ = [("activated", "bool", "Activated", True), + ("use_premium", "bool", "Use premium account if available", True), + ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default"), + ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10)] + + __description__ = """cript.to decrypter plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] + + URL_REPLACEMENTS = [(__pattern__ + ".*", "https://cript.to/folder/\g")] + + CNL_LINK_PATTERN = r'data-cnl="(.+?)"' + WEB_LINK_PATTERN = r'href="javascript:void\(0\);" onclick="popup\(\'(.+?)\'' + DLC_LINK_PATTERN = r'onclick="popup\(\'(https://cript\.to/dlc/.+?)\'' + + def api_info(self, url): + info = {} + + folder_id = re.match(self.__pattern__, url).group('ID') + folder_info = json.loads(self.load("https://cript.to/api/v1/folder/info", + get={'id': folder_id})) + if folder_info['status'] == "error": + info['status'] = 8 + info['error'] = folder_info['message'] + + else: + info['status'] = 2 + info['name'] = folder_info['data']['name'] + + return info + + def decrypt(self, pyfile): + self.data = self.load(pyfile.url) + + self.handle_captcha() + + self.handle_password() + + for handle in (self.handle_CNL, + self.handle_weblinks, + self.handle_DLC): + urls = handle() + if urls: + self.packages = [(self.pyfile.name or pyfile.package().name, + urls, + self.pyfile.name or pyfile.package().name)] + return + + elif self.packages: + return + + def handle_captcha(self): + url, inputs = self.parse_html_form('action="%s"' % self.pyfile.url) + if inputs is None: + return + + elif inputs['do'] == "captcha": + captcha_type = inputs['captcha_driver'] + + if captcha_type == "simplecaptcha": + self.log_debug("Internal captcha found") + + captcha_url = "https://cript.to/captcha/simplecaptcha" + captcha_code = self.captcha.decrypt(captcha_url, input_type="png") + inputs['simplecaptcha'] = captcha_code + + elif captcha_type == "circlecaptcha": + self.log_debug("Circle captcha found") + + captcha_url = "https://cript.to/captcha/circlecaptcha" + captcha_code = self.captcha.decrypt(captcha_url, input_type="png", output_type='positional') + inputs['button.x'] = captcha_code[0] + inputs['button.y'] = captcha_code[1] + + elif captcha_type == "solvemedia": + solvemedia = SolveMedia(self.pyfile) + captcha_key = solvemedia.detect_key() + + if captcha_key: + self.log_debug("Solvemedia captcha found") + + self.captcha = solvemedia + response, challenge = solvemedia.challenge(captcha_key) + + inputs['adcopy_challenge'] = challenge + inputs['adcopy_response'] = response + + else: + self.log_warning(_("Could not detect Solvemedia captcha key")) + self.retry_captcha() + + elif captcha_type == "recaptcha": + recaptcha = ReCaptcha(self.pyfile) + captcha_key = recaptcha.detect_key() + + if captcha_key: + self.log_debug("ReCaptcha captcha found") + + self.captcha = recaptcha + response = recaptcha.challenge(captcha_key) + + inputs['g-recaptcha-response'] = response + + else: + self.log_warning(_("Could not detect ReCaptcha captcha key")) + self.retry_captcha() + + else: + self.log_warning(_("Captcha Not found")) + + inputs['submit'] = "confirm" + + self.data = self.load(url, post=inputs) + url, inputs = self.parse_html_form('action="%s"' % self.pyfile.url) + if inputs is not None: + if inputs['do'] == "captcha": + self.captcha.invalid() + self.retry_captcha() + + def handle_password(self): + url, inputs = self.parse_html_form('action="%s"' % self.pyfile.url) + if inputs is None: + return + + elif inputs['do'] == "password": + password = self.get_password() + if not password: + self.fail(_("Password required")) + + inputs['password'] = password + inputs['submit'] = "confirm" + + self.data = self.load(url, post=inputs) + + if "That Password was incorrect" in self.data: + self.fail(_("Wrong password")) + + def handle_CNL(self): + links = [] + + m = re.search(self.CNL_LINK_PATTERN, self.data) + if m is not None: + html = self.load(m.group(1)) + _, inputs = parse_html_form("/flash/", html) + if inputs is not None: + #: Get key + key = binascii.unhexlify(re.search(r"'(\w+)'", inputs['jk']).group(1)) + crypted = inputs['crypted'] + + #: Decrypt + #Key = key + #IV = key + obj = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC, key) + text = obj.decrypt(crypted.decode('base64')) + + #: Extract links + text = text.replace("\x00", "").replace("\r", "") + links = filter(bool, text.split('\n')) + + return links + + def handle_weblinks(self): + links = [] + + weblinks = re.findall(self.WEB_LINK_PATTERN, self.data) + for weblink in weblinks: + html = self.load(weblink) + link = self.last_header['url'] + if link == "https://cript.to/bot": + for _i in range(3): + url, inputs = parse_html_form("/bot", html) + if inputs is None or "circlecaptcha" not in html: + continue + + captcha_url = "https://cript.to/captcha/circlecaptcha" + captcha_code = self.captcha.decrypt(captcha_url, input_type="png", output_type='positional') + inputs['button.x'] = captcha_code[0] + inputs['button.y'] = captcha_code[1] + html = self.load(url, post=inputs) + link = self.last_header['url'] + if not link.startswith("https://cript.to"): + self.captcha.correct() + break + + else: + self.captcha.invalid() + + else: + self.log_warning(_("Could not parse weblink (bot)")) + links = [] + break + + if link: + links.append(link) + + return links + + def handle_DLC(self): + decrypter = DLCDecrypter(self) + + dlc_urls = re.findall(self.DLC_LINK_PATTERN, self.data) + for dlc_url in dlc_urls: + dlc_data = self.load(dlc_url) + try: + packages = decrypter.decrypt(dlc_data) + + except BadDLC: + self.log_warning(_("Container is corrupted")) + continue + + self.packages.extend([(name or self.pyfile.name, links, name or self.pyfile.name) + for name, links in packages]) + + return [] diff --git a/module/plugins/crypter/CrockoComFolder.py b/module/plugins/crypter/CrockoComFolder.py deleted file mode 100644 index 3bacb05aaf..0000000000 --- a/module/plugins/crypter/CrockoComFolder.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.SimpleCrypter import SimpleCrypter - - -class CrockoComFolder(SimpleCrypter): - __name__ = "CrockoComFolder" - __type__ = "crypter" - __version__ = "0.07" - __status__ = "testing" - - __pattern__ = r'http://(?:www\.)?crocko\.com/f/.+' - __config__ = [("activated", "bool", "Activated", True), - ("use_premium", "bool", "Use premium account if available", True), - ("folder_per_package", "Default;Yes;No", - "Create folder for each package", "Default"), - ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10)] - - __description__ = """Crocko.com folder decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("zoidberg", "zoidberg@mujmail.cz")] - - LINK_PATTERN = r'download' diff --git a/module/plugins/crypter/CryptCat.py b/module/plugins/crypter/CryptCat.py deleted file mode 100644 index 5a2abdfa22..0000000000 --- a/module/plugins/crypter/CryptCat.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- - -import re - -from ..internal.SimpleCrypter import SimpleCrypter - - -class CryptCat(SimpleCrypter): - __name__ = "CryptCat" - __type__ = "crypter" - __version__ = "0.04" - __status__ = "testing" - - __pattern__ = r'https?://(?:www\.)?crypt\.cat/\w+' - __config__ = [("activated", "bool", "Activated", True), - ("use_premium", "bool", "Use premium account if available", True), - ("folder_per_package", "Default;Yes;No", - "Create folder for each package", "Default"), - ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10)] - - __description__ = """crypt.cat decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - - OFFLINE_PATTERN = r'Folder not available!' - - LINK_PATTERN = r'' - - def get_links(self): - baseurl = self.req.http.lastEffectiveURL - url, inputs = self.parse_html_form() - - if ">Enter your password.<" in self.data: - password = self.get_password() - if not password: - self.fail(_("Password required")) - - inputs['Pass1'] = password - - elif "Enter Captcha" in self.data: - m = re.search(r'playlist|user)/)?(?P[\w^_]+)(?(TYPE)|#)' @@ -104,5 +104,4 @@ def decrypt(self, pyfile): self.log_debug( "%s video\s found on playlist \"%s\"" % (len(p_videos), p_name)) - # @NOTE: Folder is NOT recognized by pyload 0.4.9! self.packages.append((p_name, p_videos, p_folder)) diff --git a/module/plugins/crypter/DatafileComFolder.py b/module/plugins/crypter/DatafileComFolder.py deleted file mode 100644 index 2197a025c2..0000000000 --- a/module/plugins/crypter/DatafileComFolder.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- - -import re - -from ..internal.Crypter import Crypter - - -class DatafileComFolder(Crypter): - __name__ = "DatafileComFolder" - __type__ = "crypter" - __version__ = "0.02" - __status__ = "testing" - - __pattern__ = r'https?://(?:www\.)?datafile\.com/f/\w{12}' - __config__ = [("activated", "bool", "Activated", True), - ("use_premium", "bool", "Use premium account if available", True), - ("folder_per_package", "Default;Yes;No", - "Create folder for each package", "Default"), - ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10)] - - __description__ = """datafile.com decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - - LINK_PATTERN = r'https?://(?:www\.)?datafile\.com/d/\w{17}' - NAME_PATTERN = r'
(?P.+?)<' - - def decrypt(self, pyfile): - self.data = self.load(pyfile.url) - - links = re.findall(self.LINK_PATTERN, self.data) - - m = re.search(self.NAME_PATTERN, self.data) - if m is not None: - name = m.group('N') - self.packages.append((name, links, name)) - - else: - self.links.extend(links) diff --git a/module/plugins/crypter/DdlstorageComFolder.py b/module/plugins/crypter/DdlstorageComFolder.py deleted file mode 100644 index e9bfc7436d..0000000000 --- a/module/plugins/crypter/DdlstorageComFolder.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.DeadCrypter import DeadCrypter - - -class DdlstorageComFolder(DeadCrypter): - __name__ = "DdlstorageComFolder" - __type__ = "crypter" - __version__ = "0.09" - __status__ = "stable" - - __pattern__ = r'https?://(?:www\.)?ddlstorage\.com/folder/\w+' - __config__ = [("activated", "bool", "Activated", True)] - - __description__ = """DDLStorage.com folder decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("godofdream", "soilfiction@gmail.com"), - ("stickell", "l.stickell@yahoo.it")] diff --git a/module/plugins/crypter/DebridlinkFrTorrent.py b/module/plugins/crypter/DebridlinkFrTorrent.py new file mode 100644 index 0000000000..7136ca1960 --- /dev/null +++ b/module/plugins/crypter/DebridlinkFrTorrent.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- + +import fnmatch +import os +import time +import urllib + +import pycurl +from module.network.HTTPRequest import BadHeader + +from ..hoster.DebridlinkFr import error_description +from ..internal.Crypter import Crypter +from ..internal.misc import exists, json, safejoin, uniqify + +try: + from module.network.HTTPRequest import FormFile +except ImportError: + pass + + +class DebridlinkFrTorrent(Crypter): + __name__ = "DebridlinkFrTorrent" + __type__ = "crypter" + __version__ = "0.04" + __status__ = "testing" + + __pattern__ = r'^unmatchable$' + __config__ = [("activated", "bool", "Activated", True), + ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default"), + ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10), + ("include_filter", "str", "File types to include (e.g. *.iso;*.zip, leave empty to select none)", "*.*"), + ("exclude_filter", "str", "File types to exclude (e.g. *.exe;advertisement.txt, leave empty to select none)", "")] + + __description__ = """"Debrid-link.fr torrents crypter plugin""" + __license__ = "GPLv3" + __authors__ = [("GammaC0de", "nitzo2001[AT}yahoo[DOT]com")] + + #: See https://debrid-link.fr/api_doc/v2 + API_URL = "https://debrid-link.fr/api/" + + def api_request(self, method, get={}, post={}, multipart=False): + self.req.http.c.setopt(pycurl.HTTPHEADER, ["Authorization: Bearer " + self.api_token]) + self.req.http.c.setopt(pycurl.USERAGENT, "pyLoad/%s" % self.pyload.version) + try: + json_data = self.load(self.API_URL + method, get=get, post=post, multipart=multipart) + except BadHeader, e: + json_data = e.content + + return json.loads(json_data) + + def api_request_safe(self, method, get={}, post={}, multipart=False): + for _i in range(2): + api_data = self.api_request(method, get=get, post=post, multipart=multipart) + + if 'error' in api_data: + if api_data['error'] == 'badToken': #: token expired, refresh the token and retry + self.account.relogin() + if not self.account.info['login']['valid']: + return api_data + + else: + self.api_token = self.account.accounts[self.account.accounts.keys()[0]]["api_token"] + continue + + else: + return api_data + + else: + return api_data + + def sleep(self, sec): + for _i in range(sec): + if self.pyfile.abort: + break + time.sleep(1) + + def send_request_to_server(self): + """ Send torrent/magnet to the server """ + + if self.pyfile.url.endswith(".torrent"): + #: torrent URL + if self.pyfile.url.startswith("http"): + #: remote URL, send to the server + api_data = self.api_request_safe("v2/seedbox/add", + post={"url": self.pyfile.url, + "wait": True, + "async": True}) + + else: + #: URL is local torrent file (uploaded container) + torrent_filename = urllib.url2pathname(self.pyfile.url[7:]).encode('latin1').decode('utf8') #: trim the starting `file://` + if not exists(torrent_filename): + self.fail(_("Torrent file does not exist")) + + #: Check if the torrent file path is inside pyLoad's config directory + if os.path.abspath(torrent_filename).startswith(os.path.abspath(os.getcwd()) + os.sep): + self.tmp_file = torrent_filename + + try: + #: send the torrent content to the server + api_data = self.api_request_safe("v2/seedbox/add", + post={"file": FormFile(torrent_filename, mimetype="application/x-bittorrent"), + "wait": True, + "async": True}, + multipart=True) + + except NameError: + self.fail(_("Posting file attachments is not supported by HTTPRequest, please update your pyLoad installation")) + + else: + self.fail(_("Illegal URL")) #: We don't allow files outside pyLoad's config directory + + else: + #: magnet URL, send to the server + api_data = self.api_request_safe("v2/seedbox/add", + post={'url': self.pyfile.url, + 'wait': True, + 'async': True}) + + if not api_data['success']: + self.fail("%s (code: %s)" % (api_data.get('error_description', error_description(api_data["error"])), api_data['error'])) + + torrent_id = api_data['value']['id'] + + self.pyfile.setCustomStatus("metadata") + self.pyfile.setProgress(0) + + #: Get the file list of the torrent + page = 0 + files = [] + while True: + api_data = self.api_request_safe("v2/seedbox/list", + get={'ids': torrent_id, + 'page': page, + 'perPage': 50}) + + if not api_data['success']: + self.fail("%s (code: %s)" % (api_data.get('error_description', error_description(api_data["error"])), api_data['error'])) + + api_files = api_data['value'][0]['files'] + if api_files: + files.extend([{'id': _file['id'], 'name': _file['name'], 'size': _file['size'], 'url': _file['downloadUrl']} + for _file in api_files]) + + page = api_data['pagination']['next'] + if page == -1: + break + + self.sleep(5) + + self.pyfile.name = api_data['value'][0]['name'] + + #: Filter and select files for downloading + exclude_filters = self.config.get('exclude_filter').split(';') + excluded_ids = [] + for _filter in exclude_filters: + excluded_ids.extend([_file['id'] for _file in files + if fnmatch.fnmatch(_file['name'], _filter)]) + + excluded_ids = uniqify(excluded_ids) + + include_filters = self.config.get('include_filter').split(';') + included_ids = [] + for _filter in include_filters: + included_ids.extend([_file['id'] for _file in files + if fnmatch.fnmatch(_file['name'], _filter)]) + + included_ids = uniqify(included_ids) + + selected_ids = [_id for _id in included_ids + if _id not in excluded_ids] + + unwanted_ids = [_file['id'] for _file in files + if _file['id'] not in selected_ids] + + self.pyfile.size = sum([_file['size'] for _file in files + if _file['id'] in selected_ids]) + + api_data = self.api_request_safe("v2/seedbox/%s/config" % torrent_id, + post={'files-unwanted': json.dumps(unwanted_ids)}) + + if not api_data['success']: + self.fail("%s (code: %s)" % (api_data.get('error_description', error_description(api_data["error"])), api_data['error'])) + + return torrent_id, [_file['url'] for _file in files + if _file['id'] in selected_ids] + + def wait_for_server_dl(self, torrent_id): + """ Show progress while the server does the download """ + + self.pyfile.setCustomStatus("torrent") + self.pyfile.setProgress(0) + + while True: + api_data = self.api_request_safe("v2/seedbox/activity", + get={'ids': torrent_id}) + + if not api_data['success']: + self.fail("%s (code: %s)" % (api_data.get('error_description', api_data.get('error_description', error_description(api_data["error"]))), api_data['error'])) + + if not api_data['value']: + self.fail("Torrent deleted from server") + + progress = int(api_data['value'][torrent_id]['downloadPercent']) + self.pyfile.setProgress(progress) + if progress == 100: + break + + self.sleep(5) + + self.pyfile.setProgress(100) + + def delete_torrent_from_server(self, torrent_id): + """ Remove the torrent from the server """ + url = "%sv2/seedbox/%s/remove" % (self.API_URL, torrent_id) + self.log_debug("DELETE URL %s" % url) + c = pycurl.Curl() + c.setopt(pycurl.URL, url) + c.setopt(pycurl.SSL_VERIFYPEER, 0) + c.setopt(pycurl.USERAGENT, "pyLoad/%s" % self.pyload.version) + c.setopt(pycurl.HTTPHEADER, ["Authorization: Bearer " + self.api_token, + "Accept: */*", + "Accept-Language: en-US,en", + "Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7", + "Connection: keep-alive", + "Keep-Alive: 300", + "Expect:"]) + c.setopt(pycurl.CUSTOMREQUEST, "DELETE") + c.perform() + code = c.getinfo(pycurl.RESPONSE_CODE) + c.close() + + return code + + def decrypt(self, pyfile): + self.tmp_file = None + if 'DebridlinkFr' not in self.pyload.accountManager.plugins: + self.fail(_("This plugin requires an active Debrid-slink.fr account")) + + self.account = self.pyload.accountManager.getAccountPlugin("DebridlinkFr") + if len(self.account.accounts) == 0: + self.fail(_("This plugin requires an active Debrid-slink.fr account")) + + self.api_token = self.account.accounts[self.account.accounts.keys()[0]]["api_token"] + + torrent_id, torrent_urls = self.send_request_to_server() + self.wait_for_server_dl(torrent_id) + + self.packages = [(pyfile.package().name, torrent_urls, pyfile.package().name)] + + if self.tmp_file: + os.remove(self.tmp_file) diff --git a/module/plugins/crypter/Dereferer.py b/module/plugins/crypter/Dereferer.py index 8b4eb7cbac..4aee444754 100644 --- a/module/plugins/crypter/Dereferer.py +++ b/module/plugins/crypter/Dereferer.py @@ -8,7 +8,7 @@ class Dereferer(SimpleCrypter): __name__ = "Dereferer" __type__ = "crypter" - __version__ = "0.26" + __version__ = "0.27" __status__ = "testing" __pattern__ = r'https?://(?:www\.)?(?:\w+\.)*?(?P(?:[\d.]+|[\w\-]{3,63}(?:\.[a-zA-Z]{2,}){1,2})(?:\:\d+)?)/.*?(?P[\w^_]+://.+)' @@ -27,10 +27,9 @@ class Dereferer(SimpleCrypter): DIRECT_LINK = False - def _log(self, level, plugintype, pluginname, messages): + def _log(self, level, plugintype, pluginname, messages, tbframe=None): messages = (self.PLUGIN_NAME,) + messages - return SimpleCrypter._log( - self, level, plugintype, pluginname, messages) + return SimpleCrypter._log(self, level, plugintype, pluginname, messages, tbframe=tbframe) def init(self): self.__pattern__ = self.pyload.pluginManager.crypterPlugins[ diff --git a/module/plugins/crypter/DlProtectCom.py b/module/plugins/crypter/DlProtectCom.py deleted file mode 100644 index 27898d09c3..0000000000 --- a/module/plugins/crypter/DlProtectCom.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -import urlparse - -from ..internal.SimpleCrypter import SimpleCrypter - - -class DlProtectCom(SimpleCrypter): - __name__ = "DlProtectCom" - __type__ = "crypter" - __version__ = "0.13" - __status__ = "testing" - - __pattern__ = r'https?://(?:www\.)?dl-protect1\.com/\w+' - __config__ = [("activated", "bool", "Activated", True), - ("use_premium", "bool", "Use premium account if available", True), - ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default"), - ("max_wait", "int", "Reconnect if waiting time is greater than minutes", 10)] - - __description__ = """Dl-protect.com decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("Walter Purcaro", "vuolter@gmail.com"), - ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - - def get_links(self): - if "Cliquez sur continuer pour voir le(s) lien" in self.data: - self.data = self.load(self.pyfile.url, - post={'submit': "Continuer"}) - - if 'img src="captcha.php' in self.data: - captcha_code = self.captcha.decrypt(urlparse.urljoin(self.pyfile.url, "/captcha.php"), input_type="jpeg") - self.data = self.load(self.pyfile.url, - post={'captchaCode': captcha_code, - 'submit': ""}) - - if u"Le code de sécurité est incorrect" in self.data: - self.retry_captcha() - - return re.findall(r'(?P=id)', self.data) diff --git a/module/plugins/crypter/DuckCryptInfo.py b/module/plugins/crypter/DuckCryptInfo.py deleted file mode 100644 index b7289b20d4..0000000000 --- a/module/plugins/crypter/DuckCryptInfo.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8 -*- - -import re - -import BeautifulSoup - -from ..internal.Crypter import Crypter - - -class DuckCryptInfo(Crypter): - __name__ = "DuckCryptInfo" - __type__ = "crypter" - __version__ = "0.08" - __status__ = "testing" - - __pattern__ = r'http://(?:www\.)?duckcrypt\.info/(folder|wait|link)/(\w+)/?(\w*)' - __config__ = [("activated", "bool", "Activated", True), - ("use_premium", "bool", "Use premium account if available", True), - ("folder_per_package", "Default;Yes;No", "Create folder for each package", "Default")] - - __description__ = """DuckCrypt.info decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("godofdream", "soilfiction@gmail.com")] - - TIMER_PATTERN = r'(.*)' - - def decrypt(self, pyfile): - url = pyfile.url - - m = re.match(self.__pattern__, url) - if m is None: - self.fail(_("Weird error in link")) - if str(m.group(1)) == "link": - self.handle_link(url) - else: - self.handle_folder(m) - - def handle_folder(self, m): - html = self.load( - "http://duckcrypt.info/ajax/auth.php?hash=" + str(m.group(2))) - m = re.match(self.__pattern__, html) - self.log_debug("Redirect to " + m.group(0)) - html = self.load(str(m.group(0))) - soup = BeautifulSoup.BeautifulSoup(html) - cryptlinks = soup.findAll("div", attrs={'class': "folderbox"}) - self.log_debug("Redirect to " + cryptlinks) - if not cryptlinks: - self.error(_("No link found")) - for clink in cryptlinks: - if clink.find("a"): - self.handle_link(clink.find("a")['href']) - - def handle_link(self, url): - html = self.load(url) - soup = BeautifulSoup(html) - self.links = [soup.find("iframe")['src']] - if not self.links: - self.log_info(_("No link found")) diff --git a/module/plugins/crypter/DuploadOrgFolder.py b/module/plugins/crypter/DuploadOrgFolder.py deleted file mode 100644 index 6c9a236d55..0000000000 --- a/module/plugins/crypter/DuploadOrgFolder.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.DeadCrypter import DeadCrypter - - -class DuploadOrgFolder(DeadCrypter): - __name__ = "DuploadOrgFolder" - __type__ = "crypter" - __version__ = "0.08" - __status__ = "stable" - - __pattern__ = r'http://(?:www\.)?dupload\.org/folder/\d+' - __config__ = [("activated", "bool", "Activated", True)] - - __description__ = """Dupload.org folder decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("stickell", "l.stickell@yahoo.it")] diff --git a/module/plugins/crypter/EasybytezComFolder.py b/module/plugins/crypter/EasybytezComFolder.py index eae5e04f03..deb95e19c9 100644 --- a/module/plugins/crypter/EasybytezComFolder.py +++ b/module/plugins/crypter/EasybytezComFolder.py @@ -6,7 +6,7 @@ class EasybytezComFolder(XFSCrypter): __name__ = "EasybytezComFolder" __type__ = "crypter" - __version__ = "0.17" + __version__ = "0.19" __status__ = "testing" __pattern__ = r'http://(?:www\.)?easybytez\.com/users/\d+/\d+' diff --git a/module/plugins/crypter/FilebeerInfoFolder.py b/module/plugins/crypter/FilebeerInfoFolder.py deleted file mode 100644 index 90ac8b6a38..0000000000 --- a/module/plugins/crypter/FilebeerInfoFolder.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..internal.DeadCrypter import DeadCrypter - - -class FilebeerInfoFolder(DeadCrypter): - __name__ = "FilebeerInfoFolder" - __type__ = "crypter" - __version__ = "0.08" - __status__ = "stable" - - __pattern__ = r'http://(?:www\.)?filebeer\.info/\d*~f\w+' - __config__ = [("activated", "bool", "Activated", True)] - - __description__ = """Filebeer.info folder decrypter plugin""" - __license__ = "GPLv3" - __authors__ = [("zoidberg", "zoidberg@mujmail.cz")] diff --git a/module/plugins/crypter/FilecryptCc.py b/module/plugins/crypter/FilecryptCc.py index de484a6033..e3cc2672fa 100644 --- a/module/plugins/crypter/FilecryptCc.py +++ b/module/plugins/crypter/FilecryptCc.py @@ -9,47 +9,22 @@ import Crypto.Cipher.AES -from module.network.CookieJar import CookieJar -from module.network.HTTPRequest import BadHeader, HTTPRequest +from module.network.HTTPRequest import BadHeader from ..captcha.CoinHive import CoinHive from ..captcha.ReCaptcha import ReCaptcha from ..captcha.SolveMedia import SolveMedia from ..internal.Crypter import Crypter - - -class BIGHTTPRequest(HTTPRequest): - """ - Overcome HTTPRequest's load() size limit to allow - loading very big web pages by overrding HTTPRequest's write() function - """ - - # @TODO: Add 'limit' parameter to HTTPRequest in v0.4.10 - def __init__(self, cookies=None, options=None, limit=1000000): - self.limit = limit - HTTPRequest.__init__(self, cookies=cookies, options=options) - - def write(self, buf): - """ writes response """ - if self.limit and self.rep.tell() > self.limit or self.abort: - rep = self.getResponse() - if self.abort: - raise Abort() - f = open("response.dump", "wb") - f.write(rep) - f.close() - raise Exception("Loaded Url exceeded limit") - - self.rep.write(buf) +from ..internal.misc import BIGHTTPRequest, replace_patterns class FilecryptCc(Crypter): __name__ = "FilecryptCc" __type__ = "crypter" - __version__ = "0.37" + __version__ = "0.50" __status__ = "testing" - __pattern__ = r'https?://(?:www\.)?filecrypt\.cc/Container/\w+' + __pattern__ = r'https?://(?:www\.)?filecrypt\.(?:cc|co)/Container/\w+' __config__ = [("activated", "bool", "Activated", True), ("handle_mirror_pages", "bool", "Handle Mirror Pages", True)] @@ -58,13 +33,11 @@ class FilecryptCc(Crypter): __authors__ = [("zapp-brannigan", "fuerst.reinje@web.de"), ("GammaC0de", "nitzo2001[AT]yahoo[DOT]com")] - # URL_REPLACEMENTS = [(r'.html$', ""), (r'$', ".html")] #@TODO: Extend - # SimpleCrypter - COOKIES = [("filecrypt.cc", "lang", "en")] + URL_REPLACEMENTS = [(r"filecrypt.co", "filecrypt.cc")] DLC_LINK_PATTERN = r'onclick="DownloadDLC\(\'(.+)\'\);">' - WEBLINK_PATTERN = r"openLink.?'([\w\-]*)'," + WEBLINK_PATTERN = r" +
+
+
+

Manage pyLoad Origins List

+
+ + + +
+
+ + +
+
+ + + +
+
+ `; + this.overlay = document.createElement("div"); + this.overlay.style.position = "fixed"; + this.overlay.style.left = "0"; + this.overlay.style.top = "0"; + this.overlay.style.width = "100vw"; + this.overlay.style.height = "100vh"; + this.overlay.style.background = "rgba(0,0,0,0.35)"; + this.overlay.style.zIndex = "900"; + this.overlay.style.pointerEvents = "auto"; + this.shadow.appendChild(this.overlay); + this.shadow.appendChild(this.panel); + + // Prevent key events from escaping the shadow DOM by stopping propagation + // at the host boundary (both capture and bubble phases) + this._stopKeyEventPropagation = (ev) => { + // Do not preventDefault here so inputs inside the panel retain native behavior + ev.stopPropagation(); + if (typeof ev.stopImmediatePropagation === "function") ev.stopImmediatePropagation(); + }; + ["keydown", "keyup", "keypress"].forEach((type) => { + this.host.addEventListener(type, this._stopKeyEventPropagation, true); // capture + this.host.addEventListener(type, this._stopKeyEventPropagation, false); // bubble + }); + + // Cache listbox reference + this.listbox = this.shadow.querySelector("#originList"); + } + + updateListbox() { + this.listbox.innerHTML = ""; + this.settings.get("trustedOrigins").forEach(str => { + const option = document.createElement("option"); + option.value = str; + option.text = str; + this.listbox.appendChild(option); + }); + } + + bindEvents() { + // Add new trusted origin + const newOriginInput = this.shadow.querySelector("#newOrigin"); + this.shadow.querySelector("#addOrigin").addEventListener("click", () => { + const newOrigin = newOriginInput.value.trim(); + const normalized = normalizeOrigin(newOrigin); + if (!normalized) { + this.showToast("Invalid URL. Please enter a valid http(s) URL including the scheme and optional port (e.g. https://pyload.example.com:8080).", "red", 10000); + return; + } + let trustedOrigins = this.settings.get("trustedOrigins"); + if (!trustedOrigins.includes(normalized)) { + trustedOrigins.push(normalized); + this.settings.set("trustedOrigins", trustedOrigins); + this.updateListbox(); + newOriginInput.value = ""; + requestAnimationFrame(() => newOriginInput.focus({ preventScroll: true })); + } + }); + + // Remove selected trusted origin + this.shadow.querySelector("#removeOrigin").addEventListener("click", () => { + const selectedIndex = this.listbox.selectedIndex; + if (selectedIndex !== -1) { + let trustedOrigins = this.settings.get("trustedOrigins") + trustedOrigins.splice(selectedIndex, 1); + this.settings.set("trustedOrigins", trustedOrigins); + this.updateListbox(); + } + }); + + // Save settings + this.shadow.querySelector("#saveSettings").addEventListener("click", () => { + this.showToast("Settings saved", "green", 1500).then(() => this.close(true)); + }); + + // Close panel button and Title bar close button + ["#closeSettings", "#windowClose"].forEach((sel) => { + this.shadow.querySelector(sel).addEventListener("click", () => { + if (this.settings.modified()) { + this.showToast("NOT saved", "red", 1500).then(() => this.close(false)); + } else { + this.close(false) + } + }); + }); + + // // Close on overlay click + // if (this.overlay) { + // this.overlay.addEventListener("click", () => this.close(false)); + // } + + // Close on Escape key + focus trap + this.panel.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + this.close(); + return; + } + if (event.key === "Tab") { + const focusables = this.getFocusableElements(); + if (!focusables.length) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = event.target; + if (event.shiftKey) { + if (active === first) { + last.focus(); + event.preventDefault(); + } + } else { + if (active === last) { + first.focus(); + event.preventDefault(); + } + } + } + }); + } + + showToast(text, color = "green", duration = 1500) { + return new Promise((resolve) => { + const toastDiv = this.shadow.querySelector("#saveStatus"); + if (!toastDiv) { + const status = document.createElement("div"); + status.id = "saveStatus"; + status.textContent = text; + status.style.color = color; + this.panel.appendChild(status); + setTimeout(() => { + if (status.parentNode) status.parentNode.removeChild(status); + resolve(); + }, duration); + } else { + setTimeout(() => { + resolve(); + }, duration); + } + }); + } + + getFocusableElements() { + if (!this.panel) return []; + const selectors = [ + "a[href]", "area[href]", "input:not([disabled])", "select:not([disabled])", + "textarea:not([disabled])", "button:not([disabled])", '[tabindex]:not([tabindex="-1"])' + ]; + return Array.from(this.panel.querySelectorAll(selectors.join(","))); + } + } + + if (window.top === window.self) { + // Register menu command to open settings (top-level only) + GM_registerMenuCommand("Open pyLoad Interactive Captcha Settings", showSettings); + + if (settings.get("initialShow")) { + showSettings(function (result) { + if (!result) { + GM_notification({ + title: "pyLoad Interactive Captcha Script", + text: "Please configure the script settings before use.\nYou can open the settings later from the user script manager menu." + }); + } + // Disable initial show on subsequent runs + settings.set("initialShow", false); + settings.save("initialShow"); + }); + } + } + + function showSettings(callback=null) { + if (window.top !== window.self) return; + + const ui = new SettingsPanel(settings); + ui.open().then(value => { + if (typeof callback === "function") callback(value); + }).catch(() => { + if (typeof callback === "function") callback(null); + }); + } + + if (settings.get("trustedOrigins").length > 0) { + // this function listens to messages from the pyload main page + window.addEventListener("message", function (ev) { + // Restrict accepted origins to a trusted set. + const trustedOrigins = settings.get("trustedOrigins"); + if (!trustedOrigins.includes(ev.origin)) { + // Ignore messages from untrusted or unknown origins + return; + } + let request; + try { + request = JSON.parse(ev.data); + } catch (e) { + return; + } + if (request && typeof request === "object" && request.actionCode === "pyloadActivateInteractive") { + if (request.params.script) { + if (typeof KJUR === "undefined" || typeof KJUR.crypto?.Signature !== "function") { + console.error("pyLoad: crypto library not available; aborting."); + return; + } + const sig = new KJUR.crypto.Signature({ "alg": "SHA384withRSA", "prov": "cryptojs/jsrsa" }); + sig.init("-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuEHE4uAeTeEQjIwB//YH\n" + + "Gl5e058aJRCRyOvApv1iC1ZQgXGHopgEd528+AtkAZKdCRkoNCWda7L/hROpZNjq\n" + + "xgO5NjjlBnotntQiZ6xr7A4Kfdctmw1DPcv/dkp6SXRpAAw8BE9CctZ3H7cE/4UT\n" + + "FIJOYQQXF2dcBTWLnUAjesNoHBz0uHTdvBIwJdfdUIrNMI4IYXL4mq9bpKNvrwrb\n" + + "iNhSqN0yV8sanofZmDX4JUmVGpWIkpX0u+LA4bJlaylwPxjuWyIn5OBED0cdqpbO\n" + + "7t7Qtl5Yu639DF1eZDR054d9OB3iKZX1a6DTg4C5DWMIcU9TsLDm/JJKGLWRxcJJ\n" + + "fwIDAQAB\n" + + "-----END PUBLIC KEY-----"); + sig.updateString(request.params.script.code); + if (sig.verify(request.params.script.signature)) { + const gpyload = { + isVisible: function (element) { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return !( + rect.width === 0 || + rect.height === 0 || + style.display === "none" || + style.visibility === "hidden" || + style.opacity === "0" + ); + }, + debounce: function (fn, delay) { + let timer = null; + return function () { + const context = this, args = arguments; + clearTimeout(timer); + timer = setTimeout(function () { + fn.apply(context, args); + }, delay); + }; + }, + submitResponse: function (response) { + if (typeof gpyload.observer !== "undefined") { + gpyload.observer.disconnect(); + } + const responseMessage = { actionCode: "pyloadSubmitResponse", params: { response: response } }; + if (window.parent !== window && gpyload.data.parentOrigin) { + parent.postMessage(JSON.stringify(responseMessage), gpyload.data.parentOrigin); + } + }, + activated: function () { + const responseMessage = { actionCode: "pyloadActivatedInteractive" }; + if (window.parent !== window && gpyload.data.parentOrigin) { + parent.postMessage(JSON.stringify(responseMessage), gpyload.data.parentOrigin); + } + }, + setSize: function (rect) { + if (gpyload.data.rectDoc.left !== rect.left || gpyload.data.rectDoc.right !== rect.right || gpyload.data.rectDoc.top !== rect.top || gpyload.data.rectDoc.bottom !== rect.bottom) { + gpyload.data.rectDoc = rect; + const responseMessage = { actionCode: "pyloadIframeSize", params: { rect: rect } }; + if (window.parent !== window && gpyload.data.parentOrigin) { + parent.postMessage(JSON.stringify(responseMessage), gpyload.data.parentOrigin); + } + } + }, + data: { + debounceInterval: 1500, + rectDoc: { top: 0, right: 0, bottom: 0, left: 0 }, + parentOrigin: ev.origin + } + }; + + try { + let scriptFunction = new Function("request", "gpyload", '"use strict";' + request.params.script.code); + scriptFunction(request, gpyload); + } catch (err) { + console.error("pyLoad: Script aborted: " + err.name + ": " + err.message + " (" + err.stack + ")"); + return; + } + if (typeof gpyload.getFrameSize === "function") { + const checkDocSize = gpyload.debounce(() => { + window.scrollTo(0, 0); + const rect = gpyload.getFrameSize(); + gpyload.setSize(rect); + }, gpyload.data.debounceInterval); + gpyload.observer = new MutationObserver(function (mutationsList) { + checkDocSize(); + }); + const startObserving = function () { + if (document.body) { + gpyload.observer.observe(document.body, { + attributes: true, + attributeOldValue: false, + characterData: true, + characterDataOldValue: false, + childList: true, + subtree: true + }); + } + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", startObserving, { once: true }); + } else { + startObserving(); + } + } + } else { + console.error("pyLoad: Script signature verification failed"); + } + } + } + }); + } +})(); diff --git a/module/web/media/js/classic/base.js b/module/web/media/js/classic/base.js index 22f32605c3..06a9dcca8c 100644 --- a/module/web/media/js/classic/base.js +++ b/module/web/media/js/classic/base.js @@ -1,3 +1,3 @@ {% autoescape true %} -var LoadJsonToContent,clear_captcha,humanFileSize,load_captcha,on_captcha_click,parseUri,root,set_captcha,submit_captcha;root=this;humanFileSize=function(f){var c,d,e,b;d=new Array("B","KiB","MiB","GiB","TiB","PiB");b=Math.log(f)/Math.log(1024);e=Math.floor(b);c=Math.pow(1024,e);if(f===0){return"0 B"}else{return Math.round(f*100/c)/100+" "+d[e]}};parseUri=function(){var b,c,g,e,d,f,a;b=$("add_links").value;g=new RegExp("(ht|f)tp(s?)://[a-zA-Z0-9-./?=_&%#]+[<| |\"|'|\r|\n|\t]{1}","g");d=b.match(g);if(d===null){return}e="";for(f=0,a=d.length;f 100; + + $goto_top.toggleClass('hidden', !topbuttonVisible).affix({offset: {top:100}}); + + $stickyNav.css(stickynavlCss($(window).scrollTop())); + function stickynavlCss(scrollTop) { + var $headPanel = $('#head-panel'); + var headpanelHeight = $headPanel.height(); + + if (scrollTop <= headpanelHeight) { + return {"display": "none"}; + } else if (scrollTop > headpanelHeight && scrollTop < headpanelHeight*2) { + return {"display": "block", "top": (scrollTop - headpanelHeight*2) + "px"}; + } else { + return {"display": "block", "top": "0"}; + } + } + + $(window).scroll(function() { + var scrollTop = $(this).scrollTop(); + var visible = Boolean(scrollTop > 100); + + if (topbuttonVisible !== visible) { + $goto_top.toggleClass('hidden', !visible); + topbuttonVisible = visible; + } + $stickyNav.css(stickynavlCss(scrollTop)); + }); + + $goto_top.click(function () { + $('html,body').animate({scrollTop:0},'slow'); + return false; + }); + + desktopNotifications = false; + if ("Notification" in window) { + if (Notification.permission === 'granted') { + desktopNotifications = true; + } else if (Notification.permission !== 'denied') { + Notification.requestPermission().then(function(result) { + desktopNotifications = (result === 'granted'); + }); + } + } + + var addlinksMinHeight = getScrollBarHeight() + Math.round(parseFloat($("#add_links").css("line-height").replace('px',''))); + var addlinksHeight; + $("#modal-content").resizable({ + minHeight: 520 + addlinksMinHeight, + minWidth: 310, + start: function (event, ui) { + addlinksHeight = $("#add_links").height(); + }, + resize: function (event, ui) { + var addlinksNewHeight = Math.max(addlinksHeight + ui.size.height - ui.originalSize.height, addlinksMinHeight); + $("#add_links").height(addlinksNewHeight); + } + }).draggable({ scroll: false }); + + $('input[type=password].reveal-pass').map(function() { + var reveal_id; + + $(this).wrap( "
" ); + var button = $(""); + reveal_id = Date.now(); + button.attr("data-reveal-pass-id", reveal_id); + $(this).after(button); + $(this).attr("data-reveal-pass-id", reveal_id); + $(this).on('input', function () { + var visible = Boolean($(this).val()); + $(this).siblings('button[data-reveal-pass-id="' + $(this).attr("data-reveal-pass-id") + '"]').toggleClass('hidden', !visible); + }); + button.mousedown(function(event) { + event.preventDefault(); + $(this).find("span.glyphicon").removeClass('glyphicon-eye-close').addClass('glyphicon-eye-open'); + $(this).siblings('input[data-reveal-pass-id="' + $(this).attr("data-reveal-pass-id") + '"]').attr('type', 'text'); + }).mouseup(function(event) { + event.preventDefault(); + $(this).find("span.glyphicon").removeClass('glyphicon-eye-open').addClass('glyphicon-eye-close'); + $(this).siblings('input[data-reveal-pass-id="' + $(this).attr("data-reveal-pass-id") + '"]').attr('type', 'password'); + }).click(function (event) { + event.preventDefault(); + }); + }); + + $('.btn, input[type="radio"]').focus(function() { this.blur(); }); + + $("#add_form").submit(function(event) { + event.preventDefault(); + var formData = new FormData(this); + var $this = $(this); + if ($this.find("#add_name").val() === "" && $this.find("#add_file").val() === "") { + alert("{{_('Please Enter a package name.')}}"); + return false; + } else { + $.ajax({ + url: "{{'/json/add_package'|url}}", + method: "POST", + data: formData, + processData: false, + contentType: false, + success: function() { + var queue = $this.find("#add_dest").val() === "1" ? "queue" : "collector"; + var re = new RegExp("/" + queue + "/?$", "i"); + if (window.location.toString().match(re)) { + window.location.reload(); + } + }, + error: function() { + indicateFail("{{_('Error occurred')}}"); + } + }); + $("#add_box").modal('hide'); + return false; + } + }); + + $(".action_add").click(function() { + $("#add_form").trigger("reset"); + }); + + $("#action_play").click(function() { + $.get("{{'/api/unpauseServer'|url}}", function () { + $.ajax({ + method: "POST", + url: "{{'/json/status'|url}}", + async: true, + timeout: 3000, + success: LoadJsonToContent + }); + }); + }); + + $("#action_cancel").click(function() { + $.get("{{'/api/stopAllDownloads'|url}}"); + }); + + $("#action_stop").click(function() { + $.get("{{'/api/pauseServer'|url}}", function () { + $.ajax({ + method: "POST", + url: "{{'/json/status'|url}}", + async: true, + timeout: 3000, + success: LoadJsonToContent + }); + }); + }); + + $(".cap_info").click(function() { + load_captcha("get", ""); + }); + + $("#cap_submit").click(function() { + submit_captcha(); + // stop()?? + }); + + $("#cap_box #cap_positional").click(submit_positional_captcha); + + $.ajax({ + method: "POST", + url: "{{'/json/status'|url}}", + async: true, + timeout: 3000, + success:LoadJsonToContent + }); + + setInterval(function() { + $.ajax({ + method: "POST", + url: "{{'/json/status'|url}}", + async: true, + timeout: 3000, + success:LoadJsonToContent + }); + }, 4000); +}); + +function LoadJsonToContent(a) { + var notification; + $("#speed").text(humanFileSize(a.speed) + "/s"); + $("#aktiv").text(a.active); + $("#aktiv_from").text(a.queue); + $("#aktiv_total").text(a.total); + var $cap_info = $(".cap_info"); + if (a.captcha) { + var notificationVisible = ($cap_info.css("display") !== "none"); + if (!notificationVisible) { + $cap_info.css('display','inline'); + mdtoast("{{_('New Captcha Request')}}", {position: "bottom center", type: "info", duration: 6000}); + } + if (desktopNotifications && !document.hasFocus() && !notificationVisible) { + notification = new Notification('pyLoad', { + icon: "{{'/favicon.ico'|url}}", + body: "{{_('New Captcha Request')}}", + tag: 'pyload_captcha' + }); + notification.onclick = function (event) { + event.preventDefault(); + parent.focus(); + window.focus(); + $("#action_cap")[0].click(); + }; + setTimeout(function() { + notification.close() + }, 8000); + } + } else { + $cap_info.css('display', 'none'); + } + if (a.download) { + $("#time").text(" {{_('on')}}").css('background-color', '#5cb85c'); + } else { + $("#time").text(" {{_('off')}}").css('background-color', "#d9534f"); + } + if (a.reconnect) { + $("#reconnect").text(" {{_('on')}}").css('background-color', "#5cb85c"); + } else { + $("#reconnect").text(" {{_('off')}}").css('background-color', "#d9534f"); + } + return null; +} + +function set_captcha(a) { + captcha_reset_default(); + + params = JSON.parse(a.params); + $("#cap_id").val(a.id); + if (a.result_type === "textual") { + $("#cap_textual_img").attr("src", params.src); + $("#cap_submit").css("display", "inline"); + $("#cap_box #cap_title").text(""); + $("#cap_textual").css("display", "block"); + $("#cap_result").focus(); + } else if (a.result_type === "positional") { + $("#cap_positional_img").attr("src", params.src); + $("#cap_box #cap_title").text("{{_('Please click on the right captcha position.')}}"); + $("#cap_positional").css("display", "block"); + } else if (a.result_type === "interactive") { + $("#cap_box #cap_title").text(""); + if(interactiveCaptchaHandlerInstance == null) { + interactiveCaptchaHandlerInstance = new interactiveCaptchaHandler("cap_interactive_iframe", "cap_interactive_loading", submit_interactive_captcha); + } + if(params.url !== undefined && params.url.indexOf("http") === 0) { + $("#cap_interactive").css("display", "block"); + interactiveCaptchaHandlerInstance.startInteraction(params.url, params); + } + } else if (a.result_type === "invisible") { + $("#cap_box #cap_title").text(""); + if (interactiveCaptchaHandlerInstance == null) { + interactiveCaptchaHandlerInstance = new interactiveCaptchaHandler("cap_interactive_iframe", "cap_invisible_loading", submit_interactive_captcha); + } + if (params.url !== undefined && params.url.indexOf("http") === 0) { + $("#cap_interactive").css("display", "block"); + interactiveCaptchaHandlerInstance.startInteraction(params.url, params); + } + } + return true; +} + +function load_captcha(b, a) { + $.ajax({ + url: "{{'/json/set_captcha'|url}}", + async: true, + method: b, + data: a, + success: function(c) { + return (c.captcha ? set_captcha(c) : clear_captcha()); + } + }); +} + +function captcha_reset_default() { + $("#cap_textual").css("display", "none"); + $("#cap_textual_img").attr("src", ""); + $("#cap_positional").css("display", "none"); + $("#cap_positional_img").attr("src", ""); + $("#cap_interactive").css("display", "none"); + $("#cap_submit").css("display", "none"); + // $("#cap_box #cap_title").text("{{_('No Captchas to read.')}}"); + $("#cap_interactive_iframe").attr("src", "").css({display: "none", top: "", left: ""}) + .parent().css({height: "", width: ""}); + $("#cap_interactive_loading").css("display", "none"); + $("#cap_invisible_loading").css("display", "none"); + if(interactiveCaptchaHandlerInstance) { + interactiveCaptchaHandlerInstance.clearEventlisteners(); + interactiveCaptchaHandlerInstance = null; + } + return true; +} + +function clear_captcha() { + captcha_reset_default(); + $('#cap_box').modal('hide'); + return true; +} + +function submit_captcha() { + var $cap_result = $("#cap_result"); + load_captcha("post", "cap_id=" + $("#cap_id").val() + "&cap_result=" + $cap_result.val()); + $cap_result.val(""); + return false; +} + +function submit_positional_captcha(c) { + var b, a, d; + // b = c.target.getPosition(); + var x = (c.pageX - $(this).offset().left).toFixed(0); + var y = (c.pageY - $(this).offset().top).toFixed(0); + $("#cap_box #cap_result").val(x + ' , ' + y); + return submit_captcha(); +} + +function submit_interactive_captcha(c) { + if (c.constructor === {}.constructor) + c = JSON.stringify(c); + else if (c.constructor !== "".constructor) + return; + + $("#cap_box #cap_result").val(c); + return submit_captcha(); +} + +function interactiveCaptchaHandler(iframeId, loadingid, captchaResponseCallback) { + this._iframeId = iframeId; + this._loadingId = loadingid; + this._captchaResponseCallback = captchaResponseCallback; + this._active = false; // true: link grabbing is running, false: standby + + $("#" + this._loadingId).css("display", "block"); + $("#" + this._iframeId).on("load", this, this.iframeLoaded); + + // Register event listener for communication with iframe + $(window).on('message', this, this.windowEventListener); +} + +// This function is called when the iframe is loaded, and it activates the link grabber of the tampermonkey script +interactiveCaptchaHandler.prototype.iframeLoaded = function(e) { + var interactiveHandlerInstance = e.data; + if(interactiveHandlerInstance._active) { + var requestMessage = { + actionCode: interactiveHandlerInstance.actionCodes.activate, + params: interactiveHandlerInstance._params}; + // Notify TamperMonkey so it can do it's magic.. + $("#" + interactiveHandlerInstance._iframeId).get(0).contentWindow.postMessage(JSON.stringify(requestMessage),"*"); + } +}; + +interactiveCaptchaHandler.prototype.startInteraction = function(url, params) { + // Activate + this._active = true; + + this._params = params; + + $("#" + this._iframeId).attr("src", url); +}; + +// This function listens to messages from the TamperMonkey script in the iframe +interactiveCaptchaHandler.prototype.windowEventListener = function(event) { + var requestMessage; + try { + requestMessage = JSON.parse(event.originalEvent.data); + } catch (e) { + if (e instanceof SyntaxError) { + return + } else { + console.error(e) + } + } + var interactiveHandlerInstance = event.data; + + if(requestMessage.actionCode === interactiveHandlerInstance.actionCodes.submitResponse) { + // We got the response! pass it to the callback function + interactiveHandlerInstance._captchaResponseCallback(requestMessage.params.response); + interactiveHandlerInstance.clearEventlisteners(); + + } else if(requestMessage.actionCode === interactiveHandlerInstance.actionCodes.activated) { + $("#" + interactiveHandlerInstance._loadingId).css("display", "none"); + $("#" + interactiveHandlerInstance._iframeId).css("display", "block"); + + } else if (requestMessage.actionCode === interactiveHandlerInstance.actionCodes.size) { + var $iframe = $("#" + interactiveHandlerInstance._iframeId); + var width = requestMessage.params.rect.right - requestMessage.params.rect.left; + var height = requestMessage.params.rect.bottom - requestMessage.params.rect.top; + $iframe.css({top : - requestMessage.params.rect.top + "px", + left : - requestMessage.params.rect.left + "px"}) + .parent().width(width).height(height); + } +}; + +interactiveCaptchaHandler.prototype.clearEventlisteners = function() { + // Deactivate + this._active = false; + + // Clean up event listeners + $("#" + this._iframeId).off("load", this.iframeLoaded); + $(window).off('message', this.windowEventListener); +}; + +// Action codes for communication with iframe via postMessage +interactiveCaptchaHandler.prototype.actionCodes = { + activate: "pyloadActivateInteractive", + activated: "pyloadActivatedInteractive", + size: "pyloadIframeSize", + submitResponse: "pyloadSubmitResponse" +}; + +{% endautoescape %} \ No newline at end of file diff --git a/module/web/media/js/modern/bootstrap.min.js b/module/web/media/js/modern/bootstrap.min.js new file mode 100644 index 0000000000..c6d36920be --- /dev/null +++ b/module/web/media/js/modern/bootstrap.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v3.3.2 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.2",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.2",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active"));a&&this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),c.preventDefault()}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.2",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));return a>this.$items.length-1||0>a?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&"show"==b&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a(this.options.trigger).filter('[href="#'+b.id+'"], [data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.2",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0,trigger:'[data-toggle="collapse"]'},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":a.extend({},e.data(),{trigger:this});c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=c(d),f={relatedTarget:this};e.hasClass("open")&&(e.trigger(b=a.Event("hide.bs.dropdown",f)),b.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f)))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.2",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(' @@ -163,7 +210,7 @@

{% block subtitle %}{{_(' ')}}{% endblock %}

{{message}}

{% endfor %}
-
+
{{_('loading')}}
@@ -175,7 +222,7 @@

{% block subtitle %}{{_(' ')}}{% endblock %}


- +