Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3233b4b9c | ||
|
|
c4fad3cd20 | ||
|
|
9b520b1941 | ||
|
|
bcf4a479ac | ||
|
|
abc923ade4 | ||
|
|
3aa72c8afb | ||
|
|
249017a4fa | ||
|
|
84f432830c | ||
|
|
e6ccd42f89 | ||
|
|
12d46d33d9 | ||
|
|
8d9f301aea | ||
|
|
0ff3f0f426 | ||
|
|
b2f5853b12 | ||
|
|
f8adcbb691 | ||
|
|
a0728b07d0 | ||
|
|
e3b895755d | ||
|
|
e33e4ac4ac | ||
|
|
2f515b725b | ||
|
|
a1d7dc5c28 | ||
|
|
1c5d1e24f7 | ||
|
|
8df45d150d | ||
|
|
a59d20bd0f | ||
|
|
e3be7595e2 | ||
|
|
e33a7a3f08 | ||
|
|
8c02ffb1cb | ||
|
|
7367e96d2b | ||
|
|
2d80187faa | ||
|
|
1d3a768dfe | ||
|
|
c09e1ed203 | ||
|
|
99e50bafc4 | ||
|
|
155cf04536 | ||
|
|
ebe7cd7ba4 | ||
|
|
2d470e0ce1 | ||
|
|
87c0b15f5f | ||
|
|
02d2c2bbf8 | ||
|
|
6996e813b5 | ||
|
|
840f4d2010 | ||
|
|
3cda0a0f32 | ||
|
|
a6931b4936 | ||
|
|
9536b2c322 | ||
|
|
099dbf95e7 | ||
|
|
23d5463213 | ||
|
|
a2ceeb678f | ||
|
|
092329c9e6 | ||
|
|
de989783e8 | ||
|
|
1d7dcce98d | ||
|
|
551828f8cf | ||
|
|
d26964ab08 | ||
|
|
0fe12fe598 | ||
|
|
b0e8024f08 | ||
|
|
bc1c097271 | ||
|
|
f98cb3d8fb | ||
|
|
488005962f | ||
|
|
6d6f8c1b1f | ||
|
|
c4a38087d9 | ||
|
|
f468b9e6e3 | ||
|
|
53a16c5a6f | ||
|
|
108caa0898 | ||
|
|
a0b776cbd1 | ||
|
|
4719797aff | ||
|
|
60cc0c9395 | ||
|
|
ded4cb7a19 | ||
|
|
f5f16a3dd2 | ||
|
|
c5f2323c8a | ||
|
|
e9bccc83c5 | ||
|
|
53ba352ed9 | ||
|
|
bbc3219d31 | ||
|
|
ea89fe07d8 | ||
|
|
ab2620a265 | ||
|
|
a20a4a201e | ||
|
|
ba0718f3d0 | ||
|
|
60dd3df861 | ||
|
|
762074596d | ||
|
|
789912d0d4 | ||
|
|
daf8dd6f90 | ||
|
|
e02610e2f0 | ||
|
|
897188ff18 | ||
|
|
09c0794f1a | ||
|
|
b28a3635f8 | ||
|
|
1a1f1498a5 | ||
|
|
92ffce1dc8 | ||
|
|
6441f5f96b | ||
|
|
d5a57e3b07 | ||
|
|
fef20c831a | ||
|
|
e52e8c6fdf | ||
|
|
89ac9ff498 | ||
|
|
059bdf1649 | ||
|
|
266ef11940 | ||
|
|
64e4a1155c | ||
|
|
f7d5480811 | ||
|
|
7aa122e6cd | ||
|
|
cfdc07e6b2 | ||
|
|
1ca1ac973a | ||
|
|
ba4235a39e | ||
|
|
7295ebaee1 | ||
|
|
d74ea7dcf2 | ||
|
|
ee23ee97d9 | ||
|
|
c73ec52842 | ||
|
|
0e3d3876f3 | ||
|
|
76d086f536 | ||
|
|
6022f48040 | ||
|
|
e0be168779 | ||
|
|
d3118c0aa9 | ||
|
|
04370944b9 | ||
|
|
af52748aca | ||
|
|
7eece7603b | ||
|
|
d6aa29b095 | ||
|
|
5d86bb0ba0 | ||
|
|
ab11919c0b | ||
|
|
014a247847 | ||
|
|
58c269881f | ||
|
|
e91dd5729a | ||
|
|
6760d6971c | ||
|
|
ad05534ed2 | ||
|
|
ee451fb236 | ||
|
|
ab13fcf60c | ||
|
|
c7fa2ac71a | ||
|
|
6f60ec7b99 | ||
|
|
93f1ccbea9 | ||
|
|
894fd9d30a | ||
|
|
2b1efdb50e | ||
|
|
fc9a9cb9ac | ||
|
|
e99be72ff7 | ||
|
|
f1ceff2b52 | ||
|
|
7e85894b3a | ||
|
|
5c49c8cdd7 | ||
|
|
c8c3b3cba3 | ||
|
|
4911843146 | ||
|
|
2c37546598 | ||
|
|
25a875b628 | ||
|
|
506f0a33cf | ||
|
|
921caf6716 | ||
|
|
60ed1904f5 | ||
|
|
cb62d36e44 | ||
|
|
737d758362 | ||
|
|
8e27912ff5 | ||
|
|
3a603cec22 | ||
|
|
b1d7badef4 | ||
|
|
e591211b57 | ||
|
|
a305c35de4 | ||
|
|
51d306b11d | ||
|
|
abb418fe86 | ||
|
|
0925f34557 | ||
|
|
15952a764c | ||
|
|
28c810b514 | ||
|
|
cfe61a26d0 | ||
|
|
964e7de920 | ||
|
|
05032aed3c | ||
|
|
7e46f9c0b1 | ||
|
|
5e15cac598 | ||
|
|
d0551274dc | ||
|
|
99c05650a1 | ||
|
|
617ee3d781 | ||
|
|
47aa3254c7 | ||
|
|
0717b9493c | ||
|
|
824ebc4d76 | ||
|
|
cbaa61bbb7 | ||
|
|
dbac685985 | ||
|
|
b25c024b66 | ||
|
|
bc65180c32 | ||
|
|
10c66b6e63 | ||
|
|
c29b1696f7 | ||
|
|
c974cd048e | ||
|
|
24e9bf2728 | ||
|
|
ff46af9c49 | ||
|
|
0a7345186d | ||
|
|
cb0d9ec95c |
@@ -89,8 +89,9 @@ Refer to the Wiki for additional installation examples: [manual installation](ht
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.5+
|
- Python 3.7+
|
||||||
- [Imagemagick](https://imagemagick.org/script/download.php) for cover extraction from EPUBs (Windows users may need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction)
|
- [Imagemagick](https://imagemagick.org/script/download.php) for cover extraction from EPUBs (Windows users may need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction)
|
||||||
|
- Windows users need to install [libmagic for 32bit python](https://gnuwin32.sourceforge.net/downlinks/file.php) or [libmagic for 64bit python](https://github.com/nscaife/file-windows/releases/tag/20170108), depending on the python version; The files need to be installed in path (e.g. script folder of your Calibre-Web venv, or in the root folder of Calibre-Web
|
||||||
- Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
|
- Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
|
||||||
- Optional: [Kepubify tool](https://github.com/pgaskin/kepubify/releases/latest) for Kobo device support (place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows)
|
- Optional: [Kepubify tool](https://github.com/pgaskin/kepubify/releases/latest) for Kobo device support (place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows)
|
||||||
|
|
||||||
@@ -107,8 +108,8 @@ Pre-built Docker images are available in the following Docker Hub repositories (
|
|||||||
|
|
||||||
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
|
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
|
||||||
|
|
||||||
- Set "path to convertertool" to `/usr/bin/ebook-convert`
|
- Set "Path to Calibre Binaries" to `/usr/bin`
|
||||||
- Set "path to unrar" to `/usr/bin/unrar`
|
- Set "Path to Unrar" to `/usr/bin/unrar`
|
||||||
|
|
||||||
## Contributor Recognition
|
## Contributor Recognition
|
||||||
|
|
||||||
|
|||||||
@@ -10,41 +10,46 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
|
|||||||
|
|
||||||
## History
|
## History
|
||||||
|
|
||||||
| Fixed in | Description |CVE number |
|
| Fixed in | Description |CVE number |
|
||||||
|---------------|--------------------------------------------------------------------------------------------------------------------|---------|
|
|---------------|--------------------------------------------------------------------------------------------------------------------------------|---------|
|
||||||
| 3rd July 2018 | Guest access acts as a backdoor ||
|
| 3rd July 2018 | Guest access acts as a backdoor ||
|
||||||
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 |
|
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 |
|
||||||
| V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
|
| V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
|
||||||
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
|
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
|
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
|
||||||
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
|
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
|
||||||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
|
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
|
||||||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
|
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
|
||||||
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
|
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
|
||||||
| V 0.6.13 | JavaScript could get executed in the shelf title ||
|
| V 0.6.13 | JavaScript could get executed in the shelf title ||
|
||||||
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
|
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
|
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
|
||||||
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
||||||
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
||||||
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
||||||
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
||||||
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
||||||
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
||||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
||||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
||||||
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
|
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
|
||||||
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
|
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
|
||||||
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
|
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
|
||||||
| V 0.6.20 | Credentials for emails are now stored encrypted ||
|
| V 0.6.20 | Credentials for emails are now stored encrypted ||
|
||||||
| V 0.6.20 | Login is rate limited ||
|
| V 0.6.20 | Login is rate limited ||
|
||||||
| V 0.6.20 | Passwordstrength can be forced ||
|
| V 0.6.20 | Passwordstrength can be forced ||
|
||||||
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
|
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
|
||||||
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
|
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
|
||||||
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
|
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
|
||||||
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
|
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
|
||||||
|
| V 0.6.22 | Upload mimetype is checked to prevent malicious file content in the books library ||
|
||||||
|
| V 0.6.22 | Cross-site scripting (XSS) stored in comments section is prevented better (switching from lxml to bleach for sanitizing strings) ||
|
||||||
|
| V 0.6.23 | Cookies are no longer stored for opds basic authentication and proxy authentication ||
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Statement regarding Log4j (CVE-2021-44228 and related)
|
## Statement regarding Log4j (CVE-2021-44228 and related)
|
||||||
|
|||||||
@@ -27,7 +27,23 @@ sys.path.insert(0, path)
|
|||||||
|
|
||||||
from cps.main import main
|
from cps.main import main
|
||||||
|
|
||||||
|
|
||||||
|
def hide_console_windows():
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
kernel32 = ctypes.WinDLL('kernel32')
|
||||||
|
user32 = ctypes.WinDLL('user32')
|
||||||
|
|
||||||
|
SW_HIDE = 0
|
||||||
|
|
||||||
|
hWnd = kernel32.GetConsoleWindow()
|
||||||
|
if hWnd:
|
||||||
|
user32.ShowWindow(hWnd, SW_HIDE)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
if os.name == "nt":
|
||||||
|
hide_console_windows()
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,33 +20,18 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .cw_login import LoginManager
|
||||||
|
from flask import session
|
||||||
|
|
||||||
from flask_login import LoginManager, confirm_login
|
|
||||||
from flask import session, current_app
|
|
||||||
from flask_login.utils import decode_cookie
|
|
||||||
from flask_login.signals import user_loaded_from_cookie
|
|
||||||
|
|
||||||
class MyLoginManager(LoginManager):
|
class MyLoginManager(LoginManager):
|
||||||
def _session_protection_failed(self):
|
def _session_protection_failed(self):
|
||||||
sess = session._get_current_object()
|
sess = session._get_current_object()
|
||||||
ident = self._session_identifier_generator()
|
ident = self._session_identifier_generator()
|
||||||
if(sess and not (len(sess) == 1
|
if(sess and not (len(sess) == 1
|
||||||
and sess.get('csrf_token', None))) and ident != sess.get('_id', None):
|
and sess.get('csrf_token', None))) and ident != sess.get('_id', None):
|
||||||
return super(). _session_protection_failed()
|
return super(). _session_protection_failed()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _load_user_from_remember_cookie(self, cookie):
|
|
||||||
user_id = decode_cookie(cookie)
|
|
||||||
if user_id is not None:
|
|
||||||
session["_user_id"] = user_id
|
|
||||||
session["_fresh"] = False
|
|
||||||
user = None
|
|
||||||
if self._user_callback:
|
|
||||||
user = self._user_callback(user_id)
|
|
||||||
if user is not None:
|
|
||||||
app = current_app._get_current_object()
|
|
||||||
user_loaded_from_cookie.send(app, user=user)
|
|
||||||
# if session was restored from remember me cookie make login valid
|
|
||||||
confirm_login()
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -56,23 +56,26 @@ except ImportError:
|
|||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||||
mimetypes.add_type('application/epub+zip', '.epub')
|
mimetypes.add_type('application/epub+zip', '.epub')
|
||||||
mimetypes.add_type('application/fb2+zip', '.fb2')
|
mimetypes.add_type('application/epub+zip', '.kepub')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
mimetypes.add_type('text/xml', '.fb2')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
|
mimetypes.add_type('application/octet-stream', '.mobi')
|
||||||
|
mimetypes.add_type('application/octet-stream', '.prc')
|
||||||
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
||||||
mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
|
mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
|
||||||
mimetypes.add_type('application/x-cbr', '.cbr')
|
mimetypes.add_type('application/x-rar', '.cbr')
|
||||||
mimetypes.add_type('application/x-cbz', '.cbz')
|
mimetypes.add_type('application/zip', '.cbz')
|
||||||
mimetypes.add_type('application/x-cbt', '.cbt')
|
mimetypes.add_type('application/x-tar', '.cbt')
|
||||||
mimetypes.add_type('application/x-cb7', '.cb7')
|
mimetypes.add_type('application/x-7z-compressed', '.cb7')
|
||||||
mimetypes.add_type('image/vnd.djv', '.djv')
|
mimetypes.add_type('image/vnd.djv', '.djv')
|
||||||
|
mimetypes.add_type('image/vnd.djv', '.djvu')
|
||||||
mimetypes.add_type('application/mpeg', '.mpeg')
|
mimetypes.add_type('application/mpeg', '.mpeg')
|
||||||
mimetypes.add_type('application/mpeg', '.mp3')
|
mimetypes.add_type('audio/mpeg', '.mp3')
|
||||||
mimetypes.add_type('application/mp4', '.m4a')
|
mimetypes.add_type('audio/x-m4a', '.m4a')
|
||||||
mimetypes.add_type('application/mp4', '.m4b')
|
mimetypes.add_type('audio/x-m4a', '.m4b')
|
||||||
mimetypes.add_type('application/ogg', '.ogg')
|
mimetypes.add_type('audio/ogg', '.ogg')
|
||||||
mimetypes.add_type('application/ogg', '.oga')
|
mimetypes.add_type('application/ogg', '.oga')
|
||||||
mimetypes.add_type('text/css', '.css')
|
mimetypes.add_type('text/css', '.css')
|
||||||
|
mimetypes.add_type('application/x-ms-reader', '.lit')
|
||||||
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -80,8 +83,8 @@ log = logger.create()
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE='Lax',
|
SESSION_COOKIE_SAMESITE='Strict',
|
||||||
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
|
REMEMBER_COOKIE_SAMESITE='Strict', # will be available in flask-login 0.5.1 earliest
|
||||||
WTF_CSRF_SSL_STRICT=False
|
WTF_CSRF_SSL_STRICT=False
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,6 +110,7 @@ if limiter_present:
|
|||||||
else:
|
else:
|
||||||
limiter = None
|
limiter = None
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
if csrf:
|
if csrf:
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
@@ -186,7 +190,6 @@ def create_app():
|
|||||||
services.ldap.init_app(app, config)
|
services.ldap.init_app(app, config)
|
||||||
if services.goodreads_support:
|
if services.goodreads_support:
|
||||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||||
config.config_goodreads_api_secret_e,
|
|
||||||
config.config_use_goodreads)
|
config.config_use_goodreads)
|
||||||
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||||
# Configure rate limiter
|
# Configure rate limiter
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ import sqlite3
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
import flask_login
|
|
||||||
import jinja2
|
import jinja2
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from . import db, calibre_db, converter, uploader, constants, dep_check
|
from . import db, calibre_db, converter, uploader, constants, dep_check
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
|
|
||||||
about = flask.Blueprint('about', __name__)
|
about = flask.Blueprint('about', __name__)
|
||||||
@@ -74,7 +74,7 @@ def collect_stats():
|
|||||||
|
|
||||||
|
|
||||||
@about.route("/stats")
|
@about.route("/stats")
|
||||||
@flask_login.login_required
|
@user_login_required
|
||||||
def stats():
|
def stats():
|
||||||
counter = calibre_db.session.query(db.Books).count()
|
counter = calibre_db.session.query(db.Books).count()
|
||||||
authors = calibre_db.session.query(db.Authors).count()
|
authors = calibre_db.session.query(db.Authors).count()
|
||||||
|
|||||||
@@ -34,10 +34,9 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from flask_login import login_required, current_user, logout_user
|
from .cw_login import current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
||||||
from flask import session as flask_session
|
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
|
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
|
||||||
@@ -52,6 +51,7 @@ from .embed_helper import get_calibre_binarypath
|
|||||||
from .gdriveutils import is_gdrive_ready, gdrive_support
|
from .gdriveutils import is_gdrive_ready, gdrive_support
|
||||||
from .render_template import render_title_template, get_sidebar_config
|
from .render_template import render_title_template, get_sidebar_config
|
||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
|
from .usermanagement import user_login_required
|
||||||
from .babel import get_available_translations, get_available_locale, get_user_locale_language
|
from .babel import get_available_translations, get_available_locale, get_user_locale_language
|
||||||
from . import debug_info
|
from . import debug_info
|
||||||
|
|
||||||
@@ -103,13 +103,13 @@ def admin_required(f):
|
|||||||
|
|
||||||
@admi.before_app_request
|
@admi.before_app_request
|
||||||
def before_request():
|
def before_request():
|
||||||
try:
|
#try:
|
||||||
if not ub.check_user_session(current_user.id,
|
#if not ub.check_user_session(current_user.id,
|
||||||
flask_session.get('_id')) and 'opds' not in request.path \
|
# flask_session.get('_id')) and 'opds' not in request.path \
|
||||||
and config.config_session == 1:
|
# and config.config_session == 1:
|
||||||
logout_user()
|
# logout_user()
|
||||||
except AttributeError:
|
#except AttributeError:
|
||||||
pass # ? fails on requesting /ajax/emailstat during restart ?
|
# pass # ? fails on requesting /ajax/emailstat during restart ?
|
||||||
g.constants = constants
|
g.constants = constants
|
||||||
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
|
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
|
||||||
g.allow_registration = config.config_public_reg
|
g.allow_registration = config.config_public_reg
|
||||||
@@ -129,14 +129,14 @@ def before_request():
|
|||||||
return redirect(url_for('admin.db_configuration'))
|
return redirect(url_for('admin.db_configuration'))
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin")
|
#@admi.route("/admin")
|
||||||
@login_required
|
#@user_login_required
|
||||||
def admin_forbidden():
|
#def admin_forbidden():
|
||||||
abort(403)
|
# abort(403)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/shutdown", methods=["POST"])
|
@admi.route("/shutdown", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def shutdown():
|
def shutdown():
|
||||||
task = request.get_json().get('parameter', -1)
|
task = request.get_json().get('parameter', -1)
|
||||||
@@ -165,7 +165,7 @@ def shutdown():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/metadata_backup", methods=["POST"])
|
@admi.route("/metadata_backup", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def queue_metadata_backup():
|
def queue_metadata_backup():
|
||||||
show_text = {}
|
show_text = {}
|
||||||
@@ -189,7 +189,7 @@ def reconnect():
|
|||||||
|
|
||||||
@admi.route("/ajax/updateThumbnails", methods=['POST'])
|
@admi.route("/ajax/updateThumbnails", methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
@login_required
|
@user_login_required
|
||||||
def update_thumbnails():
|
def update_thumbnails():
|
||||||
content = config.get_scheduled_task_settings()
|
content = config.get_scheduled_task_settings()
|
||||||
if content['schedule_generate_book_covers']:
|
if content['schedule_generate_book_covers']:
|
||||||
@@ -199,7 +199,7 @@ def update_thumbnails():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/view")
|
@admi.route("/admin/view")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin():
|
def admin():
|
||||||
version = updater_thread.get_current_version_info()
|
version = updater_thread.get_current_version_info()
|
||||||
@@ -233,7 +233,7 @@ def admin():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def db_configuration():
|
def db_configuration():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -242,7 +242,7 @@ def db_configuration():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/config", methods=["GET"])
|
@admi.route("/admin/config", methods=["GET"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def configuration():
|
def configuration():
|
||||||
return render_title_template("config_edit.html",
|
return render_title_template("config_edit.html",
|
||||||
@@ -253,28 +253,28 @@ def configuration():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def ajax_config():
|
def ajax_config():
|
||||||
return _configuration_update_helper()
|
return _configuration_update_helper()
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
|
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def ajax_db_config():
|
def ajax_db_config():
|
||||||
return _db_configuration_update_helper()
|
return _db_configuration_update_helper()
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/alive", methods=["GET"])
|
@admi.route("/admin/alive", methods=["GET"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def calibreweb_alive():
|
def calibreweb_alive():
|
||||||
return "", 200
|
return "", 200
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/viewconfig")
|
@admi.route("/admin/viewconfig")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def view_configuration():
|
def view_configuration():
|
||||||
read_column = calibre_db.session.query(db.CustomColumns) \
|
read_column = calibre_db.session.query(db.CustomColumns) \
|
||||||
@@ -291,7 +291,7 @@ def view_configuration():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/usertable")
|
@admi.route("/admin/usertable")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_user_table():
|
def edit_user_table():
|
||||||
visibility = current_user.view_settings.get('useredit', {})
|
visibility = current_user.view_settings.get('useredit', {})
|
||||||
@@ -326,7 +326,7 @@ def edit_user_table():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/listusers")
|
@admi.route("/ajax/listusers")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_users():
|
def list_users():
|
||||||
off = int(request.args.get("offset") or 0)
|
off = int(request.args.get("offset") or 0)
|
||||||
@@ -377,7 +377,7 @@ def list_users():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/deleteuser", methods=['POST'])
|
@admi.route("/ajax/deleteuser", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_user():
|
def delete_user():
|
||||||
user_ids = request.form.to_dict(flat=False)
|
user_ids = request.form.to_dict(flat=False)
|
||||||
@@ -412,7 +412,7 @@ def delete_user():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/getlocale")
|
@admi.route("/ajax/getlocale")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def table_get_locale():
|
def table_get_locale():
|
||||||
locale = get_available_locale()
|
locale = get_available_locale()
|
||||||
@@ -424,7 +424,7 @@ def table_get_locale():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/getdefaultlanguage")
|
@admi.route("/ajax/getdefaultlanguage")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def table_get_default_lang():
|
def table_get_default_lang():
|
||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
@@ -436,7 +436,7 @@ def table_get_default_lang():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
|
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_list_user(param):
|
def edit_list_user(param):
|
||||||
vals = request.form.to_dict(flat=False)
|
vals = request.form.to_dict(flat=False)
|
||||||
@@ -479,7 +479,7 @@ def edit_list_user(param):
|
|||||||
elif param.endswith('role'):
|
elif param.endswith('role'):
|
||||||
value = int(vals['field_index'])
|
value = int(vals['field_index'])
|
||||||
if user.name == "Guest" and value in \
|
if user.name == "Guest" and value in \
|
||||||
[constants.ROLE_ADMIN, constants.ROLE_PASSWD, constants.ROLE_EDIT_SHELFS]:
|
[constants.ROLE_ADMIN, constants.ROLE_PASSWD, constants.ROLE_EDIT_SHELFS]:
|
||||||
raise Exception(_("Guest can't have this role"))
|
raise Exception(_("Guest can't have this role"))
|
||||||
# check for valid value, last on checks for power of 2 value
|
# check for valid value, last on checks for power of 2 value
|
||||||
if value > 0 and value <= constants.ROLE_VIEWER and (value & value - 1 == 0 or value == 1):
|
if value > 0 and value <= constants.ROLE_VIEWER and (value & value - 1 == 0 or value == 1):
|
||||||
@@ -541,7 +541,7 @@ def edit_list_user(param):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/user_table_settings", methods=['POST'])
|
@admi.route("/ajax/user_table_settings", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_table_settings():
|
def update_table_settings():
|
||||||
current_user.view_settings['useredit'] = json.loads(request.data)
|
current_user.view_settings['useredit'] = json.loads(request.data)
|
||||||
@@ -558,7 +558,7 @@ def update_table_settings():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/viewconfig", methods=["POST"])
|
@admi.route("/admin/viewconfig", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_view_configuration():
|
def update_view_configuration():
|
||||||
to_save = request.form.to_dict()
|
to_save = request.form.to_dict()
|
||||||
@@ -603,7 +603,7 @@ def update_view_configuration():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
|
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
def load_dialogtexts(element_id):
|
def load_dialogtexts(element_id):
|
||||||
texts = {"header": "", "main": "", "valid": 1}
|
texts = {"header": "", "main": "", "valid": 1}
|
||||||
if element_id == "config_delete_kobo_token":
|
if element_id == "config_delete_kobo_token":
|
||||||
@@ -639,7 +639,7 @@ def load_dialogtexts(element_id):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
|
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_domain(allow):
|
def edit_domain(allow):
|
||||||
# POST /post
|
# POST /post
|
||||||
@@ -653,7 +653,7 @@ def edit_domain(allow):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
|
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def add_domain(allow):
|
def add_domain(allow):
|
||||||
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
|
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
|
||||||
@@ -667,7 +667,7 @@ def add_domain(allow):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/deletedomain", methods=['POST'])
|
@admi.route("/ajax/deletedomain", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_domain():
|
def delete_domain():
|
||||||
try:
|
try:
|
||||||
@@ -685,7 +685,7 @@ def delete_domain():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/domainlist/<int:allow>")
|
@admi.route("/ajax/domainlist/<int:allow>")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_domain(allow):
|
def list_domain(allow):
|
||||||
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
|
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
|
||||||
@@ -698,7 +698,7 @@ def list_domain(allow):
|
|||||||
|
|
||||||
@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
|
@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
|
||||||
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_restriction(res_type, user_id):
|
def edit_restriction(res_type, user_id):
|
||||||
element = request.form.to_dict()
|
element = request.form.to_dict()
|
||||||
@@ -764,14 +764,14 @@ def edit_restriction(res_type, user_id):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
|
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def add_user_0_restriction(res_type):
|
def add_user_0_restriction(res_type):
|
||||||
return add_restriction(res_type, 0)
|
return add_restriction(res_type, 0)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def add_restriction(res_type, user_id):
|
def add_restriction(res_type, user_id):
|
||||||
element = request.form.to_dict()
|
element = request.form.to_dict()
|
||||||
@@ -817,14 +817,14 @@ def add_restriction(res_type, user_id):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
|
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_user_0_restriction(res_type):
|
def delete_user_0_restriction(res_type):
|
||||||
return delete_restriction(res_type, 0)
|
return delete_restriction(res_type, 0)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_restriction(res_type, user_id):
|
def delete_restriction(res_type, user_id):
|
||||||
element = request.form.to_dict()
|
element = request.form.to_dict()
|
||||||
@@ -872,7 +872,7 @@ def delete_restriction(res_type, user_id):
|
|||||||
|
|
||||||
@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
|
@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
|
||||||
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
|
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_restriction(res_type, user_id):
|
def list_restriction(res_type, user_id):
|
||||||
if res_type == 0: # Tags as template
|
if res_type == 0: # Tags as template
|
||||||
@@ -916,20 +916,20 @@ def list_restriction(res_type, user_id):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/fullsync", methods=["POST"])
|
@admi.route("/ajax/fullsync", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def ajax_self_fullsync():
|
def ajax_self_fullsync():
|
||||||
return do_full_kobo_sync(current_user.id)
|
return do_full_kobo_sync(current_user.id)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def ajax_fullsync(userid):
|
def ajax_fullsync(userid):
|
||||||
return do_full_kobo_sync(userid)
|
return do_full_kobo_sync(userid)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/pathchooser/")
|
@admi.route("/ajax/pathchooser/")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def ajax_pathchooser():
|
def ajax_pathchooser():
|
||||||
return pathchooser()
|
return pathchooser()
|
||||||
@@ -945,7 +945,7 @@ def do_full_kobo_sync(userid):
|
|||||||
def check_valid_read_column(column):
|
def check_valid_read_column(column):
|
||||||
if column != "0":
|
if column != "0":
|
||||||
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
||||||
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all():
|
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all():
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -953,7 +953,7 @@ def check_valid_read_column(column):
|
|||||||
def check_valid_restricted_column(column):
|
def check_valid_restricted_column(column):
|
||||||
if column != "0":
|
if column != "0":
|
||||||
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
||||||
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all():
|
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all():
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -999,10 +999,7 @@ def get_drives(current):
|
|||||||
for d in string.ascii_uppercase:
|
for d in string.ascii_uppercase:
|
||||||
if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
|
if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
|
||||||
drive = "{}:\\".format(d)
|
drive = "{}:\\".format(d)
|
||||||
data = {"name": drive, "fullpath": drive}
|
data = {"name": drive, "fullpath": drive, "type": "dir", "size": "", "sort": "_" + drive.lower()}
|
||||||
data["sort"] = "_" + data["fullpath"].lower()
|
|
||||||
data["type"] = "dir"
|
|
||||||
data["size"] = ""
|
|
||||||
drive_letters.append(data)
|
drive_letters.append(data)
|
||||||
return drive_letters
|
return drive_letters
|
||||||
|
|
||||||
@@ -1142,12 +1139,12 @@ def _configuration_oauth_helper(to_save):
|
|||||||
reboot_required = False
|
reboot_required = False
|
||||||
for element in oauthblueprints:
|
for element in oauthblueprints:
|
||||||
if to_save["config_" + str(element['id']) + "_oauth_client_id"] != element['oauth_client_id'] \
|
if to_save["config_" + str(element['id']) + "_oauth_client_id"] != element['oauth_client_id'] \
|
||||||
or to_save["config_" + str(element['id']) + "_oauth_client_secret"] != element['oauth_client_secret']:
|
or to_save["config_" + str(element['id']) + "_oauth_client_secret"] != element['oauth_client_secret']:
|
||||||
reboot_required = True
|
reboot_required = True
|
||||||
element['oauth_client_id'] = to_save["config_" + str(element['id']) + "_oauth_client_id"]
|
element['oauth_client_id'] = to_save["config_" + str(element['id']) + "_oauth_client_id"]
|
||||||
element['oauth_client_secret'] = to_save["config_" + str(element['id']) + "_oauth_client_secret"]
|
element['oauth_client_secret'] = to_save["config_" + str(element['id']) + "_oauth_client_secret"]
|
||||||
if to_save["config_" + str(element['id']) + "_oauth_client_id"] \
|
if to_save["config_" + str(element['id']) + "_oauth_client_id"] \
|
||||||
and to_save["config_" + str(element['id']) + "_oauth_client_secret"]:
|
and to_save["config_" + str(element['id']) + "_oauth_client_secret"]:
|
||||||
active_oauths += 1
|
active_oauths += 1
|
||||||
element["active"] = 1
|
element["active"] = 1
|
||||||
else:
|
else:
|
||||||
@@ -1202,9 +1199,9 @@ def _configuration_ldap_helper(to_save):
|
|||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
if not config.config_ldap_provider_url \
|
if not config.config_ldap_provider_url \
|
||||||
or not config.config_ldap_port \
|
or not config.config_ldap_port \
|
||||||
or not config.config_ldap_dn \
|
or not config.config_ldap_dn \
|
||||||
or not config.config_ldap_user_object:
|
or not config.config_ldap_user_object:
|
||||||
return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, '
|
return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, '
|
||||||
'Port, DN and User Object Identifier'))
|
'Port, DN and User Object Identifier'))
|
||||||
|
|
||||||
@@ -1249,7 +1246,7 @@ def _configuration_ldap_helper(to_save):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/simulatedbchange", methods=['POST'])
|
@admi.route("/ajax/simulatedbchange", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def simulatedbchange():
|
def simulatedbchange():
|
||||||
db_change, db_valid = _db_simulate_change()
|
db_change, db_valid = _db_simulate_change()
|
||||||
@@ -1257,7 +1254,7 @@ def simulatedbchange():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/user/new", methods=["GET", "POST"])
|
@admi.route("/admin/user/new", methods=["GET", "POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def new_user():
|
def new_user():
|
||||||
content = ub.User()
|
content = ub.User()
|
||||||
@@ -1279,7 +1276,7 @@ def new_user():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/mailsettings", methods=["GET"])
|
@admi.route("/admin/mailsettings", methods=["GET"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_mailsettings():
|
def edit_mailsettings():
|
||||||
content = config.get_mail_settings()
|
content = config.get_mail_settings()
|
||||||
@@ -1288,7 +1285,7 @@ def edit_mailsettings():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/mailsettings", methods=["POST"])
|
@admi.route("/admin/mailsettings", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_mailsettings():
|
def update_mailsettings():
|
||||||
to_save = request.form.to_dict()
|
to_save = request.form.to_dict()
|
||||||
@@ -1345,7 +1342,7 @@ def update_mailsettings():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/scheduledtasks")
|
@admi.route("/admin/scheduledtasks")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_scheduledtasks():
|
def edit_scheduledtasks():
|
||||||
content = config.get_scheduled_task_settings()
|
content = config.get_scheduled_task_settings()
|
||||||
@@ -1366,13 +1363,13 @@ def edit_scheduledtasks():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_scheduledtasks():
|
def update_scheduledtasks():
|
||||||
error = False
|
error = False
|
||||||
to_save = request.form.to_dict()
|
to_save = request.form.to_dict()
|
||||||
if 0 <= int(to_save.get("schedule_start_time")) <= 23:
|
if 0 <= int(to_save.get("schedule_start_time")) <= 23:
|
||||||
_config_int( to_save, "schedule_start_time")
|
_config_int(to_save, "schedule_start_time")
|
||||||
else:
|
else:
|
||||||
flash(_("Invalid start time for task specified"), category="error")
|
flash(_("Invalid start time for task specified"), category="error")
|
||||||
error = True
|
error = True
|
||||||
@@ -1409,7 +1406,7 @@ def update_scheduledtasks():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
|
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_user(user_id):
|
def edit_user(user_id):
|
||||||
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
|
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
|
||||||
@@ -1438,7 +1435,7 @@ def edit_user(user_id):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
|
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def reset_user_password(user_id):
|
def reset_user_password(user_id):
|
||||||
if current_user is not None and current_user.is_authenticated:
|
if current_user is not None and current_user.is_authenticated:
|
||||||
@@ -1456,7 +1453,7 @@ def reset_user_password(user_id):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/logfile")
|
@admi.route("/admin/logfile")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def view_logfile():
|
def view_logfile():
|
||||||
logfiles = {0: logger.get_logfile(config.config_logfile),
|
logfiles = {0: logger.get_logfile(config.config_logfile),
|
||||||
@@ -1470,7 +1467,7 @@ def view_logfile():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/log/<int:logtype>")
|
@admi.route("/ajax/log/<int:logtype>")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def send_logfile(logtype):
|
def send_logfile(logtype):
|
||||||
if logtype == 1:
|
if logtype == 1:
|
||||||
@@ -1486,7 +1483,7 @@ def send_logfile(logtype):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/logdownload/<int:logtype>")
|
@admi.route("/admin/logdownload/<int:logtype>")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def download_log(logtype):
|
def download_log(logtype):
|
||||||
if logtype == 0:
|
if logtype == 0:
|
||||||
@@ -1501,14 +1498,14 @@ def download_log(logtype):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/debug")
|
@admi.route("/admin/debug")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def download_debug():
|
def download_debug():
|
||||||
return debug_info.send_debug()
|
return debug_info.send_debug()
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/get_update_status", methods=['GET'])
|
@admi.route("/get_update_status", methods=['GET'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_update_status():
|
def get_update_status():
|
||||||
if feature_support['updater']:
|
if feature_support['updater']:
|
||||||
@@ -1519,7 +1516,7 @@ def get_update_status():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/get_updater_status", methods=['GET', 'POST'])
|
@admi.route("/get_updater_status", methods=['GET', 'POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_updater_status():
|
def get_updater_status():
|
||||||
status = {}
|
status = {}
|
||||||
@@ -1614,7 +1611,7 @@ def ldap_import_create_user(user, user_data):
|
|||||||
|
|
||||||
|
|
||||||
@admi.route('/import_ldap_users', methods=["POST"])
|
@admi.route('/import_ldap_users', methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def import_ldap_users():
|
def import_ldap_users():
|
||||||
showtext = {}
|
showtext = {}
|
||||||
@@ -1631,7 +1628,10 @@ def import_ldap_users():
|
|||||||
|
|
||||||
imported = 0
|
imported = 0
|
||||||
for username in new_users:
|
for username in new_users:
|
||||||
user = username.decode('utf-8')
|
if isinstance(username, bytes):
|
||||||
|
user = username.decode('utf-8')
|
||||||
|
else:
|
||||||
|
user = username
|
||||||
if '=' in user:
|
if '=' in user:
|
||||||
# if member object field is empty take user object as filter
|
# if member object field is empty take user object as filter
|
||||||
if config.config_ldap_member_user_object:
|
if config.config_ldap_member_user_object:
|
||||||
@@ -1666,7 +1666,7 @@ def import_ldap_users():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/canceltask", methods=['POST'])
|
@admi.route("/ajax/canceltask", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def cancel_task():
|
def cancel_task():
|
||||||
task_id = request.get_json().get('task_id', None)
|
task_id = request.get_json().get('task_id', None)
|
||||||
@@ -1717,7 +1717,7 @@ def _db_configuration_update_helper():
|
|||||||
return _db_configuration_result('{}'.format(ex), gdrive_error)
|
return _db_configuration_result('{}'.format(ex), gdrive_error)
|
||||||
|
|
||||||
if db_change or not db_valid or not config.db_configured \
|
if db_change or not db_valid or not config.db_configured \
|
||||||
or config.config_calibre_dir != to_save["config_calibre_dir"]:
|
or config.config_calibre_dir != to_save["config_calibre_dir"]:
|
||||||
if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
|
if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
|
||||||
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
|
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
|
||||||
else:
|
else:
|
||||||
@@ -1777,7 +1777,7 @@ def _configuration_update_helper():
|
|||||||
to_save["config_upload_formats"] = ','.join(
|
to_save["config_upload_formats"] = ','.join(
|
||||||
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
||||||
_config_string(to_save, "config_upload_formats")
|
_config_string(to_save, "config_upload_formats")
|
||||||
constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
|
# constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
|
||||||
|
|
||||||
_config_string(to_save, "config_calibre")
|
_config_string(to_save, "config_calibre")
|
||||||
_config_string(to_save, "config_binariesdir")
|
_config_string(to_save, "config_binariesdir")
|
||||||
@@ -1806,11 +1806,8 @@ def _configuration_update_helper():
|
|||||||
# Goodreads configuration
|
# Goodreads configuration
|
||||||
_config_checkbox(to_save, "config_use_goodreads")
|
_config_checkbox(to_save, "config_use_goodreads")
|
||||||
_config_string(to_save, "config_goodreads_api_key")
|
_config_string(to_save, "config_goodreads_api_key")
|
||||||
if to_save.get("config_goodreads_api_secret_e", ""):
|
|
||||||
_config_string(to_save, "config_goodreads_api_secret_e")
|
|
||||||
if services.goodreads_support:
|
if services.goodreads_support:
|
||||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||||
config.config_goodreads_api_secret_e,
|
|
||||||
config.config_use_goodreads)
|
config.config_use_goodreads)
|
||||||
|
|
||||||
_config_int(to_save, "config_updatechannel")
|
_config_int(to_save, "config_updatechannel")
|
||||||
@@ -1830,6 +1827,7 @@ def _configuration_update_helper():
|
|||||||
reboot_required |= reboot
|
reboot_required |= reboot
|
||||||
|
|
||||||
# security configuration
|
# security configuration
|
||||||
|
_config_checkbox(to_save, "config_check_extensions")
|
||||||
_config_checkbox(to_save, "config_password_policy")
|
_config_checkbox(to_save, "config_password_policy")
|
||||||
_config_checkbox(to_save, "config_password_number")
|
_config_checkbox(to_save, "config_password_number")
|
||||||
_config_checkbox(to_save, "config_password_lower")
|
_config_checkbox(to_save, "config_password_lower")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from babel import negotiate_locale
|
|||||||
from flask_babel import Babel, Locale
|
from flask_babel import Babel, Locale
|
||||||
from babel.core import UnknownLocaleError
|
from babel.core import UnknownLocaleError
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
|
|
||||||
from . import logger
|
from . import logger
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ log = logger.create()
|
|||||||
|
|
||||||
babel = Babel()
|
babel = Babel()
|
||||||
|
|
||||||
|
|
||||||
def get_locale():
|
def get_locale():
|
||||||
# if a user is logged in, use the locale from the user settings
|
# if a user is logged in, use the locale from the user settings
|
||||||
if current_user is not None and hasattr(current_user, "locale"):
|
if current_user is not None and hasattr(current_user, "locale"):
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
|
||||||
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
|
|
||||||
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
|
|
||||||
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
|
|
||||||
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
|
|
||||||
# apetresc, nanu-c, mutschler, carderne
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
from cps.pagination import Pagination
|
|
||||||
from flask import Blueprint
|
|
||||||
from flask_babel import gettext as _
|
|
||||||
from flask_babel import get_locale
|
|
||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
from . import logger, isoLanguages
|
|
||||||
from . import db, config
|
|
||||||
from . import calibre_db
|
|
||||||
from .usermanagement import login_required_if_no_ano
|
|
||||||
from .render_template import render_title_template
|
|
||||||
from .web import get_sort_function
|
|
||||||
|
|
||||||
try:
|
|
||||||
from natsort import natsorted as sort
|
|
||||||
except ImportError:
|
|
||||||
sort = sorted # Just use regular sort then, may cause issues with badly named pages in cbz/cbr files
|
|
||||||
|
|
||||||
basic = Blueprint('basic', __name__)
|
|
||||||
|
|
||||||
log = logger.create()
|
|
||||||
|
|
||||||
|
|
||||||
@basic.route("/basic", methods=["GET"])
|
|
||||||
@login_required_if_no_ano
|
|
||||||
def index():
|
|
||||||
term = request.args.get("query", "") # default to showing all books
|
|
||||||
limit = 15
|
|
||||||
page = int(request.args.get("page") or 1)
|
|
||||||
off = (page - 1) * limit
|
|
||||||
order = get_sort_function("stored", "search")
|
|
||||||
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
|
||||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
|
||||||
config,
|
|
||||||
off,
|
|
||||||
order,
|
|
||||||
limit,
|
|
||||||
*join)
|
|
||||||
return render_title_template('basic_index.html',
|
|
||||||
searchterm=term,
|
|
||||||
pagination=pagination,
|
|
||||||
query=term,
|
|
||||||
adv_searchterm=term,
|
|
||||||
entries=entries,
|
|
||||||
result_count=result_count,
|
|
||||||
title=_("Search"),
|
|
||||||
page="search",
|
|
||||||
order=order[1])
|
|
||||||
|
|
||||||
|
|
||||||
@basic.route("/basic_book/<int:book_id>")
|
|
||||||
@login_required_if_no_ano
|
|
||||||
def show_book(book_id):
|
|
||||||
entries = calibre_db.get_book_read_archived(book_id, config.config_read_column, allow_show_archived=True)
|
|
||||||
if entries:
|
|
||||||
entry = entries[0]
|
|
||||||
for lang_index in range(0, len(entry.languages)):
|
|
||||||
entry.languages[lang_index].language_name = isoLanguages.get_language_name(get_locale(), entry.languages[
|
|
||||||
lang_index].lang_code)
|
|
||||||
entry.ordered_authors = calibre_db.order_authors([entry])
|
|
||||||
|
|
||||||
return render_title_template('basic_detail.html',
|
|
||||||
entry=entry,
|
|
||||||
is_xhr=request.headers.get('X-Requested-With') == 'XMLHttpRequest',
|
|
||||||
title=entry.title,
|
|
||||||
page="book")
|
|
||||||
else:
|
|
||||||
log.debug("Selected book is unavailable. File does not exist or is not accessible")
|
|
||||||
return redirect(url_for("basic.index"))
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2019 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
from lxml.etree import ParserError
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# at least bleach 6.0 is needed -> incomplatible change from list arguments to set arguments
|
||||||
|
from bleach import clean as clean_html
|
||||||
|
from bleach.sanitizer import ALLOWED_TAGS
|
||||||
|
bleach = True
|
||||||
|
except ImportError:
|
||||||
|
from nh3 import clean as clean_html
|
||||||
|
bleach = False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_string(unsafe_text, book_id=0):
|
||||||
|
try:
|
||||||
|
if bleach:
|
||||||
|
allowed_tags = list(ALLOWED_TAGS)
|
||||||
|
allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6"])
|
||||||
|
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
|
||||||
|
else:
|
||||||
|
safe_text = clean_html(unsafe_text)
|
||||||
|
except ParserError as e:
|
||||||
|
log.error("Comments of book {} are corrupted: {}".format(book_id, e))
|
||||||
|
safe_text = ""
|
||||||
|
except TypeError as e:
|
||||||
|
log.error("Comments can't be parsed, maybe 'lxml' is too new, try installing 'bleach': {}".format(e))
|
||||||
|
safe_text = ""
|
||||||
|
return safe_text
|
||||||
@@ -35,6 +35,19 @@ def version_info():
|
|||||||
|
|
||||||
class CliParameter(object):
|
class CliParameter(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.user_credentials = None
|
||||||
|
self.ip_address = None
|
||||||
|
self.allow_localhost = None
|
||||||
|
self.reconnect_enable = None
|
||||||
|
self.memory_backend = None
|
||||||
|
self.dry_run = None
|
||||||
|
self.certfilepath = None
|
||||||
|
self.keyfilepath = None
|
||||||
|
self.gd_path = None
|
||||||
|
self.settings_path = None
|
||||||
|
self.logpath = None
|
||||||
|
|
||||||
def init(self):
|
def init(self):
|
||||||
self.arg_parser()
|
self.arg_parser()
|
||||||
|
|
||||||
@@ -44,22 +57,25 @@ class CliParameter(object):
|
|||||||
prog='cps.py')
|
prog='cps.py')
|
||||||
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
||||||
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
||||||
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, e.g. /opt/test.cert, '
|
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, '
|
||||||
'works only in combination with keyfile')
|
'e.g. /opt/test.cert, works only in combination with keyfile')
|
||||||
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
|
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
|
||||||
'works only in combination with certfile')
|
'works only in combination with certfile')
|
||||||
parser.add_argument('-o', metavar='path', help='path and name Calibre-Web logfile')
|
parser.add_argument('-o', metavar='path', help='path and name Calibre-Web logfile')
|
||||||
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
parser.add_argument('-v', '--version', action='version', help='Shows version number '
|
||||||
|
'and exits Calibre-Web',
|
||||||
version=version_info())
|
version=version_info())
|
||||||
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
||||||
parser.add_argument('-m', action='store_true', help='Use Memory-backend as limiter backend, use this parameter in case of miss configured backend')
|
parser.add_argument('-m', action='store_true',
|
||||||
|
help='Use Memory-backend as limiter backend, use this parameter '
|
||||||
|
'in case of miss configured backend')
|
||||||
parser.add_argument('-s', metavar='user:pass',
|
parser.add_argument('-s', metavar='user:pass',
|
||||||
help='Sets specific username to new password and exits Calibre-Web')
|
help='Sets specific username to new password and exits Calibre-Web')
|
||||||
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
|
||||||
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
||||||
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions '
|
||||||
'and exits Calibre-Web')
|
'in advance and exits Calibre-Web')
|
||||||
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
parser.add_argument('-r', action='store_true', help='Enable public database reconnect '
|
||||||
|
'route under /reconnect')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
self.logpath = args.o or ""
|
self.logpath = args.o or ""
|
||||||
@@ -130,6 +146,3 @@ class CliParameter(object):
|
|||||||
if self.user_credentials and ":" not in self.user_credentials:
|
if self.user_credentials and ":" not in self.user_credentials:
|
||||||
print("No valid 'username:password' format")
|
print("No valid 'username:password' format")
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
if args.f:
|
|
||||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
|||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in cover.COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
try:
|
try:
|
||||||
cover_data = cf.read(name)[name].read()
|
cover_data = cf.read([name])[name].read()
|
||||||
except (py7zr.Bad7zFile, OSError) as ex:
|
except (py7zr.Bad7zFile, OSError) as ex:
|
||||||
log.error('7Zip file failed with error: {}'.format(ex))
|
log.error('7Zip file failed with error: {}'.format(ex))
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class _Flask_Settings(_Base):
|
|||||||
flask_session_key = Column(BLOB, default=b"")
|
flask_session_key = Column(BLOB, default=b"")
|
||||||
|
|
||||||
def __init__(self, key):
|
def __init__(self, key):
|
||||||
|
super().__init__()
|
||||||
self.flask_session_key = key
|
self.flask_session_key = key
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +83,9 @@ class _Settings(_Base):
|
|||||||
config_random_books = Column(Integer, default=4)
|
config_random_books = Column(Integer, default=4)
|
||||||
config_authors_max = Column(Integer, default=0)
|
config_authors_max = Column(Integer, default=0)
|
||||||
config_read_column = Column(Integer, default=0)
|
config_read_column = Column(Integer, default=0)
|
||||||
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
|
config_title_regex = Column(String,
|
||||||
|
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine'
|
||||||
|
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
|
||||||
config_theme = Column(Integer, default=0)
|
config_theme = Column(Integer, default=0)
|
||||||
|
|
||||||
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
||||||
@@ -114,8 +117,6 @@ class _Settings(_Base):
|
|||||||
|
|
||||||
config_use_goodreads = Column(Boolean, default=False)
|
config_use_goodreads = Column(Boolean, default=False)
|
||||||
config_goodreads_api_key = Column(String)
|
config_goodreads_api_key = Column(String)
|
||||||
config_goodreads_api_secret_e = Column(String)
|
|
||||||
config_goodreads_api_secret = Column(String)
|
|
||||||
config_register_email = Column(Boolean, default=False)
|
config_register_email = Column(Boolean, default=False)
|
||||||
config_login_type = Column(Integer, default=0)
|
config_login_type = Column(Integer, default=0)
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ class _Settings(_Base):
|
|||||||
config_ratelimiter = Column(Boolean, default=True)
|
config_ratelimiter = Column(Boolean, default=True)
|
||||||
config_limiter_uri = Column(String, default="")
|
config_limiter_uri = Column(String, default="")
|
||||||
config_limiter_options = Column(String, default="")
|
config_limiter_options = Column(String, default="")
|
||||||
|
config_check_extensions = Column(Boolean, default=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__class__.__name__
|
return self.__class__.__name__
|
||||||
@@ -180,6 +182,26 @@ class _Settings(_Base):
|
|||||||
class ConfigSQL(object):
|
class ConfigSQL(object):
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
'''self.config_calibre_uuid = None
|
||||||
|
self.config_calibre_split_dir = None
|
||||||
|
self.dirty = None
|
||||||
|
self.config_logfile = None
|
||||||
|
self.config_upload_formats = None
|
||||||
|
self.mail_gmail_token = None
|
||||||
|
self.mail_server_type = None
|
||||||
|
self.mail_server = None
|
||||||
|
self.config_log_level = None
|
||||||
|
self.config_allowed_column_value = None
|
||||||
|
self.config_denied_column_value = None
|
||||||
|
self.config_allowed_tags = None
|
||||||
|
self.config_denied_tags = None
|
||||||
|
self.config_default_show = None
|
||||||
|
self.config_default_role = None
|
||||||
|
self.config_keyfile = None
|
||||||
|
self.config_certfile = None
|
||||||
|
self.config_rarfile_location = None
|
||||||
|
self.config_kepubifypath = None
|
||||||
|
self.config_binariesdir = None'''
|
||||||
self.__dict__["dirty"] = list()
|
self.__dict__["dirty"] = list()
|
||||||
|
|
||||||
def init_config(self, session, secret_key, cli):
|
def init_config(self, session, secret_key, cli):
|
||||||
@@ -193,16 +215,16 @@ class ConfigSQL(object):
|
|||||||
|
|
||||||
change = False
|
change = False
|
||||||
|
|
||||||
if self.config_binariesdir == None: # pylint: disable=access-member-before-definition
|
if self.config_binariesdir is None:
|
||||||
change = True
|
change = True
|
||||||
self.config_binariesdir = autodetect_calibre_binaries()
|
self.config_binariesdir = autodetect_calibre_binaries()
|
||||||
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
|
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
|
||||||
|
|
||||||
if self.config_kepubifypath == None: # pylint: disable=access-member-before-definition
|
if self.config_kepubifypath is None:
|
||||||
change = True
|
change = True
|
||||||
self.config_kepubifypath = autodetect_kepubify_binary()
|
self.config_kepubifypath = autodetect_kepubify_binary()
|
||||||
|
|
||||||
if self.config_rarfile_location == None: # pylint: disable=access-member-before-definition
|
if self.config_rarfile_location is None:
|
||||||
change = True
|
change = True
|
||||||
self.config_rarfile_location = autodetect_unrar_binary()
|
self.config_rarfile_location = autodetect_unrar_binary()
|
||||||
if change:
|
if change:
|
||||||
@@ -350,7 +372,7 @@ class ConfigSQL(object):
|
|||||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||||
have_metadata_db = os.path.isfile(db_file)
|
have_metadata_db = os.path.isfile(db_file)
|
||||||
self.db_configured = have_metadata_db
|
self.db_configured = have_metadata_db
|
||||||
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
# constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||||
from . import cli_param
|
from . import cli_param
|
||||||
if os.environ.get('FLASK_DEBUG'):
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||||
@@ -422,23 +444,16 @@ def _encrypt_fields(session, secret_key):
|
|||||||
except OperationalError:
|
except OperationalError:
|
||||||
with session.bind.connect() as conn:
|
with session.bind.connect() as conn:
|
||||||
conn.execute(text("ALTER TABLE settings ADD column 'mail_password_e' String"))
|
conn.execute(text("ALTER TABLE settings ADD column 'mail_password_e' String"))
|
||||||
conn.execute(text("ALTER TABLE settings ADD column 'config_goodreads_api_secret_e' String"))
|
|
||||||
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String"))
|
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String"))
|
||||||
session.commit()
|
session.commit()
|
||||||
crypter = Fernet(secret_key)
|
crypter = Fernet(secret_key)
|
||||||
settings = session.query(_Settings.mail_password, _Settings.config_goodreads_api_secret,
|
settings = session.query(_Settings.mail_password, _Settings.config_ldap_serv_password).first()
|
||||||
_Settings.config_ldap_serv_password).first()
|
|
||||||
if settings.mail_password:
|
if settings.mail_password:
|
||||||
session.query(_Settings).update(
|
session.query(_Settings).update(
|
||||||
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
|
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
|
||||||
if settings.config_goodreads_api_secret:
|
|
||||||
session.query(_Settings).update(
|
|
||||||
{_Settings.config_goodreads_api_secret_e:
|
|
||||||
crypter.encrypt(settings.config_goodreads_api_secret.encode())})
|
|
||||||
if settings.config_ldap_serv_password:
|
if settings.config_ldap_serv_password:
|
||||||
session.query(_Settings).update(
|
session.query(_Settings).update(
|
||||||
{_Settings.config_ldap_serv_password_e:
|
{_Settings.config_ldap_serv_password_e: crypter.encrypt(settings.config_ldap_serv_password.encode())})
|
||||||
crypter.encrypt(settings.config_ldap_serv_password.encode())})
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -491,9 +506,12 @@ def autodetect_calibre_binaries():
|
|||||||
else:
|
else:
|
||||||
calibre_path = ["/opt/calibre/"]
|
calibre_path = ["/opt/calibre/"]
|
||||||
for element in calibre_path:
|
for element in calibre_path:
|
||||||
supported_binary_paths = [os.path.join(element, binary) for binary in constants.SUPPORTED_CALIBRE_BINARIES.values()]
|
supported_binary_paths = [os.path.join(element, binary)
|
||||||
if all(os.path.isfile(binary_path) and os.access(binary_path, os.X_OK) for binary_path in supported_binary_paths):
|
for binary in constants.SUPPORTED_CALIBRE_BINARIES.values()]
|
||||||
values = [process_wait([binary_path, "--version"], pattern='\(calibre (.*)\)') for binary_path in supported_binary_paths]
|
if all(os.path.isfile(binary_path) and os.access(binary_path, os.X_OK)
|
||||||
|
for binary_path in supported_binary_paths):
|
||||||
|
values = [process_wait([binary_path, "--version"],
|
||||||
|
pattern=r'\(calibre (.*)\)') for binary_path in supported_binary_paths]
|
||||||
if all(values):
|
if all(values):
|
||||||
version = values[0].group(1)
|
version = values[0].group(1)
|
||||||
log.debug("calibre version %s", version)
|
log.debug("calibre version %s", version)
|
||||||
@@ -551,7 +569,7 @@ def load_configuration(session, secret_key):
|
|||||||
|
|
||||||
def get_flask_session_key(_session):
|
def get_flask_session_key(_session):
|
||||||
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||||
if flask_settings == None:
|
if flask_settings is None:
|
||||||
flask_settings = _Flask_Settings(os.urandom(32))
|
flask_settings = _Flask_Settings(os.urandom(32))
|
||||||
_session.add(flask_settings)
|
_session.add(flask_settings)
|
||||||
_session.commit()
|
_session.commit()
|
||||||
@@ -562,6 +580,7 @@ def get_encryption_key(key_path):
|
|||||||
key_file = os.path.join(key_path, ".key")
|
key_file = os.path.join(key_path, ".key")
|
||||||
generate = True
|
generate = True
|
||||||
error = ""
|
error = ""
|
||||||
|
key = None
|
||||||
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
|
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
|
||||||
with open(key_file, "rb") as f:
|
with open(key_file, "rb") as f:
|
||||||
key = f.read()
|
key = f.read()
|
||||||
|
|||||||
@@ -159,12 +159,13 @@ EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr'
|
|||||||
_extension = ""
|
_extension = ""
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
_extension = ".exe"
|
_extension = ".exe"
|
||||||
SUPPORTED_CALIBRE_BINARIES = {binary:binary + _extension for binary in ["ebook-convert", "calibredb"]}
|
SUPPORTED_CALIBRE_BINARIES = {binary: binary + _extension for binary in ["ebook-convert", "calibredb"]}
|
||||||
|
|
||||||
|
|
||||||
def has_flag(value, bit_flag):
|
def has_flag(value, bit_flag):
|
||||||
return bit_flag == (bit_flag & (value or 0))
|
return bit_flag == (bit_flag & (value or 0))
|
||||||
|
|
||||||
|
|
||||||
def selected_roles(dictionary):
|
def selected_roles(dictionary):
|
||||||
return sum(v for k, v in ALL_ROLES.items() if k in dictionary)
|
return sum(v for k, v in ALL_ROLES.items() if k in dictionary)
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, d
|
|||||||
'series_id, languages, publisher, pubdate, identifiers')
|
'series_id, languages, publisher, pubdate, identifiers')
|
||||||
|
|
||||||
# python build process likes to have x.y.zbw -> b for beta and w a counting number
|
# python build process likes to have x.y.zbw -> b for beta and w a counting number
|
||||||
STABLE_VERSION = {'version': '0.6.22b'}
|
STABLE_VERSION = {'version': '0.6.23'}
|
||||||
|
|
||||||
NIGHTLY_VERSION = dict()
|
NIGHTLY_VERSION = dict()
|
||||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# from .__about__ import __version__
|
||||||
|
from .config import AUTH_HEADER_NAME
|
||||||
|
from .config import COOKIE_DURATION
|
||||||
|
from .config import COOKIE_HTTPONLY
|
||||||
|
from .config import COOKIE_NAME
|
||||||
|
from .config import COOKIE_SECURE
|
||||||
|
from .config import ID_ATTRIBUTE
|
||||||
|
from .config import LOGIN_MESSAGE
|
||||||
|
from .config import LOGIN_MESSAGE_CATEGORY
|
||||||
|
from .config import REFRESH_MESSAGE
|
||||||
|
from .config import REFRESH_MESSAGE_CATEGORY
|
||||||
|
from .login_manager import LoginManager
|
||||||
|
from .mixins import AnonymousUserMixin
|
||||||
|
from .mixins import UserMixin
|
||||||
|
from .signals import session_protected
|
||||||
|
from .signals import user_accessed
|
||||||
|
from .signals import user_loaded_from_cookie
|
||||||
|
from .signals import user_loaded_from_request
|
||||||
|
from .signals import user_logged_in
|
||||||
|
from .signals import user_logged_out
|
||||||
|
from .signals import user_login_confirmed
|
||||||
|
from .signals import user_needs_refresh
|
||||||
|
from .signals import user_unauthorized
|
||||||
|
# from .test_client import FlaskLoginClient
|
||||||
|
from .utils import confirm_login
|
||||||
|
from .utils import current_user
|
||||||
|
from .utils import decode_cookie
|
||||||
|
from .utils import encode_cookie
|
||||||
|
from .utils import fresh_login_required
|
||||||
|
from .utils import login_fresh
|
||||||
|
from .utils import login_remembered
|
||||||
|
from .utils import login_required
|
||||||
|
from .utils import login_url
|
||||||
|
from .utils import login_user
|
||||||
|
from .utils import logout_user
|
||||||
|
from .utils import make_next_param
|
||||||
|
from .utils import set_login_view
|
||||||
|
|
||||||
|
__version_info__ = ("0", "6", "3")
|
||||||
|
__version__ = ".".join(__version_info__)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"AUTH_HEADER_NAME",
|
||||||
|
"COOKIE_DURATION",
|
||||||
|
"COOKIE_HTTPONLY",
|
||||||
|
"COOKIE_NAME",
|
||||||
|
"COOKIE_SECURE",
|
||||||
|
"ID_ATTRIBUTE",
|
||||||
|
"LOGIN_MESSAGE",
|
||||||
|
"LOGIN_MESSAGE_CATEGORY",
|
||||||
|
"REFRESH_MESSAGE",
|
||||||
|
"REFRESH_MESSAGE_CATEGORY",
|
||||||
|
"LoginManager",
|
||||||
|
"AnonymousUserMixin",
|
||||||
|
"UserMixin",
|
||||||
|
"session_protected",
|
||||||
|
"user_accessed",
|
||||||
|
"user_loaded_from_cookie",
|
||||||
|
"user_loaded_from_request",
|
||||||
|
"user_logged_in",
|
||||||
|
"user_logged_out",
|
||||||
|
"user_login_confirmed",
|
||||||
|
"user_needs_refresh",
|
||||||
|
"user_unauthorized",
|
||||||
|
# "FlaskLoginClient",
|
||||||
|
"confirm_login",
|
||||||
|
"current_user",
|
||||||
|
"decode_cookie",
|
||||||
|
"encode_cookie",
|
||||||
|
"fresh_login_required",
|
||||||
|
"login_fresh",
|
||||||
|
"login_remembered",
|
||||||
|
"login_required",
|
||||||
|
"login_url",
|
||||||
|
"login_user",
|
||||||
|
"logout_user",
|
||||||
|
"make_next_param",
|
||||||
|
"set_login_view",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "user_loaded_from_header":
|
||||||
|
import warnings
|
||||||
|
from .signals import _user_loaded_from_header
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'user_loaded_from_header' is deprecated and will be"
|
||||||
|
" removed in Flask-Login 0.7. Use"
|
||||||
|
" 'user_loaded_from_request' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return _user_loaded_from_header
|
||||||
|
|
||||||
|
raise AttributeError(name)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
#: The default name of the "remember me" cookie (``remember_token``)
|
||||||
|
COOKIE_NAME = "remember_token"
|
||||||
|
|
||||||
|
#: The default time before the "remember me" cookie expires (365 days).
|
||||||
|
COOKIE_DURATION = timedelta(days=365)
|
||||||
|
|
||||||
|
#: Whether the "remember me" cookie requires Secure; defaults to ``False``
|
||||||
|
COOKIE_SECURE = False
|
||||||
|
|
||||||
|
#: Whether the "remember me" cookie uses HttpOnly or not; defaults to ``True``
|
||||||
|
COOKIE_HTTPONLY = True
|
||||||
|
|
||||||
|
#: Whether the "remember me" cookie requires same origin; defaults to ``None``
|
||||||
|
COOKIE_SAMESITE = None
|
||||||
|
|
||||||
|
#: The default flash message to display when users need to log in.
|
||||||
|
LOGIN_MESSAGE = "Please log in to access this page."
|
||||||
|
|
||||||
|
#: The default flash message category to display when users need to log in.
|
||||||
|
LOGIN_MESSAGE_CATEGORY = "message"
|
||||||
|
|
||||||
|
#: The default flash message to display when users need to reauthenticate.
|
||||||
|
REFRESH_MESSAGE = "Please reauthenticate to access this page."
|
||||||
|
|
||||||
|
#: The default flash message category to display when users need to
|
||||||
|
#: reauthenticate.
|
||||||
|
REFRESH_MESSAGE_CATEGORY = "message"
|
||||||
|
|
||||||
|
#: The default attribute to retreive the str id of the user
|
||||||
|
ID_ATTRIBUTE = "get_id"
|
||||||
|
|
||||||
|
#: Default name of the auth header (``Authorization``)
|
||||||
|
AUTH_HEADER_NAME = "Authorization"
|
||||||
|
|
||||||
|
#: A set of session keys that are populated by Flask-Login. Use this set to
|
||||||
|
#: purge keys safely and accurately.
|
||||||
|
SESSION_KEYS = {
|
||||||
|
"_user_id",
|
||||||
|
"_remember",
|
||||||
|
"_remember_seconds",
|
||||||
|
"_id",
|
||||||
|
"_fresh",
|
||||||
|
"next",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: A set of HTTP methods which are exempt from `login_required` and
|
||||||
|
#: `fresh_login_required`. By default, this is just ``OPTIONS``.
|
||||||
|
EXEMPT_METHODS = {"OPTIONS"}
|
||||||
|
|
||||||
|
#: If true, the page the user is attempting to access is stored in the session
|
||||||
|
#: rather than a url parameter when redirecting to the login view; defaults to
|
||||||
|
#: ``False``.
|
||||||
|
USE_SESSION_FOR_NEXT = False
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
from flask import current_app
|
||||||
|
from flask import flash
|
||||||
|
from flask import g
|
||||||
|
from flask import has_app_context
|
||||||
|
from flask import redirect
|
||||||
|
from flask import request
|
||||||
|
from flask import session
|
||||||
|
from itsdangerous import URLSafeSerializer
|
||||||
|
from flask.json.tag import TaggedJSONSerializer
|
||||||
|
|
||||||
|
from .config import AUTH_HEADER_NAME
|
||||||
|
from .config import COOKIE_DURATION
|
||||||
|
from .config import COOKIE_HTTPONLY
|
||||||
|
from .config import COOKIE_NAME
|
||||||
|
from .config import COOKIE_SAMESITE
|
||||||
|
from .config import COOKIE_SECURE
|
||||||
|
from .config import ID_ATTRIBUTE
|
||||||
|
from .config import LOGIN_MESSAGE
|
||||||
|
from .config import LOGIN_MESSAGE_CATEGORY
|
||||||
|
from .config import REFRESH_MESSAGE
|
||||||
|
from .config import REFRESH_MESSAGE_CATEGORY
|
||||||
|
from .config import SESSION_KEYS
|
||||||
|
from .config import USE_SESSION_FOR_NEXT
|
||||||
|
from .mixins import AnonymousUserMixin
|
||||||
|
from .signals import session_protected
|
||||||
|
from .signals import user_accessed
|
||||||
|
from .signals import user_loaded_from_cookie
|
||||||
|
from .signals import user_loaded_from_request
|
||||||
|
from .signals import user_needs_refresh
|
||||||
|
from .signals import user_unauthorized
|
||||||
|
from .utils import _create_identifier
|
||||||
|
from .utils import _user_context_processor
|
||||||
|
from .utils import confirm_login
|
||||||
|
from .utils import expand_login_view
|
||||||
|
from .utils import login_url as make_login_url
|
||||||
|
from .utils import make_next_param
|
||||||
|
|
||||||
|
|
||||||
|
class LoginManager:
|
||||||
|
"""This object is used to hold the settings used for logging in. Instances
|
||||||
|
of :class:`LoginManager` are *not* bound to specific apps, so you can
|
||||||
|
create one in the main body of your code and then bind it to your
|
||||||
|
app in a factory function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app=None, add_context_processor=True):
|
||||||
|
#: A class or factory function that produces an anonymous user, which
|
||||||
|
#: is used when no one is logged in.
|
||||||
|
self.anonymous_user = AnonymousUserMixin
|
||||||
|
|
||||||
|
#: The name of the view to redirect to when the user needs to log in.
|
||||||
|
#: (This can be an absolute URL as well, if your authentication
|
||||||
|
#: machinery is external to your application.)
|
||||||
|
self.login_view = None
|
||||||
|
|
||||||
|
#: Names of views to redirect to when the user needs to log in,
|
||||||
|
#: per blueprint. If the key value is set to None the value of
|
||||||
|
#: :attr:`login_view` will be used instead.
|
||||||
|
self.blueprint_login_views = {}
|
||||||
|
|
||||||
|
#: The message to flash when a user is redirected to the login page.
|
||||||
|
self.login_message = LOGIN_MESSAGE
|
||||||
|
|
||||||
|
#: The message category to flash when a user is redirected to the login
|
||||||
|
#: page.
|
||||||
|
self.login_message_category = LOGIN_MESSAGE_CATEGORY
|
||||||
|
|
||||||
|
#: The name of the view to redirect to when the user needs to
|
||||||
|
#: reauthenticate.
|
||||||
|
self.refresh_view = None
|
||||||
|
|
||||||
|
#: The message to flash when a user is redirected to the 'needs
|
||||||
|
#: refresh' page.
|
||||||
|
self.needs_refresh_message = REFRESH_MESSAGE
|
||||||
|
|
||||||
|
#: The message category to flash when a user is redirected to the
|
||||||
|
#: 'needs refresh' page.
|
||||||
|
self.needs_refresh_message_category = REFRESH_MESSAGE_CATEGORY
|
||||||
|
|
||||||
|
#: The mode to use session protection in. This can be either
|
||||||
|
#: ``'basic'`` (the default) or ``'strong'``, or ``None`` to disable
|
||||||
|
#: it.
|
||||||
|
self.session_protection = "basic"
|
||||||
|
|
||||||
|
#: If present, used to translate flash messages ``self.login_message``
|
||||||
|
#: and ``self.needs_refresh_message``
|
||||||
|
self.localize_callback = None
|
||||||
|
|
||||||
|
self.unauthorized_callback = None
|
||||||
|
|
||||||
|
self.needs_refresh_callback = None
|
||||||
|
|
||||||
|
self.id_attribute = ID_ATTRIBUTE
|
||||||
|
|
||||||
|
self._user_callback = None
|
||||||
|
|
||||||
|
self._header_callback = None
|
||||||
|
|
||||||
|
self._request_callback = None
|
||||||
|
|
||||||
|
self._session_identifier_generator = _create_identifier
|
||||||
|
|
||||||
|
if app is not None:
|
||||||
|
self.init_app(app, add_context_processor)
|
||||||
|
|
||||||
|
def setup_app(self, app, add_context_processor=True): # pragma: no cover
|
||||||
|
"""
|
||||||
|
This method has been deprecated. Please use
|
||||||
|
:meth:`LoginManager.init_app` instead.
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'setup_app' is deprecated and will be removed in"
|
||||||
|
" Flask-Login 0.7. Use 'init_app' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
self.init_app(app, add_context_processor)
|
||||||
|
|
||||||
|
def init_app(self, app, add_context_processor=True):
|
||||||
|
"""
|
||||||
|
Configures an application. This registers an `after_request` call, and
|
||||||
|
attaches this `LoginManager` to it as `app.login_manager`.
|
||||||
|
|
||||||
|
:param app: The :class:`flask.Flask` object to configure.
|
||||||
|
:type app: :class:`flask.Flask`
|
||||||
|
:param add_context_processor: Whether to add a context processor to
|
||||||
|
the app that adds a `current_user` variable to the template.
|
||||||
|
Defaults to ``True``.
|
||||||
|
:type add_context_processor: bool
|
||||||
|
"""
|
||||||
|
app.login_manager = self
|
||||||
|
app.after_request(self._update_remember_cookie)
|
||||||
|
|
||||||
|
if add_context_processor:
|
||||||
|
app.context_processor(_user_context_processor)
|
||||||
|
|
||||||
|
def unauthorized(self):
|
||||||
|
"""
|
||||||
|
This is called when the user is required to log in. If you register a
|
||||||
|
callback with :meth:`LoginManager.unauthorized_handler`, then it will
|
||||||
|
be called. Otherwise, it will take the following actions:
|
||||||
|
|
||||||
|
- Flash :attr:`LoginManager.login_message` to the user.
|
||||||
|
|
||||||
|
- If the app is using blueprints find the login view for
|
||||||
|
the current blueprint using `blueprint_login_views`. If the app
|
||||||
|
is not using blueprints or the login view for the current
|
||||||
|
blueprint is not specified use the value of `login_view`.
|
||||||
|
|
||||||
|
- Redirect the user to the login view. (The page they were
|
||||||
|
attempting to access will be passed in the ``next`` query
|
||||||
|
string variable, so you can redirect there if present instead
|
||||||
|
of the homepage. Alternatively, it will be added to the session
|
||||||
|
as ``next`` if USE_SESSION_FOR_NEXT is set.)
|
||||||
|
|
||||||
|
If :attr:`LoginManager.login_view` is not defined, then it will simply
|
||||||
|
raise a HTTP 401 (Unauthorized) error instead.
|
||||||
|
|
||||||
|
This should be returned from a view or before/after_request function,
|
||||||
|
otherwise the redirect will have no effect.
|
||||||
|
"""
|
||||||
|
user_unauthorized.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
if self.unauthorized_callback:
|
||||||
|
return self.unauthorized_callback()
|
||||||
|
|
||||||
|
if request.blueprint in self.blueprint_login_views:
|
||||||
|
login_view = self.blueprint_login_views[request.blueprint]
|
||||||
|
else:
|
||||||
|
login_view = self.login_view
|
||||||
|
|
||||||
|
if not login_view:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
if self.login_message:
|
||||||
|
if self.localize_callback is not None:
|
||||||
|
flash(
|
||||||
|
self.localize_callback(self.login_message),
|
||||||
|
category=self.login_message_category,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(self.login_message, category=self.login_message_category)
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
|
||||||
|
login_url = expand_login_view(login_view)
|
||||||
|
session["_id"] = self._session_identifier_generator()
|
||||||
|
session["next"] = make_next_param(login_url, request.url)
|
||||||
|
redirect_url = make_login_url(login_view)
|
||||||
|
else:
|
||||||
|
redirect_url = make_login_url(login_view, next_url=request.url)
|
||||||
|
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
def user_loader(self, callback):
|
||||||
|
"""
|
||||||
|
This sets the callback for reloading a user from the session. The
|
||||||
|
function you set should take a user ID (a ``str``) and return a
|
||||||
|
user object, or ``None`` if the user does not exist.
|
||||||
|
|
||||||
|
:param callback: The callback for retrieving a user object.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self._user_callback = callback
|
||||||
|
return self.user_callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_callback(self):
|
||||||
|
"""Gets the user_loader callback set by user_loader decorator."""
|
||||||
|
return self._user_callback
|
||||||
|
|
||||||
|
def request_loader(self, callback):
|
||||||
|
"""
|
||||||
|
This sets the callback for loading a user from a Flask request.
|
||||||
|
The function you set should take Flask request object and
|
||||||
|
return a user object, or `None` if the user does not exist.
|
||||||
|
|
||||||
|
:param callback: The callback for retrieving a user object.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self._request_callback = callback
|
||||||
|
return self.request_callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_callback(self):
|
||||||
|
"""Gets the request_loader callback set by request_loader decorator."""
|
||||||
|
return self._request_callback
|
||||||
|
|
||||||
|
def unauthorized_handler(self, callback):
|
||||||
|
"""
|
||||||
|
This will set the callback for the `unauthorized` method, which among
|
||||||
|
other things is used by `login_required`. It takes no arguments, and
|
||||||
|
should return a response to be sent to the user instead of their
|
||||||
|
normal view.
|
||||||
|
|
||||||
|
:param callback: The callback for unauthorized users.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self.unauthorized_callback = callback
|
||||||
|
return callback
|
||||||
|
|
||||||
|
def needs_refresh_handler(self, callback):
|
||||||
|
"""
|
||||||
|
This will set the callback for the `needs_refresh` method, which among
|
||||||
|
other things is used by `fresh_login_required`. It takes no arguments,
|
||||||
|
and should return a response to be sent to the user instead of their
|
||||||
|
normal view.
|
||||||
|
|
||||||
|
:param callback: The callback for unauthorized users.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self.needs_refresh_callback = callback
|
||||||
|
return callback
|
||||||
|
|
||||||
|
def needs_refresh(self):
|
||||||
|
"""
|
||||||
|
This is called when the user is logged in, but they need to be
|
||||||
|
reauthenticated because their session is stale. If you register a
|
||||||
|
callback with `needs_refresh_handler`, then it will be called.
|
||||||
|
Otherwise, it will take the following actions:
|
||||||
|
|
||||||
|
- Flash :attr:`LoginManager.needs_refresh_message` to the user.
|
||||||
|
|
||||||
|
- Redirect the user to :attr:`LoginManager.refresh_view`. (The page
|
||||||
|
they were attempting to access will be passed in the ``next``
|
||||||
|
query string variable, so you can redirect there if present
|
||||||
|
instead of the homepage.)
|
||||||
|
|
||||||
|
If :attr:`LoginManager.refresh_view` is not defined, then it will
|
||||||
|
simply raise a HTTP 401 (Unauthorized) error instead.
|
||||||
|
|
||||||
|
This should be returned from a view or before/after_request function,
|
||||||
|
otherwise the redirect will have no effect.
|
||||||
|
"""
|
||||||
|
user_needs_refresh.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
if self.needs_refresh_callback:
|
||||||
|
return self.needs_refresh_callback()
|
||||||
|
|
||||||
|
if not self.refresh_view:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
if self.needs_refresh_message:
|
||||||
|
if self.localize_callback is not None:
|
||||||
|
flash(
|
||||||
|
self.localize_callback(self.needs_refresh_message),
|
||||||
|
category=self.needs_refresh_message_category,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(
|
||||||
|
self.needs_refresh_message,
|
||||||
|
category=self.needs_refresh_message_category,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
|
||||||
|
login_url = expand_login_view(self.refresh_view)
|
||||||
|
session["_id"] = self._session_identifier_generator()
|
||||||
|
session["next"] = make_next_param(login_url, request.url)
|
||||||
|
redirect_url = make_login_url(self.refresh_view)
|
||||||
|
else:
|
||||||
|
login_url = self.refresh_view
|
||||||
|
redirect_url = make_login_url(login_url, next_url=request.url)
|
||||||
|
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
def _update_request_context_with_user(self, user=None):
|
||||||
|
"""Store the given user as ctx.user."""
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
user = self.anonymous_user()
|
||||||
|
|
||||||
|
g._login_user = user
|
||||||
|
|
||||||
|
def _load_user(self):
|
||||||
|
"""Loads user from session or remember_me cookie as applicable"""
|
||||||
|
|
||||||
|
if self._user_callback is None and self._request_callback is None:
|
||||||
|
raise Exception(
|
||||||
|
"Missing user_loader or request_loader. Refer to "
|
||||||
|
"https://flask-login.readthedocs.io/#how-it-works "
|
||||||
|
"for more info."
|
||||||
|
)
|
||||||
|
|
||||||
|
user_accessed.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
# Check SESSION_PROTECTION
|
||||||
|
if self._session_protection_failed():
|
||||||
|
return self._update_request_context_with_user()
|
||||||
|
|
||||||
|
user = None
|
||||||
|
|
||||||
|
# Load user from Flask Session
|
||||||
|
user_id = session.get("_user_id")
|
||||||
|
user_random = session.get("_random")
|
||||||
|
user_session_key = session.get("_id")
|
||||||
|
if (user_id is not None
|
||||||
|
and user_random is not None
|
||||||
|
and user_session_key is not None
|
||||||
|
and self._user_callback is not None):
|
||||||
|
user = self._user_callback(user_id, user_random, user_session_key)
|
||||||
|
|
||||||
|
# Load user from Remember Me Cookie or Request Loader
|
||||||
|
if user is None:
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
header_name = config.get("AUTH_HEADER_NAME", AUTH_HEADER_NAME)
|
||||||
|
has_cookie = (
|
||||||
|
cookie_name in request.cookies and session.get("_remember") != "clear"
|
||||||
|
)
|
||||||
|
if has_cookie:
|
||||||
|
cookie = request.cookies[cookie_name]
|
||||||
|
user = self._load_user_from_remember_cookie(cookie)
|
||||||
|
elif self._request_callback:
|
||||||
|
user = self._load_user_from_request(request)
|
||||||
|
elif header_name in request.headers:
|
||||||
|
header = request.headers[header_name]
|
||||||
|
user = self._load_user_from_header(header)
|
||||||
|
if not user:
|
||||||
|
self._update_request_context_with_user()
|
||||||
|
return self._update_request_context_with_user(user)
|
||||||
|
|
||||||
|
def _session_protection_failed(self):
|
||||||
|
sess = session._get_current_object()
|
||||||
|
ident = self._session_identifier_generator()
|
||||||
|
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
mode = app.config.get("SESSION_PROTECTION", self.session_protection)
|
||||||
|
|
||||||
|
if not mode or mode not in ["basic", "strong"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# if the sess is empty, it's an anonymous user or just logged out
|
||||||
|
# so we can skip this
|
||||||
|
if sess and ident != sess.get("_id", None):
|
||||||
|
if mode == "basic" or sess.permanent:
|
||||||
|
if sess.get("_fresh") is not False:
|
||||||
|
sess["_fresh"] = False
|
||||||
|
session_protected.send(app)
|
||||||
|
return False
|
||||||
|
elif mode == "strong":
|
||||||
|
for k in SESSION_KEYS:
|
||||||
|
sess.pop(k, None)
|
||||||
|
|
||||||
|
sess["_remember"] = "clear"
|
||||||
|
session_protected.send(app)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _load_user_from_remember_cookie(self, cookie):
|
||||||
|
signer_kwargs = dict(
|
||||||
|
key_derivation="hmac", digest_method=hashlib.sha1
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
remember_dict = URLSafeSerializer(
|
||||||
|
current_app.secret_key,
|
||||||
|
salt="remember",
|
||||||
|
serializer=TaggedJSONSerializer(),
|
||||||
|
signer_kwargs=signer_kwargs,
|
||||||
|
).loads(cookie)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if remember_dict['user'] is not None:
|
||||||
|
session["_user_id"] = remember_dict['user']
|
||||||
|
if "_random" not in session:
|
||||||
|
session["_random"] = remember_dict['random']
|
||||||
|
session["_fresh"] = False
|
||||||
|
user = None
|
||||||
|
if self._user_callback:
|
||||||
|
user = self._user_callback(remember_dict['user'], session["_random"], None)
|
||||||
|
if user is not None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
user_loaded_from_cookie.send(app, user=user)
|
||||||
|
# if session was restored from remember me cookie make login valid
|
||||||
|
confirm_login()
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_user_from_header(self, header):
|
||||||
|
if self._header_callback:
|
||||||
|
user = self._header_callback(header)
|
||||||
|
if user is not None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
from .signals import _user_loaded_from_header
|
||||||
|
|
||||||
|
_user_loaded_from_header.send(app, user=user)
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_user_from_request(self, request):
|
||||||
|
if self._request_callback:
|
||||||
|
user = self._request_callback(request)
|
||||||
|
if user is not None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
user_loaded_from_request.send(app, user=user)
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_remember_cookie(self, response):
|
||||||
|
# Don't modify the session unless there's something to do.
|
||||||
|
if "_remember" not in session and current_app.config.get(
|
||||||
|
"REMEMBER_COOKIE_REFRESH_EACH_REQUEST"
|
||||||
|
):
|
||||||
|
session["_remember"] = "set"
|
||||||
|
|
||||||
|
if "_remember" in session:
|
||||||
|
operation = session.pop("_remember", None)
|
||||||
|
|
||||||
|
if operation == "set" and "_user_id" in session:
|
||||||
|
self._set_cookie(response)
|
||||||
|
elif operation == "clear":
|
||||||
|
self._clear_cookie(response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _set_cookie(self, response):
|
||||||
|
# cookie settings
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||||
|
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||||
|
|
||||||
|
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
|
||||||
|
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
|
||||||
|
samesite = config.get("REMEMBER_COOKIE_SAMESITE", COOKIE_SAMESITE)
|
||||||
|
|
||||||
|
if "_remember_seconds" in session:
|
||||||
|
duration = timedelta(seconds=session["_remember_seconds"])
|
||||||
|
else:
|
||||||
|
duration = config.get("REMEMBER_COOKIE_DURATION", COOKIE_DURATION)
|
||||||
|
|
||||||
|
# prepare data
|
||||||
|
max_age = int(current_app.permanent_session_lifetime.total_seconds())
|
||||||
|
signer_kwargs = dict(
|
||||||
|
key_derivation="hmac", digest_method=hashlib.sha1
|
||||||
|
)
|
||||||
|
# save
|
||||||
|
data = URLSafeSerializer(
|
||||||
|
current_app.secret_key,
|
||||||
|
salt="remember",
|
||||||
|
serializer=TaggedJSONSerializer(),
|
||||||
|
signer_kwargs=signer_kwargs,
|
||||||
|
).dumps({"user":session["_user_id"], "random":session["_random"]})
|
||||||
|
|
||||||
|
if isinstance(duration, int):
|
||||||
|
duration = timedelta(seconds=duration)
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires = datetime.utcnow() + duration
|
||||||
|
except TypeError as e:
|
||||||
|
raise Exception(
|
||||||
|
"REMEMBER_COOKIE_DURATION must be a datetime.timedelta,"
|
||||||
|
f" instead got: {duration}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# actually set it
|
||||||
|
response.set_cookie(
|
||||||
|
cookie_name,
|
||||||
|
value=data,
|
||||||
|
expires=expires,
|
||||||
|
domain=domain,
|
||||||
|
path=path,
|
||||||
|
secure=secure,
|
||||||
|
httponly=httponly,
|
||||||
|
samesite=samesite,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_cookie(self, response):
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||||
|
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||||
|
response.delete_cookie(cookie_name, domain=domain, path=path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _login_disabled(self):
|
||||||
|
"""Legacy property, use app.config['LOGIN_DISABLED'] instead."""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'_login_disabled' is deprecated and will be removed in"
|
||||||
|
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
|
||||||
|
" instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_app_context():
|
||||||
|
return current_app.config.get("LOGIN_DISABLED", False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@_login_disabled.setter
|
||||||
|
def _login_disabled(self, newvalue):
|
||||||
|
"""Legacy property setter, use app.config['LOGIN_DISABLED'] instead."""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'_login_disabled' is deprecated and will be removed in"
|
||||||
|
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
|
||||||
|
" instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
current_app.config["LOGIN_DISABLED"] = newvalue
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
class UserMixin:
|
||||||
|
"""
|
||||||
|
This provides default implementations for the methods that Flask-Login
|
||||||
|
expects user objects to have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Python 3 implicitly set __hash__ to None if we override __eq__
|
||||||
|
# We set it back to its default implementation
|
||||||
|
__hash__ = object.__hash__
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return self.is_active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
try:
|
||||||
|
return str(self.id)
|
||||||
|
except AttributeError:
|
||||||
|
raise NotImplementedError("No `id` attribute - override `get_id`") from None
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
"""
|
||||||
|
Checks the equality of two `UserMixin` objects using `get_id`.
|
||||||
|
"""
|
||||||
|
if isinstance(other, UserMixin):
|
||||||
|
return self.get_id() == other.get_id()
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
"""
|
||||||
|
Checks the inequality of two `UserMixin` objects using `get_id`.
|
||||||
|
"""
|
||||||
|
equal = self.__eq__(other)
|
||||||
|
if equal is NotImplemented:
|
||||||
|
return NotImplemented
|
||||||
|
return not equal
|
||||||
|
|
||||||
|
|
||||||
|
class AnonymousUserMixin:
|
||||||
|
"""
|
||||||
|
This is the default object for representing an anonymous user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from flask.signals import Namespace
|
||||||
|
|
||||||
|
_signals = Namespace()
|
||||||
|
|
||||||
|
#: Sent when a user is logged in. In addition to the app (which is the
|
||||||
|
#: sender), it is passed `user`, which is the user being logged in.
|
||||||
|
user_logged_in = _signals.signal("logged-in")
|
||||||
|
|
||||||
|
#: Sent when a user is logged out. In addition to the app (which is the
|
||||||
|
#: sender), it is passed `user`, which is the user being logged out.
|
||||||
|
user_logged_out = _signals.signal("logged-out")
|
||||||
|
|
||||||
|
#: Sent when the user is loaded from the cookie. In addition to the app (which
|
||||||
|
#: is the sender), it is passed `user`, which is the user being reloaded.
|
||||||
|
user_loaded_from_cookie = _signals.signal("loaded-from-cookie")
|
||||||
|
|
||||||
|
#: Sent when the user is loaded from the header. In addition to the app (which
|
||||||
|
#: is the #: sender), it is passed `user`, which is the user being reloaded.
|
||||||
|
_user_loaded_from_header = _signals.signal("loaded-from-header")
|
||||||
|
|
||||||
|
#: Sent when the user is loaded from the request. In addition to the app (which
|
||||||
|
#: is the #: sender), it is passed `user`, which is the user being reloaded.
|
||||||
|
user_loaded_from_request = _signals.signal("loaded-from-request")
|
||||||
|
|
||||||
|
#: Sent when a user's login is confirmed, marking it as fresh. (It is not
|
||||||
|
#: called for a normal login.)
|
||||||
|
#: It receives no additional arguments besides the app.
|
||||||
|
user_login_confirmed = _signals.signal("login-confirmed")
|
||||||
|
|
||||||
|
#: Sent when the `unauthorized` method is called on a `LoginManager`. It
|
||||||
|
#: receives no additional arguments besides the app.
|
||||||
|
user_unauthorized = _signals.signal("unauthorized")
|
||||||
|
|
||||||
|
#: Sent when the `needs_refresh` method is called on a `LoginManager`. It
|
||||||
|
#: receives no additional arguments besides the app.
|
||||||
|
user_needs_refresh = _signals.signal("needs-refresh")
|
||||||
|
|
||||||
|
#: Sent whenever the user is accessed/loaded
|
||||||
|
#: receives no additional arguments besides the app.
|
||||||
|
user_accessed = _signals.signal("accessed")
|
||||||
|
|
||||||
|
#: Sent whenever session protection takes effect, and a session is either
|
||||||
|
#: marked non-fresh or deleted. It receives no additional arguments besides
|
||||||
|
#: the app.
|
||||||
|
session_protected = _signals.signal("session-protected")
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "user_loaded_from_header":
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'user_loaded_from_header' is deprecated and will be"
|
||||||
|
" removed in Flask-Login 0.7. Use"
|
||||||
|
" 'user_loaded_from_request' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return _user_loaded_from_header
|
||||||
|
|
||||||
|
raise AttributeError(name)
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
from hashlib import sha512
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from urllib.parse import urlunsplit
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from flask import g
|
||||||
|
from flask import has_request_context
|
||||||
|
from flask import request
|
||||||
|
from flask import session
|
||||||
|
from flask import url_for
|
||||||
|
from werkzeug.local import LocalProxy
|
||||||
|
|
||||||
|
from .config import COOKIE_NAME
|
||||||
|
from .config import EXEMPT_METHODS
|
||||||
|
from .signals import user_logged_in
|
||||||
|
from .signals import user_logged_out
|
||||||
|
from .signals import user_login_confirmed
|
||||||
|
|
||||||
|
#: A proxy for the current user. If no user is logged in, this will be an
|
||||||
|
#: anonymous user
|
||||||
|
current_user = LocalProxy(lambda: _get_user())
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cookie(payload, key=None):
|
||||||
|
"""
|
||||||
|
This will encode a ``str`` value into a cookie, and sign that cookie
|
||||||
|
with the app's secret key.
|
||||||
|
|
||||||
|
:param payload: The value to encode, as `str`.
|
||||||
|
:type payload: str
|
||||||
|
|
||||||
|
:param key: The key to use when creating the cookie digest. If not
|
||||||
|
specified, the SECRET_KEY value from app config will be used.
|
||||||
|
:type key: str
|
||||||
|
"""
|
||||||
|
return f"{payload}|{_cookie_digest(payload, key=key)}"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cookie(cookie, key=None):
|
||||||
|
"""
|
||||||
|
This decodes a cookie given by `encode_cookie`. If verification of the
|
||||||
|
cookie fails, ``None`` will be implicitly returned.
|
||||||
|
|
||||||
|
:param cookie: An encoded cookie.
|
||||||
|
:type cookie: str
|
||||||
|
|
||||||
|
:param key: The key to use when creating the cookie digest. If not
|
||||||
|
specified, the SECRET_KEY value from app config will be used.
|
||||||
|
:type key: str
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload, digest = cookie.rsplit("|", 1)
|
||||||
|
if hasattr(digest, "decode"):
|
||||||
|
digest = digest.decode("ascii") # pragma: no cover
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if hmac.compare_digest(_cookie_digest(payload, key=key), digest):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def make_next_param(login_url, current_url):
|
||||||
|
"""
|
||||||
|
Reduces the scheme and host from a given URL so it can be passed to
|
||||||
|
the given `login` URL more efficiently.
|
||||||
|
|
||||||
|
:param login_url: The login URL being redirected to.
|
||||||
|
:type login_url: str
|
||||||
|
:param current_url: The URL to reduce.
|
||||||
|
:type current_url: str
|
||||||
|
"""
|
||||||
|
l_url = urlsplit(login_url)
|
||||||
|
c_url = urlsplit(current_url)
|
||||||
|
|
||||||
|
if (not l_url.scheme or l_url.scheme == c_url.scheme) and (
|
||||||
|
not l_url.netloc or l_url.netloc == c_url.netloc
|
||||||
|
):
|
||||||
|
return urlunsplit(("", "", c_url.path, c_url.query, ""))
|
||||||
|
return current_url
|
||||||
|
|
||||||
|
|
||||||
|
def expand_login_view(login_view):
|
||||||
|
"""
|
||||||
|
Returns the url for the login view, expanding the view name to a url if
|
||||||
|
needed.
|
||||||
|
|
||||||
|
:param login_view: The name of the login view or a URL for the login view.
|
||||||
|
:type login_view: str
|
||||||
|
"""
|
||||||
|
if login_view.startswith(("https://", "http://", "/")):
|
||||||
|
return login_view
|
||||||
|
|
||||||
|
return url_for(login_view)
|
||||||
|
|
||||||
|
|
||||||
|
def login_url(login_view, next_url=None, next_field="next"):
|
||||||
|
"""
|
||||||
|
Creates a URL for redirecting to a login page. If only `login_view` is
|
||||||
|
provided, this will just return the URL for it. If `next_url` is provided,
|
||||||
|
however, this will append a ``next=URL`` parameter to the query string
|
||||||
|
so that the login view can redirect back to that URL. Flask-Login's default
|
||||||
|
unauthorized handler uses this function when redirecting to your login url.
|
||||||
|
To force the host name used, set `FORCE_HOST_FOR_REDIRECTS` to a host. This
|
||||||
|
prevents from redirecting to external sites if request headers Host or
|
||||||
|
X-Forwarded-For are present.
|
||||||
|
|
||||||
|
:param login_view: The name of the login view. (Alternately, the actual
|
||||||
|
URL to the login view.)
|
||||||
|
:type login_view: str
|
||||||
|
:param next_url: The URL to give the login view for redirection.
|
||||||
|
:type next_url: str
|
||||||
|
:param next_field: What field to store the next URL in. (It defaults to
|
||||||
|
``next``.)
|
||||||
|
:type next_field: str
|
||||||
|
"""
|
||||||
|
base = expand_login_view(login_view)
|
||||||
|
|
||||||
|
if next_url is None:
|
||||||
|
return base
|
||||||
|
|
||||||
|
parsed_result = urlsplit(base)
|
||||||
|
md = parse_qs(parsed_result.query, keep_blank_values=True)
|
||||||
|
md[next_field] = make_next_param(base, next_url)
|
||||||
|
netloc = current_app.config.get("FORCE_HOST_FOR_REDIRECTS") or parsed_result.netloc
|
||||||
|
parsed_result = parsed_result._replace(
|
||||||
|
netloc=netloc, query=urlencode(md, doseq=True)
|
||||||
|
)
|
||||||
|
return urlunsplit(parsed_result)
|
||||||
|
|
||||||
|
|
||||||
|
def login_fresh():
|
||||||
|
"""
|
||||||
|
This returns ``True`` if the current login is fresh.
|
||||||
|
"""
|
||||||
|
return session.get("_fresh", False)
|
||||||
|
|
||||||
|
|
||||||
|
def login_remembered():
|
||||||
|
"""
|
||||||
|
This returns ``True`` if the current login is remembered across sessions.
|
||||||
|
"""
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
has_cookie = cookie_name in request.cookies and session.get("_remember") != "clear"
|
||||||
|
if has_cookie:
|
||||||
|
cookie = request.cookies[cookie_name]
|
||||||
|
user_id = decode_cookie(cookie)
|
||||||
|
return user_id is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def login_user(user, remember=False, duration=None, force=False, fresh=True):
|
||||||
|
"""
|
||||||
|
Logs a user in. You should pass the actual user object to this. If the
|
||||||
|
user's `is_active` property is ``False``, they will not be logged in
|
||||||
|
unless `force` is ``True``.
|
||||||
|
|
||||||
|
This will return ``True`` if the log in attempt succeeds, and ``False`` if
|
||||||
|
it fails (i.e. because the user is inactive).
|
||||||
|
|
||||||
|
:param user: The user object to log in.
|
||||||
|
:type user: object
|
||||||
|
:param remember: Whether to remember the user after their session expires.
|
||||||
|
Defaults to ``False``.
|
||||||
|
:type remember: bool
|
||||||
|
:param duration: The amount of time before the remember cookie expires. If
|
||||||
|
``None`` the value set in the settings is used. Defaults to ``None``.
|
||||||
|
:type duration: :class:`datetime.timedelta`
|
||||||
|
:param force: If the user is inactive, setting this to ``True`` will log
|
||||||
|
them in regardless. Defaults to ``False``.
|
||||||
|
:type force: bool
|
||||||
|
:param fresh: setting this to ``False`` will log in the user with a session
|
||||||
|
marked as not "fresh". Defaults to ``True``.
|
||||||
|
:type fresh: bool
|
||||||
|
"""
|
||||||
|
if not force and not user.is_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_id = getattr(user, current_app.login_manager.id_attribute)()
|
||||||
|
session["_user_id"] = user_id
|
||||||
|
session["_fresh"] = fresh
|
||||||
|
session["_id"] = current_app.login_manager._session_identifier_generator()
|
||||||
|
session["_random"] = os.urandom(10).hex()
|
||||||
|
|
||||||
|
if remember:
|
||||||
|
session["_remember"] = "set"
|
||||||
|
if duration is not None:
|
||||||
|
try:
|
||||||
|
# equal to timedelta.total_seconds() but works with Python 2.6
|
||||||
|
session["_remember_seconds"] = (
|
||||||
|
duration.microseconds
|
||||||
|
+ (duration.seconds + duration.days * 24 * 3600) * 10**6
|
||||||
|
) / 10.0**6
|
||||||
|
except AttributeError as e:
|
||||||
|
raise Exception(
|
||||||
|
f"duration must be a datetime.timedelta, instead got: {duration}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
current_app.login_manager._update_request_context_with_user(user)
|
||||||
|
user_logged_in.send(current_app._get_current_object(), user=_get_user())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def logout_user():
|
||||||
|
"""
|
||||||
|
Logs a user out. (You do not need to pass the actual user.) This will
|
||||||
|
also clean up the remember me cookie if it exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = _get_user()
|
||||||
|
|
||||||
|
if "_user_id" in session:
|
||||||
|
session.pop("_user_id")
|
||||||
|
|
||||||
|
if "_fresh" in session:
|
||||||
|
session.pop("_fresh")
|
||||||
|
|
||||||
|
if "_id" in session:
|
||||||
|
session.pop("_id")
|
||||||
|
|
||||||
|
if "_random" in session:
|
||||||
|
session.pop("_random")
|
||||||
|
|
||||||
|
|
||||||
|
cookie_name = current_app.config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
if cookie_name in request.cookies:
|
||||||
|
session["_remember"] = "clear"
|
||||||
|
if "_remember_seconds" in session:
|
||||||
|
session.pop("_remember_seconds")
|
||||||
|
|
||||||
|
user_logged_out.send(current_app._get_current_object(), user=user)
|
||||||
|
|
||||||
|
current_app.login_manager._update_request_context_with_user()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_login():
|
||||||
|
"""
|
||||||
|
This sets the current session as fresh. Sessions become stale when they
|
||||||
|
are reloaded from a cookie.
|
||||||
|
"""
|
||||||
|
session["_fresh"] = True
|
||||||
|
session["_id"] = current_app.login_manager._session_identifier_generator()
|
||||||
|
user_login_confirmed.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(func):
|
||||||
|
"""
|
||||||
|
If you decorate a view with this, it will ensure that the current user is
|
||||||
|
logged in and authenticated before calling the actual view. (If they are
|
||||||
|
not, it calls the :attr:`LoginManager.unauthorized` callback.) For
|
||||||
|
example::
|
||||||
|
|
||||||
|
@app.route('/post')
|
||||||
|
@user_login_required
|
||||||
|
def post():
|
||||||
|
pass
|
||||||
|
|
||||||
|
If there are only certain times you need to require that your user is
|
||||||
|
logged in, you can do so with::
|
||||||
|
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return current_app.login_manager.unauthorized()
|
||||||
|
|
||||||
|
...which is essentially the code that this function adds to your views.
|
||||||
|
|
||||||
|
It can be convenient to globally turn off authentication when unit testing.
|
||||||
|
To enable this, if the application configuration variable `LOGIN_DISABLED`
|
||||||
|
is set to `True`, this decorator will be ignored.
|
||||||
|
|
||||||
|
.. Note ::
|
||||||
|
|
||||||
|
Per `W3 guidelines for CORS preflight requests
|
||||||
|
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
|
||||||
|
HTTP ``OPTIONS`` requests are exempt from login checks.
|
||||||
|
|
||||||
|
:param func: The view function to decorate.
|
||||||
|
:type func: function
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def decorated_view(*args, **kwargs):
|
||||||
|
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
|
||||||
|
pass
|
||||||
|
elif not current_user.is_authenticated:
|
||||||
|
return current_app.login_manager.unauthorized()
|
||||||
|
|
||||||
|
# flask 1.x compatibility
|
||||||
|
# current_app.ensure_sync is only available in Flask >= 2.0
|
||||||
|
if callable(getattr(current_app, "ensure_sync", None)):
|
||||||
|
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_view
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_login_required(func):
|
||||||
|
"""
|
||||||
|
If you decorate a view with this, it will ensure that the current user's
|
||||||
|
login is fresh - i.e. their session was not restored from a 'remember me'
|
||||||
|
cookie. Sensitive operations, like changing a password or e-mail, should
|
||||||
|
be protected with this, to impede the efforts of cookie thieves.
|
||||||
|
|
||||||
|
If the user is not authenticated, :meth:`LoginManager.unauthorized` is
|
||||||
|
called as normal. If they are authenticated, but their session is not
|
||||||
|
fresh, it will call :meth:`LoginManager.needs_refresh` instead. (In that
|
||||||
|
case, you will need to provide a :attr:`LoginManager.refresh_view`.)
|
||||||
|
|
||||||
|
Behaves identically to the :func:`login_required` decorator with respect
|
||||||
|
to configuration variables.
|
||||||
|
|
||||||
|
.. Note ::
|
||||||
|
|
||||||
|
Per `W3 guidelines for CORS preflight requests
|
||||||
|
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
|
||||||
|
HTTP ``OPTIONS`` requests are exempt from login checks.
|
||||||
|
|
||||||
|
:param func: The view function to decorate.
|
||||||
|
:type func: function
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def decorated_view(*args, **kwargs):
|
||||||
|
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
|
||||||
|
pass
|
||||||
|
elif not current_user.is_authenticated:
|
||||||
|
return current_app.login_manager.unauthorized()
|
||||||
|
elif not login_fresh():
|
||||||
|
return current_app.login_manager.needs_refresh()
|
||||||
|
try:
|
||||||
|
# current_app.ensure_sync available in Flask >= 2.0
|
||||||
|
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||||
|
except AttributeError: # pragma: no cover
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_view
|
||||||
|
|
||||||
|
|
||||||
|
def set_login_view(login_view, blueprint=None):
|
||||||
|
"""
|
||||||
|
Sets the login view for the app or blueprint. If a blueprint is passed,
|
||||||
|
the login view is set for this blueprint on ``blueprint_login_views``.
|
||||||
|
|
||||||
|
:param login_view: The user object to log in.
|
||||||
|
:type login_view: str
|
||||||
|
:param blueprint: The blueprint which this login view should be set on.
|
||||||
|
Defaults to ``None``.
|
||||||
|
:type blueprint: object
|
||||||
|
"""
|
||||||
|
|
||||||
|
num_login_views = len(current_app.login_manager.blueprint_login_views)
|
||||||
|
if blueprint is not None or num_login_views != 0:
|
||||||
|
(current_app.login_manager.blueprint_login_views[blueprint.name]) = login_view
|
||||||
|
|
||||||
|
if (
|
||||||
|
current_app.login_manager.login_view is not None
|
||||||
|
and None not in current_app.login_manager.blueprint_login_views
|
||||||
|
):
|
||||||
|
(
|
||||||
|
current_app.login_manager.blueprint_login_views[None]
|
||||||
|
) = current_app.login_manager.login_view
|
||||||
|
|
||||||
|
current_app.login_manager.login_view = None
|
||||||
|
else:
|
||||||
|
current_app.login_manager.login_view = login_view
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user():
|
||||||
|
if has_request_context():
|
||||||
|
if "flask_httpauth_user" in g:
|
||||||
|
if g.flask_httpauth_user is not None:
|
||||||
|
return g.flask_httpauth_user
|
||||||
|
if "_login_user" not in g:
|
||||||
|
current_app.login_manager._load_user()
|
||||||
|
|
||||||
|
return g._login_user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_digest(payload, key=None):
|
||||||
|
key = _secret_key(key)
|
||||||
|
|
||||||
|
return hmac.new(key, payload.encode("utf-8"), sha512).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_remote_addr():
|
||||||
|
address = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||||
|
if address is not None:
|
||||||
|
# An 'X-Forwarded-For' header includes a comma separated list of the
|
||||||
|
# addresses, the first address being the actual remote address.
|
||||||
|
address = address.encode("utf-8").split(b",")[0].strip()
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
def _create_identifier():
|
||||||
|
user_agent = request.headers.get("User-Agent")
|
||||||
|
if user_agent is not None:
|
||||||
|
user_agent = user_agent.encode("utf-8")
|
||||||
|
base = f"{_get_remote_addr()}|{user_agent}"
|
||||||
|
if str is bytes:
|
||||||
|
base = str(base, "utf-8", errors="replace") # pragma: no cover
|
||||||
|
h = sha512()
|
||||||
|
h.update(base.encode("utf8"))
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_context_processor():
|
||||||
|
return dict(current_user=_get_user())
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_key(key=None):
|
||||||
|
if key is None:
|
||||||
|
key = current_app.config["SECRET_KEY"]
|
||||||
|
|
||||||
|
if isinstance(key, str): # pragma: no cover
|
||||||
|
key = key.encode("latin1") # ensure bytes
|
||||||
|
|
||||||
|
return key
|
||||||
@@ -23,6 +23,7 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
import unidecode
|
import unidecode
|
||||||
|
from weakref import WeakSet
|
||||||
|
|
||||||
from sqlite3 import OperationalError as sqliteOperationalError
|
from sqlite3 import OperationalError as sqliteOperationalError
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -40,7 +41,7 @@ except ImportError:
|
|||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
|
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from flask import flash
|
from flask import flash
|
||||||
@@ -48,8 +49,6 @@ from flask import flash
|
|||||||
from . import logger, ub, isoLanguages
|
from . import logger, ub, isoLanguages
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
|
|
||||||
from weakref import WeakSet
|
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@@ -104,6 +103,7 @@ class Identifiers(Base):
|
|||||||
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||||
|
|
||||||
def __init__(self, val, id_type, book):
|
def __init__(self, val, id_type, book):
|
||||||
|
super().__init__()
|
||||||
self.val = val
|
self.val = val
|
||||||
self.type = id_type
|
self.type = id_type
|
||||||
self.book = book
|
self.book = book
|
||||||
@@ -128,6 +128,8 @@ class Identifiers(Base):
|
|||||||
return "Google Books"
|
return "Google Books"
|
||||||
elif format_type == "kobo":
|
elif format_type == "kobo":
|
||||||
return "Kobo"
|
return "Kobo"
|
||||||
|
elif format_type == "barnesnoble":
|
||||||
|
return "Barnes & Noble"
|
||||||
elif format_type == "litres":
|
elif format_type == "litres":
|
||||||
return "ЛитРес"
|
return "ЛитРес"
|
||||||
elif format_type == "issn":
|
elif format_type == "issn":
|
||||||
@@ -161,6 +163,8 @@ class Identifiers(Base):
|
|||||||
return "https://books.google.com/books?id={0}".format(self.val)
|
return "https://books.google.com/books?id={0}".format(self.val)
|
||||||
elif format_type == "kobo":
|
elif format_type == "kobo":
|
||||||
return "https://www.kobo.com/ebook/{0}".format(self.val)
|
return "https://www.kobo.com/ebook/{0}".format(self.val)
|
||||||
|
elif format_type == "barnesnoble":
|
||||||
|
return "https://www.barnesandnoble.com/w/{0}".format(self.val)
|
||||||
elif format_type == "lubimyczytac":
|
elif format_type == "lubimyczytac":
|
||||||
return "https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
|
return "https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
|
||||||
elif format_type == "litres":
|
elif format_type == "litres":
|
||||||
@@ -174,7 +178,7 @@ class Identifiers(Base):
|
|||||||
elif self.val.lower().startswith("javascript:"):
|
elif self.val.lower().startswith("javascript:"):
|
||||||
return quote(self.val)
|
return quote(self.val)
|
||||||
elif self.val.lower().startswith("data:"):
|
elif self.val.lower().startswith("data:"):
|
||||||
link , __, __ = str.partition(self.val, ",")
|
link, __, __ = str.partition(self.val, ",")
|
||||||
return link
|
return link
|
||||||
else:
|
else:
|
||||||
return "{0}".format(self.val)
|
return "{0}".format(self.val)
|
||||||
@@ -188,6 +192,7 @@ class Comments(Base):
|
|||||||
text = Column(String(collation='NOCASE'), nullable=False)
|
text = Column(String(collation='NOCASE'), nullable=False)
|
||||||
|
|
||||||
def __init__(self, comment, book):
|
def __init__(self, comment, book):
|
||||||
|
super().__init__()
|
||||||
self.text = comment
|
self.text = comment
|
||||||
self.book = book
|
self.book = book
|
||||||
|
|
||||||
@@ -205,6 +210,7 @@ class Tags(Base):
|
|||||||
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -226,6 +232,7 @@ class Authors(Base):
|
|||||||
link = Column(String, nullable=False, default="")
|
link = Column(String, nullable=False, default="")
|
||||||
|
|
||||||
def __init__(self, name, sort, link=""):
|
def __init__(self, name, sort, link=""):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
self.link = link
|
self.link = link
|
||||||
@@ -248,6 +255,7 @@ class Series(Base):
|
|||||||
sort = Column(String(collation='NOCASE'))
|
sort = Column(String(collation='NOCASE'))
|
||||||
|
|
||||||
def __init__(self, name, sort):
|
def __init__(self, name, sort):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
|
||||||
@@ -268,6 +276,7 @@ class Ratings(Base):
|
|||||||
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
|
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
|
||||||
|
|
||||||
def __init__(self, rating):
|
def __init__(self, rating):
|
||||||
|
super().__init__()
|
||||||
self.rating = rating
|
self.rating = rating
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -287,6 +296,7 @@ class Languages(Base):
|
|||||||
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
|
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
|
||||||
|
|
||||||
def __init__(self, lang_code):
|
def __init__(self, lang_code):
|
||||||
|
super().__init__()
|
||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -310,6 +320,7 @@ class Publishers(Base):
|
|||||||
sort = Column(String(collation='NOCASE'))
|
sort = Column(String(collation='NOCASE'))
|
||||||
|
|
||||||
def __init__(self, name, sort):
|
def __init__(self, name, sort):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
|
||||||
@@ -334,6 +345,7 @@ class Data(Base):
|
|||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
def __init__(self, book, book_format, uncompressed_size, name):
|
def __init__(self, book, book_format, uncompressed_size, name):
|
||||||
|
super().__init__()
|
||||||
self.book = book
|
self.book = book
|
||||||
self.format = book_format
|
self.format = book_format
|
||||||
self.uncompressed_size = uncompressed_size
|
self.uncompressed_size = uncompressed_size
|
||||||
@@ -353,6 +365,7 @@ class Metadata_Dirtied(Base):
|
|||||||
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
|
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
|
||||||
|
|
||||||
def __init__(self, book):
|
def __init__(self, book):
|
||||||
|
super().__init__()
|
||||||
self.book = book
|
self.book = book
|
||||||
|
|
||||||
|
|
||||||
@@ -387,6 +400,7 @@ class Books(Base):
|
|||||||
|
|
||||||
def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover,
|
def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover,
|
||||||
authors, tags, languages=None):
|
authors, tags, languages=None):
|
||||||
|
super().__init__()
|
||||||
self.title = title
|
self.title = title
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
self.author_sort = author_sort
|
self.author_sort = author_sort
|
||||||
@@ -395,12 +409,12 @@ class Books(Base):
|
|||||||
self.series_index = series_index
|
self.series_index = series_index
|
||||||
self.last_modified = last_modified
|
self.last_modified = last_modified
|
||||||
self.path = path
|
self.path = path
|
||||||
self.has_cover = (has_cover != None)
|
self.has_cover = (has_cover is not None)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
return "<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
||||||
self.timestamp, self.pubdate, self.series_index,
|
self.timestamp, self.pubdate, self.series_index,
|
||||||
self.last_modified, self.path, self.has_cover)
|
self.last_modified, self.path, self.has_cover)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def atom_timestamp(self):
|
def atom_timestamp(self):
|
||||||
@@ -444,11 +458,13 @@ class CustomColumns(Base):
|
|||||||
content['is_editable'] = self.editable
|
content['is_editable'] = self.editable
|
||||||
content['rec_index'] = sequence + 22 # toDo why ??
|
content['rec_index'] = sequence + 22 # toDo why ??
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
content['#value#'] = {"__class__": "datetime.datetime", "__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")}
|
content['#value#'] = {"__class__": "datetime.datetime",
|
||||||
|
"__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")}
|
||||||
else:
|
else:
|
||||||
content['#value#'] = value
|
content['#value#'] = value
|
||||||
content['#extra#'] = extra
|
content['#extra#'] = extra
|
||||||
content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",", "list_to_ui": ", "}
|
content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",",
|
||||||
|
"list_to_ui": ", "}
|
||||||
return json.dumps(content, ensure_ascii=False)
|
return json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -508,7 +524,6 @@ class CalibreDB:
|
|||||||
if init:
|
if init:
|
||||||
self.init_db(expire_on_commit)
|
self.init_db(expire_on_commit)
|
||||||
|
|
||||||
|
|
||||||
def init_db(self, expire_on_commit=True):
|
def init_db(self, expire_on_commit=True):
|
||||||
if self._init:
|
if self._init:
|
||||||
self.init_session(expire_on_commit)
|
self.init_session(expire_on_commit)
|
||||||
@@ -718,8 +733,8 @@ class CalibreDB:
|
|||||||
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
||||||
if not allow_show_archived:
|
if not allow_show_archived:
|
||||||
archived_books = (ub.session.query(ub.ArchivedBook)
|
archived_books = (ub.session.query(ub.ArchivedBook)
|
||||||
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
.filter(ub.ArchivedBook.user_id==int(current_user.id))
|
||||||
.filter(ub.ArchivedBook.is_archived == True)
|
.filter(ub.ArchivedBook.is_archived==True)
|
||||||
.all())
|
.all())
|
||||||
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
||||||
archived_filter = Books.id.notin_(archived_book_ids)
|
archived_filter = Books.id.notin_(archived_book_ids)
|
||||||
@@ -839,8 +854,7 @@ class CalibreDB:
|
|||||||
entries = list()
|
entries = list()
|
||||||
pagination = list()
|
pagination = list()
|
||||||
try:
|
try:
|
||||||
pagination = Pagination(page, pagesize,
|
pagination = Pagination(page, pagesize, query.count())
|
||||||
len(query.all()))
|
|
||||||
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
@@ -956,7 +970,7 @@ class CalibreDB:
|
|||||||
pagination = None
|
pagination = None
|
||||||
result = self.search_query(term, config, *join).order_by(*order).all()
|
result = self.search_query(term, config, *join).order_by(*order).all()
|
||||||
result_count = len(result)
|
result_count = len(result)
|
||||||
if offset != None and limit != None:
|
if offset is not None and limit is not None:
|
||||||
offset = int(offset)
|
offset = int(offset)
|
||||||
limit_all = offset + int(limit)
|
limit_all = offset + int(limit)
|
||||||
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||||
@@ -986,7 +1000,7 @@ class CalibreDB:
|
|||||||
if not return_all_languages:
|
if not return_all_languages:
|
||||||
no_lang_count = (self.session.query(Books)
|
no_lang_count = (self.session.query(Books)
|
||||||
.outerjoin(books_languages_link).outerjoin(Languages)
|
.outerjoin(books_languages_link).outerjoin(Languages)
|
||||||
.filter(Languages.lang_code == None)
|
.filter(Languages.lang_code==None)
|
||||||
.filter(self.common_filters())
|
.filter(self.common_filters())
|
||||||
.count())
|
.count())
|
||||||
if no_lang_count:
|
if no_lang_count:
|
||||||
@@ -1084,9 +1098,3 @@ class Category:
|
|||||||
self.id = cat_id
|
self.id = cat_id
|
||||||
self.rating = rating
|
self.rating = rating
|
||||||
self.count = 1
|
self.count = 1
|
||||||
|
|
||||||
'''class Count:
|
|
||||||
count = None
|
|
||||||
|
|
||||||
def __init__(self, count):
|
|
||||||
self.count = count'''
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from .about import collect_stats
|
|||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
class lazyEncoder(json.JSONEncoder):
|
class lazyEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, LazyString):
|
if isinstance(obj, LazyString):
|
||||||
@@ -40,6 +41,7 @@ class lazyEncoder(json.JSONEncoder):
|
|||||||
# Let the base class default method raise the TypeError
|
# Let the base class default method raise the TypeError
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
def assemble_logfiles(file_name):
|
def assemble_logfiles(file_name):
|
||||||
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
|
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
|
||||||
wfd = BytesIO()
|
wfd = BytesIO()
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ def load_dependencies(optional=False):
|
|||||||
|
|
||||||
def dependency_check(optional=False):
|
def dependency_check(optional=False):
|
||||||
d = list()
|
d = list()
|
||||||
|
dep_version_int = None
|
||||||
|
low_check = None
|
||||||
deps = load_dependencies(optional)
|
deps = load_dependencies(optional)
|
||||||
for dep in deps:
|
for dep in deps:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -27,40 +27,26 @@ from shutil import copyfile
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from markupsafe import escape, Markup # dependency of flask
|
from markupsafe import escape, Markup # dependency of flask
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from lxml.etree import ParserError
|
|
||||||
|
|
||||||
try:
|
|
||||||
# at least bleach 6.0 is needed -> incomplatible change from list arguments to set arguments
|
|
||||||
from bleach import clean_text as clean_html
|
|
||||||
BLEACH = True
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
from nh3 import clean as clean_html
|
|
||||||
BLEACH = False
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
from lxml.html.clean import clean_html
|
|
||||||
BLEACH = False
|
|
||||||
except ImportError:
|
|
||||||
clean_html = None
|
|
||||||
|
|
||||||
from flask import Blueprint, request, flash, redirect, url_for, abort, Response
|
from flask import Blueprint, request, flash, redirect, url_for, abort, Response
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from flask_login import current_user, login_required
|
from .cw_login import current_user, login_required
|
||||||
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
|
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status
|
from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status
|
||||||
|
from .clean_html import clean_string
|
||||||
from . import config, ub, db, calibre_db
|
from . import config, ub, db, calibre_db
|
||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
from .tasks.upload import TaskUpload
|
from .tasks.upload import TaskUpload
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
from .usermanagement import login_required_if_no_ano
|
|
||||||
from .kobo_sync_status import change_archived_books
|
from .kobo_sync_status import change_archived_books
|
||||||
from .redirect import get_redirect_location
|
from .redirect import get_redirect_location
|
||||||
|
from .file_helper import validate_mime_type
|
||||||
|
from .usermanagement import user_login_required, login_required_if_no_ano
|
||||||
|
|
||||||
|
|
||||||
editbook = Blueprint('edit-book', __name__)
|
editbook = Blueprint('edit-book', __name__)
|
||||||
@@ -88,14 +74,14 @@ def edit_required(f):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def delete_book_from_details(book_id):
|
def delete_book_from_details(book_id):
|
||||||
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
|
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
|
||||||
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def delete_book_ajax(book_id, book_format):
|
def delete_book_ajax(book_id, book_format):
|
||||||
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
|
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
|
||||||
|
|
||||||
@@ -133,14 +119,13 @@ def edit_book(book_id):
|
|||||||
# handle book title change
|
# handle book title change
|
||||||
title_change = handle_title_on_edit(book, to_save["book_title"])
|
title_change = handle_title_on_edit(book, to_save["book_title"])
|
||||||
# handle book author change
|
# handle book author change
|
||||||
input_authors, author_change, renamed = handle_author_on_edit(book, to_save["author_name"])
|
input_authors, author_change = handle_author_on_edit(book, to_save["author_name"])
|
||||||
if author_change or title_change:
|
if author_change or title_change:
|
||||||
edited_books_id = book.id
|
edited_books_id = book.id
|
||||||
modify_date = True
|
modify_date = True
|
||||||
title_author_error = helper.update_dir_structure(edited_books_id,
|
title_author_error = helper.update_dir_structure(edited_books_id,
|
||||||
config.get_book_path(),
|
config.get_book_path(),
|
||||||
input_authors[0],
|
input_authors[0])
|
||||||
renamed_author=renamed)
|
|
||||||
if title_author_error:
|
if title_author_error:
|
||||||
flash(title_author_error, category="error")
|
flash(title_author_error, category="error")
|
||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
@@ -149,8 +134,9 @@ def edit_book(book_id):
|
|||||||
# handle upload other formats from local disk
|
# handle upload other formats from local disk
|
||||||
meta = upload_single_file(request, book, book_id)
|
meta = upload_single_file(request, book, book_id)
|
||||||
# only merge metadata if file was uploaded and no error occurred (meta equals not false or none)
|
# only merge metadata if file was uploaded and no error occurred (meta equals not false or none)
|
||||||
|
upload_format = False
|
||||||
if meta:
|
if meta:
|
||||||
merge_metadata(to_save, meta)
|
upload_format = merge_metadata(to_save, meta)
|
||||||
# handle upload covers from local disk
|
# handle upload covers from local disk
|
||||||
cover_upload_success = upload_cover(request, book)
|
cover_upload_success = upload_cover(request, book)
|
||||||
if cover_upload_success:
|
if cover_upload_success:
|
||||||
@@ -194,7 +180,7 @@ def edit_book(book_id):
|
|||||||
modify_date |= edit_book_publisher(to_save['publisher'], book)
|
modify_date |= edit_book_publisher(to_save['publisher'], book)
|
||||||
# handle book languages
|
# handle book languages
|
||||||
try:
|
try:
|
||||||
modify_date |= edit_book_languages(to_save['languages'], book)
|
modify_date |= edit_book_languages(to_save['languages'], book, upload_format)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
flash(str(e), category="error")
|
flash(str(e), category="error")
|
||||||
edit_error = True
|
edit_error = True
|
||||||
@@ -266,7 +252,7 @@ def upload():
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
db_book, input_authors, title_dir, renamed_authors = create_book_on_upload(modify_date, meta)
|
db_book, input_authors, title_dir = create_book_on_upload(modify_date, meta)
|
||||||
|
|
||||||
# Comments need book id therefore only possible after flush
|
# Comments need book id therefore only possible after flush
|
||||||
modify_date |= edit_book_comments(Markup(meta.description).unescape(), db_book)
|
modify_date |= edit_book_comments(Markup(meta.description).unescape(), db_book)
|
||||||
@@ -276,18 +262,19 @@ def upload():
|
|||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
helper.upload_new_file_gdrive(book_id,
|
helper.upload_new_file_gdrive(book_id,
|
||||||
input_authors[0],
|
input_authors[0],
|
||||||
renamed_authors,
|
|
||||||
title,
|
title,
|
||||||
title_dir,
|
title_dir,
|
||||||
meta.file_path,
|
meta.file_path,
|
||||||
meta.extension.lower())
|
meta.extension.lower())
|
||||||
|
for file_format in db_book.data:
|
||||||
|
file_format.name = (helper.get_valid_filename(title, chars=42) + ' - '
|
||||||
|
+ helper.get_valid_filename(input_authors[0], chars=42))
|
||||||
else:
|
else:
|
||||||
error = helper.update_dir_structure(book_id,
|
error = helper.update_dir_structure(book_id,
|
||||||
config.get_book_path(),
|
config.get_book_path(),
|
||||||
input_authors[0],
|
input_authors[0],
|
||||||
meta.file_path,
|
meta.file_path,
|
||||||
title_dir + meta.extension.lower(),
|
title_dir + meta.extension.lower())
|
||||||
renamed_author=renamed_authors)
|
|
||||||
|
|
||||||
move_coverfile(meta, db_book)
|
move_coverfile(meta, db_book)
|
||||||
|
|
||||||
@@ -346,7 +333,7 @@ def convert_bookformat(book_id):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/getcustomenum/<int:c_id>")
|
@editbook.route("/ajax/getcustomenum/<int:c_id>")
|
||||||
@login_required
|
@user_login_required
|
||||||
def table_get_custom_enum(c_id):
|
def table_get_custom_enum(c_id):
|
||||||
ret = list()
|
ret = list()
|
||||||
cc = (calibre_db.session.query(db.CustomColumns)
|
cc = (calibre_db.session.query(db.CustomColumns)
|
||||||
@@ -420,9 +407,8 @@ def edit_list_book(param):
|
|||||||
ret = Response(json.dumps({'success': True, 'newValue': book.comments[0].text}),
|
ret = Response(json.dumps({'success': True, 'newValue': book.comments[0].text}),
|
||||||
mimetype='application/json')
|
mimetype='application/json')
|
||||||
elif param == 'authors':
|
elif param == 'authors':
|
||||||
input_authors, __, renamed = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
|
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
|
||||||
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0],
|
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0])
|
||||||
renamed_author=renamed)
|
|
||||||
if not rename_error:
|
if not rename_error:
|
||||||
ret = Response(json.dumps({
|
ret = Response(json.dumps({
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -471,7 +457,7 @@ def edit_list_book(param):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
||||||
@login_required
|
@user_login_required
|
||||||
def get_sorted_entry(field, bookid):
|
def get_sorted_entry(field, bookid):
|
||||||
if field in ['title', 'authors', 'sort', 'author_sort']:
|
if field in ['title', 'authors', 'sort', 'author_sort']:
|
||||||
book = calibre_db.get_filtered_book(bookid)
|
book = calibre_db.get_filtered_book(bookid)
|
||||||
@@ -488,7 +474,7 @@ def get_sorted_entry(field, bookid):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/simulatemerge", methods=['POST'])
|
@editbook.route("/ajax/simulatemerge", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def simulate_merge_list_book():
|
def simulate_merge_list_book():
|
||||||
vals = request.get_json().get('Merge_books')
|
vals = request.get_json().get('Merge_books')
|
||||||
@@ -504,7 +490,7 @@ def simulate_merge_list_book():
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/mergebooks", methods=['POST'])
|
@editbook.route("/ajax/mergebooks", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def merge_list_book():
|
def merge_list_book():
|
||||||
vals = request.get_json().get('Merge_books')
|
vals = request.get_json().get('Merge_books')
|
||||||
@@ -542,7 +528,7 @@ def merge_list_book():
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/xchange", methods=['POST'])
|
@editbook.route("/ajax/xchange", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def table_xchange_author_title():
|
def table_xchange_author_title():
|
||||||
vals = request.get_json().get('xchange')
|
vals = request.get_json().get('xchange')
|
||||||
@@ -558,7 +544,7 @@ def table_xchange_author_title():
|
|||||||
author_names.append(authr.name.replace('|', ','))
|
author_names.append(authr.name.replace('|', ','))
|
||||||
|
|
||||||
title_change = handle_title_on_edit(book, " ".join(author_names))
|
title_change = handle_title_on_edit(book, " ".join(author_names))
|
||||||
input_authors, author_change, renamed = handle_author_on_edit(book, authors)
|
input_authors, author_change = handle_author_on_edit(book, authors)
|
||||||
if author_change or title_change:
|
if author_change or title_change:
|
||||||
edited_books_id = book.id
|
edited_books_id = book.id
|
||||||
modify_date = True
|
modify_date = True
|
||||||
@@ -568,8 +554,7 @@ def table_xchange_author_title():
|
|||||||
|
|
||||||
if edited_books_id:
|
if edited_books_id:
|
||||||
# toDo: Handle error
|
# toDo: Handle error
|
||||||
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0],
|
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0])
|
||||||
renamed_author=renamed)
|
|
||||||
if modify_date:
|
if modify_date:
|
||||||
book.last_modified = datetime.utcnow()
|
book.last_modified = datetime.utcnow()
|
||||||
calibre_db.set_metadata_dirty(book.id)
|
calibre_db.set_metadata_dirty(book.id)
|
||||||
@@ -591,6 +576,10 @@ def merge_metadata(to_save, meta):
|
|||||||
to_save['author_name'] = ''
|
to_save['author_name'] = ''
|
||||||
if to_save.get('book_title', "") == _('Unknown'):
|
if to_save.get('book_title', "") == _('Unknown'):
|
||||||
to_save['book_title'] = ''
|
to_save['book_title'] = ''
|
||||||
|
if not to_save["languages"] and meta.languages:
|
||||||
|
upload_language = True
|
||||||
|
else:
|
||||||
|
upload_language = False
|
||||||
for s_field, m_field in [
|
for s_field, m_field in [
|
||||||
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
|
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
|
||||||
('series_index', 'series_id'), ('languages', 'languages'),
|
('series_index', 'series_id'), ('languages', 'languages'),
|
||||||
@@ -598,7 +587,7 @@ def merge_metadata(to_save, meta):
|
|||||||
to_save[s_field] = to_save[s_field] or getattr(meta, m_field, '')
|
to_save[s_field] = to_save[s_field] or getattr(meta, m_field, '')
|
||||||
to_save["description"] = to_save["description"] or Markup(
|
to_save["description"] = to_save["description"] or Markup(
|
||||||
getattr(meta, 'description', '')).unescape()
|
getattr(meta, 'description', '')).unescape()
|
||||||
|
return upload_language
|
||||||
|
|
||||||
def identifier_list(to_save, book):
|
def identifier_list(to_save, book):
|
||||||
"""Generate a list of Identifiers from form information"""
|
"""Generate a list of Identifiers from form information"""
|
||||||
@@ -617,7 +606,9 @@ def identifier_list(to_save, book):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def prepare_authors(authr):
|
def prepare_authors(authr, calibre_path, gdrive=False):
|
||||||
|
if gdrive:
|
||||||
|
calibre_path = ""
|
||||||
# handle authors
|
# handle authors
|
||||||
input_authors = authr.split('&')
|
input_authors = authr.split('&')
|
||||||
# handle_authors(input_authors)
|
# handle_authors(input_authors)
|
||||||
@@ -629,18 +620,44 @@ def prepare_authors(authr):
|
|||||||
if input_authors == ['']:
|
if input_authors == ['']:
|
||||||
input_authors = [_('Unknown')] # prevent empty Author
|
input_authors = [_('Unknown')] # prevent empty Author
|
||||||
|
|
||||||
renamed = list()
|
|
||||||
for in_aut in input_authors:
|
for in_aut in input_authors:
|
||||||
renamed_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == in_aut).first()
|
renamed_author = calibre_db.session.query(db.Authors).filter(func.lower(db.Authors.name).ilike(in_aut)).first()
|
||||||
if renamed_author and in_aut != renamed_author.name:
|
if renamed_author and in_aut != renamed_author.name:
|
||||||
renamed.append(renamed_author.name)
|
old_author_name = renamed_author.name
|
||||||
|
# rename author in Database
|
||||||
|
create_objects_for_addition(renamed_author, in_aut,"author")
|
||||||
|
# rename all Books with this author as first author:
|
||||||
|
# rename all book author_sort strings with the new author name
|
||||||
all_books = calibre_db.session.query(db.Books) \
|
all_books = calibre_db.session.query(db.Books) \
|
||||||
.filter(db.Books.authors.any(db.Authors.name == renamed_author.name)).all()
|
.filter(db.Books.authors.any(db.Authors.name == renamed_author.name)).all()
|
||||||
sorted_renamed_author = helper.get_sorted_author(renamed_author.name)
|
|
||||||
sorted_old_author = helper.get_sorted_author(in_aut)
|
|
||||||
for one_book in all_books:
|
for one_book in all_books:
|
||||||
one_book.author_sort = one_book.author_sort.replace(sorted_renamed_author, sorted_old_author)
|
# ToDo: check
|
||||||
return input_authors, renamed
|
sorted_old_author = helper.get_sorted_author(old_author_name)
|
||||||
|
sorted_renamed_author = helper.get_sorted_author(in_aut)
|
||||||
|
# change author sort path
|
||||||
|
try:
|
||||||
|
author_index = one_book.author_sort.index(sorted_old_author)
|
||||||
|
one_book.author_sort = one_book.author_sort.replace(sorted_old_author, sorted_renamed_author)
|
||||||
|
except ValueError:
|
||||||
|
log.error("Sorted author {} not found in database".format(sorted_old_author))
|
||||||
|
author_index = -1
|
||||||
|
# change book path if changed author is first author -> match on first position
|
||||||
|
if author_index == 0:
|
||||||
|
one_titledir = one_book.path.split('/')[1]
|
||||||
|
one_old_authordir = one_book.path.split('/')[0]
|
||||||
|
# rename author path only once per renamed author -> search all books with author name in book.path
|
||||||
|
# das muss einmal geschehen aber pro Buch geprüft werden ansonsten habe ich das Problem das vlt. 2 gleiche Ordner bis auf Groß/Kleinschreibung vorhanden sind im Umzug
|
||||||
|
new_author_dir = helper.rename_author_path(in_aut, one_old_authordir, renamed_author.name, calibre_path, gdrive)
|
||||||
|
one_book.path = os.path.join(new_author_dir, one_titledir).replace('\\', '/')
|
||||||
|
# rename all books in book data with the new author name and move corresponding files to new locations
|
||||||
|
# old_path = os.path.join(calibre_path, new_author_dir, one_titledir)
|
||||||
|
new_path = os.path.join(calibre_path, new_author_dir, one_titledir)
|
||||||
|
all_new_name = helper.get_valid_filename(one_book.title, chars=42) + ' - ' \
|
||||||
|
+ helper.get_valid_filename(renamed_author.name, chars=42)
|
||||||
|
# change location in database to new author/title path
|
||||||
|
helper.rename_all_files_on_change(one_book, new_path, new_path, all_new_name, gdrive)
|
||||||
|
|
||||||
|
return input_authors
|
||||||
|
|
||||||
|
|
||||||
def prepare_authors_on_upload(title, authr):
|
def prepare_authors_on_upload(title, authr):
|
||||||
@@ -651,12 +668,13 @@ def prepare_authors_on_upload(title, authr):
|
|||||||
flash(_("Uploaded book probably exists in the library, consider to change before upload new: ")
|
flash(_("Uploaded book probably exists in the library, consider to change before upload new: ")
|
||||||
+ Markup(render_title_template('book_exists_flash.html', entry=entry)), category="warning")
|
+ Markup(render_title_template('book_exists_flash.html', entry=entry)), category="warning")
|
||||||
|
|
||||||
input_authors, renamed = prepare_authors(authr)
|
input_authors = prepare_authors(authr, config.get_book_path(), config.config_use_google_drive)
|
||||||
|
|
||||||
sort_authors_list = list()
|
sort_authors_list = list()
|
||||||
db_author = None
|
db_author = None
|
||||||
for inp in input_authors:
|
for inp in input_authors:
|
||||||
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
|
# stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
|
||||||
|
stored_author = calibre_db.session.query(db.Authors).filter(func.lower(db.Authors.name).ilike(inp)).first()
|
||||||
if not stored_author:
|
if not stored_author:
|
||||||
if not db_author:
|
if not db_author:
|
||||||
db_author = db.Authors(inp, helper.get_sorted_author(inp), "")
|
db_author = db.Authors(inp, helper.get_sorted_author(inp), "")
|
||||||
@@ -669,13 +687,13 @@ def prepare_authors_on_upload(title, authr):
|
|||||||
sort_author = stored_author.sort
|
sort_author = stored_author.sort
|
||||||
sort_authors_list.append(sort_author)
|
sort_authors_list.append(sort_author)
|
||||||
sort_authors = ' & '.join(sort_authors_list)
|
sort_authors = ' & '.join(sort_authors_list)
|
||||||
return sort_authors, input_authors, db_author, renamed
|
return sort_authors, input_authors, db_author
|
||||||
|
|
||||||
|
|
||||||
def create_book_on_upload(modify_date, meta):
|
def create_book_on_upload(modify_date, meta):
|
||||||
title = meta.title
|
title = meta.title
|
||||||
authr = meta.author
|
authr = meta.author
|
||||||
sort_authors, input_authors, db_author, renamed_authors = prepare_authors_on_upload(title, authr)
|
sort_authors, input_authors, db_author = prepare_authors_on_upload(title, authr)
|
||||||
|
|
||||||
title_dir = helper.get_valid_filename(title, chars=96)
|
title_dir = helper.get_valid_filename(title, chars=96)
|
||||||
author_dir = helper.get_valid_filename(db_author.name, chars=96)
|
author_dir = helper.get_valid_filename(db_author.name, chars=96)
|
||||||
@@ -732,14 +750,20 @@ def create_book_on_upload(modify_date, meta):
|
|||||||
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
|
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
|
||||||
modify_date |= modification
|
modify_date |= modification
|
||||||
|
|
||||||
return db_book, input_authors, title_dir, renamed_authors
|
return db_book, input_authors, title_dir
|
||||||
|
|
||||||
|
|
||||||
def file_handling_on_upload(requested_file):
|
def file_handling_on_upload(requested_file):
|
||||||
# check if file extension is correct
|
# check if file extension is correct
|
||||||
|
allowed_extensions = config.config_upload_formats.split(',')
|
||||||
|
if requested_file:
|
||||||
|
if config.config_check_extensions and allowed_extensions != ['']:
|
||||||
|
if not validate_mime_type(requested_file, allowed_extensions):
|
||||||
|
flash(_("File type isn't allowed to be uploaded to this server"), category="error")
|
||||||
|
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
if '.' in requested_file.filename:
|
if '.' in requested_file.filename:
|
||||||
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
||||||
if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD:
|
if file_ext not in allowed_extensions and '' not in allowed_extensions:
|
||||||
flash(
|
flash(
|
||||||
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
|
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
|
||||||
ext=file_ext), category="error")
|
ext=file_ext), category="error")
|
||||||
@@ -1004,14 +1028,7 @@ def edit_book_series_index(series_index, book):
|
|||||||
def edit_book_comments(comments, book):
|
def edit_book_comments(comments, book):
|
||||||
modify_date = False
|
modify_date = False
|
||||||
if comments:
|
if comments:
|
||||||
try:
|
comments = clean_string(comments, book.id)
|
||||||
if BLEACH:
|
|
||||||
comments = clean_html(comments, tags=set(), attributes=set())
|
|
||||||
else:
|
|
||||||
comments = clean_html(comments)
|
|
||||||
except ParserError as e:
|
|
||||||
log.error("Comments of book {} are corrupted: {}".format(book.id, e))
|
|
||||||
comments = ""
|
|
||||||
if len(book.comments):
|
if len(book.comments):
|
||||||
if book.comments[0].text != comments:
|
if book.comments[0].text != comments:
|
||||||
book.comments[0].text = comments
|
book.comments[0].text = comments
|
||||||
@@ -1069,7 +1086,7 @@ def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
|
|||||||
elif c.datatype == 'comments':
|
elif c.datatype == 'comments':
|
||||||
to_save[cc_string] = Markup(to_save[cc_string]).unescape()
|
to_save[cc_string] = Markup(to_save[cc_string]).unescape()
|
||||||
if to_save[cc_string]:
|
if to_save[cc_string]:
|
||||||
to_save[cc_string] = clean_html(to_save[cc_string])
|
to_save[cc_string] = clean_string(to_save[cc_string], book_id)
|
||||||
elif c.datatype == 'datetime':
|
elif c.datatype == 'datetime':
|
||||||
try:
|
try:
|
||||||
to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
|
to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
|
||||||
@@ -1174,7 +1191,12 @@ def edit_cc_data(book_id, book, to_save, cc):
|
|||||||
def upload_single_file(file_request, book, book_id):
|
def upload_single_file(file_request, book, book_id):
|
||||||
# Check and handle Uploaded file
|
# Check and handle Uploaded file
|
||||||
requested_file = file_request.files.get('btn-upload-format', None)
|
requested_file = file_request.files.get('btn-upload-format', None)
|
||||||
|
allowed_extensions = config.config_upload_formats.split(',')
|
||||||
if requested_file:
|
if requested_file:
|
||||||
|
if config.config_check_extensions and allowed_extensions != ['']:
|
||||||
|
if not validate_mime_type(requested_file, allowed_extensions):
|
||||||
|
flash(_("File type isn't allowed to be uploaded to this server"), category="error")
|
||||||
|
return False
|
||||||
# check for empty request
|
# check for empty request
|
||||||
if requested_file.filename != '':
|
if requested_file.filename != '':
|
||||||
if not current_user.role_upload():
|
if not current_user.role_upload():
|
||||||
@@ -1182,7 +1204,7 @@ def upload_single_file(file_request, book, book_id):
|
|||||||
return False
|
return False
|
||||||
if '.' in requested_file.filename:
|
if '.' in requested_file.filename:
|
||||||
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
||||||
if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD:
|
if file_ext not in allowed_extensions and '' not in allowed_extensions:
|
||||||
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
|
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
|
||||||
category="error")
|
category="error")
|
||||||
return False
|
return False
|
||||||
@@ -1199,7 +1221,8 @@ def upload_single_file(file_request, book, book_id):
|
|||||||
try:
|
try:
|
||||||
os.makedirs(filepath)
|
os.makedirs(filepath)
|
||||||
except OSError:
|
except OSError:
|
||||||
flash(_("Failed to create path %(path)s (Permission denied).", path=filepath), category="error")
|
flash(_("Failed to create path %(path)s (Permission denied).", path=filepath),
|
||||||
|
category="error")
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
requested_file.save(saved_filename)
|
requested_file.save(saved_filename)
|
||||||
@@ -1269,9 +1292,8 @@ def handle_title_on_edit(book, book_title):
|
|||||||
def handle_author_on_edit(book, author_name, update_stored=True):
|
def handle_author_on_edit(book, author_name, update_stored=True):
|
||||||
change = False
|
change = False
|
||||||
# handle author(s)
|
# handle author(s)
|
||||||
input_authors, renamed = prepare_authors(author_name)
|
input_authors = prepare_authors(author_name, config.get_book_path(), config.config_use_google_drive)
|
||||||
|
|
||||||
# change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
|
||||||
# Search for each author if author is in database, if not, author name and sorted author name is generated new
|
# Search for each author if author is in database, if not, author name and sorted author name is generated new
|
||||||
# everything then is assembled for sorted author field in database
|
# everything then is assembled for sorted author field in database
|
||||||
sort_authors_list = list()
|
sort_authors_list = list()
|
||||||
@@ -1289,15 +1311,13 @@ def handle_author_on_edit(book, author_name, update_stored=True):
|
|||||||
|
|
||||||
change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
||||||
|
|
||||||
return input_authors, change, renamed
|
return input_authors, change
|
||||||
|
|
||||||
|
|
||||||
def search_objects_remove(db_book_object, db_type, input_elements):
|
def search_objects_remove(db_book_object, db_type, input_elements):
|
||||||
del_elements = []
|
del_elements = []
|
||||||
for c_elements in db_book_object:
|
for c_elements in db_book_object:
|
||||||
found = False
|
found = False
|
||||||
#if db_type == 'languages':
|
|
||||||
# type_elements = c_elements.lang_code
|
|
||||||
if db_type == 'custom':
|
if db_type == 'custom':
|
||||||
type_elements = c_elements.value
|
type_elements = c_elements.value
|
||||||
else:
|
else:
|
||||||
@@ -1353,11 +1373,9 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
|
|||||||
for add_element in add_elements:
|
for add_element in add_elements:
|
||||||
# check if an element with that name exists
|
# check if an element with that name exists
|
||||||
changed = True
|
changed = True
|
||||||
# db_session.query(db.Tags).filter((func.lower(db.Tags.name).ilike("GênOt"))).all()
|
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).all()
|
||||||
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).first()
|
|
||||||
# db_element = db_session.query(db_object).filter(func.lower(db_filter) == add_element.lower()).first()
|
|
||||||
# if no element is found add it
|
# if no element is found add it
|
||||||
if db_element is None:
|
if not db_element:
|
||||||
if db_type == 'author':
|
if db_type == 'author':
|
||||||
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
|
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
|
||||||
elif db_type == 'series':
|
elif db_type == 'series':
|
||||||
@@ -1371,12 +1389,11 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
|
|||||||
db_session.add(new_element)
|
db_session.add(new_element)
|
||||||
db_book_object.append(new_element)
|
db_book_object.append(new_element)
|
||||||
else:
|
else:
|
||||||
db_no_case = db_session.query(db_object).filter(db_filter == add_element).first()
|
if len(db_element) == 1:
|
||||||
if db_no_case:
|
db_element = create_objects_for_addition(db_element[0], add_element, db_type)
|
||||||
# check for new case of element
|
|
||||||
db_element = create_objects_for_addition(db_element, add_element, db_type)
|
|
||||||
else:
|
else:
|
||||||
db_element = create_objects_for_addition(db_element, add_element, db_type)
|
db_el = db_session.query(db_object).filter(db_filter == add_element).first()
|
||||||
|
db_element = db_element[0] if not db_el else db_el
|
||||||
# add element to book
|
# add element to book
|
||||||
db_book_object.append(db_element)
|
db_book_object.append(db_element)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ log = logger.create()
|
|||||||
|
|
||||||
def do_calibre_export(book_id, book_format):
|
def do_calibre_export(book_id, book_format):
|
||||||
try:
|
try:
|
||||||
quotes = [3, 5, 7, 9]
|
quotes = [4, 6]
|
||||||
tmp_dir = get_temp_dir()
|
tmp_dir = get_temp_dir()
|
||||||
calibredb_binarypath = get_calibre_binarypath("calibredb")
|
calibredb_binarypath = get_calibre_binarypath("calibredb")
|
||||||
temp_file_name = str(uuid4())
|
temp_file_name = str(uuid4())
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
|||||||
cf = zip_file.read(zip_cover_path)
|
cf = zip_file.read(zip_cover_path)
|
||||||
return cover.cover_processing(tmp_file_name, cf, extension)
|
return cover.cover_processing(tmp_file_name, cf, extension)
|
||||||
|
|
||||||
|
|
||||||
def get_epub_layout(book, book_data):
|
def get_epub_layout(book, book_data):
|
||||||
file_path = os.path.normpath(os.path.join(config.get_book_path(),
|
file_path = os.path.normpath(os.path.join(config.get_book_path(),
|
||||||
book.path, book_data.name + "." + book_data.format.lower()))
|
book.path, book_data.name + "." + book_data.format.lower()))
|
||||||
@@ -54,7 +55,7 @@ def get_epub_layout(book, book_data):
|
|||||||
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
||||||
|
|
||||||
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=default_ns)
|
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=default_ns)
|
||||||
except (etree.XMLSyntaxError, KeyError, IndexError) as e:
|
except (etree.XMLSyntaxError, KeyError, IndexError, OSError) as e:
|
||||||
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
|
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
|
||||||
layout = []
|
layout = []
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def updateEpub(src, dest, filename, data, ):
|
|||||||
# create a temp copy of the archive without filename
|
# create a temp copy of the archive without filename
|
||||||
with zipfile.ZipFile(src, 'r') as zin:
|
with zipfile.ZipFile(src, 'r') as zin:
|
||||||
with zipfile.ZipFile(dest, 'w') as zout:
|
with zipfile.ZipFile(dest, 'w') as zout:
|
||||||
zout.comment = zin.comment # preserve the comment
|
zout.comment = zin.comment # preserve the comment
|
||||||
for item in zin.infolist():
|
for item in zin.infolist():
|
||||||
if item.filename != filename:
|
if item.filename != filename:
|
||||||
zout.writestr(item, zin.read(item.filename))
|
zout.writestr(item, zin.read(item.filename))
|
||||||
@@ -53,7 +53,9 @@ def updateEpub(src, dest, filename, data, ):
|
|||||||
zf.writestr(filename, data)
|
zf.writestr(filename, data)
|
||||||
|
|
||||||
|
|
||||||
def get_content_opf(file_path, ns=default_ns):
|
def get_content_opf(file_path, ns=None):
|
||||||
|
if ns is None:
|
||||||
|
ns = default_ns
|
||||||
epubZip = zipfile.ZipFile(file_path)
|
epubZip = zipfile.ZipFile(file_path)
|
||||||
txt = epubZip.read('META-INF/container.xml')
|
txt = epubZip.read('META-INF/container.xml')
|
||||||
tree = etree.fromstring(txt)
|
tree = etree.fromstring(txt)
|
||||||
@@ -154,13 +156,14 @@ def create_new_metadata_backup(book, custom_columns, export_language, translate
|
|||||||
|
|
||||||
return package
|
return package
|
||||||
|
|
||||||
|
|
||||||
def replace_metadata(tree, package):
|
def replace_metadata(tree, package):
|
||||||
rep_element = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
rep_element = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
||||||
new_element = package.xpath('//metadata', namespaces=default_ns)[0]
|
new_element = package.xpath('//metadata', namespaces=default_ns)[0]
|
||||||
tree.replace(rep_element, new_element)
|
tree.replace(rep_element, new_element)
|
||||||
return etree.tostring(tree,
|
return etree.tostring(tree,
|
||||||
xml_declaration=True,
|
xml_declaration=True,
|
||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
pretty_print=True).decode('utf-8')
|
pretty_print=True).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from . import config, app, logger, services
|
|||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
# custom error page
|
# custom error page
|
||||||
|
|
||||||
def error_http(error):
|
def error_http(error):
|
||||||
return render_template('http_error.html',
|
return render_template('http_error.html',
|
||||||
error_code="Error {0}".format(error.code),
|
error_code="Error {0}".format(error.code),
|
||||||
@@ -52,6 +53,7 @@ def internal_error(error):
|
|||||||
instance=config.config_calibre_web_title
|
instance=config.config_calibre_web_title
|
||||||
), 500
|
), 500
|
||||||
|
|
||||||
|
|
||||||
def init_errorhandler():
|
def init_errorhandler():
|
||||||
# http error handling
|
# http error handling
|
||||||
for ex in default_exceptions:
|
for ex in default_exceptions:
|
||||||
@@ -60,7 +62,6 @@ def init_errorhandler():
|
|||||||
elif ex == 500:
|
elif ex == 500:
|
||||||
app.register_error_handler(ex, internal_error)
|
app.register_error_handler(ex, internal_error)
|
||||||
|
|
||||||
|
|
||||||
if services.ldap:
|
if services.ldap:
|
||||||
# Only way of catching the LDAPException upon logging in with LDAP server down
|
# Only way of catching the LDAPException upon logging in with LDAP server down
|
||||||
@app.errorhandler(services.ldap.LDAPException)
|
@app.errorhandler(services.ldap.LDAPException)
|
||||||
|
|||||||
@@ -19,6 +19,20 @@
|
|||||||
from tempfile import gettempdir
|
from tempfile import gettempdir
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
import mimetypes
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import magic
|
||||||
|
error = None
|
||||||
|
except ImportError as e:
|
||||||
|
error = "Cannot import python-magic, checking uploaded file metadata will not work: {}".format(e)
|
||||||
|
|
||||||
|
|
||||||
def get_temp_dir():
|
def get_temp_dir():
|
||||||
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||||
@@ -30,3 +44,31 @@ def get_temp_dir():
|
|||||||
def del_temp_dir():
|
def del_temp_dir():
|
||||||
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||||
shutil.rmtree(tmp_dir)
|
shutil.rmtree(tmp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mime_type(file_buffer, allowed_extensions):
|
||||||
|
if error:
|
||||||
|
log.error(error)
|
||||||
|
return False
|
||||||
|
mime = magic.Magic(mime=True)
|
||||||
|
allowed_mimetypes = list()
|
||||||
|
for x in allowed_extensions:
|
||||||
|
try:
|
||||||
|
allowed_mimetypes.append(mimetypes.types_map["." + x])
|
||||||
|
except KeyError:
|
||||||
|
log.error("Unkown mimetype for Extension: {}".format(x))
|
||||||
|
tmp_mime_type = mime.from_buffer(file_buffer.read())
|
||||||
|
file_buffer.seek(0)
|
||||||
|
if any(mime_type in tmp_mime_type for mime_type in allowed_mimetypes):
|
||||||
|
return True
|
||||||
|
# Some epubs show up as zip mimetypes
|
||||||
|
elif "zip" in tmp_mime_type:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(BytesIO(file_buffer.read()), 'r') as epub:
|
||||||
|
file_buffer.seek(0)
|
||||||
|
if "mimetype" in epub.namelist():
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
file_buffer.seek(0)
|
||||||
|
log.error("Mimetype '{}' not found in allowed types".format(tmp_mime_type))
|
||||||
|
return False
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ from shutil import move, copyfile
|
|||||||
|
|
||||||
from flask import Blueprint, flash, request, redirect, url_for, abort
|
from flask import Blueprint, flash, request, redirect, url_for, abort
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import login_required
|
|
||||||
|
|
||||||
from . import logger, gdriveutils, config, ub, calibre_db, csrf
|
from . import logger, gdriveutils, config, ub, calibre_db, csrf
|
||||||
from .admin import admin_required
|
from .admin import admin_required
|
||||||
from .file_helper import get_temp_dir
|
from .file_helper import get_temp_dir
|
||||||
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -45,11 +45,11 @@ except ImportError as err:
|
|||||||
|
|
||||||
current_milli_time = lambda: int(round(time() * 1000))
|
current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
gdrive_watch_callback_token = 'target=calibreweb-watch_files' #nosec
|
gdrive_watch_callback_token = 'target=calibreweb-watch_files' # nosec
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/authenticate")
|
@gdrive.route("/authenticate")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def authenticate_google_drive():
|
def authenticate_google_drive():
|
||||||
try:
|
try:
|
||||||
@@ -76,7 +76,7 @@ def google_drive_callback():
|
|||||||
|
|
||||||
|
|
||||||
@gdrive.route("/watch/subscribe")
|
@gdrive.route("/watch/subscribe")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def watch_gdrive():
|
def watch_gdrive():
|
||||||
if not config.config_google_drive_watch_changes_response:
|
if not config.config_google_drive_watch_changes_response:
|
||||||
@@ -86,11 +86,12 @@ def watch_gdrive():
|
|||||||
notification_id = str(uuid4())
|
notification_id = str(uuid4())
|
||||||
try:
|
try:
|
||||||
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
|
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
|
||||||
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
|
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
|
||||||
|
|
||||||
config.config_google_drive_watch_changes_response = result
|
config.config_google_drive_watch_changes_response = result
|
||||||
config.save()
|
config.save()
|
||||||
except HttpError as e:
|
except HttpError as e:
|
||||||
reason=json.loads(e.content)['error']['errors'][0]
|
reason = json.loads(e.content)['error']['errors'][0]
|
||||||
if reason['reason'] == 'push.webhookUrlUnauthorized':
|
if reason['reason'] == 'push.webhookUrlUnauthorized':
|
||||||
flash(_('Callback domain is not verified, '
|
flash(_('Callback domain is not verified, '
|
||||||
'please follow steps to verify domain in google developer console'), category="error")
|
'please follow steps to verify domain in google developer console'), category="error")
|
||||||
@@ -101,7 +102,7 @@ def watch_gdrive():
|
|||||||
|
|
||||||
|
|
||||||
@gdrive.route("/watch/revoke")
|
@gdrive.route("/watch/revoke")
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def revoke_watch_gdrive():
|
def revoke_watch_gdrive():
|
||||||
last_watch_response = config.config_google_drive_watch_changes_response
|
last_watch_response = config.config_google_drive_watch_changes_response
|
||||||
@@ -115,6 +116,7 @@ def revoke_watch_gdrive():
|
|||||||
config.save()
|
config.save()
|
||||||
return redirect(url_for('admin.db_configuration'))
|
return redirect(url_for('admin.db_configuration'))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@gdrive.route("/watch/callback", methods=['GET', 'POST'])
|
@gdrive.route("/watch/callback", methods=['GET', 'POST'])
|
||||||
@@ -138,7 +140,7 @@ try:
|
|||||||
if response:
|
if response:
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
||||||
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
||||||
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
||||||
tmp_dir = get_temp_dir()
|
tmp_dir = get_temp_dir()
|
||||||
|
|
||||||
log.info('Database file updated')
|
log.info('Database file updated')
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import chardet
|
import chardet
|
||||||
import ssl
|
import ssl
|
||||||
|
import sqlite3
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from werkzeug.datastructures import Headers
|
||||||
from flask import Response, stream_with_context
|
from flask import Response, stream_with_context
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import Column, UniqueConstraint
|
from sqlalchemy import Column, UniqueConstraint
|
||||||
@@ -63,7 +66,7 @@ except ImportError as err:
|
|||||||
importError = err
|
importError = err
|
||||||
gdrive_support = False
|
gdrive_support = False
|
||||||
|
|
||||||
from . import logger, cli_param, config
|
from . import logger, cli_param, config, db
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +210,7 @@ def getDrive(drive=None, gauth=None):
|
|||||||
log.error("Google Drive error: {}".format(e))
|
log.error("Google Drive error: {}".format(e))
|
||||||
return drive
|
return drive
|
||||||
|
|
||||||
|
|
||||||
def listRootFolders():
|
def listRootFolders():
|
||||||
try:
|
try:
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
@@ -224,7 +228,7 @@ def getEbooksFolder(drive):
|
|||||||
|
|
||||||
def getFolderInFolder(parentId, folderName, drive):
|
def getFolderInFolder(parentId, folderName, drive):
|
||||||
# drive = getDrive(drive)
|
# drive = getDrive(drive)
|
||||||
query=""
|
query = ""
|
||||||
if folderName:
|
if folderName:
|
||||||
query = "title = '%s' and " % folderName.replace("'", r"\'")
|
query = "title = '%s' and " % folderName.replace("'", r"\'")
|
||||||
folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder'" \
|
folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder'" \
|
||||||
@@ -235,6 +239,7 @@ def getFolderInFolder(parentId, folderName, drive):
|
|||||||
else:
|
else:
|
||||||
return fileList[0]
|
return fileList[0]
|
||||||
|
|
||||||
|
|
||||||
# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database
|
# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database
|
||||||
def getEbooksFolderId(drive=None):
|
def getEbooksFolderId(drive=None):
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first()
|
storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first()
|
||||||
@@ -256,17 +261,20 @@ def getEbooksFolderId(drive=None):
|
|||||||
return gDriveId.gdrive_id
|
return gDriveId.gdrive_id
|
||||||
|
|
||||||
|
|
||||||
def getFile(pathId, fileName, drive):
|
def getFile(pathId, fileName, drive, nocase):
|
||||||
metaDataFile = "'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", r"\'"))
|
metaDataFile = "'%s' in parents and trashed = false and title contains '%s'" % (pathId, fileName.replace("'", r"\'"))
|
||||||
fileList = drive.ListFile({'q': metaDataFile}).GetList()
|
fileList = drive.ListFile({'q': metaDataFile}).GetList()
|
||||||
if fileList.__len__() == 0:
|
if fileList.__len__() == 0:
|
||||||
return None
|
return None
|
||||||
else:
|
if nocase:
|
||||||
return fileList[0]
|
return fileList[0] if db.lcase(fileList[0]['title']) == db.lcase(fileName) else None
|
||||||
|
for f in fileList:
|
||||||
|
if f['title'] == fileName:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def getFolderId(path, drive):
|
def getFolderId(path, drive):
|
||||||
# drive = getDrive(drive)
|
|
||||||
currentFolderId = None
|
currentFolderId = None
|
||||||
try:
|
try:
|
||||||
currentFolderId = getEbooksFolderId(drive)
|
currentFolderId = getEbooksFolderId(drive)
|
||||||
@@ -300,7 +308,7 @@ def getFolderId(path, drive):
|
|||||||
session.commit()
|
session.commit()
|
||||||
else:
|
else:
|
||||||
currentFolderId = storedPathName.gdrive_id
|
currentFolderId = storedPathName.gdrive_id
|
||||||
except (OperationalError, IntegrityError, StaleDataError) as ex:
|
except (OperationalError, IntegrityError, StaleDataError, sqlite3.IntegrityError) as ex:
|
||||||
log.error_or_exception('Database error: {}'.format(ex))
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
session.rollback()
|
session.rollback()
|
||||||
except ApiRequestError as ex:
|
except ApiRequestError as ex:
|
||||||
@@ -311,7 +319,7 @@ def getFolderId(path, drive):
|
|||||||
return currentFolderId
|
return currentFolderId
|
||||||
|
|
||||||
|
|
||||||
def getFileFromEbooksFolder(path, fileName):
|
def getFileFromEbooksFolder(path, fileName, nocase=False):
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
if path:
|
if path:
|
||||||
# sqlCheckPath=path if path[-1] =='/' else path + '/'
|
# sqlCheckPath=path if path[-1] =='/' else path + '/'
|
||||||
@@ -319,7 +327,7 @@ def getFileFromEbooksFolder(path, fileName):
|
|||||||
else:
|
else:
|
||||||
folderId = getEbooksFolderId(drive)
|
folderId = getEbooksFolderId(drive)
|
||||||
if folderId:
|
if folderId:
|
||||||
return getFile(folderId, fileName, drive)
|
return getFile(folderId, fileName, drive, nocase)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -335,33 +343,37 @@ def downloadFile(path, filename, output):
|
|||||||
f.GetContentFile(output)
|
f.GetContentFile(output)
|
||||||
|
|
||||||
|
|
||||||
def moveGdriveFolderRemote(origin_file, target_folder):
|
def moveGdriveFolderRemote(origin_file, target_folder, single_book=False):
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')])
|
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')])
|
||||||
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
||||||
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
if single_book:
|
||||||
if not gFileTargetDir:
|
gFileTargetDir = getFileFromEbooksFolder(None, target_folder, nocase=True)
|
||||||
gFileTargetDir = drive.CreateFile(
|
if gFileTargetDir:
|
||||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
# Move the file to the new folder
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||||
gFileTargetDir.Upload()
|
addParents=gFileTargetDir['id'],
|
||||||
# Move the file to the new folder
|
removeParents=previous_parents,
|
||||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
fields='id, parents').execute()
|
||||||
addParents=gFileTargetDir['id'],
|
else:
|
||||||
removeParents=previous_parents,
|
gFileTargetDir = drive.CreateFile(
|
||||||
fields='id, parents').execute()
|
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||||
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
elif gFileTargetDir['title'] != target_folder:
|
gFileTargetDir.Upload()
|
||||||
|
# Move the file to the new folder
|
||||||
|
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||||
|
addParents=gFileTargetDir['id'],
|
||||||
|
removeParents=previous_parents,
|
||||||
|
fields='id, parents').execute()
|
||||||
|
elif origin_file['title'] != target_folder:
|
||||||
|
#gFileTargetDir = getFileFromEbooksFolder(None, target_folder, nocase=True)
|
||||||
|
#if gFileTargetDir:
|
||||||
|
deleteDatabasePath(origin_file['title'])
|
||||||
# Folder is not existing, create, and move folder
|
# Folder is not existing, create, and move folder
|
||||||
drive.auth.service.files().patch(fileId=origin_file['id'],
|
drive.auth.service.files().patch(fileId=origin_file['id'],
|
||||||
body={'title': target_folder},
|
body={'title': target_folder},
|
||||||
fields='title').execute()
|
fields='title').execute()
|
||||||
else:
|
|
||||||
# Move the file to the new folder
|
|
||||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
|
||||||
addParents=gFileTargetDir['id'],
|
|
||||||
removeParents=previous_parents,
|
|
||||||
fields='id, parents').execute()
|
|
||||||
# if previous_parents has no children anymore, delete original fileparent
|
# if previous_parents has no children anymore, delete original fileparent
|
||||||
if len(children['items']) == 1:
|
if len(children['items']) == 1:
|
||||||
deleteDatabaseEntry(previous_parents)
|
deleteDatabaseEntry(previous_parents)
|
||||||
@@ -369,20 +381,20 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
|||||||
|
|
||||||
|
|
||||||
def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||||
ignoreFiles=None,
|
ignoreFiles=None,
|
||||||
parent=None, prevDir=''):
|
parent=None, prevDir=''):
|
||||||
ignoreFiles = ignoreFiles or []
|
ignoreFiles = ignoreFiles or []
|
||||||
drive = getDrive(drive)
|
drive = getDrive(drive)
|
||||||
isInitial = not bool(parent)
|
isInitial = not bool(parent)
|
||||||
if not parent:
|
if not parent:
|
||||||
parent = getEbooksFolder(drive)
|
parent = getEbooksFolder(drive)
|
||||||
if os.path.isdir(os.path.join(prevDir,uploadFile)):
|
if os.path.isdir(os.path.join(prevDir, uploadFile)):
|
||||||
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||||
(os.path.basename(uploadFile).replace("'", r"\'"), parent['id'])}).GetList()
|
(os.path.basename(uploadFile).replace("'", r"\'"), parent['id'])}).GetList()
|
||||||
if len(existingFolder) == 0 and (not isInitial or createRoot):
|
if len(existingFolder) == 0 and (not isInitial or createRoot):
|
||||||
parent = drive.CreateFile({'title': os.path.basename(uploadFile),
|
parent = drive.CreateFile({'title': os.path.basename(uploadFile),
|
||||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
parent.Upload()
|
parent.Upload()
|
||||||
else:
|
else:
|
||||||
if (not isInitial or createRoot) and len(existingFolder) > 0:
|
if (not isInitial or createRoot) and len(existingFolder) > 0:
|
||||||
@@ -398,7 +410,7 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
|||||||
driveFile = existingFiles[0]
|
driveFile = existingFiles[0]
|
||||||
else:
|
else:
|
||||||
driveFile = drive.CreateFile({'title': os.path.basename(uploadFile).replace("'", r"\'"),
|
driveFile = drive.CreateFile({'title': os.path.basename(uploadFile).replace("'", r"\'"),
|
||||||
'parents': [{"kind":"drive#fileLink", 'id': parent['id']}], })
|
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||||
driveFile.SetContentFile(os.path.join(prevDir, uploadFile))
|
driveFile.SetContentFile(os.path.join(prevDir, uploadFile))
|
||||||
driveFile.Upload()
|
driveFile.Upload()
|
||||||
|
|
||||||
@@ -410,7 +422,7 @@ def uploadFileToEbooksFolder(destFile, f, string=False):
|
|||||||
for i, x in enumerate(splitDir):
|
for i, x in enumerate(splitDir):
|
||||||
if i == len(splitDir)-1:
|
if i == len(splitDir)-1:
|
||||||
existing_Files = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
existing_Files = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||||
if len(existing_Files) > 0:
|
if len(existing_Files) > 0:
|
||||||
driveFile = existing_Files[0]
|
driveFile = existing_Files[0]
|
||||||
else:
|
else:
|
||||||
@@ -423,17 +435,17 @@ def uploadFileToEbooksFolder(destFile, f, string=False):
|
|||||||
driveFile.Upload()
|
driveFile.Upload()
|
||||||
else:
|
else:
|
||||||
existing_Folder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
existing_Folder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||||
if len(existing_Folder) == 0:
|
if len(existing_Folder) == 0:
|
||||||
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
parent.Upload()
|
parent.Upload()
|
||||||
else:
|
else:
|
||||||
parent = existing_Folder[0]
|
parent = existing_Folder[0]
|
||||||
|
|
||||||
|
|
||||||
def watchChange(drive, channel_id, channel_type, channel_address,
|
def watchChange(drive, channel_id, channel_type, channel_address,
|
||||||
channel_token=None, expiration=None):
|
channel_token=None, expiration=None):
|
||||||
# Watch for all changes to a user's Drive.
|
# Watch for all changes to a user's Drive.
|
||||||
# Args:
|
# Args:
|
||||||
# service: Drive API service instance.
|
# service: Drive API service instance.
|
||||||
@@ -504,7 +516,7 @@ def stopChannel(drive, channel_id, resource_id):
|
|||||||
return drive.auth.service.channels().stop(body=body).execute()
|
return drive.auth.service.channels().stop(body=body).execute()
|
||||||
|
|
||||||
|
|
||||||
def getChangeById (drive, change_id):
|
def getChangeById(drive, change_id):
|
||||||
# Print a single Change resource information.
|
# Print a single Change resource information.
|
||||||
#
|
#
|
||||||
# Args:
|
# Args:
|
||||||
@@ -538,8 +550,9 @@ def updateGdriveCalibreFromLocal():
|
|||||||
if os.path.isdir(os.path.join(config.config_calibre_dir, x)):
|
if os.path.isdir(os.path.join(config.config_calibre_dir, x)):
|
||||||
shutil.rmtree(os.path.join(config.config_calibre_dir, x))
|
shutil.rmtree(os.path.join(config.config_calibre_dir, x))
|
||||||
|
|
||||||
|
|
||||||
# update gdrive.db on edit of books title
|
# update gdrive.db on edit of books title
|
||||||
def updateDatabaseOnEdit(ID,newPath):
|
def updateDatabaseOnEdit(ID, newPath):
|
||||||
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/'
|
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/'
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
||||||
if storedPathName:
|
if storedPathName:
|
||||||
@@ -560,6 +573,14 @@ def deleteDatabaseEntry(ID):
|
|||||||
log.error_or_exception('Database error: {}'.format(ex))
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
|
||||||
|
def deleteDatabasePath(Pathname):
|
||||||
|
session.query(GdriveId).filter(GdriveId.path.contains(Pathname)).delete()
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
# Gets cover file from gdrive
|
# Gets cover file from gdrive
|
||||||
# ToDo: Check is this right everyone get read permissions on cover files?
|
# ToDo: Check is this right everyone get read permissions on cover files?
|
||||||
@@ -578,13 +599,17 @@ def get_cover_via_gdrive(cover_path):
|
|||||||
session.add(permissionAdded)
|
session.add(permissionAdded)
|
||||||
try:
|
try:
|
||||||
session.commit()
|
session.commit()
|
||||||
except OperationalError as ex:
|
except (OperationalError, IntegrityError) as ex:
|
||||||
log.error_or_exception('Database error: {}'.format(ex))
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return df.metadata.get('webContentLink')
|
headers = Headers()
|
||||||
|
headers["Content-Type"] = 'image/jpeg'
|
||||||
|
resp, content = df.auth.Get_Http_Object().request(df.metadata.get('downloadUrl'), headers=headers)
|
||||||
|
return content
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Gets cover file from gdrive
|
# Gets cover file from gdrive
|
||||||
def get_metadata_backup_via_gdrive(metadata_path):
|
def get_metadata_backup_via_gdrive(metadata_path):
|
||||||
df = getFileFromEbooksFolder(metadata_path, 'metadata.opf')
|
df = getFileFromEbooksFolder(metadata_path, 'metadata.opf')
|
||||||
@@ -608,6 +633,7 @@ def get_metadata_backup_via_gdrive(metadata_path):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Creates chunks for downloading big files
|
# Creates chunks for downloading big files
|
||||||
def partial(total_byte_len, part_size_limit):
|
def partial(total_byte_len, part_size_limit):
|
||||||
s = []
|
s = []
|
||||||
@@ -616,6 +642,7 @@ def partial(total_byte_len, part_size_limit):
|
|||||||
s.append([p, last])
|
s.append([p, last])
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
# downloads files in chunks from gdrive
|
# downloads files in chunks from gdrive
|
||||||
def do_gdrive_download(df, headers, convert_encoding=False):
|
def do_gdrive_download(df, headers, convert_encoding=False):
|
||||||
total_size = int(df.metadata.get('fileSize'))
|
total_size = int(df.metadata.get('fileSize'))
|
||||||
@@ -655,6 +682,7 @@ oauth_scope:
|
|||||||
- https://www.googleapis.com/auth/drive
|
- https://www.googleapis.com/auth/drive
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def update_settings(client_id, client_secret, redirect_uri):
|
def update_settings(client_id, client_secret, redirect_uri):
|
||||||
if redirect_uri.endswith('/'):
|
if redirect_uri.endswith('/'):
|
||||||
redirect_uri = redirect_uri[:-1]
|
redirect_uri = redirect_uri[:-1]
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from gevent.pywsgi import WSGIHandler
|
from gevent.pywsgi import WSGIHandler
|
||||||
|
|
||||||
|
|
||||||
class MyWSGIHandler(WSGIHandler):
|
class MyWSGIHandler(WSGIHandler):
|
||||||
def get_environ(self):
|
def get_environ(self):
|
||||||
env = super().get_environ()
|
env = super().get_environ()
|
||||||
@@ -26,4 +27,26 @@ class MyWSGIHandler(WSGIHandler):
|
|||||||
env['RAW_URI'] = path
|
env['RAW_URI'] = path
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
def format_request(self):
|
||||||
|
now = datetime.now().replace(microsecond=0)
|
||||||
|
length = self.response_length or '-'
|
||||||
|
if self.time_finish:
|
||||||
|
delta = '%.6f' % (self.time_finish - self.time_start)
|
||||||
|
else:
|
||||||
|
delta = '-'
|
||||||
|
forwarded = self.environ.get('HTTP_X_FORWARDED_FOR', None)
|
||||||
|
if forwarded:
|
||||||
|
client_address = forwarded
|
||||||
|
else:
|
||||||
|
client_address = self.client_address[0] if isinstance(self.client_address, tuple) else self.client_address
|
||||||
|
return '%s - - [%s] "%s" %s %s %s' % (
|
||||||
|
client_address or '-',
|
||||||
|
now,
|
||||||
|
self.requestline or '',
|
||||||
|
# Use the native string version of the status, saved so we don't have to
|
||||||
|
# decode. But fallback to the encoded 'status' in case of subclasses
|
||||||
|
# (Is that really necessary? At least there's no overhead.)
|
||||||
|
(self._orig_status or self.status or '000').split()[0],
|
||||||
|
length,
|
||||||
|
delta)
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ import requests
|
|||||||
import unidecode
|
import unidecode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from flask import send_from_directory, make_response, redirect, abort, url_for
|
from flask import send_from_directory, make_response, abort, url_for, Response
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
from sqlalchemy.sql.expression import true, false, and_, or_, text, func
|
from sqlalchemy.sql.expression import true, false, and_, or_, text, func
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from werkzeug.datastructures import Headers
|
from werkzeug.datastructures import Headers
|
||||||
@@ -117,9 +117,11 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
|
|||||||
|
|
||||||
# Texts are not lazy translated as they are supposed to get send out as is
|
# Texts are not lazy translated as they are supposed to get send out as is
|
||||||
def send_test_mail(ereader_mail, user_name):
|
def send_test_mail(ereader_mail, user_name):
|
||||||
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
|
for email in ereader_mail.split(','):
|
||||||
config.get_mail_settings(), ereader_mail, N_("Test Email"),
|
email = email.strip()
|
||||||
_('This Email has been sent via Calibre-Web.')))
|
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
|
||||||
|
config.get_mail_settings(), email, N_("Test Email"),
|
||||||
|
_('This Email has been sent via Calibre-Web.')))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -225,9 +227,11 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
|
|||||||
converted_file_name = entry.name + '.' + book_format.lower()
|
converted_file_name = entry.name + '.' + book_format.lower()
|
||||||
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
|
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
|
||||||
email_text = N_("%(book)s send to eReader", book=link)
|
email_text = N_("%(book)s send to eReader", book=link)
|
||||||
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
|
for email in ereader_mail.split(','):
|
||||||
config.get_mail_settings(), ereader_mail,
|
email = email.strip()
|
||||||
email_text, _('This Email has been sent via Calibre-Web.'),book.id))
|
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
|
||||||
|
config.get_mail_settings(), email,
|
||||||
|
email_text, _('This Email has been sent via Calibre-Web.'), book.id))
|
||||||
return
|
return
|
||||||
return _("The requested file could not be read. Maybe wrong permissions?")
|
return _("The requested file could not be read. Maybe wrong permissions?")
|
||||||
|
|
||||||
@@ -388,75 +392,56 @@ def delete_book_file(book, calibrepath, book_format=None):
|
|||||||
id=book.id,
|
id=book.id,
|
||||||
path=book.path)
|
path=book.path)
|
||||||
|
|
||||||
|
def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdrive=False):
|
||||||
def clean_author_database(renamed_author, calibre_path="", local_book=None, gdrive=None):
|
for file_format in one_book.data:
|
||||||
valid_filename_authors = [get_valid_filename(r, chars=96) for r in renamed_author]
|
if not gdrive:
|
||||||
for r in renamed_author:
|
if not os.path.exists(new_path):
|
||||||
if local_book:
|
os.makedirs(new_path)
|
||||||
all_books = [local_book]
|
shutil.move(os.path.join(old_path, file_format.name + '.' + file_format.format.lower()),
|
||||||
|
os.path.join(new_path, all_new_name + '.' + file_format.format.lower()))
|
||||||
else:
|
else:
|
||||||
all_books = calibre_db.session.query(db.Books) \
|
g_file = gd.getFileFromEbooksFolder(old_path,
|
||||||
.filter(db.Books.authors.any(db.Authors.name == r)).all()
|
file_format.name + '.' + file_format.format.lower())
|
||||||
for book in all_books:
|
if g_file:
|
||||||
book_author_path = book.path.split('/')[0]
|
gd.moveGdriveFileRemote(g_file, all_new_name + '.' + file_format.format.lower())
|
||||||
if book_author_path in valid_filename_authors or local_book:
|
gd.updateDatabaseOnEdit(g_file['id'], all_new_name + '.' + file_format.format.lower())
|
||||||
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
|
else:
|
||||||
all_new_authordir = get_valid_filename(new_author.name, chars=96)
|
log.error("File {} not found on gdrive"
|
||||||
all_titledir = book.path.split('/')[1]
|
.format(old_path, file_format.name + '.' + file_format.format.lower()))
|
||||||
all_new_path = os.path.join(calibre_path, all_new_authordir, all_titledir)
|
|
||||||
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
|
# change name in Database
|
||||||
+ get_valid_filename(new_author.name, chars=42)
|
file_format.name = all_new_name
|
||||||
# change location in database to new author/title path
|
|
||||||
book.path = os.path.join(all_new_authordir, all_titledir).replace('\\', '/')
|
|
||||||
for file_format in book.data:
|
|
||||||
if not gdrive:
|
|
||||||
shutil.move(os.path.normcase(os.path.join(all_new_path,
|
|
||||||
file_format.name + '.' + file_format.format.lower())),
|
|
||||||
os.path.normcase(os.path.join(all_new_path,
|
|
||||||
all_new_name + '.' + file_format.format.lower())))
|
|
||||||
else:
|
|
||||||
g_file = gd.getFileFromEbooksFolder(all_new_path,
|
|
||||||
file_format.name + '.' + file_format.format.lower())
|
|
||||||
if g_file:
|
|
||||||
gd.moveGdriveFileRemote(g_file, all_new_name + '.' + file_format.format.lower())
|
|
||||||
gd.updateDatabaseOnEdit(g_file['id'], all_new_name + '.' + file_format.format.lower())
|
|
||||||
else:
|
|
||||||
log.error("File {} not found on gdrive"
|
|
||||||
.format(all_new_path, file_format.name + '.' + file_format.format.lower()))
|
|
||||||
file_format.name = all_new_name
|
|
||||||
|
|
||||||
|
|
||||||
def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=None, gdrive=False):
|
def rename_author_path(first_author, old_author_dir, renamed_author, calibre_path="", gdrive=False):
|
||||||
# Create new_author_dir from parameter or from database
|
# Create new_author_dir from parameter or from database
|
||||||
# Create new title_dir from database and add id
|
# Create new title_dir from database and add id
|
||||||
if first_author:
|
new_authordir = get_valid_filename(first_author, chars=96)
|
||||||
new_authordir = get_valid_filename(first_author, chars=96)
|
# new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == renamed_author).first()
|
||||||
for r in renamed_author:
|
# old_author_dir = get_valid_filename(old_author_name, chars=96)
|
||||||
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
|
new_author_rename_dir = get_valid_filename(renamed_author, chars=96)
|
||||||
old_author_dir = get_valid_filename(r, chars=96)
|
if gdrive:
|
||||||
new_author_rename_dir = get_valid_filename(new_author.name, chars=96)
|
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
|
||||||
if gdrive:
|
if g_file:
|
||||||
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
|
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
|
||||||
if g_file:
|
|
||||||
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
|
|
||||||
else:
|
|
||||||
if os.path.isdir(os.path.join(calibre_path, old_author_dir)):
|
|
||||||
try:
|
|
||||||
old_author_path = os.path.join(calibre_path, old_author_dir)
|
|
||||||
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
|
|
||||||
shutil.move(os.path.normcase(old_author_path), os.path.normcase(new_author_path))
|
|
||||||
except OSError as ex:
|
|
||||||
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
|
|
||||||
log.debug(ex, exc_info=True)
|
|
||||||
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
|
||||||
src=old_author_path, dest=new_author_path, error=str(ex))
|
|
||||||
else:
|
else:
|
||||||
new_authordir = get_valid_filename(localbook.authors[0].name, chars=96)
|
if os.path.isdir(os.path.join(calibre_path, old_author_dir)):
|
||||||
|
old_author_path = os.path.join(calibre_path, old_author_dir)
|
||||||
|
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
|
||||||
|
try:
|
||||||
|
os.rename(old_author_path, new_author_path)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
shutil.move(old_author_path, new_author_path)
|
||||||
|
except OSError as ex:
|
||||||
|
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
raise Exception(_("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
||||||
|
src=old_author_path, dest=new_author_path, error=str(ex)))
|
||||||
return new_authordir
|
return new_authordir
|
||||||
|
|
||||||
|
|
||||||
# Moves files in file storage during author/title rename, or from temp dir to file storage
|
# Moves files in file storage during author/title rename, or from temp dir to file storage
|
||||||
def update_dir_structure_file(book_id, calibre_path, first_author, original_filepath, db_filename, renamed_author):
|
def update_dir_structure_file(book_id, calibre_path, original_filepath, new_author, db_filename):
|
||||||
# get book database entry from id, if original path overwrite source with original_filepath
|
# get book database entry from id, if original path overwrite source with original_filepath
|
||||||
local_book = calibre_db.get_book(book_id)
|
local_book = calibre_db.get_book(book_id)
|
||||||
if original_filepath:
|
if original_filepath:
|
||||||
@@ -468,15 +453,11 @@ def update_dir_structure_file(book_id, calibre_path, first_author, original_file
|
|||||||
author_dir = local_book.path.split('/')[0]
|
author_dir = local_book.path.split('/')[0]
|
||||||
title_dir = local_book.path.split('/')[1]
|
title_dir = local_book.path.split('/')[1]
|
||||||
|
|
||||||
# Create new_author_dir from parameter or from database
|
|
||||||
# Create new title_dir from database and add id
|
|
||||||
new_author_dir = rename_all_authors(first_author, renamed_author, calibre_path, local_book)
|
|
||||||
if first_author:
|
|
||||||
if first_author.lower() in [r.lower() for r in renamed_author]:
|
|
||||||
if os.path.isdir(os.path.join(calibre_path, new_author_dir)):
|
|
||||||
path = os.path.join(calibre_path, new_author_dir, title_dir)
|
|
||||||
|
|
||||||
new_title_dir = get_valid_filename(local_book.title, chars=96) + " (" + str(book_id) + ")"
|
new_title_dir = get_valid_filename(local_book.title, chars=96) + " (" + str(book_id) + ")"
|
||||||
|
if new_author:
|
||||||
|
new_author_dir = get_valid_filename(new_author, chars=96)
|
||||||
|
else:
|
||||||
|
new_author = new_author_dir = author_dir
|
||||||
|
|
||||||
if title_dir != new_title_dir or author_dir != new_author_dir or original_filepath:
|
if title_dir != new_title_dir or author_dir != new_author_dir or original_filepath:
|
||||||
error = move_files_on_change(calibre_path,
|
error = move_files_on_change(calibre_path,
|
||||||
@@ -486,36 +467,42 @@ def update_dir_structure_file(book_id, calibre_path, first_author, original_file
|
|||||||
db_filename,
|
db_filename,
|
||||||
original_filepath,
|
original_filepath,
|
||||||
path)
|
path)
|
||||||
|
# old_path = os.path.join(calibre_path, author_dir, new_title_dir).replace('\\', '/')
|
||||||
|
new_path = os.path.join(calibre_path, new_author_dir, new_title_dir).replace('\\', '/')
|
||||||
|
all_new_name = get_valid_filename(local_book.title, chars=42) + ' - ' \
|
||||||
|
+ get_valid_filename(new_author, chars=42)
|
||||||
|
# Book folder already moved, only files need to be renamed
|
||||||
|
rename_all_files_on_change(local_book, new_path, new_path, all_new_name)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
# Rename all files from old names to new names
|
# Rename all files from old names to new names
|
||||||
return rename_files_on_change(first_author, renamed_author, local_book, original_filepath, path, calibre_path)
|
return False
|
||||||
|
|
||||||
|
|
||||||
def upload_new_file_gdrive(book_id, first_author, renamed_author, title, title_dir, original_filepath, filename_ext):
|
def upload_new_file_gdrive(book_id, first_author, title, title_dir, original_filepath, filename_ext):
|
||||||
book = calibre_db.get_book(book_id)
|
book = calibre_db.get_book(book_id)
|
||||||
file_name = get_valid_filename(title, chars=42) + ' - ' + \
|
file_name = get_valid_filename(title, chars=42) + ' - ' + \
|
||||||
get_valid_filename(first_author, chars=42) + filename_ext
|
get_valid_filename(first_author, chars=42) + filename_ext
|
||||||
rename_all_authors(first_author, renamed_author, gdrive=True)
|
|
||||||
gdrive_path = os.path.join(get_valid_filename(first_author, chars=96),
|
gdrive_path = os.path.join(get_valid_filename(first_author, chars=96),
|
||||||
title_dir + " (" + str(book_id) + ")")
|
title_dir + " (" + str(book_id) + ")")
|
||||||
book.path = gdrive_path.replace("\\", "/")
|
book.path = gdrive_path.replace("\\", "/")
|
||||||
gd.uploadFileToEbooksFolder(os.path.join(gdrive_path, file_name).replace("\\", "/"), original_filepath)
|
gd.uploadFileToEbooksFolder(os.path.join(gdrive_path, file_name).replace("\\", "/"), original_filepath)
|
||||||
return rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
|
return False # rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_dir_structure_gdrive(book_id, first_author):
|
||||||
def update_dir_structure_gdrive(book_id, first_author, renamed_author):
|
|
||||||
book = calibre_db.get_book(book_id)
|
book = calibre_db.get_book(book_id)
|
||||||
|
|
||||||
authordir = book.path.split('/')[0]
|
authordir = book.path.split('/')[0]
|
||||||
titledir = book.path.split('/')[1]
|
titledir = book.path.split('/')[1]
|
||||||
new_authordir = rename_all_authors(first_author, renamed_author, gdrive=True)
|
# new_authordir = rename_all_authors(first_author, renamed_author, gdrive=True)
|
||||||
|
new_authordir = get_valid_filename(first_author, chars=96)
|
||||||
new_titledir = get_valid_filename(book.title, chars=96) + " (" + str(book_id) + ")"
|
new_titledir = get_valid_filename(book.title, chars=96) + " (" + str(book_id) + ")"
|
||||||
|
|
||||||
if titledir != new_titledir:
|
if titledir != new_titledir:
|
||||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir)
|
g_file = gd.getFileFromEbooksFolder(authordir, titledir)
|
||||||
if g_file:
|
if g_file:
|
||||||
gd.moveGdriveFileRemote(g_file, new_titledir)
|
gd.moveGdriveFileRemote(g_file, new_titledir)
|
||||||
book.path = book.path.split('/')[0] + '/' + new_titledir
|
book.path = book.path.split('/')[0] + '/' + new_titledir
|
||||||
@@ -523,44 +510,51 @@ def update_dir_structure_gdrive(book_id, first_author, renamed_author):
|
|||||||
else:
|
else:
|
||||||
return _('File %(file)s not found on Google Drive', file=book.path) # file not found
|
return _('File %(file)s not found on Google Drive', file=book.path) # file not found
|
||||||
|
|
||||||
if authordir != new_authordir and authordir not in renamed_author:
|
if authordir != new_authordir:
|
||||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir)
|
g_file = gd.getFileFromEbooksFolder(authordir, new_titledir)
|
||||||
if g_file:
|
if g_file:
|
||||||
gd.moveGdriveFolderRemote(g_file, new_authordir)
|
gd.moveGdriveFolderRemote(g_file, new_authordir, single_book=True)
|
||||||
book.path = new_authordir + '/' + book.path.split('/')[1]
|
book.path = new_authordir + '/' + book.path.split('/')[1]
|
||||||
gd.updateDatabaseOnEdit(g_file['id'], book.path)
|
gd.updateDatabaseOnEdit(g_file['id'], book.path)
|
||||||
else:
|
else:
|
||||||
return _('File %(file)s not found on Google Drive', file=authordir) # file not found
|
return _('File %(file)s not found on Google Drive', file=authordir) # file not found'''
|
||||||
|
if titledir != new_titledir or authordir != new_authordir :
|
||||||
|
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
|
||||||
|
+ get_valid_filename(new_authordir, chars=42)
|
||||||
|
rename_all_files_on_change(book, book.path, book.path, all_new_name, gdrive=True) # todo: Move filenames on gdrive
|
||||||
# change location in database to new author/title path
|
# change location in database to new author/title path
|
||||||
book.path = os.path.join(new_authordir, new_titledir).replace('\\', '/')
|
# book.path = os.path.join(authordir, new_titledir).replace('\\', '/')
|
||||||
return rename_files_on_change(first_author, renamed_author, book, gdrive=True)
|
return False
|
||||||
|
|
||||||
|
|
||||||
def move_files_on_change(calibre_path, new_authordir, new_titledir, localbook, db_filename, original_filepath, path):
|
def move_files_on_change(calibre_path, new_author_dir, new_titledir, localbook, db_filename, original_filepath, path):
|
||||||
new_path = os.path.join(calibre_path, new_authordir, new_titledir)
|
new_path = os.path.join(calibre_path, new_author_dir, new_titledir)
|
||||||
new_name = get_valid_filename(localbook.title, chars=96) + ' - ' + new_authordir
|
# new_name = get_valid_filename(localbook.title, chars=96) + ' - ' + new_author_dir
|
||||||
try:
|
try:
|
||||||
if original_filepath:
|
if original_filepath:
|
||||||
if not os.path.isdir(new_path):
|
if not os.path.isdir(new_path):
|
||||||
os.makedirs(new_path)
|
os.makedirs(new_path)
|
||||||
shutil.move(os.path.normcase(original_filepath), os.path.normcase(os.path.join(new_path, db_filename)))
|
shutil.move(original_filepath, os.path.join(new_path, db_filename))
|
||||||
log.debug("Moving title: %s to %s/%s", original_filepath, new_path, new_name)
|
log.debug("Moving title: %s to %s", original_filepath, new_path)
|
||||||
else:
|
else:
|
||||||
# Check new path is not valid path
|
# Check new path is not valid path
|
||||||
if not os.path.exists(new_path):
|
if not os.path.exists(new_path):
|
||||||
# move original path to new path
|
# move original path to new path
|
||||||
log.debug("Moving title: %s to %s", path, new_path)
|
log.debug("Moving title: %s to %s", path, new_path)
|
||||||
shutil.move(os.path.normcase(path), os.path.normcase(new_path))
|
shutil.move(path, new_path)
|
||||||
else: # path is valid copy only files to new location (merge)
|
else: # path is valid copy only files to new location (merge)
|
||||||
log.info("Moving title: %s into existing: %s", path, new_path)
|
log.info("Moving title: %s into existing: %s", path, new_path)
|
||||||
# Take all files and subfolder from old path (strange command)
|
# Take all files and subfolder from old path (strange command)
|
||||||
for dir_name, __, file_list in os.walk(path):
|
for dir_name, __, file_list in os.walk(path):
|
||||||
for file in file_list:
|
for file in file_list:
|
||||||
shutil.move(os.path.normcase(os.path.join(dir_name, file)),
|
shutil.move(os.path.join(dir_name, file), os.path.join(new_path + dir_name[len(path):], file))
|
||||||
os.path.normcase(os.path.join(new_path + dir_name[len(path):], file)))
|
if not os.listdir(os.path.split(path)[0]):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(os.path.split(path)[0])
|
||||||
|
except (IOError, OSError) as ex:
|
||||||
|
log.error("Deleting authorpath for book %s failed: %s", localbook.id, ex)
|
||||||
# change location in database to new author/title path
|
# change location in database to new author/title path
|
||||||
localbook.path = os.path.join(new_authordir, new_titledir).replace('\\', '/')
|
localbook.path = os.path.join(new_author_dir, new_titledir).replace('\\', '/')
|
||||||
except OSError as ex:
|
except OSError as ex:
|
||||||
log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex))
|
log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex))
|
||||||
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
||||||
@@ -576,15 +570,15 @@ def rename_files_on_change(first_author,
|
|||||||
calibre_path="",
|
calibre_path="",
|
||||||
gdrive=False):
|
gdrive=False):
|
||||||
# Rename all files from old names to new names
|
# Rename all files from old names to new names
|
||||||
try:
|
#try:
|
||||||
clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
|
#clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
|
||||||
if first_author and first_author not in renamed_author:
|
#if first_author and first_author not in renamed_author:
|
||||||
clean_author_database([first_author], calibre_path, local_book, gdrive)
|
# clean_author_database([first_author], calibre_path, local_book, gdrive)
|
||||||
if not gdrive and not renamed_author and not original_filepath and len(os.listdir(os.path.dirname(path))) == 0:
|
#if not gdrive and not renamed_author and not original_filepath and len(os.listdir(os.path.dirname(path))) == 0:
|
||||||
shutil.rmtree(os.path.dirname(path))
|
# shutil.rmtree(os.path.dirname(path))
|
||||||
except (OSError, FileNotFoundError) as ex:
|
#except (OSError, FileNotFoundError) as ex:
|
||||||
log.error_or_exception("Error in rename file in path {}".format(ex))
|
# log.error_or_exception("Error in rename file in path {}".format(ex))
|
||||||
return _("Error in rename file in path: {}".format(str(ex)))
|
# return _("Error in rename file in path: {}".format(str(ex)))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -595,7 +589,7 @@ def delete_book_gdrive(book, book_format):
|
|||||||
for entry in book.data:
|
for entry in book.data:
|
||||||
if entry.format.upper() == book_format:
|
if entry.format.upper() == book_format:
|
||||||
name = entry.name + '.' + book_format
|
name = entry.name + '.' + book_format
|
||||||
g_file = gd.getFileFromEbooksFolder(book.path, name)
|
g_file = gd.getFileFromEbooksFolder(book.path, name, nocase=True)
|
||||||
else:
|
else:
|
||||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), book.path.split('/')[1])
|
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), book.path.split('/')[1])
|
||||||
if g_file:
|
if g_file:
|
||||||
@@ -623,6 +617,7 @@ def reset_password(user_id):
|
|||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
return 0, None
|
return 0, None
|
||||||
|
|
||||||
|
|
||||||
def generate_random_password(min_length):
|
def generate_random_password(min_length):
|
||||||
min_length = max(8, min_length) - 4
|
min_length = max(8, min_length) - 4
|
||||||
random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
|
random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
|
||||||
@@ -648,12 +643,6 @@ def generate_random_password(min_length):
|
|||||||
return ''.join(password)
|
return ''.join(password)
|
||||||
|
|
||||||
|
|
||||||
'''def generate_random_password(min_length):
|
|
||||||
s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
|
|
||||||
passlen = min_length
|
|
||||||
return "".join(s[c % len(s)] for c in os.urandom(passlen))'''
|
|
||||||
|
|
||||||
|
|
||||||
def uniq(inpt):
|
def uniq(inpt):
|
||||||
output = []
|
output = []
|
||||||
inpt = [" ".join(inp.split()) for inp in inpt]
|
inpt = [" ".join(inp.split()) for inp in inpt]
|
||||||
@@ -679,17 +668,19 @@ def check_username(username):
|
|||||||
return username
|
return username
|
||||||
|
|
||||||
|
|
||||||
def valid_email(email):
|
def valid_email(emails):
|
||||||
email = email.strip()
|
for email in emails.split(','):
|
||||||
# if email is not deleted
|
email = email.strip()
|
||||||
if email:
|
# if email is not deleted
|
||||||
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
if email:
|
||||||
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
||||||
email):
|
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
||||||
log.error("Invalid Email address format")
|
email):
|
||||||
raise Exception(_("Invalid Email address format"))
|
log.error("Invalid Email address format")
|
||||||
|
raise Exception(_("Invalid Email address format"))
|
||||||
return email
|
return email
|
||||||
|
|
||||||
|
|
||||||
def valid_password(check_password):
|
def valid_password(check_password):
|
||||||
if config.config_password_policy:
|
if config.config_password_policy:
|
||||||
verify = ""
|
verify = ""
|
||||||
@@ -716,22 +707,20 @@ def update_dir_structure(book_id,
|
|||||||
calibre_path,
|
calibre_path,
|
||||||
first_author=None, # change author of book to this author
|
first_author=None, # change author of book to this author
|
||||||
original_filepath=None,
|
original_filepath=None,
|
||||||
db_filename=None,
|
db_filename=None):
|
||||||
renamed_author=None):
|
|
||||||
renamed_author = renamed_author or []
|
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
return update_dir_structure_gdrive(book_id, first_author, renamed_author)
|
return update_dir_structure_gdrive(book_id, first_author)
|
||||||
else:
|
else:
|
||||||
return update_dir_structure_file(book_id,
|
return update_dir_structure_file(book_id,
|
||||||
calibre_path,
|
calibre_path,
|
||||||
first_author,
|
|
||||||
original_filepath,
|
original_filepath,
|
||||||
db_filename, renamed_author)
|
first_author,
|
||||||
|
db_filename)
|
||||||
|
|
||||||
|
|
||||||
def delete_book(book, calibrepath, book_format):
|
def delete_book(book, calibrepath, book_format):
|
||||||
if not book_format:
|
if not book_format:
|
||||||
clear_cover_thumbnail_cache(book.id) ## here it breaks
|
clear_cover_thumbnail_cache(book.id) # here it breaks
|
||||||
calibre_db.delete_dirty_metadata(book.id)
|
calibre_db.delete_dirty_metadata(book.id)
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
return delete_book_gdrive(book, book_format)
|
return delete_book_gdrive(book, book_format)
|
||||||
@@ -776,9 +765,9 @@ def get_book_cover_internal(book, resolution=None):
|
|||||||
try:
|
try:
|
||||||
if not gd.is_gdrive_ready():
|
if not gd.is_gdrive_ready():
|
||||||
return get_cover_on_failure()
|
return get_cover_on_failure()
|
||||||
path = gd.get_cover_via_gdrive(book.path)
|
cover_file = gd.get_cover_via_gdrive(book.path)
|
||||||
if path:
|
if cover_file:
|
||||||
return redirect(path)
|
return Response(cover_file, mimetype='image/jpeg')
|
||||||
else:
|
else:
|
||||||
log.error('{}/cover.jpg not found on Google Drive'.format(book.path))
|
log.error('{}/cover.jpg not found on Google Drive'.format(book.path))
|
||||||
return get_cover_on_failure()
|
return get_cover_on_failure()
|
||||||
@@ -884,7 +873,7 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
|
|||||||
log.error("Failed to create path for cover")
|
log.error("Failed to create path for cover")
|
||||||
return False, _("Failed to create path for cover")
|
return False, _("Failed to create path for cover")
|
||||||
try:
|
try:
|
||||||
# upload of jgp file without wand
|
# upload of jpg file without wand
|
||||||
if isinstance(img, requests.Response):
|
if isinstance(img, requests.Response):
|
||||||
with open(os.path.join(filepath, saved_filename), 'wb') as f:
|
with open(os.path.join(filepath, saved_filename), 'wb') as f:
|
||||||
f.write(img.content)
|
f.write(img.content)
|
||||||
@@ -943,13 +932,14 @@ def save_cover(img, book_path):
|
|||||||
|
|
||||||
def do_download_file(book, book_format, client, data, headers):
|
def do_download_file(book, book_format, client, data, headers):
|
||||||
book_name = data.name
|
book_name = data.name
|
||||||
|
download_name = filename = None
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
# startTime = time.time()
|
# startTime = time.time()
|
||||||
df = gd.getFileFromEbooksFolder(book.path, book_name + "." + book_format)
|
df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format)
|
||||||
# log.debug('%s', time.time() - startTime)
|
# log.debug('%s', time.time() - startTime)
|
||||||
if df:
|
if df:
|
||||||
if config.config_embed_metadata and (
|
if config.config_embed_metadata and (
|
||||||
(book_format == "kepub" and config.config_kepubifypath ) or
|
(book_format == "kepub" and config.config_kepubifypath) or
|
||||||
(book_format != "kepub" and config.config_binariesdir)):
|
(book_format != "kepub" and config.config_binariesdir)):
|
||||||
output_path = os.path.join(config.config_calibre_dir, book.path)
|
output_path = os.path.join(config.config_calibre_dir, book.path)
|
||||||
if not os.path.exists(output_path):
|
if not os.path.exists(output_path):
|
||||||
@@ -977,7 +967,7 @@ def do_download_file(book, book_format, client, data, headers):
|
|||||||
filename, download_name = do_kepubify_metadata_replace(book, os.path.join(filename,
|
filename, download_name = do_kepubify_metadata_replace(book, os.path.join(filename,
|
||||||
book_name + "." + book_format))
|
book_name + "." + book_format))
|
||||||
elif book_format != "kepub" and config.config_binariesdir and config.config_embed_metadata:
|
elif book_format != "kepub" and config.config_binariesdir and config.config_embed_metadata:
|
||||||
filename, download_name = do_calibre_export(book.id, book_format)
|
filename, download_name = do_calibre_export(book.id, book_format)
|
||||||
else:
|
else:
|
||||||
download_name = book_name
|
download_name = book_name
|
||||||
|
|
||||||
@@ -1013,14 +1003,14 @@ def check_unrar(unrar_location):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(unrar_location):
|
if not os.path.exists(unrar_location):
|
||||||
return _('Unrar binary file not found')
|
return _('UnRar binary file not found')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
unrar_location = [unrar_location]
|
unrar_location = [unrar_location]
|
||||||
value = process_wait(unrar_location, pattern='UNRAR (.*) freeware')
|
value = process_wait(unrar_location, pattern='UNRAR (.*) freeware')
|
||||||
if value:
|
if value:
|
||||||
version = value.group(1)
|
version = value.group(1)
|
||||||
log.debug("unrar version %s", version)
|
log.debug("UnRar version %s", version)
|
||||||
|
|
||||||
except (OSError, UnicodeDecodeError) as err:
|
except (OSError, UnicodeDecodeError) as err:
|
||||||
log.error_or_exception(err)
|
log.error_or_exception(err)
|
||||||
@@ -1052,11 +1042,11 @@ def check_calibre(calibre_location):
|
|||||||
return _('Calibre binaries not viable')
|
return _('Calibre binaries not viable')
|
||||||
else:
|
else:
|
||||||
ret_val = []
|
ret_val = []
|
||||||
missing_binaries=[path for path, available in
|
missing_binaries = [path for path, available in
|
||||||
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_available) if not available]
|
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_available) if not available]
|
||||||
|
|
||||||
missing_perms=[path for path, available in
|
missing_perms = [path for path, available in
|
||||||
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_executable) if not available]
|
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_executable) if not available]
|
||||||
if missing_binaries:
|
if missing_binaries:
|
||||||
ret_val.append(_('Missing calibre binaries: %(missing)s', missing=", ".join(missing_binaries)))
|
ret_val.append(_('Missing calibre binaries: %(missing)s', missing=", ".join(missing_binaries)))
|
||||||
if missing_perms:
|
if missing_perms:
|
||||||
@@ -1065,7 +1055,7 @@ def check_calibre(calibre_location):
|
|||||||
|
|
||||||
except (OSError, UnicodeDecodeError) as err:
|
except (OSError, UnicodeDecodeError) as err:
|
||||||
log.error_or_exception(err)
|
log.error_or_exception(err)
|
||||||
return _('Error excecuting Calibre')
|
return _('Error executing Calibre')
|
||||||
|
|
||||||
|
|
||||||
def json_serial(obj):
|
def json_serial(obj):
|
||||||
|
|||||||
@@ -49,15 +49,24 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
def get_language_names(locale):
|
def get_language_names(locale):
|
||||||
return _LANGUAGE_NAMES.get(str(locale))
|
names = _LANGUAGE_NAMES.get(str(locale))
|
||||||
|
if names is None:
|
||||||
|
names = _LANGUAGE_NAMES.get(locale.language)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def get_language_name(locale, lang_code):
|
def get_language_name(locale, lang_code):
|
||||||
try:
|
UNKNOWN_TRANSLATION = "Unknown"
|
||||||
return get_language_names(locale)[lang_code]
|
names = get_language_names(locale)
|
||||||
except KeyError:
|
if names is None:
|
||||||
log.error('Missing translation for language name: {}'.format(lang_code))
|
log.error(f"Missing language names for locale: {str(locale)}/{locale.language}")
|
||||||
return "Unknown"
|
return UNKNOWN_TRANSLATION
|
||||||
|
|
||||||
|
name = names.get(lang_code, UNKNOWN_TRANSLATION)
|
||||||
|
if name == UNKNOWN_TRANSLATION:
|
||||||
|
log.error("Missing translation for language name: {}".format(lang_code))
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
def get_language_codes(locale, language_names, remainder=None):
|
def get_language_codes(locale, language_names, remainder=None):
|
||||||
@@ -73,7 +82,6 @@ def get_language_codes(locale, language_names, remainder=None):
|
|||||||
return lang
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_valid_language_codes(locale, language_names, remainder=None):
|
def get_valid_language_codes(locale, language_names, remainder=None):
|
||||||
lang = list()
|
lang = list()
|
||||||
if "" in language_names:
|
if "" in language_names:
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ import datetime
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
# from babel.dates import format_date
|
|
||||||
from flask import Blueprint, request, url_for
|
from flask import Blueprint, request, url_for
|
||||||
from flask_babel import format_date
|
from flask_babel import format_date
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
|
|
||||||
from . import constants, logger
|
from . import constants, logger
|
||||||
|
|
||||||
@@ -44,8 +43,6 @@ def url_for_other_page(page):
|
|||||||
args = request.view_args.copy()
|
args = request.view_args.copy()
|
||||||
args['page'] = page
|
args['page'] = page
|
||||||
for get, val in request.args.items():
|
for get, val in request.args.items():
|
||||||
if get == "page":
|
|
||||||
continue
|
|
||||||
args[get] = val
|
args[get] = val
|
||||||
return url_for(request.endpoint, **args)
|
return url_for(request.endpoint, **args)
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from flask import (
|
|||||||
redirect,
|
redirect,
|
||||||
abort
|
abort
|
||||||
)
|
)
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
from werkzeug.datastructures import Headers
|
from werkzeug.datastructures import Headers
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.sql.expression import and_, or_
|
from sqlalchemy.sql.expression import and_, or_
|
||||||
@@ -44,11 +44,10 @@ from sqlalchemy.exc import StatementError
|
|||||||
from sqlalchemy.sql import select
|
from sqlalchemy.sql import select
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
||||||
from . import isoLanguages
|
from . import isoLanguages
|
||||||
from .epub import get_epub_layout
|
from .epub import get_epub_layout
|
||||||
from .constants import COVER_THUMBNAIL_SMALL #, sqlalchemy_version2
|
from .constants import COVER_THUMBNAIL_SMALL
|
||||||
from .helper import get_download_link
|
from .helper import get_download_link
|
||||||
from .services import SyncToken as SyncToken
|
from .services import SyncToken as SyncToken
|
||||||
from .web import download_required
|
from .web import download_required
|
||||||
@@ -145,7 +144,7 @@ def HandleSyncRequest():
|
|||||||
sync_token = SyncToken.SyncToken.from_headers(request.headers)
|
sync_token = SyncToken.SyncToken.from_headers(request.headers)
|
||||||
log.info("Kobo library sync request received")
|
log.info("Kobo library sync request received")
|
||||||
log.debug("SyncToken: {}".format(sync_token))
|
log.debug("SyncToken: {}".format(sync_token))
|
||||||
log.debug("Download link format {}".format(get_download_url_for_book('[bookid]','[bookformat]')))
|
log.debug("Download link format {}".format(get_download_url_for_book('[bookid]', '[bookformat]')))
|
||||||
if not current_app.wsgi_app.is_proxied:
|
if not current_app.wsgi_app.is_proxied:
|
||||||
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
||||||
|
|
||||||
@@ -212,7 +211,7 @@ def HandleSyncRequest():
|
|||||||
|
|
||||||
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
||||||
entitlement = {
|
entitlement = {
|
||||||
"BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived == True)),
|
"BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived==True)),
|
||||||
"BookMetadata": get_metadata(book.Books),
|
"BookMetadata": get_metadata(book.Books),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,8 +920,8 @@ def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
|||||||
log.debug("Redirecting request for cover image of unknown book %s to Kobo" % book_uuid)
|
log.debug("Redirecting request for cover image of unknown book %s to Kobo" % book_uuid)
|
||||||
return redirect(KOBO_IMAGEHOST_URL +
|
return redirect(KOBO_IMAGEHOST_URL +
|
||||||
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
|
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
|
||||||
width=width,
|
width=width,
|
||||||
height=height), 307)
|
height=height), 307)
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("")
|
@kobo.route("")
|
||||||
@@ -951,7 +950,8 @@ def HandleBookDeletionRequest(book_uuid):
|
|||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
|
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
|
||||||
def HandleUnimplementedRequest(dummy=None):
|
def HandleUnimplementedRequest(dummy=None):
|
||||||
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)", request.base_url)
|
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)",
|
||||||
|
request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
|
|
||||||
@@ -1004,7 +1004,8 @@ def handle_getests():
|
|||||||
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
|
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/deals", methods=["GET", "POST"])
|
@kobo.route("/v1/deals", methods=["GET", "POST"])
|
||||||
def HandleProductsRequest(dummy=None):
|
def HandleProductsRequest(dummy=None):
|
||||||
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)", request.base_url)
|
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)",
|
||||||
|
request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
|
|
||||||
@@ -1021,7 +1022,7 @@ def make_calibre_web_auth_response():
|
|||||||
"RefreshToken": RefreshToken,
|
"RefreshToken": RefreshToken,
|
||||||
"TokenType": "Bearer",
|
"TokenType": "Bearer",
|
||||||
"TrackingId": str(uuid.uuid4()),
|
"TrackingId": str(uuid.uuid4()),
|
||||||
"UserKey": content.get('UserKey',""),
|
"UserKey": content.get('UserKey', ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ from os import urandom
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import g, Blueprint, abort, request
|
from flask import g, Blueprint, abort, request
|
||||||
from flask_login import login_user, current_user, login_required
|
from .cw_login import login_user, current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_limiter import RateLimitExceeded
|
from flask_limiter import RateLimitExceeded
|
||||||
|
|
||||||
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
|
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
|||||||
|
|
||||||
|
|
||||||
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||||
@login_required
|
@user_login_required
|
||||||
def generate_auth_token(user_id):
|
def generate_auth_token(user_id):
|
||||||
warning = False
|
warning = False
|
||||||
host_list = request.host.rsplit(':')
|
host_list = request.host.rsplit(':')
|
||||||
@@ -115,12 +117,12 @@ def generate_auth_token(user_id):
|
|||||||
"generate_kobo_auth_url.html",
|
"generate_kobo_auth_url.html",
|
||||||
title=_("Kobo Setup"),
|
title=_("Kobo Setup"),
|
||||||
auth_token=auth_token.auth_token,
|
auth_token=auth_token.auth_token,
|
||||||
warning = warning
|
warning=warning
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def delete_auth_token(user_id):
|
def delete_auth_token(user_id):
|
||||||
# Invalidate any previously generated Kobo Auth token for this user
|
# Invalidate any previously generated Kobo Auth token for this user
|
||||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||||
|
|||||||
@@ -17,11 +17,12 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
from . import ub
|
from . import ub
|
||||||
import datetime
|
import datetime
|
||||||
from sqlalchemy.sql.expression import or_, and_, true
|
from sqlalchemy.sql.expression import or_, and_, true
|
||||||
from sqlalchemy import exc
|
# from sqlalchemy import exc
|
||||||
|
|
||||||
|
|
||||||
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
||||||
# do nothing (safety precaution)
|
# do nothing (safety precaution)
|
||||||
@@ -50,7 +51,6 @@ def remove_synced_book(book_id, all=False, session=None):
|
|||||||
ub.session_commit(_session=session)
|
ub.session_commit(_session=session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def change_archived_books(book_id, state=None, message=None):
|
def change_archived_books(book_id, state=None, message=None):
|
||||||
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
|
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
|
||||||
ub.ArchivedBook.book_id == book_id)).first()
|
ub.ArchivedBook.book_id == book_id)).first()
|
||||||
@@ -71,7 +71,7 @@ def update_on_sync_shelfs(user_id):
|
|||||||
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
||||||
.join(ub.BookShelf, ub.KoboSyncedBooks.book_id == ub.BookShelf.book_id, isouter=True)
|
.join(ub.BookShelf, ub.KoboSyncedBooks.book_id == ub.BookShelf.book_id, isouter=True)
|
||||||
.join(ub.Shelf, ub.Shelf.user_id == user_id, isouter=True)
|
.join(ub.Shelf, ub.Shelf.user_id == user_id, isouter=True)
|
||||||
.filter(or_(ub.Shelf.kobo_sync == 0, ub.Shelf.kobo_sync == None))
|
.filter(or_(ub.Shelf.kobo_sync == 0, ub.Shelf.kobo_sync==None))
|
||||||
.filter(ub.KoboSyncedBooks.user_id == user_id).all())
|
.filter(ub.KoboSyncedBooks.user_id == user_id).all())
|
||||||
for b in books_to_archive:
|
for b in books_to_archive:
|
||||||
change_archived_books(b.book_id, True)
|
change_archived_books(b.book_id, True)
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ logging.addLevelName(logging.CRITICAL, "CRIT")
|
|||||||
class _Logger(logging.Logger):
|
class _Logger(logging.Logger):
|
||||||
|
|
||||||
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
||||||
|
is_debug = self.getEffectiveLevel() <= logging.DEBUG
|
||||||
if sys.version_info > (3, 7):
|
if sys.version_info > (3, 7):
|
||||||
if is_debug_enabled():
|
if is_debug:
|
||||||
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
if is_debug_enabled():
|
if is_debug:
|
||||||
self.exception(message, stack_info=True, *args, **kwargs)
|
self.exception(message, stack_info=True, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
self.error(message, *args, **kwargs)
|
self.error(message, *args, **kwargs)
|
||||||
|
|||||||
@@ -20,18 +20,17 @@ import sys
|
|||||||
|
|
||||||
from . import create_app, limiter
|
from . import create_app, limiter
|
||||||
from .jinjia import jinjia
|
from .jinjia import jinjia
|
||||||
from .remotelogin import remotelogin
|
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
|
|
||||||
def request_username():
|
def request_username():
|
||||||
return request.authorization.username
|
return request.authorization.username
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
from .web import web
|
from .web import web
|
||||||
from .basic import basic
|
|
||||||
from .opds import opds
|
from .opds import opds
|
||||||
from .admin import admi
|
from .admin import admi
|
||||||
from .gdrive import gdrive
|
from .gdrive import gdrive
|
||||||
@@ -42,6 +41,7 @@ def main():
|
|||||||
from .shelf import shelf
|
from .shelf import shelf
|
||||||
from .tasks_status import tasks
|
from .tasks_status import tasks
|
||||||
from .error_handler import init_errorhandler
|
from .error_handler import init_errorhandler
|
||||||
|
from .remotelogin import remotelogin
|
||||||
try:
|
try:
|
||||||
from .kobo import kobo, get_kobo_activated
|
from .kobo import kobo, get_kobo_activated
|
||||||
from .kobo_auth import kobo_auth
|
from .kobo_auth import kobo_auth
|
||||||
@@ -49,12 +49,14 @@ def main():
|
|||||||
kobo_available = get_kobo_activated()
|
kobo_available = get_kobo_activated()
|
||||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||||
kobo_available = False
|
kobo_available = False
|
||||||
|
kobo = kobo_auth = get_remote_address = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .oauth_bb import oauth
|
from .oauth_bb import oauth
|
||||||
oauth_available = True
|
oauth_available = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
oauth_available = False
|
oauth_available = False
|
||||||
|
oauth = None
|
||||||
|
|
||||||
from . import web_server
|
from . import web_server
|
||||||
init_errorhandler()
|
init_errorhandler()
|
||||||
@@ -62,9 +64,8 @@ def main():
|
|||||||
app.register_blueprint(search)
|
app.register_blueprint(search)
|
||||||
app.register_blueprint(tasks)
|
app.register_blueprint(tasks)
|
||||||
app.register_blueprint(web)
|
app.register_blueprint(web)
|
||||||
app.register_blueprint(basic)
|
|
||||||
app.register_blueprint(opds)
|
app.register_blueprint(opds)
|
||||||
limiter.limit("3/minute",key_func=request_username)(opds)
|
limiter.limit("3/minute", key_func=request_username)(opds)
|
||||||
app.register_blueprint(jinjia)
|
app.register_blueprint(jinjia)
|
||||||
app.register_blueprint(about)
|
app.register_blueprint(about)
|
||||||
app.register_blueprint(shelf)
|
app.register_blueprint(shelf)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ try:
|
|||||||
import cchardet #optional for better speed
|
import cchardet #optional for better speed
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
from cps import logger
|
|
||||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
import cps.logger as logger
|
import cps.logger as logger
|
||||||
|
|
||||||
@@ -33,8 +33,6 @@ import cps.logger as logger
|
|||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
log = logger.create()
|
|
||||||
|
|
||||||
|
|
||||||
class Amazon(Metadata):
|
class Amazon(Metadata):
|
||||||
__name__ = "Amazon"
|
__name__ = "Amazon"
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ class Douban(Metadata):
|
|||||||
|
|
||||||
return match
|
return match
|
||||||
|
|
||||||
def _clean_date(self, date: str) -> str:
|
@staticmethod
|
||||||
|
def _clean_date(date: str) -> str:
|
||||||
"""
|
"""
|
||||||
Clean up the date string to be in the format YYYY-MM-DD
|
Clean up the date string to be in the format YYYY-MM-DD
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,14 @@ class LubimyCzytac(Metadata):
|
|||||||
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
||||||
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
||||||
SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()"
|
SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()"
|
||||||
|
TRANSLATOR = f"{CONTAINER}//dt[contains(text(),'Tłumacz:')]{SIBLINGS}/a/text()"
|
||||||
|
|
||||||
DETAILS = "//div[@id='book-details']"
|
DETAILS = "//div[@id='book-details']"
|
||||||
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||||
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
||||||
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
||||||
TAGS = "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
|
TAGS = "//a[contains(@href,'/ksiazki/t/')]/text()" # "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
|
||||||
|
|
||||||
|
|
||||||
RATING = "//meta[@property='books:rating:value']/@content"
|
RATING = "//meta[@property='books:rating:value']/@content"
|
||||||
COVER = "//meta[@property='og:image']/@content"
|
COVER = "//meta[@property='og:image']/@content"
|
||||||
@@ -135,7 +137,7 @@ class LubimyCzytac(Metadata):
|
|||||||
|
|
||||||
def _prepare_query(self, title: str) -> str:
|
def _prepare_query(self, title: str) -> str:
|
||||||
query = ""
|
query = ""
|
||||||
characters_to_remove = "\?()\/"
|
characters_to_remove = r"\?()\/"
|
||||||
pattern = "[" + characters_to_remove + "]"
|
pattern = "[" + characters_to_remove + "]"
|
||||||
title = re.sub(pattern, "", title)
|
title = re.sub(pattern, "", title)
|
||||||
title = title.replace("_", " ")
|
title = title.replace("_", " ")
|
||||||
@@ -158,6 +160,7 @@ class LubimyCzytac(Metadata):
|
|||||||
|
|
||||||
class LubimyCzytacParser:
|
class LubimyCzytacParser:
|
||||||
PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>"
|
PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>"
|
||||||
|
TRANSLATOR_TEMPLATE = "<p id='translator'>Tłumacz: {0}</p>"
|
||||||
PUBLISH_DATE_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>"
|
PUBLISH_DATE_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>"
|
||||||
PUBLISH_DATE_PL_TEMPLATE = (
|
PUBLISH_DATE_PL_TEMPLATE = (
|
||||||
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
||||||
@@ -282,11 +285,13 @@ class LubimyCzytacParser:
|
|||||||
|
|
||||||
def _parse_tags(self) -> List[str]:
|
def _parse_tags(self) -> List[str]:
|
||||||
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
||||||
return [
|
if tags:
|
||||||
strip_accents(w.replace(", itd.", " itd."))
|
return [
|
||||||
for w in tags
|
strip_accents(w.replace(", itd.", " itd."))
|
||||||
if isinstance(w, str)
|
for w in tags
|
||||||
]
|
if isinstance(w, str)
|
||||||
|
]
|
||||||
|
return None
|
||||||
|
|
||||||
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
||||||
value = None
|
value = None
|
||||||
@@ -346,5 +351,9 @@ class LubimyCzytacParser:
|
|||||||
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
||||||
first_publish_date_pl.strftime("%d.%m.%Y")
|
first_publish_date_pl.strftime("%d.%m.%Y")
|
||||||
)
|
)
|
||||||
|
translator = self._parse_xpath_node(xpath=LubimyCzytac.TRANSLATOR)
|
||||||
|
if translator:
|
||||||
|
description += LubimyCzytacParser.TRANSLATOR_TEMPLATE.format(translator)
|
||||||
|
|
||||||
|
|
||||||
return description
|
return description
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ from flask_dance.consumer import oauth_authorized, oauth_error
|
|||||||
from flask_dance.contrib.github import make_github_blueprint, github
|
from flask_dance.contrib.github import make_github_blueprint, github
|
||||||
from flask_dance.contrib.google import make_google_blueprint, google
|
from flask_dance.contrib.google import make_google_blueprint, google
|
||||||
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
|
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
|
||||||
from flask_login import login_user, current_user, login_required
|
from .cw_login import login_user, current_user
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
from . import constants, logger, config, app, ub
|
from . import constants, logger, config, app, ub
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
|
|||||||
if oauth_entry.user:
|
if oauth_entry.user:
|
||||||
login_user(oauth_entry.user)
|
login_user(oauth_entry.user)
|
||||||
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
|
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
|
||||||
flash(_("Success! You are now logged in as: %(nickname)s", nickname= oauth_entry.user.name),
|
flash(_("Success! You are now logged in as: %(nickname)s", nickname=oauth_entry.user.name),
|
||||||
category="success")
|
category="success")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
else:
|
else:
|
||||||
@@ -205,6 +206,7 @@ def unlink_oauth(provider):
|
|||||||
flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
|
flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
|
||||||
return redirect(url_for('web.profile'))
|
return redirect(url_for('web.profile'))
|
||||||
|
|
||||||
|
|
||||||
def generate_oauth_blueprints():
|
def generate_oauth_blueprints():
|
||||||
if not ub.session.query(ub.OAuthProvider).count():
|
if not ub.session.query(ub.OAuthProvider).count():
|
||||||
for provider in ("github", "google"):
|
for provider in ("github", "google"):
|
||||||
@@ -291,6 +293,7 @@ if ub.oauth_support:
|
|||||||
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
|
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# notify on OAuth provider error
|
# notify on OAuth provider error
|
||||||
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
||||||
def github_error(blueprint, error, error_description=None, error_uri=None):
|
def github_error(blueprint, error, error_description=None, error_uri=None):
|
||||||
@@ -338,7 +341,7 @@ def github_login():
|
|||||||
|
|
||||||
|
|
||||||
@oauth.route('/unlink/github', methods=["GET"])
|
@oauth.route('/unlink/github', methods=["GET"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def github_login_unlink():
|
def github_login_unlink():
|
||||||
return unlink_oauth(oauthblueprints[0]['id'])
|
return unlink_oauth(oauthblueprints[0]['id'])
|
||||||
|
|
||||||
@@ -362,6 +365,6 @@ def google_login():
|
|||||||
|
|
||||||
|
|
||||||
@oauth.route('/unlink/google', methods=["GET"])
|
@oauth.route('/unlink/google', methods=["GET"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def google_login_unlink():
|
def google_login_unlink():
|
||||||
return unlink_oauth(oauthblueprints[1]['id'])
|
return unlink_oauth(oauthblueprints[1]['id'])
|
||||||
|
|||||||
@@ -24,15 +24,16 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
from urllib.parse import unquote_plus
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, make_response, abort, Response
|
from flask import Blueprint, request, render_template, make_response, abort, Response, g
|
||||||
from flask_login import current_user
|
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
|
|
||||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
|
|
||||||
from . import logger, config, db, calibre_db, ub, isoLanguages
|
from . import logger, config, db, calibre_db, ub, isoLanguages, constants
|
||||||
from .usermanagement import requires_basic_auth_if_no_ano
|
from .usermanagement import requires_basic_auth_if_no_ano, auth
|
||||||
from .helper import get_download_link, get_book_cover
|
from .helper import get_download_link, get_book_cover
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
from .web import render_read_books
|
from .web import render_read_books
|
||||||
@@ -94,6 +95,8 @@ def feed_letter_books(book_id):
|
|||||||
@opds.route("/opds/new")
|
@opds.route("/opds/new")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_new():
|
def feed_new():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_RECENT):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books, True, [db.Books.timestamp.desc()],
|
db.Books, True, [db.Books.timestamp.desc()],
|
||||||
@@ -104,6 +107,8 @@ def feed_new():
|
|||||||
@opds.route("/opds/discover")
|
@opds.route("/opds/discover")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_discover():
|
def feed_discover():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_RANDOM):
|
||||||
|
abort(404)
|
||||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
|
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
|
||||||
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
||||||
@@ -113,6 +118,8 @@ def feed_discover():
|
|||||||
@opds.route("/opds/rated")
|
@opds.route("/opds/rated")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_best_rated():
|
def feed_best_rated():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_BEST_RATED):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
|
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
|
||||||
@@ -124,6 +131,8 @@ def feed_best_rated():
|
|||||||
@opds.route("/opds/hot")
|
@opds.route("/opds/hot")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_hot():
|
def feed_hot():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_HOT):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
|
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
|
||||||
func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id)
|
func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id)
|
||||||
@@ -146,12 +155,16 @@ def feed_hot():
|
|||||||
@opds.route("/opds/author")
|
@opds.route("/opds/author")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_authorindex():
|
def feed_authorindex():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
|
||||||
|
abort(404)
|
||||||
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
|
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author/letter/<book_id>")
|
@opds.route("/opds/author/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_letter_author(book_id):
|
def feed_letter_author(book_id):
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
|
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
|
||||||
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
|
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
|
||||||
@@ -173,6 +186,8 @@ def feed_author(book_id):
|
|||||||
@opds.route("/opds/publisher")
|
@opds.route("/opds/publisher")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_publisherindex():
|
def feed_publisherindex():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_PUBLISHER):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = calibre_db.session.query(db.Publishers)\
|
entries = calibre_db.session.query(db.Publishers)\
|
||||||
.join(db.books_publishers_link)\
|
.join(db.books_publishers_link)\
|
||||||
@@ -194,12 +209,16 @@ def feed_publisher(book_id):
|
|||||||
@opds.route("/opds/category")
|
@opds.route("/opds/category")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_categoryindex():
|
def feed_categoryindex():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
|
||||||
|
abort(404)
|
||||||
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
|
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/category/letter/<book_id>")
|
@opds.route("/opds/category/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_letter_category(book_id):
|
def feed_letter_category(book_id):
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
||||||
entries = calibre_db.session.query(db.Tags)\
|
entries = calibre_db.session.query(db.Tags)\
|
||||||
@@ -223,12 +242,16 @@ def feed_category(book_id):
|
|||||||
@opds.route("/opds/series")
|
@opds.route("/opds/series")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_seriesindex():
|
def feed_seriesindex():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
|
||||||
|
abort(404)
|
||||||
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
|
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/series/letter/<book_id>")
|
@opds.route("/opds/series/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_letter_series(book_id):
|
def feed_letter_series(book_id):
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
||||||
entries = calibre_db.session.query(db.Series)\
|
entries = calibre_db.session.query(db.Series)\
|
||||||
@@ -258,6 +281,8 @@ def feed_series(book_id):
|
|||||||
@opds.route("/opds/ratings")
|
@opds.route("/opds/ratings")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_ratingindex():
|
def feed_ratingindex():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_RATING):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
||||||
(db.Ratings.rating / 2).label('name')) \
|
(db.Ratings.rating / 2).label('name')) \
|
||||||
@@ -284,6 +309,8 @@ def feed_ratings(book_id):
|
|||||||
@opds.route("/opds/formats")
|
@opds.route("/opds/formats")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_formatindex():
|
def feed_formatindex():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_FORMAT):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
||||||
.filter(calibre_db.common_filters()) \
|
.filter(calibre_db.common_filters()) \
|
||||||
@@ -291,7 +318,6 @@ def feed_formatindex():
|
|||||||
.order_by(db.Data.format).all()
|
.order_by(db.Data.format).all()
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(entries))
|
len(entries))
|
||||||
|
|
||||||
element = list()
|
element = list()
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
element.append(FeedObject(entry.format, entry.format))
|
element.append(FeedObject(entry.format, entry.format))
|
||||||
@@ -314,12 +340,14 @@ def feed_format(book_id):
|
|||||||
@opds.route("/opds/language/")
|
@opds.route("/opds/language/")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_languagesindex():
|
def feed_languagesindex():
|
||||||
|
if not auth.current_user().check_visibility(constants.SIDEBAR_LANGUAGE):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
if current_user.filter_language() == "all":
|
if auth.current_user().filter_language() == "all":
|
||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
else:
|
else:
|
||||||
languages = calibre_db.session.query(db.Languages).filter(
|
languages = calibre_db.session.query(db.Languages).filter(
|
||||||
db.Languages.lang_code == current_user.filter_language()).all()
|
db.Languages.lang_code == auth.current_user().filter_language()).all()
|
||||||
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
|
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(languages))
|
len(languages))
|
||||||
@@ -341,9 +369,11 @@ def feed_languages(book_id):
|
|||||||
@opds.route("/opds/shelfindex")
|
@opds.route("/opds/shelfindex")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_shelfindex():
|
def feed_shelfindex():
|
||||||
|
if not (auth.current_user().is_authenticated or g.allow_anonymous):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
shelf = ub.session.query(ub.Shelf).filter(
|
shelf = ub.session.query(ub.Shelf).filter(
|
||||||
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
|
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == auth.current_user().id)).order_by(ub.Shelf.name).all()
|
||||||
number = len(shelf)
|
number = len(shelf)
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
number)
|
number)
|
||||||
@@ -353,16 +383,19 @@ def feed_shelfindex():
|
|||||||
@opds.route("/opds/shelf/<int:book_id>")
|
@opds.route("/opds/shelf/<int:book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_shelf(book_id):
|
def feed_shelf(book_id):
|
||||||
|
if not (auth.current_user().is_authenticated or g.allow_anonymous):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
if current_user.is_anonymous:
|
if auth.current_user().is_anonymous:
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
||||||
ub.Shelf.id == book_id).first()
|
ub.Shelf.id == book_id).first()
|
||||||
else:
|
else:
|
||||||
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
|
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(auth.current_user().id),
|
||||||
ub.Shelf.id == book_id),
|
ub.Shelf.id == book_id),
|
||||||
and_(ub.Shelf.is_public == 1,
|
and_(ub.Shelf.is_public == 1,
|
||||||
ub.Shelf.id == book_id))).first()
|
ub.Shelf.id == book_id))).first()
|
||||||
result = list()
|
result = list()
|
||||||
|
pagination = list()
|
||||||
# user is allowed to access shelf
|
# user is allowed to access shelf
|
||||||
if shelf:
|
if shelf:
|
||||||
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
||||||
@@ -390,7 +423,7 @@ def feed_shelf(book_id):
|
|||||||
@opds.route("/opds/download/<book_id>/<book_format>/")
|
@opds.route("/opds/download/<book_id>/<book_format>/")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def opds_download_link(book_id, book_format):
|
def opds_download_link(book_id, book_format):
|
||||||
if not current_user.role_download():
|
if not auth.current_user().role_download():
|
||||||
return abort(403)
|
return abort(403)
|
||||||
if "Kobo" in request.headers.get('User-Agent'):
|
if "Kobo" in request.headers.get('User-Agent'):
|
||||||
client = "kobo"
|
client = "kobo"
|
||||||
@@ -436,6 +469,8 @@ def feed_get_cover(book_id):
|
|||||||
@opds.route("/opds/readbooks")
|
@opds.route("/opds/readbooks")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_read_books():
|
def feed_read_books():
|
||||||
|
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
|
||||||
|
return abort(403)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
|
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
@@ -444,6 +479,8 @@ def feed_read_books():
|
|||||||
@opds.route("/opds/unreadbooks")
|
@opds.route("/opds/unreadbooks")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_unread_books():
|
def feed_unread_books():
|
||||||
|
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
|
||||||
|
return abort(403)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
@@ -477,7 +514,7 @@ def feed_search(term):
|
|||||||
def render_xml_template(*args, **kwargs):
|
def render_xml_template(*args, **kwargs):
|
||||||
# ToDo: return time in current timezone similar to %z
|
# ToDo: return time in current timezone similar to %z
|
||||||
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||||
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs)
|
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, constants=constants.sidebar_settings, *args, **kwargs)
|
||||||
response = make_response(xml)
|
response = make_response(xml)
|
||||||
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ from datetime import datetime
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
||||||
from flask_login import login_required, current_user, login_user
|
from .cw_login import login_user, current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from sqlalchemy.sql.expression import true
|
from sqlalchemy.sql.expression import true
|
||||||
|
|
||||||
from . import config, logger, ub
|
from . import config, logger, ub
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
|
|
||||||
remotelogin = Blueprint('remotelogin', __name__)
|
remotelogin = Blueprint('remotelogin', __name__)
|
||||||
@@ -65,7 +66,7 @@ def remote_login():
|
|||||||
|
|
||||||
@remotelogin.route('/verify/<token>')
|
@remotelogin.route('/verify/<token>')
|
||||||
@remote_login_required
|
@remote_login_required
|
||||||
@login_required
|
@user_login_required
|
||||||
def verify_token(token):
|
def verify_token(token):
|
||||||
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
|
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,13 @@
|
|||||||
from flask import render_template, g, abort, request
|
from flask import render_template, g, abort, request
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from werkzeug.local import LocalProxy
|
from werkzeug.local import LocalProxy
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
from sqlalchemy.sql.expression import or_
|
from sqlalchemy.sql.expression import or_
|
||||||
|
|
||||||
from . import config, constants, logger, ub
|
from . import config, constants, logger, ub
|
||||||
from .ub import User
|
from .ub import User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
def get_sidebar_config(kwargs=None):
|
def get_sidebar_config(kwargs=None):
|
||||||
@@ -112,7 +111,7 @@ def render_title_template(*args, **kwargs):
|
|||||||
sidebar, simple = get_sidebar_config(kwargs)
|
sidebar, simple = get_sidebar_config(kwargs)
|
||||||
try:
|
try:
|
||||||
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
|
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
|
||||||
accept=constants.EXTENSIONS_UPLOAD,
|
accept=config.config_upload_formats.split(','),
|
||||||
*args, **kwargs)
|
*args, **kwargs)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
log.error("No permission to access {} file.".format(args[0]))
|
log.error("No permission to access {} file.".format(args[0]))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import datetime
|
|||||||
from . import config, constants
|
from . import config, constants
|
||||||
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
|
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
|
||||||
from .tasks.database import TaskReconnectDatabase
|
from .tasks.database import TaskReconnectDatabase
|
||||||
from .tasks.tempFolder import TaskDeleteTempFolder
|
from .tasks.clean import TaskClean
|
||||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
from .tasks.metadata_backup import TaskBackupMetadata
|
from .tasks.metadata_backup import TaskBackupMetadata
|
||||||
@@ -33,7 +33,7 @@ def get_scheduled_tasks(reconnect=True):
|
|||||||
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||||
|
|
||||||
# Delete temp folder
|
# Delete temp folder
|
||||||
tasks.append([lambda: TaskDeleteTempFolder(), 'delete temp', True])
|
tasks.append([lambda: TaskClean(), 'delete temp', True])
|
||||||
|
|
||||||
# Generate metadata.opf file for each changed book
|
# Generate metadata.opf file for each changed book
|
||||||
if config.schedule_metadata_backup:
|
if config.schedule_metadata_backup:
|
||||||
@@ -69,9 +69,12 @@ def register_scheduled_tasks(reconnect=True):
|
|||||||
duration = config.schedule_duration
|
duration = config.schedule_duration
|
||||||
|
|
||||||
# Register scheduled tasks
|
# Register scheduled tasks
|
||||||
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger=CronTrigger(hour=start))
|
timezone_info = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
|
||||||
|
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger=CronTrigger(hour=start,
|
||||||
|
timezone=timezone_info))
|
||||||
end_time = calclulate_end_time(start, duration)
|
end_time = calclulate_end_time(start, duration)
|
||||||
scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute),
|
scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute,
|
||||||
|
timezone=timezone_info),
|
||||||
name="end scheduled task")
|
name="end scheduled task")
|
||||||
|
|
||||||
# Kick-off tasks, if they should currently be running
|
# Kick-off tasks, if they should currently be running
|
||||||
@@ -91,7 +94,7 @@ def register_startup_tasks():
|
|||||||
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
||||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||||
else:
|
else:
|
||||||
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskDeleteTempFolder(), 'delete temp', True]])
|
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskClean(), 'delete temp', True]])
|
||||||
|
|
||||||
|
|
||||||
def should_task_be_running(start, duration):
|
def should_task_be_running(start, duration):
|
||||||
|
|||||||
@@ -19,17 +19,19 @@ from datetime import datetime
|
|||||||
|
|
||||||
from flask import Blueprint, request, redirect, url_for, flash
|
from flask import Blueprint, request, redirect, url_for, flash
|
||||||
from flask import session as flask_session
|
from flask import session as flask_session
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
from flask_babel import format_date
|
from flask_babel import format_date
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
|
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
|
||||||
from sqlalchemy.sql.functions import coalesce
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
from sqlalchemy import exists
|
||||||
|
|
||||||
from . import logger, db, calibre_db, config, ub
|
from . import logger, db, calibre_db, config, ub
|
||||||
from .usermanagement import login_required_if_no_ano
|
from .usermanagement import login_required_if_no_ano
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
search = Blueprint('search', __name__)
|
search = Blueprint('search', __name__)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -80,16 +82,27 @@ def adv_search_custom_columns(cc, term, q):
|
|||||||
if custom_end:
|
if custom_end:
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
|
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
|
||||||
|
elif c.datatype in ["int", "float"]:
|
||||||
|
custom_low = term.get('custom_column_' + str(c.id) + '_low')
|
||||||
|
custom_high = term.get('custom_column_' + str(c.id) + '_high')
|
||||||
|
if custom_low:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value >= custom_low))
|
||||||
|
if custom_high:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value <= custom_high))
|
||||||
else:
|
else:
|
||||||
custom_query = term.get('custom_column_' + str(c.id))
|
custom_query = term.get('custom_column_' + str(c.id))
|
||||||
if custom_query != '' and custom_query is not None:
|
if c.datatype == 'bool':
|
||||||
if c.datatype == 'bool':
|
if custom_query != "Any":
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
if custom_query == "":
|
||||||
db.cc_classes[c.id].value == (custom_query == "True")))
|
q = q.filter(~getattr(db.Books, 'custom_column_' + str(c.id)).
|
||||||
elif c.datatype == 'int' or c.datatype == 'float':
|
any(db.cc_classes[c.id].value >= 0))
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
else:
|
||||||
db.cc_classes[c.id].value == custom_query))
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
elif c.datatype == 'rating':
|
db.cc_classes[c.id].value == bool(custom_query == "True")))
|
||||||
|
elif custom_query != '' and custom_query is not None:
|
||||||
|
if c.datatype == 'rating':
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
||||||
else:
|
else:
|
||||||
@@ -128,10 +141,10 @@ def adv_search_read_status(read_status):
|
|||||||
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
|
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
if read_status == "True":
|
if read_status == "":
|
||||||
db_filter = db.cc_classes[config.config_read_column].value == True
|
db_filter = coalesce(db.cc_classes[config.config_read_column].value, 2) == 2
|
||||||
else:
|
else:
|
||||||
db_filter = coalesce(db.cc_classes[config.config_read_column].value, False) != True
|
db_filter = db.cc_classes[config.config_read_column].value == bool(read_status == "True")
|
||||||
except (KeyError, AttributeError, IndexError):
|
except (KeyError, AttributeError, IndexError):
|
||||||
log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column))
|
log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column))
|
||||||
flash(_("Custom Column No.%(column)d does not exist in calibre database",
|
flash(_("Custom Column No.%(column)d does not exist in calibre database",
|
||||||
@@ -274,10 +287,23 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|||||||
cc_present = True
|
cc_present = True
|
||||||
if column_end:
|
if column_end:
|
||||||
search_term.extend(["{} <= {}".format(c.name,
|
search_term.extend(["{} <= {}".format(c.name,
|
||||||
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||||
format='medium')
|
format='medium')
|
||||||
)])
|
)])
|
||||||
cc_present = True
|
cc_present = True
|
||||||
|
if c.datatype in ["int", "float"]:
|
||||||
|
column_low = term.get('custom_column_' + str(c.id) + '_low')
|
||||||
|
column_high = term.get('custom_column_' + str(c.id) + '_high')
|
||||||
|
if column_low:
|
||||||
|
search_term.extend(["{} >= {}".format(c.name, column_low)])
|
||||||
|
cc_present = True
|
||||||
|
if column_high:
|
||||||
|
search_term.extend(["{} <= {}".format(c.name,column_high)])
|
||||||
|
cc_present = True
|
||||||
|
elif c.datatype == "bool":
|
||||||
|
if term.get('custom_column_' + str(c.id)) != "Any":
|
||||||
|
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||||
|
cc_present = True
|
||||||
elif term.get('custom_column_' + str(c.id)):
|
elif term.get('custom_column_' + str(c.id)):
|
||||||
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||||
cc_present = True
|
cc_present = True
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from flask import Blueprint, Response, request, url_for
|
from flask import Blueprint, Response, request, url_for
|
||||||
from flask_login import current_user
|
from .cw_login import current_user
|
||||||
from flask_login import login_required
|
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from cps.services.Metadata import Metadata
|
from cps.services.Metadata import Metadata
|
||||||
from . import constants, logger, ub, web_server
|
from . import constants, logger, ub, web_server
|
||||||
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
# current_milli_time = lambda: int(round(time() * 1000))
|
# current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ cl = list_classes(new_list)
|
|||||||
|
|
||||||
|
|
||||||
@meta.route("/metadata/provider")
|
@meta.route("/metadata/provider")
|
||||||
@login_required
|
@user_login_required
|
||||||
def metadata_provider():
|
def metadata_provider():
|
||||||
active = current_user.view_settings.get("metadata", {})
|
active = current_user.view_settings.get("metadata", {})
|
||||||
provider = list()
|
provider = list()
|
||||||
@@ -95,7 +95,7 @@ def metadata_provider():
|
|||||||
|
|
||||||
@meta.route("/metadata/provider", methods=["POST"])
|
@meta.route("/metadata/provider", methods=["POST"])
|
||||||
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
|
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def metadata_change_active_provider(prov_name):
|
def metadata_change_active_provider(prov_name):
|
||||||
new_state = request.get_json()
|
new_state = request.get_json()
|
||||||
active = current_user.view_settings.get("metadata", {})
|
active = current_user.view_settings.get("metadata", {})
|
||||||
@@ -122,7 +122,7 @@ def metadata_change_active_provider(prov_name):
|
|||||||
|
|
||||||
|
|
||||||
@meta.route("/metadata/search", methods=["POST"])
|
@meta.route("/metadata/search", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def metadata_search():
|
def metadata_search():
|
||||||
query = request.form.to_dict().get("query")
|
query = request.form.to_dict().get("query")
|
||||||
data = list()
|
data = list()
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import os
|
|||||||
import errno
|
import errno
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
@@ -97,7 +96,8 @@ class WebServer(object):
|
|||||||
log.warning('Cert path: %s', certfile_path)
|
log.warning('Cert path: %s', certfile_path)
|
||||||
log.warning('Key path: %s', keyfile_path)
|
log.warning('Key path: %s', keyfile_path)
|
||||||
|
|
||||||
def _make_gevent_socket_activated(self):
|
@staticmethod
|
||||||
|
def _make_gevent_socket_activated():
|
||||||
# Reuse an already open socket on fd=SD_LISTEN_FDS_START
|
# Reuse an already open socket on fd=SD_LISTEN_FDS_START
|
||||||
SD_LISTEN_FDS_START = 3
|
SD_LISTEN_FDS_START = 3
|
||||||
return GeventSocket(fileno=SD_LISTEN_FDS_START)
|
return GeventSocket(fileno=SD_LISTEN_FDS_START)
|
||||||
@@ -139,8 +139,8 @@ class WebServer(object):
|
|||||||
return ((self.listen_address, self.listen_port),
|
return ((self.listen_address, self.listen_port),
|
||||||
_readable_listen_address(self.listen_address, self.listen_port))
|
_readable_listen_address(self.listen_address, self.listen_port))
|
||||||
|
|
||||||
|
address = ('::', self.listen_port)
|
||||||
try:
|
try:
|
||||||
address = ('::', self.listen_port)
|
|
||||||
sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
|
sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
|
||||||
except socket.error as ex:
|
except socket.error as ex:
|
||||||
log.error('%s', ex)
|
log.error('%s', ex)
|
||||||
@@ -301,7 +301,6 @@ class WebServer(object):
|
|||||||
log.info("Performing restart of Calibre-Web")
|
log.info("Performing restart of Calibre-Web")
|
||||||
args = self._get_args_for_reloading()
|
args = self._get_args_for_reloading()
|
||||||
os.execv(args[0].lstrip('"').rstrip('"'), args)
|
os.execv(args[0].lstrip('"').rstrip('"'), args)
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def shutdown_scheduler():
|
def shutdown_scheduler():
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ log = logger.create()
|
|||||||
|
|
||||||
|
|
||||||
def b64encode_json(json_data):
|
def b64encode_json(json_data):
|
||||||
return b64encode(json.dumps(json_data).encode())
|
return b64encode(json.dumps(json_data).encode()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2.
|
# Python3 has a timestamp() method we could be calling, however it's not available in python2.
|
||||||
def to_epoch_timestamp(datetime_object):
|
def to_epoch_timestamp(datetime_object):
|
||||||
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ def get_datetime_from_json(json_object, field_name):
|
|||||||
|
|
||||||
|
|
||||||
class SyncToken:
|
class SyncToken:
|
||||||
""" The SyncToken is used to persist state accross requests.
|
""" The SyncToken is used to persist state across requests.
|
||||||
When serialized over the response headers, the Kobo device will propagate the token onto following
|
When serialized over the response headers, the Kobo device will propagate the token onto following
|
||||||
requests to the service. As an example use-case, the SyncToken is used to detect books that have been added
|
requests to the service. As an example use-case, the SyncToken is used to detect books that have been added
|
||||||
to the library since the last time the device synced to the server.
|
to the library since the last time the device synced to the server.
|
||||||
@@ -173,8 +173,8 @@ class SyncToken:
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{},{},{},{},{},{}".format(self.books_last_created,
|
return "{},{},{},{},{},{}".format(self.books_last_created,
|
||||||
self.books_last_modified,
|
self.books_last_modified,
|
||||||
self.archive_last_modified,
|
self.archive_last_modified,
|
||||||
self.reading_state_last_modified,
|
self.reading_state_last_modified,
|
||||||
self.tags_last_modified,
|
self.tags_last_modified,
|
||||||
self.raw_kobo_store_token)
|
self.raw_kobo_store_token)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.g
|
|||||||
def setup_gmail(token):
|
def setup_gmail(token):
|
||||||
# If there are no (valid) credentials available, let the user log in.
|
# If there are no (valid) credentials available, let the user log in.
|
||||||
creds = None
|
creds = None
|
||||||
|
user_info = None
|
||||||
if "token" in token:
|
if "token" in token:
|
||||||
creds = Credentials(
|
creds = Credentials(
|
||||||
token=token['token'],
|
token=token['token'],
|
||||||
|
|||||||
@@ -18,16 +18,51 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from goodreads.client import GoodreadsClient
|
||||||
|
from goodreads.request import GoodreadsRequest
|
||||||
|
import xmltodict
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from goodreads.client import GoodreadsClient
|
import Levenshtein
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from betterreads.client import GoodreadsClient
|
Levenshtein = False
|
||||||
|
|
||||||
try: import Levenshtein
|
|
||||||
except ImportError: Levenshtein = False
|
|
||||||
|
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
from ..clean_html import clean_string
|
||||||
|
|
||||||
|
|
||||||
|
class my_GoodreadsClient(GoodreadsClient):
|
||||||
|
|
||||||
|
def request(self, *args, **kwargs):
|
||||||
|
"""Create a GoodreadsRequest object and make that request"""
|
||||||
|
req = my_GoodreadsRequest(self, *args, **kwargs)
|
||||||
|
return req.request()
|
||||||
|
|
||||||
|
|
||||||
|
class GoodreadsRequestException(Exception):
|
||||||
|
def __init__(self, error_msg, url):
|
||||||
|
self.error_msg = error_msg
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.url, ':', self.error_msg
|
||||||
|
|
||||||
|
|
||||||
|
class my_GoodreadsRequest(GoodreadsRequest):
|
||||||
|
|
||||||
|
def request(self):
|
||||||
|
resp = requests.get(self.host+self.path, params=self.params,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) "
|
||||||
|
"Gecko/20100101 Firefox/125.0"})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise GoodreadsRequestException(resp.reason, self.path)
|
||||||
|
if self.req_format == 'xml':
|
||||||
|
data_dict = xmltodict.parse(resp.content)
|
||||||
|
return data_dict['GoodreadsResponse']
|
||||||
|
else:
|
||||||
|
raise Exception("Invalid format")
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -38,20 +73,20 @@ _CACHE_TIMEOUT = 23 * 60 * 60 # 23 hours (in seconds)
|
|||||||
_AUTHORS_CACHE = {}
|
_AUTHORS_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
def connect(key=None, secret=None, enabled=True):
|
def connect(key=None, enabled=True):
|
||||||
global _client
|
global _client
|
||||||
|
|
||||||
if not enabled or not key or not secret:
|
if not enabled or not key:
|
||||||
_client = None
|
_client = None
|
||||||
return
|
return
|
||||||
|
|
||||||
if _client:
|
if _client:
|
||||||
# make sure the configuration has not changed since last we used the client
|
# make sure the configuration has not changed since last we used the client
|
||||||
if _client.client_key != key or _client.client_secret != secret:
|
if _client.client_key != key:
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
if not _client:
|
if not _client:
|
||||||
_client = GoodreadsClient(key, secret)
|
_client = my_GoodreadsClient(key, None)
|
||||||
|
|
||||||
|
|
||||||
def get_author_info(author_name):
|
def get_author_info(author_name):
|
||||||
@@ -76,6 +111,7 @@ def get_author_info(author_name):
|
|||||||
|
|
||||||
if author_info:
|
if author_info:
|
||||||
author_info._timestamp = now
|
author_info._timestamp = now
|
||||||
|
author_info.safe_about = clean_string(author_info.about)
|
||||||
_AUTHORS_CACHE[author_name] = author_info
|
_AUTHORS_CACHE[author_name] = author_info
|
||||||
return author_info
|
return author_info
|
||||||
|
|
||||||
@@ -91,7 +127,8 @@ def get_other_books(author_info, library_books=None):
|
|||||||
identifiers = []
|
identifiers = []
|
||||||
library_titles = []
|
library_titles = []
|
||||||
if library_books:
|
if library_books:
|
||||||
identifiers = list(reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
|
identifiers = list(
|
||||||
|
reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
|
||||||
library_titles = [book.title for book in library_books]
|
library_titles = [book.title for book in library_books]
|
||||||
|
|
||||||
for book in author_info.books:
|
for book in author_info.books:
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ except ImportError:
|
|||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
class LDAPLogger(object):
|
class LDAPLogger(object):
|
||||||
|
|
||||||
def write(self, message):
|
@staticmethod
|
||||||
|
def write(message):
|
||||||
try:
|
try:
|
||||||
log.debug(message.strip("\n").replace("\n", ""))
|
log.debug(message.strip("\n").replace("\n", ""))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -71,6 +73,7 @@ class mySimpleLDap(LDAP):
|
|||||||
|
|
||||||
_ldap = mySimpleLDap()
|
_ldap = mySimpleLDap()
|
||||||
|
|
||||||
|
|
||||||
def init_app(app, config):
|
def init_app(app, config):
|
||||||
if config.config_login_type != constants.LOGIN_LDAP:
|
if config.config_login_type != constants.LOGIN_LDAP:
|
||||||
return
|
return
|
||||||
@@ -124,7 +127,7 @@ def init_app(app, config):
|
|||||||
log.error(e)
|
log.error(e)
|
||||||
|
|
||||||
|
|
||||||
def get_object_details(user=None,query_filter=None):
|
def get_object_details(user=None, query_filter=None):
|
||||||
return _ldap.get_object_details(user, query_filter=query_filter)
|
return _ldap.get_object_details(user, query_filter=query_filter)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ from datetime import datetime
|
|||||||
|
|
||||||
from flask import Blueprint, flash, redirect, request, url_for, abort
|
from flask import Blueprint, flash, redirect, request, url_for, abort
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from .cw_login import current_user
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from sqlalchemy.sql.expression import func, true
|
from sqlalchemy.sql.expression import func, true
|
||||||
|
|
||||||
from . import calibre_db, config, db, logger, ub
|
from . import calibre_db, config, db, logger, ub
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
from .usermanagement import login_required_if_no_ano
|
from .usermanagement import login_required_if_no_ano, user_login_required
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ shelf = Blueprint('shelf', __name__)
|
|||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def add_to_shelf(shelf_id, book_id):
|
def add_to_shelf(shelf_id, book_id):
|
||||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
@@ -103,7 +103,7 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def search_to_shelf(shelf_id):
|
def search_to_shelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if shelf is None:
|
if shelf is None:
|
||||||
@@ -155,7 +155,7 @@ def search_to_shelf(shelf_id):
|
|||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def remove_from_shelf(shelf_id, book_id):
|
def remove_from_shelf(shelf_id, book_id):
|
||||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
@@ -212,14 +212,14 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/create", methods=["GET", "POST"])
|
@shelf.route("/shelf/create", methods=["GET", "POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def create_shelf():
|
def create_shelf():
|
||||||
shelf = ub.Shelf()
|
shelf = ub.Shelf()
|
||||||
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
|
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def edit_shelf(shelf_id):
|
def edit_shelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if not check_shelf_edit_permissions(shelf):
|
if not check_shelf_edit_permissions(shelf):
|
||||||
@@ -229,7 +229,7 @@ def edit_shelf(shelf_id):
|
|||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def delete_shelf(shelf_id):
|
def delete_shelf(shelf_id):
|
||||||
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
try:
|
try:
|
||||||
@@ -259,7 +259,7 @@ def show_shelf(shelf_id, sort_param, page):
|
|||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def order_shelf(shelf_id):
|
def order_shelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if shelf and check_shelf_view_permissions(shelf):
|
if shelf and check_shelf_view_permissions(shelf):
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 920 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||||
|
<path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||||
|
<path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z"/>
|
||||||
|
<path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 3.09C12.28 3.01 12.43 3 12.43 3C12.48 3 12.58 3.02 12.66 3.1L14.45 4.89C14.58 5.02 14.58 5.22 14.45 5.35L11.7713 8.02872L9.51628 5.77372L12.2 3.09ZM13.2658 5.12L11.7713 6.6145L10.9305 5.77372L12.425 4.27921L13.2658 5.12Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 9.32L8.23 11.57L10.7106 9.08938L8.45562 6.83438L5.98 9.31V9.32ZM8.23 10.1558L9.29641 9.08938L8.45562 8.24859L7.38921 9.315L8.23 10.1558Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 13.1816L16.2125 7.1217C16.7576 6.58919 17.05 5.8707 17.05 5.12C17.05 4.36931 16.7576 3.65084 16.2126 3.11834L14.4317 1.33747C13.8992 0.79242 13.1807 0.5 12.43 0.5C11.6643 0.5 10.9529 0.812929 10.4329 1.33289L3.68289 8.08289C3.04127 8.72452 3.00459 9.75075 3.57288 10.4363L1.29187 12.7239C1.09186 12.9245 0.990263 13.1957 1.0007 13.4685L1 14.5C0.447715 14.5 0 14.9477 0 15.5V17.5C0 18.0523 0.447715 18.5 1 18.5H16C16.5523 18.5 17 18.0523 17 17.5V15.5C17 14.9477 16.5523 14.5 16 14.5H10.2325C9.83594 14.5 9.39953 13.9347 10.1526 13.1816ZM4.39 9.85L4.9807 10.4407L2.39762 13.0312H6.63877L7.10501 12.565L7.57125 13.0312H8.88875L15.51 6.41C15.86 6.07 16.05 5.61 16.05 5.12C16.05 4.63 15.86 4.17 15.51 3.83L13.72 2.04C13.38 1.69 12.92 1.5 12.43 1.5C11.94 1.5 11.48 1.7 11.14 2.04L4.39 8.79C4.1 9.08 4.1 9.56 4.39 9.85ZM16 17.5V15.5H1V17.5H16Z" fill="#FBFBFE"/>
|
||||||
|
<path d="M15.1616 6.05136L15.1616 6.05132L15.1564 6.05645L8.40645 12.8064C8.35915 12.8537 8.29589 12.88 8.23 12.88C8.16411 12.88 8.10085 12.8537 8.05355 12.8064L7.45857 12.2115L7.10501 11.8579L6.75146 12.2115L6.03289 12.93H3.20465L5.33477 10.7937L5.6873 10.4402L5.33426 10.0871L4.74355 9.49645C4.64882 9.40171 4.64882 9.23829 4.74355 9.14355L11.4936 2.39355C11.7436 2.14354 12.0779 2 12.43 2C12.7883 2 13.1179 2.13776 13.3614 2.38839L13.3613 2.38843L13.3664 2.39355L15.1564 4.18355L15.1564 4.18359L15.1616 4.18864C15.4122 4.43211 15.55 4.76166 15.55 5.12C15.55 5.47834 15.4122 5.80789 15.1616 6.05136ZM7.87645 11.9236L8.23 12.2771L8.58355 11.9236L11.0642 9.44293L11.4177 9.08938L11.0642 8.73582L8.80918 6.48082L8.45562 6.12727L8.10207 6.48082L5.62645 8.95645L5.48 9.10289V9.31V9.32V9.52711L5.62645 9.67355L7.87645 11.9236ZM11.4177 8.38227L11.7713 8.73582L12.1248 8.38227L14.8036 5.70355C15.1288 5.37829 15.1288 4.86171 14.8036 4.53645L13.0136 2.74645C12.8186 2.55146 12.5792 2.5 12.43 2.5H12.4134L12.3967 2.50111L12.43 3C12.3967 2.50111 12.3966 2.50112 12.3965 2.50112L12.3963 2.50114L12.3957 2.50117L12.3947 2.50125L12.3924 2.50142L12.387 2.50184L12.3732 2.50311C12.3628 2.50416 12.3498 2.50567 12.3346 2.50784C12.3049 2.51208 12.2642 2.51925 12.2178 2.53146C12.1396 2.55202 11.9797 2.60317 11.8464 2.73645L9.16273 5.42016L8.80918 5.77372L9.16273 6.12727L11.4177 8.38227ZM1.5 16H15.5V17H1.5V16Z" stroke="#15141A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2.75H12.5V2.25V1V0.5H12H10.358C9.91165 0.5 9.47731 0.625661 9.09989 0.860442L9.09886 0.861087L8 1.54837L6.89997 0.860979L6.89911 0.860443C6.5218 0.625734 6.08748 0.5 5.642 0.5H4H3.5V1V2.25V2.75H4H5.642C5.66478 2.75 5.6885 2.75641 5.71008 2.76968C5.71023 2.76977 5.71038 2.76986 5.71053 2.76995L6.817 3.461C6.81704 3.46103 6.81709 3.46105 6.81713 3.46108C6.81713 3.46108 6.81713 3.46108 6.81714 3.46109C6.8552 3.48494 6.876 3.52285 6.876 3.567V8V12.433C6.876 12.4771 6.85523 12.515 6.81722 12.5389C6.81715 12.5389 6.81707 12.539 6.817 12.539L5.70953 13.23C5.70941 13.2301 5.70929 13.2302 5.70917 13.2303C5.68723 13.2438 5.6644 13.25 5.641 13.25H4H3.5V13.75V15V15.5H4H5.642C6.08835 15.5 6.52269 15.3743 6.90011 15.1396L6.90086 15.1391L8 14.4526L9.10003 15.14L9.10089 15.1406C9.47831 15.3753 9.91265 15.501 10.359 15.501H12H12.5V15.001V13.751V13.251H12H10.358C10.3352 13.251 10.3115 13.2446 10.2899 13.2313C10.2897 13.2312 10.2896 13.2311 10.2895 13.231L9.183 12.54C9.18298 12.54 9.18295 12.54 9.18293 12.54C9.18291 12.5399 9.18288 12.5399 9.18286 12.5399C9.14615 12.5169 9.125 12.4797 9.125 12.434V8V3.567C9.125 3.52266 9.14603 3.48441 9.18364 3.4606C9.18377 3.46052 9.1839 3.46043 9.18404 3.46035L10.2895 2.76995C10.2896 2.76985 10.2898 2.76975 10.2899 2.76966C10.3119 2.75619 10.3346 2.75 10.358 2.75H12Z" fill="black" stroke="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645Z" fill="white"/>
|
||||||
|
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645ZM12.472 5.1965L13.632 4.0365L13.631 3.1885L11.811 1.3675L10.963 1.3685L9.80299 2.5285L12.472 5.1965ZM4.31099 13.1585C4.47099 13.1285 4.61799 13.0515 4.73399 12.9345L11.587 6.0815L8.91899 3.4135L2.06599 10.2655C1.94899 10.3835 1.87199 10.5305 1.84099 10.6915L1.36699 13.2485L1.75199 13.6335L4.31099 13.1585Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="29" height="32" viewBox="0 0 29 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M28 16.75C28.2761 16.75 28.5 16.5261 28.5 16.25V15C28.5 14.7239 28.2761 14.5 28 14.5H26.358C25.9117 14.5 25.4773 14.6257 25.0999 14.8604L25.0989 14.8611L24 15.5484L22.9 14.861L22.8991 14.8604C22.5218 14.6257 22.0875 14.5 21.642 14.5H20C19.7239 14.5 19.5 14.7239 19.5 15V16.25C19.5 16.5261 19.7239 16.75 20 16.75H21.642C21.6648 16.75 21.6885 16.7564 21.7101 16.7697C21.7102 16.7698 21.7104 16.7699 21.7105 16.77L22.817 17.461C22.817 17.461 22.8171 17.4611 22.8171 17.4611C22.8171 17.4611 22.8171 17.4611 22.8171 17.4611C22.8552 17.4849 22.876 17.5229 22.876 17.567V22.625V27.683C22.876 27.7271 22.8552 27.765 22.8172 27.7889C22.8171 27.7889 22.8171 27.789 22.817 27.789L21.7095 28.48C21.7094 28.4801 21.7093 28.4802 21.7092 28.4803C21.6872 28.4938 21.6644 28.5 21.641 28.5H20C19.7239 28.5 19.5 28.7239 19.5 29V30.25C19.5 30.5261 19.7239 30.75 20 30.75H21.642C22.0883 30.75 22.5227 30.6243 22.9001 30.3896L22.9009 30.3891L24 29.7026L25.1 30.39L25.1009 30.3906C25.4783 30.6253 25.9127 30.751 26.359 30.751H28C28.2761 30.751 28.5 30.5271 28.5 30.251V29.001C28.5 28.7249 28.2761 28.501 28 28.501H26.358C26.3352 28.501 26.3115 28.4946 26.2899 28.4813C26.2897 28.4812 26.2896 28.4811 26.2895 28.481L25.183 27.79C25.183 27.79 25.183 27.79 25.1829 27.79C25.1829 27.7899 25.1829 27.7899 25.1829 27.7899C25.1462 27.7669 25.125 27.7297 25.125 27.684V22.625V17.567C25.125 17.5227 25.146 17.4844 25.1836 17.4606C25.1838 17.4605 25.1839 17.4604 25.184 17.4603L26.2895 16.77C26.2896 16.7699 26.2898 16.7698 26.2899 16.7697C26.3119 16.7562 26.3346 16.75 26.358 16.75H28Z" fill="black" stroke="#FBFBFE" stroke-linejoin="round"/>
|
||||||
|
<path d="M24.625 17.567C24.625 17.35 24.735 17.152 24.918 17.037L26.026 16.345C26.126 16.283 26.24 16.25 26.358 16.25H28V15H26.358C26.006 15 25.663 15.099 25.364 15.285L24.256 15.978C24.161 16.037 24.081 16.113 24 16.187C23.918 16.113 23.839 16.037 23.744 15.978L22.635 15.285C22.336 15.099 21.993 15 21.642 15H20V16.25H21.642C21.759 16.25 21.874 16.283 21.974 16.345L23.082 17.037C23.266 17.152 23.376 17.35 23.376 17.567V22.625V27.683C23.376 27.9 23.266 28.098 23.082 28.213L21.973 28.905C21.873 28.967 21.759 29 21.641 29H20V30.25H21.642C21.994 30.25 22.337 30.151 22.636 29.965L23.744 29.273C23.84 29.213 23.919 29.137 24 29.064C24.081 29.137 24.161 29.213 24.256 29.273L25.365 29.966C25.664 30.152 26.007 30.251 26.359 30.251H28V29.001H26.358C26.241 29.001 26.126 28.968 26.026 28.906L24.918 28.214C24.734 28.099 24.625 27.901 24.625 27.684V22.625V17.567Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 2.59C12.28 2.51 12.43 2.5 12.43 2.5C12.48 2.5 12.58 2.52 12.66 2.6L14.45 4.39C14.58 4.52 14.58 4.72 14.45 4.85L11.7713 7.52872L9.51628 5.27372L12.2 2.59ZM13.2658 4.62L11.7713 6.1145L10.9305 5.27372L12.425 3.77921L13.2658 4.62Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 8.82L8.23 11.07L10.7106 8.58938L8.45562 6.33438L5.98 8.81V8.82ZM8.23 9.65579L9.29641 8.58938L8.45562 7.74859L7.38921 8.815L8.23 9.65579Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 12.6816L16.2125 6.6217C16.7576 6.08919 17.05 5.3707 17.05 4.62C17.05 3.86931 16.7576 3.15084 16.2126 2.61834L14.4317 0.837474C13.8992 0.29242 13.1807 0 12.43 0C11.6643 0 10.9529 0.312929 10.4329 0.832893L3.68289 7.58289C3.04127 8.22452 3.00459 9.25075 3.57288 9.93634L1.29187 12.2239C1.09186 12.4245 0.990263 12.6957 1.0007 12.9685L1 14C0.447715 14 0 14.4477 0 15V17C0 17.5523 0.447715 18 1 18H16C16.5523 18 17 17.5523 17 17V15C17 14.4477 16.5523 14 16 14H10.2325C9.83594 14 9.39953 13.4347 10.1526 12.6816ZM4.39 9.35L4.9807 9.9407L2.39762 12.5312H6.63877L7.10501 12.065L7.57125 12.5312H8.88875L15.51 5.91C15.86 5.57 16.05 5.11 16.05 4.62C16.05 4.13 15.86 3.67 15.51 3.33L13.72 1.54C13.38 1.19 12.92 1 12.43 1C11.94 1 11.48 1.2 11.14 1.54L4.39 8.29C4.1 8.58 4.1 9.06 4.39 9.35ZM16 17V15H1V17H16Z" fill="#FBFBFE"/>
|
||||||
|
<path d="M15.1616 5.55136L15.1616 5.55132L15.1564 5.55645L8.40645 12.3064C8.35915 12.3537 8.29589 12.38 8.23 12.38C8.16411 12.38 8.10085 12.3537 8.05355 12.3064L7.45857 11.7115L7.10501 11.3579L6.75146 11.7115L6.03289 12.43H3.20465L5.33477 10.2937L5.6873 9.94019L5.33426 9.58715L4.74355 8.99645C4.64882 8.90171 4.64882 8.73829 4.74355 8.64355L11.4936 1.89355C11.7436 1.64354 12.0779 1.5 12.43 1.5C12.7883 1.5 13.1179 1.63776 13.3614 1.88839L13.3613 1.88843L13.3664 1.89355L15.1564 3.68355L15.1564 3.68359L15.1616 3.68864C15.4122 3.93211 15.55 4.26166 15.55 4.62C15.55 4.97834 15.4122 5.30789 15.1616 5.55136ZM5.48 8.82V9.02711L5.62645 9.17355L7.87645 11.4236L8.23 11.7771L8.58355 11.4236L11.0642 8.94293L11.4177 8.58938L11.0642 8.23582L8.80918 5.98082L8.45562 5.62727L8.10207 5.98082L5.62645 8.45645L5.48 8.60289V8.81V8.82ZM11.4177 7.88227L11.7713 8.23582L12.1248 7.88227L14.8036 5.20355C15.1288 4.87829 15.1288 4.36171 14.8036 4.03645L13.0136 2.24645C12.8186 2.05146 12.5792 2 12.43 2H12.4134L12.3967 2.00111L12.43 2.5C12.3967 2.00111 12.3966 2.00112 12.3965 2.00112L12.3963 2.00114L12.3957 2.00117L12.3947 2.00125L12.3924 2.00142L12.387 2.00184L12.3732 2.00311C12.3628 2.00416 12.3498 2.00567 12.3346 2.00784C12.3049 2.01208 12.2642 2.01925 12.2178 2.03146C12.1396 2.05202 11.9797 2.10317 11.8464 2.23645L9.16273 4.92016L8.80918 5.27372L9.16273 5.62727L11.4177 7.88227ZM1.5 16.5V15.5H15.5V16.5H1.5Z" stroke="#15141A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M11 3H13.6C14 3 14.3 3.3 14.3 3.6C14.3 3.9 14 4.2 13.7 4.2H13.3V14C13.3 15.1 12.4 16 11.3 16H4.80005C3.70005 16 2.80005 15.1 2.80005 14V4.2H2.40005C2.00005 4.2 1.80005 4 1.80005 3.6C1.80005 3.2 2.00005 3 2.40005 3H5.00005V2C5.00005 0.9 5.90005 0 7.00005 0H9.00005C10.1 0 11 0.9 11 2V3ZM6.90005 1.2L6.30005 1.8V3H9.80005V1.8L9.20005 1.2H6.90005ZM11.4 14.7L12 14.1V4.2H4.00005V14.1L4.60005 14.7H11.4ZM7.00005 12.4C7.00005 12.7 6.70005 13 6.40005 13C6.10005 13 5.80005 12.7 5.80005 12.4V7.6C5.70005 7.3 6.00005 7 6.40005 7C6.80005 7 7.00005 7.3 7.00005 7.6V12.4ZM10.2001 12.4C10.2001 12.7 9.90006 13 9.60006 13C9.30006 13 9.00006 12.7 9.00006 12.4V7.6C9.00006 7.3 9.30006 7 9.60006 7C9.90006 7 10.2001 7.3 10.2001 7.6V12.4Z"
|
||||||
|
fill="black" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 909 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M10.999 8.352L5.534 13.818C5.41551 13.9303 5.25786 13.9918 5.09466 13.9895C4.93146 13.9872 4.77561 13.9212 4.66033 13.8057C4.54505 13.6902 4.47945 13.5342 4.47752 13.3709C4.47559 13.2077 4.53748 13.0502 4.65 12.932L9.585 7.998L4.651 3.067C4.53862 2.94864 4.47691 2.79106 4.47903 2.62786C4.48114 2.46466 4.54692 2.30874 4.66233 2.19333C4.77774 2.07792 4.93366 2.01215 5.09686 2.01003C5.26006 2.00792 5.41763 2.06962 5.536 2.182L11 7.647L10.999 8.352Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 578 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M5.001 8.352L10.466 13.818C10.5845 13.9303 10.7421 13.9918 10.9053 13.9895C11.0685 13.9872 11.2244 13.9212 11.3397 13.8057C11.4549 13.6902 11.5205 13.5342 11.5225 13.3709C11.5244 13.2077 11.4625 13.0502 11.35 12.932L6.416 7.999L11.349 3.067C11.4614 2.94864 11.5231 2.79106 11.521 2.62786C11.5189 2.46466 11.4531 2.30874 11.3377 2.19333C11.2223 2.07792 11.0663 2.01215 10.9031 2.01003C10.7399 2.00792 10.5824 2.06962 10.464 2.182L5 7.647L5.001 8.352Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 431 B After Width: | Height: | Size: 578 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.969 10.059C17.262 9.766 17.737 9.766 18.03 10.059C18.323 10.352 18.323 10.827 18.03 11.12L12.15 17H11.35L5.46896 11.12C5.17596 10.827 5.17596 10.352 5.46896 10.059C5.76196 9.766 6.23696 9.766 6.52996 10.059L11 14.529V2.75C11 2.336 11.336 2 11.75 2C12.164 2 12.5 2.336 12.499 2.75V14.529L16.969 10.059ZM4.98193 19.7L5.78193 20.5H17.7169L18.5169 19.7V17.75C18.5169 17.336 18.8529 17 19.2669 17C19.6809 17 20.0169 17.336 20.0169 17.75V19.5C20.0169 20.881 18.8979 22 17.5169 22H5.98193C4.60093 22 3.48193 20.881 3.48193 19.5V17.75C3.48193 17.336 3.81793 17 4.23193 17C4.64593 17 4.98193 17.336 4.98193 17.75V19.7Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 782 B |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
|
|
||||||
fill="rgba(255,255,255,1)" style="animation:spinLoadingIcon 1s steps(12,end)
|
|
||||||
infinite"><style>@keyframes
|
|
||||||
spinLoadingIcon{to{transform:rotate(360deg)}}</style><path
|
|
||||||
d="M7 3V1s0-1 1-1 1 1 1 1v2s0 1-1 1-1-1-1-1z"/><path d="M4.63
|
|
||||||
4.1l-1-1.73S3.13 1.5 4 1c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37
|
|
||||||
1.37c-.87.57-1.37-.37-1.37-.37z" fill-opacity=".93"/><path
|
|
||||||
d="M3.1 6.37l-1.73-1S.5 4.87 1 4c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37
|
|
||||||
1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".86"/><path d="M3
|
|
||||||
9H1S0 9 0 8s1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".79"/><path d="M4.1 11.37l-1.73 1S1.5 12.87 1
|
|
||||||
12c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37 1.37z"
|
|
||||||
fill-opacity=".72"/><path d="M3.63 13.56l1-1.73s.5-.87
|
|
||||||
1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z"
|
|
||||||
fill-opacity=".65"/><path d="M7 15v-2s0-1 1-1 1 1 1 1v2s0 1-1
|
|
||||||
1-1-1-1-1z" fill-opacity=".58"/><path d="M10.63
|
|
||||||
14.56l-1-1.73s-.5-.87.37-1.37c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37
|
|
||||||
1.37c-.87.5-1.37-.37-1.37-.37z" fill-opacity=".51"/><path
|
|
||||||
d="M13.56 12.37l-1.73-1s-.87-.5-.37-1.37c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37
|
|
||||||
1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".44"/><path d="M15
|
|
||||||
9h-2s-1 0-1-1 1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".37"/><path d="M14.56 5.37l-1.73
|
|
||||||
1s-.87.5-1.37-.37c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37
|
|
||||||
1.37z" fill-opacity=".3"/><path d="M9.64 3.1l.98-1.66s.5-.874
|
|
||||||
1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z"
|
|
||||||
fill-opacity=".23"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,15 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5ZM0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8ZM8.75 4V5.5H7.25V4H8.75ZM8.75 12V7H7.25V12H8.75Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
|
|
||||||
16">
|
|
||||||
<path
|
|
||||||
d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z">
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
d="M8 7a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z">
|
|
||||||
</path>
|
|
||||||
<circle
|
|
||||||
cx="8" cy="5" r="1.188">
|
|
||||||
</circle>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 530 B After Width: | Height: | Size: 417 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M13 13c-.3 0-.5-.1-.7-.3L8 8.4l-4.3 4.3c-.9.9-2.3-.5-1.4-1.4l5-5c.4-.4 1-.4 1.4 0l5 5c.6.6.2 1.7-.7 1.7zm0-11H3C1.7 2 1.7 4 3 4h10c1.3 0 1.3-2 0-2z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 3.5H2V5H14V3.5ZM8 8.811L12.939 13.75L14.001 12.689L8.531 7.219C8.238 6.926 7.763 6.926 7.47 7.219L2 12.689L3.061 13.75L8 8.811Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 228 B After Width: | Height: | Size: 260 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M15 3.7V13c0 1.5-1.53 3-3 3H7.13c-.72 0-1.63-.5-2.13-1l-5-5s.84-1 .87-1c.13-.1.33-.2.53-.2.1 0 .3.1.4.2L4 10.6V2.7c0-.6.4-1 1-1s1 .4 1 1v4.6h1V1c0-.6.4-1 1-1s1 .4 1 1v6.3h1V1.7c0-.6.4-1 1-1s1 .4 1 1v5.7h1V3.7c0-.6.4-1 1-1s1 .4 1 1z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.75 2.125C7.75 1.78021 8.03021 1.5 8.375 1.5C8.71979 1.5 9 1.78021 9 2.125V3.125V8H10.5V3.125C10.5 2.78021 10.7802 2.5 11.125 2.5C11.4698 2.5 11.75 2.78021 11.75 3.125V4.625V8H13.25V4.625C13.25 4.28021 13.5302 4 13.875 4C14.2198 4 14.5 4.28021 14.5 4.625V12.0188L13.3802 13.6628C13.2954 13.7872 13.25 13.9344 13.25 14.085V16H14.75V14.3162L15.8698 12.6722C15.9546 12.5478 16 12.4006 16 12.25V4.625C16 3.45179 15.0482 2.5 13.875 2.5C13.6346 2.5 13.4035 2.53996 13.188 2.6136C12.959 1.68724 12.1219 1 11.125 1C10.8235 1 10.5366 1.06286 10.2768 1.17618C9.9281 0.478968 9.20726 0 8.375 0C7.54274 0 6.8219 0.478968 6.47323 1.17618C6.21337 1.06286 5.9265 1 5.625 1C4.45179 1 3.5 1.95179 3.5 3.125V7.25317C2.66504 6.54282 1.41035 6.58199 0.621672 7.37067C-0.208221 8.20056 -0.208221 9.54644 0.621672 10.3763L0.62188 10.3765L5.499 15.2498V16H6.999V14.939C6.999 14.74 6.9199 14.5491 6.77912 14.4085L1.68233 9.31567C1.43823 9.07156 1.43823 8.67544 1.68233 8.43133C1.92644 8.18722 2.32257 8.18722 2.56667 8.43133L3.71967 9.58433C3.93417 9.79883 4.25676 9.863 4.53701 9.74691C4.81727 9.63082 5 9.35735 5 9.054V3.125C5 2.78021 5.28022 2.5 5.625 2.5C5.96921 2.5 6.24906 2.77927 6.25 3.12326V8H7.75L7.75 3.125L7.75 3.12178V2.125Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 1.3 KiB |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 10c-.3 0-.5-.1-.7-.3l-5-5c-.9-.9.5-2.3 1.4-1.4L8 7.6l4.3-4.3c.9-.9 2.3.5 1.4 1.4l-5 5c-.2.2-.4.3-.7.3zm5 2H3c-1.3 0-1.3 2 0 2h10c1.3 0 1.3-2 0-2z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 8.189L12.939 3.25L14 4.311L8.531 9.781C8.238 10.074 7.763 10.074 7.47 9.781L2 4.311L3.061 3.25L8 8.189ZM14 13.5V12H2V13.5H14Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 257 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M1 1a1 1 0 011 1v2.4A7 7 0 118 15a7 7 0 01-4.9-2 1 1 0 011.4-1.5 5 5 0 10-1-5.5H6a1 1 0 010 2H1a1 1 0 01-1-1V2a1 1 0 011-1z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.4105 4.83612L4.77001 6.19601C5.06701 6.49201 4.85701 7.00001 4.43701 7.00001H0.862006C0.602006 7.00001 0.391006 6.78901 0.391006 6.52901V2.95401C0.391006 2.53401 0.899006 2.32401 1.19601 2.62101L2.32796 3.75328C3.67958 1.78973 5.9401 0.5 8.5 0.5C12.636 0.5 16 3.864 16 8C16 12.136 12.636 15.5 8.5 15.5C4.704 15.5 1.566 12.663 1.075 9H2.59C3.068 11.833 5.532 14 8.5 14C11.809 14 14.5 11.309 14.5 8C14.5 4.691 11.809 2 8.5 2C6.35262 2 4.46893 3.13503 3.4105 4.83612Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 204 B After Width: | Height: | Size: 596 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M12.5895 4.83613L11.23 6.19601C10.933 6.49201 11.143 7.00001 11.563 7.00001H15.138C15.398 7.00001 15.609 6.78901 15.609 6.52901V2.95401C15.609 2.53401 15.101 2.32401 14.804 2.62101L13.672 3.75328C12.3204 1.78973 10.0599 0.5 7.5 0.5C3.364 0.5 0 3.864 0 8C0 12.136 3.364 15.5 7.5 15.5C11.296 15.5 14.434 12.663 14.925 9H13.41C12.932 11.833 10.468 14 7.5 14C4.191 14 1.5 11.309 1.5 8C1.5 4.691 4.191 2 7.5 2C9.64738 2 11.5311 3.13503 12.5895 4.83613Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 494 B After Width: | Height: | Size: 576 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 3.78C3 2.7621 2.13279 2.11834 1.25 2.01476V2H1V3.5C1.18133 3.5 1.32279 3.5609 1.40708 3.63029C1.48961 3.69823 1.5 3.75458 1.5 3.78V11.72C1.5 11.7454 1.48961 11.8018 1.40708 11.8697C1.32279 11.9391 1.18133 12 1 12V13.5H1.25V13.4852C2.13279 13.3817 3 12.7379 3 11.72V3.78ZM10.5 4C10.5 3.72386 10.2761 3.5 10 3.5H6.5C6.22386 3.5 6 3.72386 6 4V11.5C6 11.7761 6.22386 12 6.5 12H10C10.2761 12 10.5 11.7761 10.5 11.5V4ZM10 2C11.1046 2 12 2.89543 12 4V11.5C12 12.6046 11.1046 13.5 10 13.5H6.5C5.39543 13.5 4.5 12.6046 4.5 11.5V4C4.5 2.89543 5.39543 2 6.5 2H10ZM15.5 2H15.25V2.01476C14.3672 2.11834 13.5 2.7621 13.5 3.78V11.72C13.5 12.7379 14.3672 13.3817 15.25 13.4852V13.5H15.5V12C15.3187 12 15.1772 11.9391 15.0929 11.8697C15.0104 11.8018 15 11.7454 15 11.72V3.78C15 3.75458 15.0104 3.69823 15.0929 3.63029C15.1772 3.5609 15.3187 3.5 15.5 3.5V2Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 275 B After Width: | Height: | Size: 971 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.5 2C3.5 1.72421 3.72421 1.5 4 1.5H12C12.2758 1.5 12.5 1.72421 12.5 2V14C12.5 14.2758 12.2758 14.5 12 14.5H4C3.72421 14.5 3.5 14.2758 3.5 14V2ZM4 0C2.89579 0 2 0.895786 2 2V14C2 15.1042 2.89579 16 4 16H12C13.1042 16 14 15.1042 14 14V2C14 0.895786 13.1042 0 12 0H4ZM5.89301 6H7.25V10H5.89301C5.54301 10 5.36801 10.423 5.61501 10.67L7.72101 12.776C7.87401 12.929 8.12301 12.929 8.27601 12.776L10.383 10.669C10.63 10.422 10.455 9.99902 10.105 9.99902H8.75V6H10.106C10.456 6 10.632 5.577 10.383 5.331L8.27601 3.224C8.12301 3.071 7.87401 3.071 7.72101 3.224L5.61501 5.33C5.36801 5.577 5.54301 6 5.89301 6Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 171 B After Width: | Height: | Size: 731 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 1V1.25H2.01476C2.11834 2.13279 2.7621 3 3.78 3H11.72C12.7379 3 13.3817 2.13279 13.4852 1.25H13.5V1H12C12 1.18133 11.9391 1.32279 11.8697 1.40708C11.8018 1.48961 11.7454 1.5 11.72 1.5H3.78C3.75458 1.5 3.69823 1.48961 3.63029 1.40708C3.5609 1.32279 3.5 1.18133 3.5 1H2ZM4 6C3.72386 6 3.5 6.22386 3.5 6.5V10C3.5 10.2761 3.72386 10.5 4 10.5H11.5C11.7761 10.5 12 10.2761 12 10V6.5C12 6.22386 11.7761 6 11.5 6H4ZM2 6.5C2 5.39543 2.89543 4.5 4 4.5H11.5C12.6046 4.5 13.5 5.39543 13.5 6.5V10C13.5 11.1046 12.6046 12 11.5 12H4C2.89543 12 2 11.1046 2 10V6.5ZM3.78 13.5C2.7621 13.5 2.11834 14.3672 2.01476 15.25H2V15.5H3.5C3.5 15.3187 3.5609 15.1772 3.63029 15.0929C3.69823 15.0104 3.75458 15 3.78 15H11.72C11.7454 15 11.8018 15.0104 11.8697 15.0929C11.9391 15.1772 12 15.3187 12 15.5H13.5V15.25H13.4852C13.3817 14.3672 12.7379 13.5 11.72 13.5H3.78Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 969 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.5 1C2.5 1.27579 2.72421 1.5 3 1.5H5C5.27579 1.5 5.5 1.27579 5.5 1H7C7 2.10421 6.10421 3 5 3H3C1.89579 3 1 2.10421 1 1H2.5ZM2.5 6C2.5 5.72421 2.72421 5.5 3 5.5H5C5.27579 5.5 5.5 5.72421 5.5 6V10C5.5 10.2758 5.27579 10.5 5 10.5H3C2.72421 10.5 2.5 10.2758 2.5 10V6ZM3 4C1.89579 4 1 4.89579 1 6V10C1 11.1042 1.89579 12 3 12H5C6.10421 12 7 11.1042 7 10V6C7 4.89579 6.10421 4 5 4H3ZM10 6C10 5.72421 10.2242 5.5 10.5 5.5H12.5C12.7758 5.5 13 5.72421 13 6V10C13 10.2758 12.7758 10.5 12.5 10.5H10.5C10.2242 10.5 10 10.2758 10 10V6ZM10.5 4C9.39579 4 8.5 4.89579 8.5 6V10C8.5 11.1042 9.39579 12 10.5 12H12.5C13.6042 12 14.5 11.1042 14.5 10V6C14.5 4.89579 13.6042 4 12.5 4H10.5ZM3 14.5C2.72421 14.5 2.5 14.7242 2.5 15H1C1 13.8958 1.89579 13 3 13H5C6.10421 13 7 13.8958 7 15H5.5C5.5 14.7242 5.27579 14.5 5 14.5H3ZM10 15C10 14.7242 10.2242 14.5 10.5 14.5H12.5C12.7758 14.5 13 14.7242 13 15H14.5C14.5 13.8958 13.6042 13 12.5 13H10.5C9.39579 13 8.5 13.8958 8.5 15H10ZM10.5 1.5C10.2242 1.5 10 1.27579 10 1H8.5C8.5 2.10421 9.39579 3 10.5 3H12.5C13.6042 3 14.5 2.10421 14.5 1H13C13 1.27579 12.7758 1.5 12.5 1.5H10.5Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M0.371588 2.93131C-0.203366 1.33422 1.3342 -0.20335 2.93129 0.371603L2.93263 0.372085L12.0716 3.68171C12.0718 3.68178 12.0714 3.68163 12.0716 3.68171C13.4459 4.17758 13.8478 5.9374 12.8076 6.9776L11.8079 7.97727L14.6876 10.8569C15.4705 11.6398 15.4705 12.9047 14.6876 13.6876L13.6476 14.7276C12.8647 15.5105 11.5998 15.5105 10.8169 14.7276L7.93725 11.8479L6.97758 12.8076C5.93739 13.8478 4.17779 13.4465 3.68192 12.0722C3.68184 12.072 3.682 12.0724 3.68192 12.0722L0.371588 2.93131ZM1.78292 2.42323C1.78298 2.4234 1.78286 2.42305 1.78292 2.42323L5.09281 11.5629C5.21725 11.9082 5.65728 12.0066 5.91692 11.7469L7.93725 9.72661L11.8776 13.6669C12.0747 13.864 12.3898 13.864 12.5869 13.6669L13.6269 12.6269C13.824 12.4298 13.824 12.1147 13.6269 11.9176L9.68659 7.97727L11.7469 5.91694C12.0066 5.65729 11.9081 5.21727 11.5629 5.09283L11.5619 5.09245L2.42321 1.78293C2.42304 1.78287 2.42339 1.783 2.42321 1.78293C2.02067 1.63847 1.63846 2.02069 1.78292 2.42323Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M12.408 8.217l-8.083-6.7A.2.2 0 0 0 4 1.672V12.3a.2.2 0 0 0 .333.146l2.56-2.372 1.857 3.9A1.125 1.125 0 1 0 10.782 13L8.913 9.075l3.4-.51a.2.2 0 0 0 .095-.348z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 478 B After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1.5 3.5C.5 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm2 1.2c.8 0 1.4.2 1.8.6.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.4-.2.3-.5.7-1 1l-.6.4c-.4.3-.6.4-.75.56-.15.14-.25.24-.35.44H6v1.3H1c0-.6.1-1.1.3-1.5.3-.6.7-1 1.5-1.6.7-.4 1.1-.8 1.28-1 .32-.3.42-.6.42-1 0-.3-.1-.6-.23-.8-.17-.2-.37-.3-.77-.3s-.7.1-.9.5c-.04.2-.1.5-.1.9H1.1c0-.6.1-1.1.3-1.5.4-.7 1.1-1.1 2.1-1.1zM10.54 3.54C9.5 3.54 9 4 9 5v6.5c0 1 .5 1.5 1.54 1.5h4c.96 0 1.46-.5 1.46-1.5V5c0-1-.5-1.46-1.5-1.46zm1.9.95c.7 0 1.3.2 1.7.5.4.4.6.8.6 1.4 0 .4-.1.8-.4 1.1-.2.2-.3.3-.5.4.1 0 .3.1.6.3.4.3.5.8.5 1.4 0 .6-.2 1.2-.6 1.6-.4.5-1.1.7-1.9.7-1 0-1.8-.3-2.2-1-.14-.29-.24-.69-.24-1.29h1.4c0 .3 0 .5.1.7.2.4.5.5 1 .5.3 0 .5-.1.7-.3.2-.2.3-.5.3-.8 0-.5-.2-.8-.6-.95-.2-.05-.5-.15-1-.15v-1c.5 0 .8-.1 1-.14.3-.1.5-.4.5-.9 0-.3-.1-.5-.2-.7-.2-.2-.4-.3-.7-.3-.3 0-.6.1-.75.3-.2.2-.2.5-.2.86h-1.34c0-.4.1-.7.19-1.1 0-.12.2-.32.4-.62.2-.2.4-.3.7-.4.3-.1.6-.1 1-.1z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M2 3.5C1.72421 3.5 1.5 3.72421 1.5 4V12.5C1.5 12.7758 1.72421 13 2 13H7.25V3.5H2ZM14 13H8.75V3.5H14C14.2758 3.5 14.5 3.72421 14.5 4V12.5C14.5 12.7758 14.2758 13 14 13ZM0 4C0 2.89579 0.895786 2 2 2H14C15.1042 2 16 2.89579 16 4V12.5C16 13.6042 15.1042 14.5 14 14.5H2C0.895786 14.5 0 13.6042 0 12.5V4ZM10 6.5H11.5V7.5H10V9H11.5V10H10V11.5H12.25C12.6642 11.5 13 11.1642 13 10.75V5.75C13 5.33579 12.6642 5 12.25 5H10V6.5ZM4.5 6.5H3V5H5.25C5.66421 5 6 5.33579 6 5.75V7.75C6 8.03408 5.8395 8.29378 5.58541 8.42082L4.5 8.96353V10H6V11.5H3.75C3.33579 11.5 3 11.1642 3 10.75V8.5C3 8.21592 3.1605 7.95622 3.41459 7.82918L4.5 7.28647V6.5Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1022 B After Width: | Height: | Size: 775 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M6 3c-1 0-1.5.5-1.5 1.5v7c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5v-7c0-1-.5-1.5-1.5-1.5z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 1.5C3.72421 1.5 3.5 1.72421 3.5 2V14C3.5 14.2758 3.72421 14.5 4 14.5H12C12.2758 14.5 12.5 14.2758 12.5 14V2C12.5 1.72421 12.2758 1.5 12 1.5H4ZM2 2C2 0.895786 2.89579 0 4 0H12C13.1042 0 14 0.895786 14 2V14C14 15.1042 13.1042 16 12 16H4C2.89579 16 2 15.1042 2 14V2Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 169 B After Width: | Height: | Size: 395 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M10.56 3.5C9.56 3.5 9 4 9 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.93 1.2c.8 0 1.4.2 1.8.64.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.44-.2.3-.6.6-1 .93l-.6.4c-.4.3-.6.4-.7.55-.1.1-.2.2-.3.4h3.2v1.27h-5c0-.5.1-1 .3-1.43.2-.49.7-1 1.5-1.54.7-.5 1.1-.8 1.3-1.02.3-.3.4-.7.4-1.05 0-.3-.1-.6-.3-.77-.2-.2-.4-.3-.7-.3-.4 0-.7.2-.9.5-.1.2-.1.5-.2.9h-1.4c0-.6.2-1.1.3-1.5.4-.7 1.1-1.1 2-1.1zM1.54 3.5C.54 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.54 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.8 1.125H4.5V12H3V6.9H1.3v-1c.5 0 .8 0 .97-.03.33-.07.53-.17.73-.37.1-.2.2-.3.25-.5.05-.2.05-.3.05-.3z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.5 4C1.5 3.72421 1.72421 3.5 2 3.5H7.25V13H2C1.72421 13 1.5 12.7758 1.5 12.5V4ZM8.75 13V3.5H14C14.2758 3.5 14.5 3.72421 14.5 4V12.5C14.5 12.7758 14.2758 13 14 13H8.75ZM2 2C0.895786 2 0 2.89579 0 4V12.5C0 13.6042 0.895786 14.5 2 14.5H14C15.1042 14.5 16 13.6042 16 12.5V4C16 2.89579 15.1042 2 14 2H2ZM4.75 5H3V6.5H4V11.5H5.5V5.75C5.5 5.33579 5.16421 5 4.75 5ZM10 6.5H11.5V7.28647L10.4146 7.82918C10.1605 7.95622 10 8.21592 10 8.5V10.75C10 11.1642 10.3358 11.5 10.75 11.5H13V10H11.5V8.96353L12.5854 8.42082C12.8395 8.29378 13 8.03408 13 7.75V5.75C13 5.33579 12.6642 5 12.25 5H10V6.5Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 678 B After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 269 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M4 16V2s0-1 1-1h6s1 0 1 1v14l-4-5z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 3.5C1.72421 3.5 1.5 3.72421 1.5 4V12C1.5 12.2758 1.72421 12.5 2 12.5H14C14.2758 12.5 14.5 12.2758 14.5 12V4C14.5 3.72421 14.2758 3.5 14 3.5H2ZM0 4C0 2.89579 0.895786 2 2 2H14C15.1042 2 16 2.89579 16 4V12C16 13.1042 15.1042 14 14 14H2C0.895786 14 0 13.1042 0 12V4ZM8.75 8.75H7.25V7.25H8.75V8.75ZM8.00001 4.625C5.91142 4.625 4.14736 5.94291 3.45159 7.77847L3.36761 8L3.45159 8.22153C4.14736 10.0571 5.91142 11.375 8.00001 11.375C10.0886 11.375 11.8527 10.0571 12.5484 8.22153L12.6324 8L12.5484 7.77847C11.8527 5.94291 10.0886 4.625 8.00001 4.625ZM8.00001 10.125C6.53912 10.125 5.28508 9.25455 4.71282 8C5.28508 6.74545 6.53912 5.875 8.00001 5.875C9.4609 5.875 10.7149 6.74545 11.2872 8C10.7149 9.25455 9.4609 10.125 8.00001 10.125Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 115 B After Width: | Height: | Size: 861 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="m14 9h-6c-1.3 0-1.3 2 0 2h6c1.3 0 1.3-2 0-2zm-5.2-8h-3.8c-1.3 0-1.3 2 0 2h1.7zm-6.8 0c-1 0-1.3 1-0.7 1.7 0.7 0.6 1.7 0.3 1.7-0.7 0-0.5-0.4-1-1-1zm3 8c-1 0-1.3 1-0.7 1.7 0.6 0.6 1.7 0.2 1.7-0.7 0-0.5-0.4-1-1-1zm0.3-4h-0.3c-1.4 0-1.4 2 0 2h2.3zm-3.3 0c-0.9 0-1.4 1-0.7 1.7 0.7 0.6 1.7 0.2 1.7-0.7 0-0.6-0.5-1-1-1zm12 8h-9c-1.3 0-1.3 2 0 2h9c1.3 0 1.3-2 0-2zm-12 0c-1 0-1.3 1-0.7 1.7 0.7 0.6 1.7 0.2 1.7-0.712 0-0.5-0.4-1-1-1z"/><path d="m7.37 4.838 3.93-3.911v2.138h3.629v3.546h-3.629v2.138l-3.93-3.911"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.803 4.74998V6.02436C10.803 6.39302 10.3571 6.57793 10.0967 6.31753L7.87716 4.098C7.71566 3.93649 7.71566 3.67434 7.87716 3.51283L10.0967 1.29329C10.3571 1.0329 10.8036 1.21722 10.8036 1.58588V3.24998H15V4.74998H10.803ZM8 1.24998H3V2.74998H6.5L8 1.24998ZM6.5 5.24998H3V6.74998H8L6.5 5.24998ZM3 13.25H15V14.75H3V13.25ZM6 9.24998H15V10.75H6V9.24998ZM1.5 5.24998H0V6.74998H1.5V5.24998ZM0 13.25H1.5V14.75H0V13.25ZM1.5 1.24998H0V2.74998H1.5V1.24998ZM3 9.24998H4.5V10.75H3V9.24998Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 581 B After Width: | Height: | Size: 607 B |
@@ -1,4 +1,4 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M9.79407 7.31811H7.86307C7.41807 7.31811 7.19407 7.85711 7.50907 8.17211L10.1911 10.8541C10.3861 11.0491 10.7031 11.0491 10.8981 10.8541L13.5801 8.17211C13.8951 7.85711 13.6721 7.31811 13.2261 7.31811H11.2941V4.38211H11.2961V3.13211H11.2941V2.30811H9.79407V3.13211H9.79107V4.38211H9.79507V7.31811H9.79407Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
<path d="M14 3.13208H12.796V4.38208H14C14.345 4.38208 14.625 4.66208 14.625 5.00708V13.0071C14.625 13.3521 14.345 13.6321 14 13.6321H2C1.655 13.6321 1.375 13.3521 1.375 13.0071V3.00708C1.375 2.66208 1.655 2.38208 2 2.38208H5.643C5.82 2.38208 5.989 2.45808 6.108 2.58908L7.536 4.17508C7.654 4.30708 7.823 4.38208 8 4.38208H8.291V3.13208H8.278L7.036 1.75208C6.681 1.35808 6.173 1.13208 5.642 1.13208H2C0.966 1.13208 0.125 1.97308 0.125 3.00708V13.0071C0.125 14.0411 0.966 14.8821 2 14.8821H14C15.034 14.8821 15.875 14.0411 15.875 13.0071V5.00708C15.875 3.97308 15.034 3.13208 14 3.13208Z" fill="black"/>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14 3h-2v2h2v8H2V5h7V3h-.849L6.584 1.538A2 2 0 0 0 5.219 1H2a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM2 3h3.219l1.072 1H2z"></path><path d="M8.146 6.146a.5.5 0 0 0 0 .707l2 2a.5.5 0 0 0 .707 0l2-2a.5.5 0 1 0-.707-.707L11 7.293V.5a.5.5 0 0 0-1 0v6.793L8.854 6.146a.5.5 0 0 0-.708 0z"></path></svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 1.0 KiB |
@@ -1,24 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- copied from https://www.svgrepo.com/svg/255881/text -->
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 2.5C2.72421 2.5 2.5 2.72421 2.5 3V4.75H1V3C1 1.89579 1.89579 1 3 1H13C14.1042 1 15 1.89579 15 3V4.75H13.5V3C13.5 2.72421 13.2758 2.5 13 2.5H3Z" fill="black"/>
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 15H5V13.5H11V15Z" fill="black"/>
|
||||||
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.75 2.25V14.25H7.25V2.25H8.75Z" fill="black"/>
|
||||||
<g>
|
|
||||||
<g transform="scale(0.03125)">
|
|
||||||
<path d="M405.787,43.574H8.17c-4.513,0-8.17,3.658-8.17,8.17v119.83c0,4.512,3.657,8.17,8.17,8.17h32.681
|
|
||||||
c4.513,0,8.17-3.658,8.17-8.17v-24.511h95.319v119.83c0,4.512,3.657,8.17,8.17,8.17c4.513,0,8.17-3.658,8.17-8.17v-128
|
|
||||||
c0-4.512-3.657-8.17-8.17-8.17H40.851c-4.513,0-8.17,3.658-8.17,8.17v24.511H16.34V59.915h381.277v103.489h-16.34v-24.511
|
|
||||||
c0-4.512-3.657-8.17-8.17-8.17h-111.66c-4.513,0-8.17,3.658-8.17,8.17v288.681c0,4.512,3.657,8.17,8.17,8.17h57.191v16.34H95.319
|
|
||||||
v-16.34h57.191c4.513,0,8.17-3.658,8.17-8.17v-128c0-4.512-3.657-8.17-8.17-8.17c-4.513,0-8.17,3.658-8.17,8.17v119.83H87.149
|
|
||||||
c-4.513,0-8.17,3.658-8.17,8.17v32.681c0,4.512,3.657,8.17,8.17,8.17h239.66c4.513,0,8.17-3.658,8.17-8.17v-32.681
|
|
||||||
c0-4.512-3.657-8.17-8.17-8.17h-57.192v-272.34h95.319v24.511c0,4.512,3.657,8.17,8.17,8.17h32.681c4.513,0,8.17-3.658,8.17-8.17
|
|
||||||
V51.745C413.957,47.233,410.3,43.574,405.787,43.574z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<g transform="scale(0.03125)">
|
|
||||||
<path d="M503.83,452.085h-24.511V59.915h24.511c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17h-65.362
|
|
||||||
c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17h24.511v392.17h-24.511c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17
|
|
||||||
h65.362c4.513,0,8.17-3.658,8.17-8.17S508.343,452.085,503.83,452.085z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 498 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.10918 11.66C7.24918 11.8 7.43918 11.88 7.63918 11.88C7.83918 11.88 8.02918 11.8 8.16918 11.66L14.9192 4.91C15.2692 4.57 15.4592 4.11 15.4592 3.62C15.4592 3.13 15.2692 2.67 14.9192 2.33L13.1292 0.54C12.7892 0.19 12.3292 0 11.8392 0C11.3492 0 10.8892 0.2 10.5492 0.54L3.79918 7.29C3.50918 7.58 3.50918 8.06 3.79918 8.35L4.38988 8.9407L1.40918 11.93H5.64918L6.51419 11.065L7.10918 11.66ZM7.63918 10.07L5.38918 7.82V7.81L7.8648 5.33438L10.1198 7.58938L7.63918 10.07ZM11.1805 6.52872L13.8592 3.85C13.9892 3.72 13.9892 3.52 13.8592 3.39L12.0692 1.6C11.9892 1.52 11.8892 1.5 11.8392 1.5C11.8392 1.5 11.6892 1.51 11.6092 1.59L8.92546 4.27372L11.1805 6.52872Z" fill="#000"/>
|
||||||
|
<path d="M0.40918 14H15.4092V16H0.40918V14Z" fill="#000"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 910 B |
@@ -1,9 +1,4 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
|
<path d="M2.49913 12.6251C2.61913 12.6251 2.73913 12.6051 2.85713 12.5661L6.29013 11.4201L13.2891 4.4221C14.0191 3.6911 14.0191 2.5011 13.2891 1.7701L12.2291 0.710098C11.4971 -0.0199023 10.3091 -0.0199023 9.57713 0.710098L2.57813 7.7091L1.43313 11.1451C1.29813 11.5511 1.40213 11.9931 1.70513 12.2951C1.92113 12.5101 2.20613 12.6251 2.49913 12.6251ZM10.4611 1.5951C10.7031 1.3511 11.1021 1.3511 11.3441 1.5951L12.4051 2.6561C12.6491 2.8991 12.6491 3.2961 12.4051 3.5391L11.3401 4.6051L9.39513 2.6601L10.4611 1.5951ZM3.67013 8.3851L8.51013 3.5451L10.4541 5.4891L5.61413 10.3301L2.69713 11.3031L3.67013 8.3851Z" fill="black"/>
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 16 16">
|
<path d="M14.8169 13.314L13.0229 13.862C12.3309 14.073 11.5909 14.111 10.8859 13.968L8.80391 13.551C7.58491 13.308 6.29791 13.48 5.18491 14.036C3.95291 14.652 2.46691 14.412 1.49191 13.436L1.44091 13.385L0.60791 14.321C1.46291 15.175 2.59991 15.625 3.75291 15.625C4.42891 15.625 5.10991 15.471 5.74391 15.153C6.60891 14.721 7.60891 14.586 8.55891 14.777L10.6409 15.194C11.5509 15.376 12.5009 15.327 13.3879 15.056L15.1819 14.508L14.8169 13.314Z" fill="black"/>
|
||||||
<g>
|
|
||||||
<g transform="scale(0.03125)">
|
|
||||||
<path d="m455.1,137.9l-32.4,32.4-81-81.1 32.4-32.4c6.6-6.6 18.1-6.6 24.7,0l56.3,56.4c6.8,6.8 6.8,17.9 0,24.7zm-270.7,271l-81-81.1 209.4-209.7 81,81.1-209.4,209.7zm-99.7-42l60.6,60.7-84.4,23.8 23.8-84.5zm399.3-282.6l-56.3-56.4c-11-11-50.7-31.8-82.4,0l-285.3,285.5c-2.5,2.5-4.3,5.5-5.2,8.9l-43,153.1c-2,7.1 0.1,14.7 5.2,20 5.2,5.3 15.6,6.2 20,5.2l153-43.1c3.4-0.9 6.4-2.7 8.9-5.2l285.1-285.5c22.7-22.7 22.7-59.7 0-82.5z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 804 B After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="black">
|
||||||
|
<path d="M3 1a2 2 0 0 0-2 2l0 10a2 2 0 0 0 2 2l10 0a2 2 0 0 0 2-2l0-10a2 2 0 0 0-2-2L3 1zm10.75 12.15-.6.6-10.3 0-.6-.6 0-10.3.6-.6 10.3 0 .6.6 0 10.3z"/>
|
||||||
|
<path d="m11 12-6 0a1 1 0 0 1-1-1l0-1.321a.75.75 0 0 1 .218-.529L6.35 7.005a.75.75 0 0 1 1.061-.003l2.112 2.102.612-.577a.75.75 0 0 1 1.047.017l.6.605a.75.75 0 0 1 .218.529L12 11a1 1 0 0 1-1 1z"/>
|
||||||
|
<path d="m11.6 5-1.2 0-.4.4 0 1.2.4.4 1.2 0 .4-.4 0-1.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 734 B |
@@ -1 +1,3 @@
|
|||||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M8 11a1 1 0 01-.707-.293l-2.99-2.99c-.91-.942.471-2.324 1.414-1.414L8 8.586l2.283-2.283c.943-.91 2.324.472 1.414 1.414l-2.99 2.99A1 1 0 018 11z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.23336 10.4664L11.8474 6.85339C11.894 6.8071 11.931 6.75203 11.9563 6.69136C11.9816 6.63069 11.9946 6.56562 11.9946 6.49989C11.9946 6.43417 11.9816 6.3691 11.9563 6.30843C11.931 6.24776 11.894 6.19269 11.8474 6.14639C11.7536 6.05266 11.6264 6 11.4939 6C11.3613 6 11.2341 6.05266 11.1404 6.14639L7.99236 9.29339L4.84736 6.14739C4.75305 6.05631 4.62675 6.00592 4.49566 6.00706C4.36456 6.0082 4.23915 6.06078 4.14645 6.15348C4.05374 6.24619 4.00116 6.37159 4.00002 6.50269C3.99888 6.63379 4.04928 6.76009 4.14036 6.85439L7.75236 10.4674L8.23336 10.4664Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 224 B After Width: | Height: | Size: 681 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M10.4287 1.08398C10.5111 1.02905 10.608 0.999824 10.707 1H14.7L15 1.3V5.293C15 5.39194 14.9706 5.48864 14.9156 5.57088C14.8606 5.65311 14.7824 5.71718 14.691 5.75498C14.5996 5.79277 14.499 5.80259 14.402 5.78319C14.3049 5.76379 14.2159 5.71605 14.146 5.646L12.973 4.473L12.692 4.192L9.067 7.817C8.94923 7.93347 8.79034 7.99888 8.6247 7.99907C8.45907 7.99925 8.30003 7.93421 8.182 7.818C8.06518 7.70036 7.99962 7.54129 7.99962 7.3755C7.99962 7.20971 8.06518 7.05065 8.182 6.933L11.807 3.308L10.353 1.854C10.2829 1.78407 10.2351 1.6949 10.2158 1.59779C10.1964 1.50068 10.2063 1.40001 10.2442 1.30854C10.2821 1.21707 10.3464 1.13891 10.4287 1.08398ZM7.81694 2.06694C7.69973 2.18415 7.54076 2.25 7.375 2.25H2.85L2.25 2.85V13.15L2.85 13.75H13.15L13.75 13.15V8.625C13.75 8.45924 13.8158 8.30027 13.9331 8.18306C14.0503 8.06585 14.2092 8 14.375 8C14.5408 8 14.6997 8.06585 14.8169 8.18306C14.9342 8.30027 15 8.45924 15 8.625V13C15 13.5304 14.7893 14.0391 14.4142 14.4142C14.0391 14.7893 13.5304 15 13 15H3C2.46957 15 1.96086 14.7893 1.58579 14.4142C1.21071 14.0391 1 13.5304 1 13V3C1 2.46957 1.21071 1.96086 1.58579 1.58579C1.96086 1.21071 2.46957 1 3 1H7.375C7.54076 1 7.69973 1.06585 7.81694 1.18306C7.93415 1.30027 8 1.45924 8 1.625C8 1.79076 7.93415 1.94973 7.81694 2.06694Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14.859 3.2a1.335 1.335 0 0 1-1.217.8H13v1h1v8H2V5h8V4h-.642a1.365 1.365 0 0 1-1.325-1.11L6.584 1.538A2 2 0 0 0 5.219 1H2a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5a2 2 0 0 0-1.141-1.8zM2 3h3.219l1.072 1H2zm7.854-.146L11 1.707V8.5a.5.5 0 0 0 1 0V1.707l1.146 1.146a.5.5 0 1 0 .707-.707l-2-2a.5.5 0 0 0-.707 0l-2 2a.5.5 0 0 0 .707.707z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 1.4 KiB |
@@ -1,7 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M8.35176 10.9989L13.8178 5.53391C13.876 5.47594 13.9222 5.40702 13.9537 5.33113C13.9851 5.25524 14.0013 5.17387 14.0012 5.0917C14.0011 5.00954 13.9848 4.9282 13.9531 4.85238C13.9215 4.77656 13.8751 4.70775 13.8168 4.64991C13.6991 4.53309 13.5401 4.46753 13.3743 4.46753C13.2085 4.46753 13.0494 4.53309 12.9318 4.64991L7.99776 9.58491L3.06776 4.65091C2.9494 4.53853 2.79183 4.47682 2.62863 4.47894C2.46542 4.48106 2.3095 4.54683 2.19409 4.66224C2.07868 4.77765 2.01291 4.93357 2.01079 5.09677C2.00868 5.25997 2.07039 5.41754 2.18276 5.53591L7.64776 10.9999L8.35176 10.9989Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
|
|
||||||
16"><path transform='rotate(90) translate(0, -16)'
|
|
||||||
d="M15.707 7.293l-6-6a1 1 0 0 0-1.414 1.414L12.586 7H1a1 1 0 0 0 0 2h11.586l-4.293
|
|
||||||
4.293a1 1 0 1 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 490 B After Width: | Height: | Size: 701 B |
@@ -1,12 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M8.35179 5.001L13.8178 10.466C13.876 10.524 13.9222 10.5929 13.9537 10.6688C13.9852 10.7447 14.0013 10.826 14.0012 10.9082C14.0011 10.9904 13.9848 11.0717 13.9531 11.1475C13.9215 11.2234 13.8751 11.2922 13.8168 11.35C13.6991 11.4668 13.5401 11.5324 13.3743 11.5324C13.2085 11.5324 13.0494 11.4668 12.9318 11.35L7.99879 6.416L3.06679 11.349C2.94842 11.4614 2.79085 11.5231 2.62765 11.521C2.46445 11.5189 2.30853 11.4531 2.19312 11.3377C2.07771 11.2223 2.01193 11.0663 2.00982 10.9031C2.0077 10.7399 2.06941 10.5824 2.18179 10.464L7.64779 5L8.35179 5.001Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
|
|
||||||
16">
|
|
||||||
<path
|
|
||||||
transform='rotate(90) translate(0, -16)'
|
|
||||||
d="M15 7H3.414l4.293-4.293a1 1 0 0
|
|
||||||
0-1.414-1.414l-6 6a1 1 0 0 0 0 1.414l6 6a1 1 0 0 0 1.414-1.414L3.414 9H15a1 1 0 0
|
|
||||||
0 0-2z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 490 B After Width: | Height: | Size: 682 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M.5 1H7s0-1 1-1 1 1 1 1h6.5s.5 0 .5.5-.5.5-.5.5H.5S0 2 0 1.5.5 1 .5 1zM1 3h14v7c0 2-1 2-2 2H3c-1 0-2 0-2-2zm5 1v7l6-3.5zM3.72 15.33l.53-2s0-.5.65-.35c.51.13.38.63.38.63l-.53 2s0 .5-.64.35c-.53-.13-.39-.63-.39-.63zM11.24 15.61l-.53-1.99s0-.5.38-.63c.51-.13.64.35.64.35l.53 2s0 .5-.38.63c-.5.13-.64-.35-.65-.35z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.5 3C1.5 2.72421 1.72421 2.5 2 2.5H14C14.2758 2.5 14.5 2.72421 14.5 3V11C14.5 11.2758 14.2758 11.5 14 11.5H2C1.72421 11.5 1.5 11.2758 1.5 11V3ZM2 1C0.895786 1 0 1.89579 0 3V11C0 12.1042 0.895786 13 2 13H2.64979L1.35052 15.2499L2.64949 16L4.38194 13H11.6391L13.3715 16L14.6705 15.2499L13.3712 13H14C15.1042 13 16 12.1042 16 11V3C16 1.89579 15.1042 1 14 1H2ZM5.79501 4.64401V9.35601C5.79501 9.85001 6.32901 10.159 6.75701 9.91401L10.88 7.55801C11.312 7.31201 11.312 6.68901 10.88 6.44201L6.75701 4.08601C6.32801 3.84101 5.79501 4.15001 5.79501 4.64401Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 390 B After Width: | Height: | Size: 681 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M13 4H12V2C12 1.46957 11.7893 0.960859 11.4142 0.585786C11.0391 0.210714 10.5304 0 10 0L6 0C5.46957 0 4.96086 0.210714 4.58579 0.585786C4.21071 0.960859 4 1.46957 4 2V4H3C2.46957 4 1.96086 4.21071 1.58579 4.58579C1.21071 4.96086 1 5.46957 1 6V11C1 11.5304 1.21071 12.0391 1.58579 12.4142C1.96086 12.7893 2.46957 13 3 13H4V14C4 14.5304 4.21071 15.0391 4.58579 15.4142C4.96086 15.7893 5.46957 16 6 16H10C10.5304 16 11.0391 15.7893 11.4142 15.4142C11.7893 15.0391 12 14.5304 12 14V13H13C13.5304 13 14.0391 12.7893 14.4142 12.4142C14.7893 12.0391 15 11.5304 15 11V6C15 5.46957 14.7893 4.96086 14.4142 4.58579C14.0391 4.21071 13.5304 4 13 4V4ZM10.75 14.15L10.15 14.75H5.85L5.25 14.15V10H10.75V14.15ZM10.75 4H5.25V1.85L5.85 1.25H10.15L10.75 1.85V4V4ZM13 7.6L12.6 8H11.4L11 7.6V6.4L11.4 6H12.6L13 6.4V7.6Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14 5h-1V1a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v4H2a2 2 0 0 0-2 2v5h3v3a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-3h3V7a2 2 0 0 0-2-2zM2.5 8a.5.5 0 1 1 .5-.5.5.5 0 0 1-.5.5zm9.5 7H4v-5h8zm0-10H4V1h8zm-6.5 7h4a.5.5 0 0 0 0-1h-4a.5.5 0 1 0 0 1zm0 2h5a.5.5 0 0 0 0-1h-5a.5.5 0 1 0 0 1z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 583 B After Width: | Height: | Size: 927 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M10.089 10.973L13.934 14.817C13.9918 14.8754 14.0605 14.9218 14.1364 14.9534C14.2122 14.9851 14.2936 15.0013 14.3757 15.0012C14.4579 15.0011 14.5392 14.9847 14.6149 14.9529C14.6907 14.9211 14.7594 14.8746 14.817 14.816C14.875 14.7579 14.921 14.6889 14.9523 14.613C14.9836 14.5372 14.9997 14.4559 14.9996 14.3738C14.9995 14.2917 14.9833 14.2104 14.9518 14.1346C14.9203 14.0588 14.8741 13.99 14.816 13.932L10.983 10.1L10.989 9.67299C11.489 8.96674 11.8152 8.15249 11.9413 7.29642C12.0674 6.44034 11.9897 5.5666 11.7145 4.74621C11.4394 3.92582 10.9745 3.18192 10.3578 2.57498C9.74104 1.96804 8.98979 1.51519 8.16509 1.25322C7.34039 0.991255 6.46551 0.927572 5.61157 1.06735C4.75763 1.20712 3.94871 1.54641 3.25057 2.05764C2.55243 2.56887 1.98476 3.23761 1.59371 4.0095C1.20265 4.7814 0.999236 5.63468 1 6.49999C1 7.95868 1.57946 9.35763 2.61091 10.3891C3.64236 11.4205 5.04131 12 6.5 12C7.689 12 8.788 11.62 9.687 10.978L10.089 10.973V10.973ZM6.5 10.75C4.157 10.75 2.25 8.84299 2.25 6.49999C2.25 4.15699 4.157 2.24999 6.5 2.24999C8.843 2.24999 10.75 4.15699 10.75 6.49999C10.75 8.84299 8.843 10.75 6.5 10.75Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M15.707 14.293l-4.822-4.822a6.019 6.019 0 1 0-1.414 1.414l4.822 4.822a1 1 0 0 0 1.414-1.414zM6 10a4 4 0 1 1 4-4 4 4 0 0 1-4 4z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M2.53406 13.818L7.99906 8.35203L8.00006 7.64703L2.53606 2.18203C2.41769 2.06965 2.26012 2.00795 2.09692 2.01006C1.93372 2.01218 1.7778 2.07795 1.66239 2.19336C1.54698 2.30877 1.48121 2.46469 1.47909 2.62789C1.47697 2.79109 1.53868 2.94867 1.65106 3.06703L6.58506 7.99803L1.65006 12.932C1.53754 13.0503 1.47565 13.2078 1.47758 13.371C1.47951 13.5342 1.54511 13.6902 1.66039 13.8057C1.77567 13.9213 1.93152 13.9872 2.09472 13.9895C2.25792 13.9918 2.41557 13.9303 2.53406 13.818ZM8.53406 13.818L13.9991 8.35203L14.0001 7.64703L8.53606 2.18203C8.4177 2.06965 8.26012 2.00795 8.09692 2.01006C7.93372 2.01218 7.7778 2.07795 7.66239 2.19336C7.54698 2.30877 7.48121 2.46469 7.47909 2.62789C7.47697 2.79109 7.53868 2.94867 7.65106 3.06703L12.5851 7.99803L7.65006 12.932C7.53754 13.0503 7.47565 13.2078 7.47758 13.371C7.47951 13.5342 7.54511 13.6902 7.66039 13.8057C7.77567 13.9213 7.93152 13.9872 8.09472 13.9895C8.25792 13.9918 8.41557 13.9303 8.53406 13.818Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.707 7.293l-5-5a1 1 0 0 0-1.414 1.414L6.586 8l-4.293 4.293a1 1 0 1 0 1.414 1.414l5-5a1 1 0 0 0 0-1.414zm6 0l-5-5a1 1 0 0 0-1.414 1.414L12.586 8l-4.293 4.293a1 1 0 1 0 1.414 1.414l5-5a1 1 0 0 0 0-1.414z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 522 B After Width: | Height: | Size: 1.1 KiB |