Compare commits
544 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776eeccc67 | ||
|
|
91e36b2c7e | ||
|
|
4fe28028ea | ||
|
|
e2c88b3a33 | ||
|
|
f58c8eb916 | ||
|
|
6f5af36ecf | ||
|
|
7c1d372086 | ||
|
|
37b9ee9b0d | ||
|
|
bcfdeacf15 | ||
|
|
cb588c2c82 | ||
|
|
0b777c47bb | ||
|
|
4dce8abb1d | ||
|
|
f27d62e22c | ||
|
|
0eb8583dce | ||
|
|
6f969ed6a6 | ||
|
|
707c41c48a | ||
|
|
054256067a | ||
|
|
8c0891a078 | ||
|
|
cc0fbd7ce8 | ||
|
|
50be750460 | ||
|
|
5127061775 | ||
|
|
0d2611c8a0 | ||
|
|
521b46e60d | ||
|
|
36a99b0b5c | ||
|
|
4e67a56ed2 | ||
|
|
2b2ccb1710 | ||
|
|
4583fc8023 | ||
|
|
2c9d4d2487 | ||
|
|
af09b50153 | ||
|
|
b8c3674be9 | ||
|
|
65a29ed4c9 | ||
|
|
854a7d020d | ||
|
|
a215b73e39 | ||
|
|
6bfe022de0 | ||
|
|
c6cde961b6 | ||
|
|
86e6161b89 | ||
|
|
646937272a | ||
|
|
6dd7a023f3 | ||
|
|
f0077e73a8 | ||
|
|
e485f10e9e | ||
|
|
81a0b15a3d | ||
|
|
89577914ba | ||
|
|
c289cb5d4b | ||
|
|
80bcfb12de | ||
|
|
46f111cd6e | ||
|
|
3d9594cfaa | ||
|
|
bc0e218e73 | ||
|
|
f9267ba391 | ||
|
|
eb02c9e5d4 | ||
|
|
22a93a28e2 | ||
|
|
1ee49791f4 | ||
|
|
90bccb86ef | ||
|
|
ff6b341c36 | ||
|
|
9d3b2b007b | ||
|
|
efc7ce31d2 | ||
|
|
4ccc2c0aec | ||
|
|
4512ab4524 | ||
|
|
0ef9e46133 | ||
|
|
88a06deb8c | ||
|
|
e4f1ce9c5a | ||
|
|
f5048ee1a8 | ||
|
|
e7c2a85fd4 | ||
|
|
ee7f393e67 | ||
|
|
71686e4490 | ||
|
|
ea1ba54a9d | ||
|
|
659535f5de | ||
|
|
d977d1b81b | ||
|
|
01215aaa68 | ||
|
|
ed600a9aeb | ||
|
|
e542151652 | ||
|
|
e49402649d | ||
|
|
21989c3ccf | ||
|
|
42924d9508 | ||
|
|
f9390ceea5 | ||
|
|
dc7a0575dd | ||
|
|
a972447dbc | ||
|
|
87b200cc13 | ||
|
|
14e57e3714 | ||
|
|
d45ed1c921 | ||
|
|
344c0c7bc3 | ||
|
|
190ee09e60 | ||
|
|
e6031844d5 | ||
|
|
7c6347442f | ||
|
|
16255870a8 | ||
|
|
65bcffc129 | ||
|
|
b28547b248 | ||
|
|
8873381290 | ||
|
|
ebb7ddec44 | ||
|
|
b07541a251 | ||
|
|
85a66a3bfe | ||
|
|
f493d4e4e5 | ||
|
|
5d8908db49 | ||
|
|
14b044d055 | ||
|
|
9b0b82ec1b | ||
|
|
881b48729c | ||
|
|
812265324d | ||
|
|
621f8debea | ||
|
|
43040f6162 | ||
|
|
0bf3bb381f | ||
|
|
2bfbbfd4ba | ||
|
|
6cb1449ff3 | ||
|
|
92fbc9c792 | ||
|
|
f9cfb38ad3 | ||
|
|
e5dc7e2834 | ||
|
|
0af52f2053 | ||
|
|
2fff002699 | ||
|
|
5a87c1c4b3 | ||
|
|
38a7454e7a | ||
|
|
f0b770224a | ||
|
|
4c3fc90830 | ||
|
|
d6798fb286 | ||
|
|
6315f40800 | ||
|
|
1b73d8a7e4 | ||
|
|
54a0ddaab5 | ||
|
|
a84c316e3d | ||
|
|
985e59ad68 | ||
|
|
5334acac37 | ||
|
|
0a53719394 | ||
|
|
3cc89d4ca0 | ||
|
|
2eee5fbdd8 | ||
|
|
e0b2734d66 | ||
|
|
9926e03009 | ||
|
|
3aa0c82795 | ||
|
|
85368c89dd | ||
|
|
75499b989c | ||
|
|
bf89bbc8bc | ||
|
|
deb1ff37b2 | ||
|
|
2927b5d544 | ||
|
|
bf9313ddeb | ||
|
|
c2ffa940cd | ||
|
|
b94e1df518 | ||
|
|
2eb4f77a23 | ||
|
|
d5059979d5 | ||
|
|
4fa7520598 | ||
|
|
e8cb84136b | ||
|
|
8a3409e911 | ||
|
|
abedb13473 | ||
|
|
366a65f99f | ||
|
|
cd01aaa69e | ||
|
|
d4502713ff | ||
|
|
c523463591 | ||
|
|
cca8158bdf | ||
|
|
b9085129a7 | ||
|
|
cb073daca3 | ||
|
|
c40c8a2255 | ||
|
|
e42e11e2a4 | ||
|
|
07bf9047b0 | ||
|
|
230573dc7a | ||
|
|
94df767c28 | ||
|
|
fea8a9d40d | ||
|
|
1eb9cd44dc | ||
|
|
eb6563480f | ||
|
|
2f39e51e1c | ||
|
|
24252e09b4 | ||
|
|
0e7eed4d3c | ||
|
|
884f7666fa | ||
|
|
c54e9fe588 | ||
|
|
d82944d08c | ||
|
|
97764acbcd | ||
|
|
53f0647c5d | ||
|
|
b5ee07eba8 | ||
|
|
5fe13f53bf | ||
|
|
13dc0b9c8e | ||
|
|
78d45f0208 | ||
|
|
d0e166140f | ||
|
|
2617fa8af4 | ||
|
|
89a1dcfbf6 | ||
|
|
9d619ae5c7 | ||
|
|
10d0542e05 | ||
|
|
111c93c391 | ||
|
|
1d9471b252 | ||
|
|
88cb58c285 | ||
|
|
eea01b0ff5 | ||
|
|
d755878bd2 | ||
|
|
2d7cf1e6d3 | ||
|
|
b8a770a470 | ||
|
|
481237dc77 | ||
|
|
5e9dfc0724 | ||
|
|
ce720f40fe | ||
|
|
c92115faf3 | ||
|
|
a1f9f03fc1 | ||
|
|
6a504673e5 | ||
|
|
839118e4f1 | ||
|
|
fd730f6609 | ||
|
|
1be0ff9620 | ||
|
|
6717683ac3 | ||
|
|
0f9b5d08e9 | ||
|
|
1eb77c9cd4 | ||
|
|
ca92dbb168 | ||
|
|
8bc1f03164 | ||
|
|
d8d9d405e9 | ||
|
|
8f2f6f5c91 | ||
|
|
4c01af340f | ||
|
|
88783257b4 | ||
|
|
c2474aa91d | ||
|
|
d2860fc374 | ||
|
|
87a1971d74 | ||
|
|
a9359bc1db | ||
|
|
c7f0293568 | ||
|
|
22affedafc | ||
|
|
4d79c593f7 | ||
|
|
87b82424d5 | ||
|
|
5f81084e66 | ||
|
|
095074f977 | ||
|
|
acd3c8d103 | ||
|
|
3733fd442f | ||
|
|
4eb9845ff7 | ||
|
|
ec6efafbed | ||
|
|
10668571e8 | ||
|
|
878fd85c63 | ||
|
|
3827d46d66 | ||
|
|
7feff3b55c | ||
|
|
4523a12708 | ||
|
|
7a8d054b28 | ||
|
|
a56d1c80ae | ||
|
|
074aed6997 | ||
|
|
2f8db1f7f0 | ||
|
|
b3d878bae8 | ||
|
|
9b12852544 | ||
|
|
a7d58386eb | ||
|
|
68531a44d8 | ||
|
|
d3233b4b9c | ||
|
|
0e9ad0f831 | ||
|
|
c53a516887 | ||
|
|
bdac7a3257 | ||
|
|
0d359e9e57 | ||
|
|
c4fad3cd20 | ||
|
|
9b520b1941 | ||
|
|
9955e348a0 | ||
|
|
bcf4a479ac | ||
|
|
26fb9921b2 | ||
|
|
ba383643f5 | ||
|
|
6bd01c2d2b | ||
|
|
26c8e15436 | ||
|
|
fd993b5063 | ||
|
|
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 | ||
|
|
fcc95bd895 | ||
|
|
964e7de920 | ||
|
|
14b578dd3a | ||
|
|
becb84a73d | ||
|
|
c901ccbb01 | ||
|
|
f987fb0aba | ||
|
|
c30460d76b | ||
|
|
97380b4b3f | ||
|
|
4fbd064b85 | ||
|
|
abbd9a5888 | ||
|
|
e860b4e097 | ||
|
|
23a8a4657d | ||
|
|
b38a1b2298 | ||
|
|
0ebfba8d05 | ||
|
|
990ad8d72d | ||
|
|
c3fc125501 | ||
|
|
3c4ed0de1a | ||
|
|
117c92233d | ||
|
|
2ba14acf4f | ||
|
|
80a2d07009 | ||
|
|
ff9e1ed7c8 | ||
|
|
8e5bee5352 | ||
|
|
d659430116 | ||
|
|
859dac462b | ||
|
|
2bea4dbd06 | ||
|
|
0180b4b6b5 | ||
|
|
2bfb02c448 | ||
|
|
4864254e37 | ||
|
|
09dce28a0e | ||
|
|
e55d09d8bb | ||
|
|
92c162b2fd | ||
|
|
57fb5001e2 | ||
|
|
64e5314148 | ||
|
|
873602a5c9 | ||
|
|
09e966e18a | ||
|
|
f7718cae0c | ||
|
|
90e728516c | ||
|
|
7c04b68c88 | ||
|
|
8549689a0f | ||
|
|
d8f5c17518 | ||
|
|
05367d2df5 | ||
|
|
eb6fbfc90c | ||
|
|
c2267b6902 | ||
|
|
0e5520a261 | ||
|
|
6f5e9f167e | ||
|
|
ce83fb6816 | ||
|
|
fbfb7adef6 | ||
|
|
cc52ad5d27 | ||
|
|
706b9c4013 | ||
|
|
6972c1b841 | ||
|
|
b9c329535d | ||
|
|
8fdf7a94ab | ||
|
|
31a344b410 | ||
|
|
3814fbf08f | ||
|
|
ffc13a5565 | ||
|
|
74c61d9685 | ||
|
|
b8031cd53f | ||
|
|
898e76fc37 | ||
|
|
af71a1a2ed | ||
|
|
e0327db08f | ||
|
|
05032aed3c | ||
|
|
bf2ac97c47 | ||
|
|
902fa254b0 | ||
|
|
f0cc93abd3 | ||
|
|
977f07364b | ||
|
|
00acd745f4 | ||
|
|
d272f43424 | ||
|
|
7a8d8375d0 | ||
|
|
3aa75ef4a7 | ||
|
|
25fb8d934f | ||
|
|
f08c8faaff | ||
|
|
bc0ebdb78d | ||
|
|
2a4b3cb7af | ||
|
|
4401cf66d1 | ||
|
|
d353c9b6d3 | ||
|
|
0aba96c032 | ||
|
|
c60b7e9192 | ||
|
|
23033255b8 | ||
|
|
31c8909dea | ||
|
|
9ef89dbcc3 | ||
|
|
1086296d1d | ||
|
|
d341faf204 | ||
|
|
2334e8f9c9 | ||
|
|
90ad570578 | ||
|
|
fd90d6e375 | ||
|
|
7fbbb85f47 | ||
|
|
52c7557878 | ||
|
|
794cd354ca | ||
|
|
389e3f09f5 | ||
|
|
285979b68d | ||
|
|
3a012c900e | ||
|
|
ec45de3212 | ||
|
|
f644a2a136 | ||
|
|
01108aac42 | ||
|
|
400c745692 | ||
|
|
9841a4d068 | ||
|
|
7fd1d10fca | ||
|
|
4f6bbfa8b8 | ||
|
|
cf6810db87 | ||
|
|
5afff2231e | ||
|
|
d611582b78 | ||
|
|
3bbd8ee27e | ||
|
|
f78e0ff938 | ||
|
|
bd71391bfb | ||
|
|
20b2936cc1 | ||
|
|
19825a635a | ||
|
|
0d611d35de | ||
|
|
effd026fe2 | ||
|
|
d68e57c4fc | ||
|
|
184ce23351 | ||
|
|
2fbc3da451 | ||
|
|
fad6550ff1 | ||
|
|
b7aaa0f24d | ||
|
|
5040bb762c | ||
|
|
55deca1ec8 | ||
|
|
40a16f4717 | ||
|
|
d26e60724a | ||
|
|
d877fa1c68 | ||
|
|
d55bafdfa9 | ||
|
|
a2a431802a | ||
|
|
b2e4907165 | ||
|
|
6c2e40f544 | ||
|
|
5e3d0ec2ad | ||
|
|
c550d6c90d | ||
|
|
3b1d0b4013 | ||
|
|
3d07efbb4f | ||
|
|
c0ae5bb381 | ||
|
|
6e755a26f9 | ||
|
|
e32312b54a | ||
|
|
d7ea569e5d | ||
|
|
b3d1558df8 | ||
|
|
074e611705 | ||
|
|
7e46f9c0b1 | ||
|
|
5e15cac598 | ||
|
|
d0551274dc | ||
|
|
99c05650a1 | ||
|
|
617ee3d781 | ||
|
|
47aa3254c7 | ||
|
|
0717b9493c | ||
|
|
824ebc4d76 | ||
|
|
cbaa61bbb7 | ||
|
|
dbac685985 | ||
|
|
b25c024b66 | ||
|
|
bc65180c32 | ||
|
|
10c66b6e63 | ||
|
|
c29b1696f7 | ||
|
|
c974cd048e | ||
|
|
24e9bf2728 | ||
|
|
9bcbe523d7 | ||
|
|
e176d63ca6 | ||
|
|
80b0e88650 | ||
|
|
0b4731913e | ||
|
|
fc7ce8da2d | ||
|
|
ff46af9c49 | ||
|
|
0a7345186d | ||
|
|
c89bc12c9b | ||
|
|
4913673e8f | ||
|
|
fc004f4f0c | ||
|
|
c5c3874243 | ||
|
|
0d34f41a48 | ||
|
|
a77aef83c6 | ||
|
|
e39c6130c3 | ||
|
|
03359599ed | ||
|
|
3c4330ba51 | ||
|
|
8c781ad4a4 | ||
|
|
5e9ec706c5 | ||
|
|
cb0d9ec95c |
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -6,12 +6,23 @@ labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
<!-- Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md) -->
|
||||
|
||||
**Describe the bug/problem**
|
||||
## Short Notice from the maintainer
|
||||
|
||||
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
||||
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
|
||||
I have turned off all notifications from GitHub/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
|
||||
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||
|
||||
|
||||
Please also have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||
|
||||
**Describe the bug/problem**
|
||||
|
||||
A clear and concise description of what the bug is. If you are asking for support, please check our [Wiki](https://github.com/janeczku/calibre-web/wiki) if your question is already answered there.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
@@ -19,15 +30,19 @@ Steps to reproduce the behavior:
|
||||
4. See error
|
||||
|
||||
**Logfile**
|
||||
|
||||
Add content of calibre-web.log file or the relevant error, try to reproduce your problem with "debug" log-level to get more output.
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
|
||||
- OS: [e.g. Windows 10/Raspberry Pi OS]
|
||||
- Python version: [e.g. python2.7]
|
||||
- Calibre-Web version: [e.g. 0.6.8 or 087c4c59 (git rev-parse --short HEAD)]:
|
||||
@@ -37,3 +52,4 @@ If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. [e.g. access via reverse proxy, database background sync, special database location]
|
||||
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -7,7 +7,14 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md) -->
|
||||
# Short Notice from the maintainer
|
||||
|
||||
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
||||
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
|
||||
I have turned off all notifications from GitHub/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
|
||||
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||
|
||||
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
@@ -20,7 +20,7 @@ Some of the user languages in Calibre-Web having missing translations. We are ha
|
||||
|
||||
### **Documentation**
|
||||
|
||||
The Calibre-Web documentation is hosted in the Github [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consistent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
||||
The Calibre-Web documentation is hosted in the GitHub [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consistent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
||||
|
||||
### **Reporting a bug**
|
||||
|
||||
@@ -28,12 +28,12 @@ Do not open up a GitHub issue if the bug is a **security vulnerability** in Cali
|
||||
|
||||
Ensure the **bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
|
||||
|
||||
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new/choose). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you providing the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
|
||||
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new/choose). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you provide the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
|
||||
|
||||
### **Feature Request**
|
||||
|
||||
If there is a feature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
||||
We will not extend Calibre-Web with any more login abilities or add further files storages, or file syncing ability. Furthermore Calibre-Web is made for home usage for company in-house usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or web site analytics integration will not be implemented.
|
||||
We will not extend Calibre-Web with any more login abilities or add further files storages, or file syncing ability. Calibre-Web is made for home usage for company in-house usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or website analytics integration will not be implemented.
|
||||
|
||||
### **Contributing code to Calibre-Web**
|
||||
|
||||
@@ -42,5 +42,5 @@ Open a new GitHub pull request with the patch. Ensure the PR description clearly
|
||||
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consists of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
|
||||
|
||||
Please check if your code runs with python 3, python 2 is no longer supported. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
|
||||
Calibre-Web is automatically tested on Linux in combination with python 3.8. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests.
|
||||
Calibre-Web is automatically tested on Linux in combination with python 3.8. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on GitHub. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests.
|
||||
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
graft src/calibreweb
|
||||
global-exclude __pycache__
|
||||
global-exclude *.pyc
|
||||
|
||||
138
README.md
@@ -19,13 +19,13 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin
|
||||
- [Quick start](#quick-start)
|
||||
- [Requirements](#requirements)
|
||||
4. [Docker Images](#docker-images)
|
||||
5. [Contributor Recognition](#contributor-recognition)
|
||||
6. [Contact](#contact)
|
||||
7. [Contributing to Calibre-Web](#contributing-to-calibre-web)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
6. [Contributor Recognition](#contributor-recognition)
|
||||
7. [Contact](#contact)
|
||||
8. [Contributing to Calibre-Web](#contributing-to-calibre-web)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
|
||||
|
||||

|
||||
@@ -57,51 +57,102 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin
|
||||
|
||||
## Installation
|
||||
|
||||
#### Installation via pip (recommended)
|
||||
1. Create a virtual environment for Calibre-Web to avoid conflicts with existing Python dependencies
|
||||
2. Install Calibre-Web via pip: `pip install calibreweb` (or `pip3` depending on your OS/distro)
|
||||
3. Install optional features via pip as needed, see [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details
|
||||
4. Start Calibre-Web by typing `cps`
|
||||
### Installation via pip (recommended)
|
||||
|
||||
*Note: Raspberry Pi OS users may encounter issues during installation. If so, please update pip (`./venv/bin/python3 -m pip install --upgrade pip`) and/or install cargo (`sudo apt install cargo`) before retrying the installation.*
|
||||
1. **Create a virtual environment**: It’s essential to isolate your Calibre-Web installation to avoid dependency conflicts. You can create a virtual environment by running:
|
||||
```
|
||||
python3 -m venv calibre-web-env
|
||||
```
|
||||
2. **Activate the virtual environment**:
|
||||
```
|
||||
source calibre-web-env/bin/activate
|
||||
```
|
||||
3. **Install Calibre-Web**: Use pip to install the application:
|
||||
```
|
||||
pip install calibreweb
|
||||
```
|
||||
4. **Install optional features**: For additional functionality, you may need to install optional features. Refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details on what can be installed.
|
||||
5. **Start Calibre-Web**: After installation, you can start the application with:
|
||||
```
|
||||
cps
|
||||
```
|
||||
|
||||
Refer to the Wiki for additional installation examples: [manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation), [Linux Mint](https://github.com/janeczku/calibre-web/wiki/How-To:Install-Calibre-Web-in-Linux-Mint-19-or-20), [Cloud Provider](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider).
|
||||
*Note: Users of Raspberry Pi OS may encounter installation issues. If you do, try upgrading pip and/or installing cargo as follows:*
|
||||
```
|
||||
./venv/bin/python3 -m pip install --upgrade pip
|
||||
sudo apt install cargo
|
||||
```
|
||||
|
||||
### Important Links
|
||||
- For additional installation examples, check the following:
|
||||
- [Manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation)
|
||||
- [Linux Mint installation](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20)
|
||||
- [Cloud Provider setup](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
||||
2. Log in with the default admin credentials
|
||||
3. If you don't have a Calibre database, you can use [this database](https://github.com/janeczku/calibre-web/raw/master/library/metadata.db) (move it out of the Calibre-Web folder to prevent overwriting during updates)
|
||||
4. Set `Location of Calibre database` to the path of the folder containing your Calibre library (metadata.db) and click "Save"
|
||||
5. Optionally, use Google Drive to host your Calibre library by following the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration)
|
||||
6. Configure your Calibre-Web instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides
|
||||
|
||||
#### Default Admin Login:
|
||||
- **Username:** admin
|
||||
- **Password:** admin123
|
||||
1. **Access Calibre-Web**: Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:8083
|
||||
```
|
||||
or for the OPDS catalog:
|
||||
```
|
||||
http://localhost:8083/opds
|
||||
```
|
||||
2. **Log in**: Use the default admin credentials:
|
||||
- **Username:** admin
|
||||
- **Password:** admin123
|
||||
3. **Database Setup**: If you do not have a Calibre database, download a sample from:
|
||||
```
|
||||
https://github.com/janeczku/calibre-web/raw/master/library/metadata.db
|
||||
```
|
||||
Move it out of the Calibre-Web folder to avoid overwriting during updates.
|
||||
4. **Configure Calibre Database**: In the admin interface, set the `Location of Calibre database` to the path of the folder containing your Calibre library (where `metadata.db` is located) and click "Save".
|
||||
5. **Google Drive Integration**: For hosting your Calibre library on Google Drive, refer to the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration).
|
||||
6. **Admin Configuration**: Configure your instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.5+
|
||||
- [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)
|
||||
- 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)
|
||||
- **Python Version**: Ensure you have Python 3.7 or newer.
|
||||
- **Imagemagick**: Required for cover extraction from EPUBs. Windows users may also need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction.
|
||||
- **Optional Tools**:
|
||||
- **Calibre desktop program**: Recommended for on-the-fly conversion and metadata editing. Set the path to Calibre’s converter tool on the setup page.
|
||||
- **Kepubify tool**: Needed for Kobo device support. Download the tool and place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows.
|
||||
|
||||
## Docker Images
|
||||
|
||||
Pre-built Docker images are available in the following Docker Hub repositories (maintained by the LinuxServer team):
|
||||
Pre-built Docker images are available:
|
||||
|
||||
#### **LinuxServer - x64, aarch64**
|
||||
- [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||
- [GitHub](https://github.com/linuxserver/docker-calibre-web)
|
||||
- [GitHub - Optional Calibre layer](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
|
||||
### **LinuxServer - x64, aarch64**
|
||||
- **Docker Hub**: [linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||
- **GitHub**: [linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web)
|
||||
- **Optional Calibre layer**: [linuxserver/docker-mods](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
|
||||
|
||||
Include the environment variable `DOCKER_MODS=linuxserver/mods:universal-calibre` in your Docker run/compose file to add the Calibre `ebook-convert` binary (x64 only). Omit this variable for a lightweight image.
|
||||
To include the Calibre `ebook-convert` binary (x64 only), add the environment variable:
|
||||
```
|
||||
DOCKER_MODS=linuxserver/mods:universal-calibre
|
||||
```
|
||||
in your Docker run/compose file. Omit this variable for a lightweight image.
|
||||
|
||||
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
|
||||
- **Paths Configuration**:
|
||||
- Set **Path to Calibre Binaries** to `/usr/bin`.
|
||||
- Set **Path to Unrar** to `/usr/bin/unrar`.
|
||||
|
||||
- Set "path to convertertool" to `/usr/bin/ebook-convert`
|
||||
- Set "path to unrar" to `/usr/bin/unrar`
|
||||
## Troubleshooting
|
||||
|
||||
- **Common Issues**:
|
||||
- If you experience issues starting the application, check the log files located in the `logs` directory for error messages.
|
||||
- If eBooks fail to load, verify that the `Location of Calibre database` is correctly set and that the database file is accessible.
|
||||
|
||||
- **Configuration Errors**: Ensure that your Calibre database is compatible and properly formatted. Refer to the Calibre documentation for guidance on maintaining the database.
|
||||
|
||||
- **Performance Problems**:
|
||||
- If the application is slow, consider increasing the allocated resources (CPU/RAM) to your server or optimizing the Calibre database by removing duplicates and unnecessary entries.
|
||||
- Regularly clear the cache in your web browser to improve loading times.
|
||||
|
||||
- **User Management Issues**: If users are unable to log in or register, check the user permission settings in the admin interface. Ensure that registration is enabled and that users are being assigned appropriate roles.
|
||||
|
||||
- **Support Resources**: For additional help, consider visiting the [FAQ section](https://github.com/janeczku/calibre-web/wiki/FAQ) of the wiki or posting your questions in the [Discord community](https://discord.gg/h2VsJ2NEfB).
|
||||
|
||||
## Contributor Recognition
|
||||
|
||||
@@ -115,4 +166,21 @@ For more information, How To's, and FAQs, please visit the [Wiki](https://github
|
||||
|
||||
## Contributing to Calibre-Web
|
||||
|
||||
Check out our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||
To contribute, please check our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md). We welcome issues, feature requests, and pull requests from the community.
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
If you encounter bugs or issues, please report them in the [issues section](https://github.com/janeczku/calibre-web/issues) of the repository. Be sure to include detailed information about your setup and the problem encountered.
|
||||
|
||||
### Feature Requests
|
||||
|
||||
We welcome suggestions for new features. Please create a new issue in the repository to discuss your ideas.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Documentation**: Comprehensive documentation is available on the [Calibre-Web wiki](https://github.com/janeczku/calibre-web/wiki).
|
||||
- **Community Contributions**: Explore the [community contributions](https://github.com/janeczku/calibre-web/pulls) to see ongoing work and how you can get involved.
|
||||
|
||||
---
|
||||
|
||||
Thank you for using Calibre-Web! We hope you enjoy managing your eBook library with our tool.
|
||||
|
||||
68
SECURITY.md
@@ -10,34 +10,46 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
|
||||
|
||||
## History
|
||||
|
||||
| Fixed in | Description |CVE number |
|
||||
|---------------|--------------------------------------------------------------------------------------------------------------------|---------|
|
||||
| 3rd July 2018 | Guest access acts as a backdoor ||
|
||||
| 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 | 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 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 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 shelf title ||
|
||||
| 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 | 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 | 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 | 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 | 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 | 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 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 | 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|
|
||||
| Fixed in | Description | CVE number |
|
||||
|---------------|----------------------------------------------------------------------------------------------------------------------------------|----------------|
|
||||
| 3rd July 2018 | Guest access acts as a backdoor | |
|
||||
| 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 | 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 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 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 shelf title | |
|
||||
| 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 | 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 | 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 | 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 | 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 | 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 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 | 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.20 | Credentials for emails are now stored encrypted | |
|
||||
| V 0.6.20 | Login is rate limited | |
|
||||
| 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 | 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 | 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)
|
||||
|
||||
16
cps.py
@@ -27,7 +27,23 @@ sys.path.insert(0, path)
|
||||
|
||||
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 os.name == "nt":
|
||||
hide_console_windows()
|
||||
main()
|
||||
|
||||
|
||||
|
||||
@@ -20,33 +20,18 @@
|
||||
# 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 .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):
|
||||
def _session_protection_failed(self):
|
||||
sess = session._get_current_object()
|
||||
ident = self._session_identifier_generator()
|
||||
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 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
|
||||
|
||||
|
||||
|
||||
@@ -31,12 +31,10 @@ from flask_principal import Principal
|
||||
|
||||
from . import logger
|
||||
from .cli import CliParameter
|
||||
from .constants import CONFIG_DIR
|
||||
from .reverseproxy import ReverseProxied
|
||||
from .server import WebServer
|
||||
from .dep_check import dependency_check
|
||||
from .updater import Updater
|
||||
from .babel import babel, get_locale
|
||||
from . import config_sql
|
||||
from . import cache_buster
|
||||
from . import ub, db
|
||||
@@ -56,24 +54,31 @@ except ImportError:
|
||||
mimetypes.init()
|
||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||
mimetypes.add_type('application/epub+zip', '.epub')
|
||||
mimetypes.add_type('application/epub+zip', '.kepub')
|
||||
mimetypes.add_type('application/fb2+zip', '.fb2')
|
||||
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
||||
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
|
||||
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
||||
mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
|
||||
mimetypes.add_type('application/octet-stream', '.prc')
|
||||
mimetypes.add_type('application/x-mobipocket-ebook', '.azw')
|
||||
mimetypes.add_type('application/x-mobipocket-ebook', '.azw3')
|
||||
mimetypes.add_type('application/x-cbr', '.cbr')
|
||||
mimetypes.add_type('application/x-cbz', '.cbz')
|
||||
mimetypes.add_type('application/x-cbt', '.cbt')
|
||||
mimetypes.add_type('application/x-cb7', '.cb7')
|
||||
mimetypes.add_type('image/vnd.djv', '.djv')
|
||||
mimetypes.add_type('application/x-tar', '.cbt')
|
||||
mimetypes.add_type('application/x-7z-compressed', '.cb7')
|
||||
mimetypes.add_type('image/vnd.djvu', '.djv')
|
||||
mimetypes.add_type('image/vnd.djvu', '.djvu')
|
||||
mimetypes.add_type('application/mpeg', '.mpeg')
|
||||
mimetypes.add_type('application/mpeg', '.mp3')
|
||||
mimetypes.add_type('application/mp4', '.m4a')
|
||||
mimetypes.add_type('application/mp4', '.m4b')
|
||||
mimetypes.add_type('application/ogg', '.ogg')
|
||||
mimetypes.add_type('audio/mpeg', '.mp3')
|
||||
mimetypes.add_type('audio/x-m4a', '.m4a')
|
||||
mimetypes.add_type('audio/x-m4a', '.m4b')
|
||||
mimetypes.add_type('audio/x-hx-aac-adts', '.aac')
|
||||
mimetypes.add_type('audio/vnd.dolby.dd-raw', '.ac3')
|
||||
mimetypes.add_type('video/x-ms-asf', '.asf')
|
||||
mimetypes.add_type('audio/ogg', '.ogg')
|
||||
mimetypes.add_type('application/ogg', '.oga')
|
||||
mimetypes.add_type('text/css', '.css')
|
||||
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||
mimetypes.add_type('application/x-ms-reader', '.lit')
|
||||
mimetypes.add_type('text/javascript', '.js')
|
||||
mimetypes.add_type('text/rtf', '.rtf')
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@@ -81,8 +86,10 @@ app = Flask(__name__)
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE='Lax',
|
||||
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
|
||||
WTF_CSRF_SSL_STRICT=False
|
||||
REMEMBER_COOKIE_SAMESITE='Strict',
|
||||
WTF_CSRF_SSL_STRICT=False,
|
||||
SESSION_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "session",
|
||||
REMEMBER_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "remember_token"
|
||||
)
|
||||
|
||||
lm = MyLoginManager()
|
||||
@@ -96,17 +103,18 @@ if wtf_present:
|
||||
else:
|
||||
csrf = None
|
||||
|
||||
calibre_db = db.CalibreDB()
|
||||
calibre_db = db.CalibreDB(app)
|
||||
|
||||
web_server = WebServer()
|
||||
|
||||
updater_thread = Updater()
|
||||
|
||||
if limiter_present:
|
||||
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=True)
|
||||
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=False)
|
||||
else:
|
||||
limiter = None
|
||||
|
||||
|
||||
def create_app():
|
||||
if csrf:
|
||||
csrf.init_app(app)
|
||||
@@ -125,13 +133,6 @@ def create_app():
|
||||
|
||||
ub.password_change(cli_param.user_credentials)
|
||||
|
||||
if not limiter:
|
||||
log.info('*** "flask-limiter" is needed for calibre-web to run. '
|
||||
'Please install it using pip: "pip install flask-limiter" ***')
|
||||
print('*** "flask-limiter" is needed for calibre-web to run. '
|
||||
'Please install it using pip: "pip install flask-limiter" ***')
|
||||
web_server.stop(True)
|
||||
sys.exit(8)
|
||||
if sys.version_info < (3, 0):
|
||||
log.info(
|
||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
|
||||
@@ -141,30 +142,29 @@ def create_app():
|
||||
'please update your installation to Python3 ***')
|
||||
web_server.stop(True)
|
||||
sys.exit(5)
|
||||
if not wtf_present:
|
||||
log.info('*** "flask-WTF" is needed for calibre-web to run. '
|
||||
'Please install it using pip: "pip install flask-WTF" ***')
|
||||
print('*** "flask-WTF" is needed for calibre-web to run. '
|
||||
'Please install it using pip: "pip install flask-WTF" ***')
|
||||
web_server.stop(True)
|
||||
sys.exit(7)
|
||||
|
||||
lm.login_view = 'web.login'
|
||||
lm.anonymous_user = ub.Anonymous
|
||||
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
|
||||
|
||||
db.CalibreDB.update_config(config)
|
||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||
calibre_db.init_db()
|
||||
db.CalibreDB.update_config(config, config.config_calibre_dir, cli_param.settings_path)
|
||||
|
||||
updater_thread.init_updater(config, web_server)
|
||||
# Perform dry run of updater and exit afterwards
|
||||
# Perform dry run of updater and exit afterward
|
||||
if cli_param.dry_run:
|
||||
updater_thread.dry_run()
|
||||
sys.exit(0)
|
||||
updater_thread.start()
|
||||
|
||||
for res in dependency_check() + dependency_check(True):
|
||||
requirements = dependency_check()
|
||||
for res in requirements:
|
||||
if res['found'] == "not installed":
|
||||
message = ('Cannot import {name} module, it is needed to run calibre-web, '
|
||||
'please install it using "pip install {name}"').format(name=res["name"])
|
||||
log.info(message)
|
||||
print("*** " + message + " ***")
|
||||
web_server.stop(True)
|
||||
sys.exit(8)
|
||||
for res in requirements + dependency_check(True):
|
||||
log.info('*** "{}" version does not meet the requirements. '
|
||||
'Should: {}, Found: {}, please consider installing required version ***'
|
||||
.format(res['name'],
|
||||
@@ -180,6 +180,7 @@ def create_app():
|
||||
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
||||
|
||||
web_server.init_app(app, config)
|
||||
from .cw_babel import babel, get_locale
|
||||
if hasattr(babel, "localeselector"):
|
||||
babel.init_app(app)
|
||||
babel.localeselector(get_locale)
|
||||
@@ -192,12 +193,21 @@ def create_app():
|
||||
services.ldap.init_app(app, config)
|
||||
if services.goodreads_support:
|
||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||
config.config_goodreads_api_secret_e,
|
||||
config.config_use_goodreads)
|
||||
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||
# Configure rate limiter
|
||||
# https://limits.readthedocs.io/en/stable/storage.html
|
||||
app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter)
|
||||
limiter.init_app(app)
|
||||
if config.config_limiter_uri != "" and not cli_param.memory_backend:
|
||||
app.config.update(RATELIMIT_STORAGE_URI=config.config_limiter_uri)
|
||||
if config.config_limiter_options != "":
|
||||
app.config.update(RATELIMIT_STORAGE_OPTIONS=config.config_limiter_options)
|
||||
try:
|
||||
limiter.init_app(app)
|
||||
except Exception as e:
|
||||
log.error('Wrong Flask Limiter configuration, falling back to default: {}'.format(e))
|
||||
app.config.update(RATELIMIT_STORAGE_URI=None)
|
||||
limiter.init_app(app)
|
||||
|
||||
# Register scheduled tasks
|
||||
from .schedule import register_scheduled_tasks, register_startup_tasks
|
||||
|
||||
15
cps/about.py
@@ -23,15 +23,15 @@
|
||||
import sys
|
||||
import platform
|
||||
import sqlite3
|
||||
from importlib.metadata import metadata
|
||||
from collections import OrderedDict
|
||||
|
||||
import flask
|
||||
import flask_login
|
||||
import jinja2
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from . import db, calibre_db, converter, uploader, constants, dep_check
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
|
||||
about = flask.Blueprint('about', __name__)
|
||||
@@ -41,17 +41,18 @@ req = dep_check.load_dependencies(False)
|
||||
opt = dep_check.load_dependencies(True)
|
||||
for i in (req + opt):
|
||||
modules[i[1]] = i[0]
|
||||
modules['Jinja2'] = jinja2.__version__
|
||||
modules['pySqlite'] = sqlite3.version
|
||||
modules['Jinja2'] = metadata("jinja2")["Version"]
|
||||
if sys.version_info < (3, 12):
|
||||
modules['pySqlite'] = sqlite3.version
|
||||
modules['SQLite'] = sqlite3.sqlite_version
|
||||
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))
|
||||
|
||||
|
||||
def collect_stats():
|
||||
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
||||
calibre_web_version = constants.STABLE_VERSION['version']
|
||||
calibre_web_version = constants.STABLE_VERSION.replace("b", " Beta")
|
||||
else:
|
||||
calibre_web_version = (constants.STABLE_VERSION['version'] + ' - '
|
||||
calibre_web_version = (constants.STABLE_VERSION.replace("b", " Beta") + ' - '
|
||||
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
||||
|
||||
@@ -74,7 +75,7 @@ def collect_stats():
|
||||
|
||||
|
||||
@about.route("/stats")
|
||||
@flask_login.login_required
|
||||
@user_login_required
|
||||
def stats():
|
||||
counter = calibre_db.session.query(db.Books).count()
|
||||
authors = calibre_db.session.query(db.Authors).count()
|
||||
|
||||
291
cps/admin.py
@@ -32,12 +32,12 @@ from datetime import time as datetime_time
|
||||
from functools import wraps
|
||||
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, jsonify
|
||||
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 get_locale, format_time, format_datetime, format_timedelta
|
||||
from flask import session as flask_session
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
|
||||
@@ -48,11 +48,14 @@ from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutil
|
||||
kobo_sync_status, schedule
|
||||
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
|
||||
valid_email, check_username
|
||||
from .embed_helper import get_calibre_binarypath
|
||||
from .gdriveutils import is_gdrive_ready, gdrive_support
|
||||
from .render_template import render_title_template, get_sidebar_config
|
||||
from .services.worker import WorkerThread
|
||||
from .babel import get_available_translations, get_available_locale, get_user_locale_language
|
||||
from .usermanagement import user_login_required
|
||||
from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language
|
||||
from . import debug_info
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@@ -102,10 +105,13 @@ def admin_required(f):
|
||||
|
||||
@admi.before_app_request
|
||||
def before_request():
|
||||
if not ub.check_user_session(current_user.id,
|
||||
flask_session.get('_id')) and 'opds' not in request.path \
|
||||
and config.config_session == 1:
|
||||
logout_user()
|
||||
#try:
|
||||
#if not ub.check_user_session(current_user.id,
|
||||
# flask_session.get('_id')) and 'opds' not in request.path \
|
||||
# and config.config_session == 1:
|
||||
# logout_user()
|
||||
#except AttributeError:
|
||||
# pass # ? fails on requesting /ajax/emailstat during restart ?
|
||||
g.constants = constants
|
||||
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
|
||||
g.allow_registration = config.config_public_reg
|
||||
@@ -113,7 +119,7 @@ def before_request():
|
||||
g.allow_upload = config.config_uploading
|
||||
g.current_theme = config.config_theme
|
||||
g.config_authors_max = config.config_authors_max
|
||||
if '/static/' not in request.path and not config.db_configured and \
|
||||
if ('/static/' not in request.path and not config.db_configured and
|
||||
request.endpoint not in ('admin.ajax_db_config',
|
||||
'admin.simulatedbchange',
|
||||
'admin.db_configuration',
|
||||
@@ -121,25 +127,24 @@ def before_request():
|
||||
'web.login_post',
|
||||
'web.logout',
|
||||
'admin.load_dialogtexts',
|
||||
'admin.ajax_pathchooser'):
|
||||
'admin.ajax_pathchooser')):
|
||||
return redirect(url_for('admin.db_configuration'))
|
||||
|
||||
|
||||
@admi.route("/admin")
|
||||
@login_required
|
||||
def admin_forbidden():
|
||||
abort(403)
|
||||
#@admi.route("/admin")
|
||||
#@user_login_required
|
||||
#def admin_forbidden():
|
||||
# abort(403)
|
||||
|
||||
|
||||
@admi.route("/shutdown", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def shutdown():
|
||||
task = request.get_json().get('parameter', -1)
|
||||
show_text = {}
|
||||
if task in (0, 1): # valid commandos received
|
||||
# close all database connections
|
||||
calibre_db.dispose()
|
||||
ub.dispose()
|
||||
|
||||
if task == 0:
|
||||
@@ -161,7 +166,7 @@ def shutdown():
|
||||
|
||||
|
||||
@admi.route("/metadata_backup", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def queue_metadata_backup():
|
||||
show_text = {}
|
||||
@@ -184,8 +189,8 @@ def reconnect():
|
||||
|
||||
|
||||
@admi.route("/ajax/updateThumbnails", methods=['POST'])
|
||||
@user_login_required
|
||||
@admin_required
|
||||
@login_required
|
||||
def update_thumbnails():
|
||||
content = config.get_scheduled_task_settings()
|
||||
if content['schedule_generate_book_covers']:
|
||||
@@ -195,7 +200,7 @@ def update_thumbnails():
|
||||
|
||||
|
||||
@admi.route("/admin/view")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def admin():
|
||||
version = updater_thread.get_current_version_info()
|
||||
@@ -214,7 +219,7 @@ def admin():
|
||||
form_date += timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
|
||||
commit = format_datetime(form_date - tz, format='short')
|
||||
else:
|
||||
commit = version['version']
|
||||
commit = version['version'].replace("b", " Beta")
|
||||
|
||||
all_user = ub.session.query(ub.User).all()
|
||||
# email_settings = mail_config.get_mail_settings()
|
||||
@@ -229,7 +234,7 @@ def admin():
|
||||
|
||||
|
||||
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def db_configuration():
|
||||
if request.method == "POST":
|
||||
@@ -238,7 +243,7 @@ def db_configuration():
|
||||
|
||||
|
||||
@admi.route("/admin/config", methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def configuration():
|
||||
return render_title_template("config_edit.html",
|
||||
@@ -249,28 +254,28 @@ def configuration():
|
||||
|
||||
|
||||
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_config():
|
||||
return _configuration_update_helper()
|
||||
|
||||
|
||||
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_db_config():
|
||||
return _db_configuration_update_helper()
|
||||
|
||||
|
||||
@admi.route("/admin/alive", methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def calibreweb_alive():
|
||||
return "", 200
|
||||
|
||||
|
||||
@admi.route("/admin/viewconfig")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def view_configuration():
|
||||
read_column = calibre_db.session.query(db.CustomColumns) \
|
||||
@@ -287,7 +292,7 @@ def view_configuration():
|
||||
|
||||
|
||||
@admi.route("/admin/usertable")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_user_table():
|
||||
visibility = current_user.view_settings.get('useredit', {})
|
||||
@@ -301,7 +306,15 @@ def edit_user_table():
|
||||
.group_by(text('books_tags_link.tag')) \
|
||||
.order_by(db.Tags.name).all()
|
||||
if config.config_restricted_column:
|
||||
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
||||
try:
|
||||
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
custom_values = []
|
||||
log.error("Custom Column No.{} does not exist in calibre database".format(
|
||||
config.config_restricted_column))
|
||||
flash(_("Custom Column No.%(column)d does not exist in calibre database",
|
||||
column=config.config_restricted_column),
|
||||
category="error")
|
||||
else:
|
||||
custom_values = []
|
||||
if not config.config_anonbrowse:
|
||||
@@ -322,7 +335,7 @@ def edit_user_table():
|
||||
|
||||
|
||||
@admi.route("/ajax/listusers")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def list_users():
|
||||
off = int(request.args.get("offset") or 0)
|
||||
@@ -366,14 +379,11 @@ def list_users():
|
||||
user.default = get_user_locale_language(user.default_language)
|
||||
|
||||
table_entries = {'totalNotFiltered': total_count, 'total': filtered_count, "rows": users}
|
||||
js_list = json.dumps(table_entries, cls=db.AlchemyEncoder)
|
||||
response = make_response(js_list)
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return response
|
||||
return make_response(json.dumps(table_entries, cls=db.AlchemyEncoder))
|
||||
|
||||
|
||||
@admi.route("/ajax/deleteuser", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_user():
|
||||
user_ids = request.form.to_dict(flat=False)
|
||||
@@ -388,7 +398,7 @@ def delete_user():
|
||||
success = list()
|
||||
if not users:
|
||||
log.error("User not found")
|
||||
return Response(json.dumps({'type': "danger", 'message': _("User not found")}), mimetype='application/json')
|
||||
return make_response(jsonify(type="danger", message=_("User not found")))
|
||||
for user in users:
|
||||
try:
|
||||
message = _delete_user(user)
|
||||
@@ -404,11 +414,11 @@ def delete_user():
|
||||
log.info("Users {} deleted".format(user_ids))
|
||||
success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
|
||||
success.extend(errors)
|
||||
return Response(json.dumps(success), mimetype='application/json')
|
||||
return make_response(jsonify(success))
|
||||
|
||||
|
||||
@admi.route("/ajax/getlocale")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def table_get_locale():
|
||||
locale = get_available_locale()
|
||||
@@ -416,11 +426,11 @@ def table_get_locale():
|
||||
current_locale = get_locale()
|
||||
for loc in locale:
|
||||
ret.append({'value': str(loc), 'text': loc.get_language_name(current_locale)})
|
||||
return json.dumps(ret)
|
||||
return json.dumps(sorted(ret, key=lambda x: x['text']))
|
||||
|
||||
|
||||
@admi.route("/ajax/getdefaultlanguage")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def table_get_default_lang():
|
||||
languages = calibre_db.speaking_language()
|
||||
@@ -428,11 +438,11 @@ def table_get_default_lang():
|
||||
ret.append({'value': 'all', 'text': _('Show All')})
|
||||
for lang in languages:
|
||||
ret.append({'value': lang.lang_code, 'text': lang.name})
|
||||
return json.dumps(ret)
|
||||
return json.dumps(sorted(ret, key=lambda x: x['text']))
|
||||
|
||||
|
||||
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_list_user(param):
|
||||
vals = request.form.to_dict(flat=False)
|
||||
@@ -459,9 +469,9 @@ def edit_list_user(param):
|
||||
if 'value[]' in vals:
|
||||
setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
|
||||
else:
|
||||
setattr(user, param, vals['value'].strip())
|
||||
setattr(user, param, strip_whitespaces(vals['value']))
|
||||
else:
|
||||
vals['value'] = vals['value'].strip()
|
||||
vals['value'] = strip_whitespaces(vals['value'])
|
||||
if param == 'name':
|
||||
if user.name == "Guest":
|
||||
raise Exception(_("Guest Name can't be changed"))
|
||||
@@ -475,7 +485,7 @@ def edit_list_user(param):
|
||||
elif param.endswith('role'):
|
||||
value = int(vals['field_index'])
|
||||
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"))
|
||||
# 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):
|
||||
@@ -486,10 +496,10 @@ def edit_list_user(param):
|
||||
if not ub.session.query(ub.User). \
|
||||
filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
||||
ub.User.id != user.id).count():
|
||||
return Response(
|
||||
json.dumps([{'type': "danger",
|
||||
return make_response(
|
||||
jsonify([{'type': "danger",
|
||||
'message': _("No admin user remaining, can't remove admin role",
|
||||
nick=user.name)}]), mimetype='application/json')
|
||||
nick=user.name)}]))
|
||||
user.role &= ~value
|
||||
else:
|
||||
raise Exception(_("Value has to be true or false"))
|
||||
@@ -537,7 +547,7 @@ def edit_list_user(param):
|
||||
|
||||
|
||||
@admi.route("/ajax/user_table_settings", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_table_settings():
|
||||
current_user.view_settings['useredit'] = json.loads(request.data)
|
||||
@@ -554,7 +564,7 @@ def update_table_settings():
|
||||
|
||||
|
||||
@admi.route("/admin/viewconfig", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_view_configuration():
|
||||
to_save = request.form.to_dict()
|
||||
@@ -562,7 +572,7 @@ def update_view_configuration():
|
||||
_config_string(to_save, "config_calibre_web_title")
|
||||
_config_string(to_save, "config_columns_to_ignore")
|
||||
if _config_string(to_save, "config_title_regex"):
|
||||
calibre_db.update_title_sort(config)
|
||||
calibre_db.create_functions(config)
|
||||
|
||||
if not check_valid_read_column(to_save.get("config_read_column", "0")):
|
||||
flash(_("Invalid Read Column"), category="error")
|
||||
@@ -599,7 +609,7 @@ def update_view_configuration():
|
||||
|
||||
|
||||
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def load_dialogtexts(element_id):
|
||||
texts = {"header": "", "main": "", "valid": 1}
|
||||
if element_id == "config_delete_kobo_token":
|
||||
@@ -635,7 +645,7 @@ def load_dialogtexts(element_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_domain(allow):
|
||||
# POST /post
|
||||
@@ -649,7 +659,7 @@ def edit_domain(allow):
|
||||
|
||||
|
||||
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def add_domain(allow):
|
||||
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
|
||||
@@ -663,7 +673,7 @@ def add_domain(allow):
|
||||
|
||||
|
||||
@admi.route("/ajax/deletedomain", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_domain():
|
||||
try:
|
||||
@@ -681,12 +691,12 @@ def delete_domain():
|
||||
|
||||
|
||||
@admi.route("/ajax/domainlist/<int:allow>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def list_domain(allow):
|
||||
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
|
||||
json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer])
|
||||
js = json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"')
|
||||
js = json.dumps(json_dumps.replace('"', "'")).strip('"')
|
||||
response = make_response(js.replace("'", '"'))
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return response
|
||||
@@ -694,7 +704,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>/<int:user_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_restriction(res_type, user_id):
|
||||
element = request.form.to_dict()
|
||||
@@ -760,14 +770,14 @@ def edit_restriction(res_type, user_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def add_user_0_restriction(res_type):
|
||||
return add_restriction(res_type, 0)
|
||||
|
||||
|
||||
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def add_restriction(res_type, user_id):
|
||||
element = request.form.to_dict()
|
||||
@@ -813,14 +823,14 @@ def add_restriction(res_type, user_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_user_0_restriction(res_type):
|
||||
return delete_restriction(res_type, 0)
|
||||
|
||||
|
||||
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_restriction(res_type, user_id):
|
||||
element = request.form.to_dict()
|
||||
@@ -868,7 +878,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>/<int:user_id>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def list_restriction(res_type, user_id):
|
||||
if res_type == 0: # Tags as template
|
||||
@@ -912,25 +922,36 @@ def list_restriction(res_type, user_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/fullsync", methods=["POST"])
|
||||
@login_required
|
||||
def ajax_fullsync():
|
||||
count = ub.session.query(ub.KoboSyncedBooks).filter(current_user.id == ub.KoboSyncedBooks.user_id).delete()
|
||||
message = _("{} sync entries deleted").format(count)
|
||||
ub.session_commit(message)
|
||||
return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json')
|
||||
@user_login_required
|
||||
def ajax_self_fullsync():
|
||||
return do_full_kobo_sync(current_user.id)
|
||||
|
||||
|
||||
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_fullsync(userid):
|
||||
return do_full_kobo_sync(userid)
|
||||
|
||||
|
||||
@admi.route("/ajax/pathchooser/")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_pathchooser():
|
||||
return pathchooser()
|
||||
|
||||
|
||||
def do_full_kobo_sync(userid):
|
||||
count = ub.session.query(ub.KoboSyncedBooks).filter(userid == ub.KoboSyncedBooks.user_id).delete()
|
||||
message = _("{} sync entries deleted").format(count)
|
||||
ub.session_commit(message)
|
||||
return make_response(jsonify(type="success", message=message))
|
||||
|
||||
|
||||
def check_valid_read_column(column):
|
||||
if column != "0":
|
||||
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 True
|
||||
|
||||
@@ -938,7 +959,7 @@ def check_valid_read_column(column):
|
||||
def check_valid_restricted_column(column):
|
||||
if column != "0":
|
||||
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 True
|
||||
|
||||
@@ -966,8 +987,14 @@ def prepare_tags(user, action, tags_name, id_list):
|
||||
raise Exception(_("Tag not found"))
|
||||
new_tags_list = [x.name for x in tags]
|
||||
else:
|
||||
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
|
||||
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
|
||||
try:
|
||||
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
|
||||
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
log.error("Custom Column No.{} does not exist in calibre database".format(
|
||||
config.config_restricted_column))
|
||||
raise Exception(_("Custom Column No.%(column)d does not exist in calibre database",
|
||||
column=config.config_restricted_column))
|
||||
new_tags_list = [x.value for x in tags]
|
||||
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
|
||||
if action == "remove":
|
||||
@@ -984,10 +1011,7 @@ def get_drives(current):
|
||||
for d in string.ascii_uppercase:
|
||||
if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
|
||||
drive = "{}:\\".format(d)
|
||||
data = {"name": drive, "fullpath": drive}
|
||||
data["sort"] = "_" + data["fullpath"].lower()
|
||||
data["type"] = "dir"
|
||||
data["size"] = ""
|
||||
data = {"name": drive, "fullpath": drive, "type": "dir", "size": "", "sort": "_" + drive.lower()}
|
||||
drive_letters.append(data)
|
||||
return drive_letters
|
||||
|
||||
@@ -1088,7 +1112,7 @@ def _config_checkbox_int(to_save, x):
|
||||
|
||||
|
||||
def _config_string(to_save, x):
|
||||
return config.set_from_dictionary(to_save, x, lambda y: y.strip().strip(u'\u200B\u200C\u200D\ufeff') if y else y)
|
||||
return config.set_from_dictionary(to_save, x, lambda y: strip_whitespaces(y) if y else y)
|
||||
|
||||
|
||||
def _configuration_gdrive_helper(to_save):
|
||||
@@ -1127,12 +1151,12 @@ def _configuration_oauth_helper(to_save):
|
||||
reboot_required = False
|
||||
for element in oauthblueprints:
|
||||
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
|
||||
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"]
|
||||
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
|
||||
element["active"] = 1
|
||||
else:
|
||||
@@ -1187,9 +1211,9 @@ def _configuration_ldap_helper(to_save):
|
||||
config.save()
|
||||
|
||||
if not config.config_ldap_provider_url \
|
||||
or not config.config_ldap_port \
|
||||
or not config.config_ldap_dn \
|
||||
or not config.config_ldap_user_object:
|
||||
or not config.config_ldap_port \
|
||||
or not config.config_ldap_dn \
|
||||
or not config.config_ldap_user_object:
|
||||
return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, '
|
||||
'Port, DN and User Object Identifier'))
|
||||
|
||||
@@ -1234,15 +1258,15 @@ def _configuration_ldap_helper(to_save):
|
||||
|
||||
|
||||
@admi.route("/ajax/simulatedbchange", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def simulatedbchange():
|
||||
db_change, db_valid = _db_simulate_change()
|
||||
return Response(json.dumps({"change": db_change, "valid": db_valid}), mimetype='application/json')
|
||||
return make_response(jsonify(change=db_change, valid=db_valid))
|
||||
|
||||
|
||||
@admi.route("/admin/user/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def new_user():
|
||||
content = ub.User()
|
||||
@@ -1264,7 +1288,7 @@ def new_user():
|
||||
|
||||
|
||||
@admi.route("/admin/mailsettings", methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_mailsettings():
|
||||
content = config.get_mail_settings()
|
||||
@@ -1273,7 +1297,7 @@ def edit_mailsettings():
|
||||
|
||||
|
||||
@admi.route("/admin/mailsettings", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_mailsettings():
|
||||
to_save = request.form.to_dict()
|
||||
@@ -1299,9 +1323,9 @@ def update_mailsettings():
|
||||
if to_save.get("mail_password_e", ""):
|
||||
_config_string(to_save, "mail_password_e")
|
||||
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
|
||||
config.mail_server = to_save.get('mail_server', "").strip()
|
||||
config.mail_from = to_save.get('mail_from', "").strip()
|
||||
config.mail_login = to_save.get('mail_login', "").strip()
|
||||
config.mail_server = strip_whitespaces(to_save.get('mail_server', ""))
|
||||
config.mail_from = strip_whitespaces(to_save.get('mail_from', ""))
|
||||
config.mail_login = strip_whitespaces(to_save.get('mail_login', ""))
|
||||
try:
|
||||
config.save()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
@@ -1330,7 +1354,7 @@ def update_mailsettings():
|
||||
|
||||
|
||||
@admi.route("/admin/scheduledtasks")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_scheduledtasks():
|
||||
content = config.get_scheduled_task_settings()
|
||||
@@ -1351,13 +1375,13 @@ def edit_scheduledtasks():
|
||||
|
||||
|
||||
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_scheduledtasks():
|
||||
error = False
|
||||
to_save = request.form.to_dict()
|
||||
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:
|
||||
flash(_("Invalid start time for task specified"), category="error")
|
||||
error = True
|
||||
@@ -1394,7 +1418,7 @@ def update_scheduledtasks():
|
||||
|
||||
|
||||
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_user(user_id):
|
||||
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
|
||||
@@ -1423,7 +1447,7 @@ def edit_user(user_id):
|
||||
|
||||
|
||||
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def reset_user_password(user_id):
|
||||
if current_user is not None and current_user.is_authenticated:
|
||||
@@ -1441,7 +1465,7 @@ def reset_user_password(user_id):
|
||||
|
||||
|
||||
@admi.route("/admin/logfile")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def view_logfile():
|
||||
logfiles = {0: logger.get_logfile(config.config_logfile),
|
||||
@@ -1455,7 +1479,7 @@ def view_logfile():
|
||||
|
||||
|
||||
@admi.route("/ajax/log/<int:logtype>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def send_logfile(logtype):
|
||||
if logtype == 1:
|
||||
@@ -1471,7 +1495,7 @@ def send_logfile(logtype):
|
||||
|
||||
|
||||
@admi.route("/admin/logdownload/<int:logtype>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def download_log(logtype):
|
||||
if logtype == 0:
|
||||
@@ -1486,14 +1510,14 @@ def download_log(logtype):
|
||||
|
||||
|
||||
@admi.route("/admin/debug")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def download_debug():
|
||||
return debug_info.send_debug()
|
||||
|
||||
|
||||
@admi.route("/get_update_status", methods=['GET'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def get_update_status():
|
||||
if feature_support['updater']:
|
||||
@@ -1504,7 +1528,7 @@ def get_update_status():
|
||||
|
||||
|
||||
@admi.route("/get_updater_status", methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def get_updater_status():
|
||||
status = {}
|
||||
@@ -1599,7 +1623,7 @@ def ldap_import_create_user(user, user_data):
|
||||
|
||||
|
||||
@admi.route('/import_ldap_users', methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def import_ldap_users():
|
||||
showtext = {}
|
||||
@@ -1616,7 +1640,10 @@ def import_ldap_users():
|
||||
|
||||
imported = 0
|
||||
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 member object field is empty take user object as filter
|
||||
if config.config_ldap_member_user_object:
|
||||
@@ -1651,7 +1678,7 @@ def import_ldap_users():
|
||||
|
||||
|
||||
@admi.route("/ajax/canceltask", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def cancel_task():
|
||||
task_id = request.get_json().get('task_id', None)
|
||||
@@ -1663,10 +1690,10 @@ def cancel_task():
|
||||
def _db_simulate_change():
|
||||
param = request.form.to_dict()
|
||||
to_save = dict()
|
||||
to_save['config_calibre_dir'] = re.sub(r'[\\/]metadata\.db$',
|
||||
to_save['config_calibre_dir'] = strip_whitespaces(re.sub(r'[\\/]metadata\.db$',
|
||||
'',
|
||||
param['config_calibre_dir'],
|
||||
flags=re.IGNORECASE).strip()
|
||||
flags=re.IGNORECASE))
|
||||
db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"],
|
||||
ub.app_DB_path,
|
||||
config.config_calibre_uuid)
|
||||
@@ -1700,14 +1727,19 @@ def _db_configuration_update_helper():
|
||||
db_change = True
|
||||
except Exception as ex:
|
||||
return _db_configuration_result('{}'.format(ex), gdrive_error)
|
||||
|
||||
if db_change or not db_valid or not config.db_configured \
|
||||
or config.config_calibre_dir != to_save["config_calibre_dir"]:
|
||||
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
|
||||
if config.config_calibre_split:
|
||||
split_dir = to_save.get("config_calibre_split_dir")
|
||||
if not os.path.exists(split_dir):
|
||||
return _db_configuration_result(_("Books path not valid"), gdrive_error)
|
||||
else:
|
||||
_config_string(to_save, "config_calibre_split_dir")
|
||||
if (db_change or not db_valid or not config.db_configured
|
||||
or config.config_calibre_dir != 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)
|
||||
else:
|
||||
calibre_db.setup_db(to_save['config_calibre_dir'], ub.app_DB_path)
|
||||
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||
# if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
|
||||
if db_change:
|
||||
log.info("Calibre Database changed, all Calibre-Web info related to old Database gets deleted")
|
||||
@@ -1721,10 +1753,20 @@ def _db_configuration_update_helper():
|
||||
ub.session.query(ub.KoboSyncedBooks).delete()
|
||||
helper.delete_thumbnail_cache()
|
||||
ub.session_commit()
|
||||
# deleted visibilities based on custom column and tags
|
||||
config.config_restricted_column = 0
|
||||
config.config_denied_tags = ""
|
||||
config.config_allowed_tags = ""
|
||||
config.config_columns_to_ignore = ""
|
||||
config.config_denied_column_value = ""
|
||||
config.config_allowed_column_value = ""
|
||||
config.config_read_column = 0
|
||||
_config_string(to_save, "config_calibre_dir")
|
||||
calibre_db.update_config(config)
|
||||
calibre_db.update_config(config, config.config_calibre_dir, ub.app_DB_path)
|
||||
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
|
||||
flash(_("DB is not Writeable"), category="warning")
|
||||
calibre_db.update_config(config, config.config_calibre_dir, ub.app_DB_path)
|
||||
config.save()
|
||||
return _db_configuration_result(None, gdrive_error)
|
||||
|
||||
@@ -1745,6 +1787,7 @@ def _configuration_update_helper():
|
||||
|
||||
_config_checkbox_int(to_save, "config_uploading")
|
||||
_config_checkbox_int(to_save, "config_unicode_filename")
|
||||
_config_checkbox_int(to_save, "config_embed_metadata")
|
||||
# Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case
|
||||
reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse")
|
||||
and config.config_login_type == constants.LOGIN_LDAP)
|
||||
@@ -1756,13 +1799,18 @@ def _configuration_update_helper():
|
||||
|
||||
if "config_upload_formats" in to_save:
|
||||
to_save["config_upload_formats"] = ','.join(
|
||||
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
||||
helper.uniq([x.strip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
||||
_config_string(to_save, "config_upload_formats")
|
||||
constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
|
||||
|
||||
_config_string(to_save, "config_calibre")
|
||||
_config_string(to_save, "config_converterpath")
|
||||
_config_string(to_save, "config_binariesdir")
|
||||
_config_string(to_save, "config_kepubifypath")
|
||||
if "config_binariesdir" in to_save:
|
||||
calibre_status = helper.check_calibre(config.config_binariesdir)
|
||||
if calibre_status:
|
||||
return _configuration_result(calibre_status)
|
||||
to_save["config_converterpath"] = get_calibre_binarypath("ebook-convert")
|
||||
_config_string(to_save, "config_converterpath")
|
||||
|
||||
reboot_required |= _config_int(to_save, "config_login_type")
|
||||
|
||||
@@ -1781,11 +1829,8 @@ def _configuration_update_helper():
|
||||
# Goodreads configuration
|
||||
_config_checkbox(to_save, "config_use_goodreads")
|
||||
_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:
|
||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||
config.config_goodreads_api_secret_e,
|
||||
config.config_use_goodreads)
|
||||
|
||||
_config_int(to_save, "config_updatechannel")
|
||||
@@ -1805,10 +1850,12 @@ def _configuration_update_helper():
|
||||
reboot_required |= reboot
|
||||
|
||||
# security configuration
|
||||
_config_checkbox(to_save, "config_check_extensions")
|
||||
_config_checkbox(to_save, "config_password_policy")
|
||||
_config_checkbox(to_save, "config_password_number")
|
||||
_config_checkbox(to_save, "config_password_lower")
|
||||
_config_checkbox(to_save, "config_password_upper")
|
||||
_config_checkbox(to_save, "config_password_character")
|
||||
_config_checkbox(to_save, "config_password_special")
|
||||
if 0 < int(to_save.get("config_password_min_length", "0")) < 41:
|
||||
_config_int(to_save, "config_password_min_length")
|
||||
@@ -1816,6 +1863,8 @@ def _configuration_update_helper():
|
||||
return _configuration_result(_('Password length has to be between 1 and 40'))
|
||||
reboot_required |= _config_int(to_save, "config_session")
|
||||
reboot_required |= _config_checkbox(to_save, "config_ratelimiter")
|
||||
reboot_required |= _config_string(to_save, "config_limiter_uri")
|
||||
reboot_required |= _config_string(to_save, "config_limiter_options")
|
||||
|
||||
# Rarfile Content configuration
|
||||
_config_string(to_save, "config_rarfile_location")
|
||||
@@ -1845,7 +1894,7 @@ def _configuration_result(error_flash=None, reboot=False):
|
||||
resp['result'] = [{'type': "success", 'message': _("Calibre-Web configuration updated")}]
|
||||
resp['reboot'] = reboot
|
||||
resp['config_upload'] = config.config_upload_formats
|
||||
return Response(json.dumps(resp), mimetype='application/json')
|
||||
return make_response(jsonify(resp))
|
||||
|
||||
|
||||
def _db_configuration_result(error_flash=None, gdrive_error=None):
|
||||
@@ -2050,7 +2099,7 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
||||
|
||||
|
||||
def extract_user_data_from_field(user, field):
|
||||
match = re.search(field + r"=([@\.\d\s\w-]+)", user, re.IGNORECASE | re.UNICODE)
|
||||
match = re.search(field + r"=(.*?)($|(?<!\\),)", user, re.IGNORECASE | re.UNICODE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
else:
|
||||
@@ -2058,7 +2107,7 @@ def extract_user_data_from_field(user, field):
|
||||
|
||||
|
||||
def extract_dynamic_field_from_filter(user, filtr):
|
||||
match = re.search("([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
|
||||
match = re.search(r"([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
else:
|
||||
|
||||
147
cps/audio.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2024 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/>.
|
||||
|
||||
import mutagen
|
||||
import base64
|
||||
from . import cover, logger
|
||||
|
||||
from cps.constants import BookMeta
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def get_audio_file_info(tmp_file_path, original_file_extension, original_file_name, no_cover_processing):
|
||||
tmp_cover_name = None
|
||||
audio_file = mutagen.File(tmp_file_path)
|
||||
comments = None
|
||||
if original_file_extension in [".mp3", ".wav", ".aiff"]:
|
||||
cover_data = list()
|
||||
for key, val in audio_file.tags.items():
|
||||
if key.startswith("APIC:"):
|
||||
cover_data.append(val)
|
||||
if key.startswith("COMM:"):
|
||||
comments = val.text[0]
|
||||
title = audio_file.tags.get('TIT2').text[0] if "TIT2" in audio_file.tags else None
|
||||
author = audio_file.tags.get('TPE1').text[0] if "TPE1" in audio_file.tags else None
|
||||
if author is None:
|
||||
author = audio_file.tags.get('TPE2').text[0] if "TPE2" in audio_file.tags else None
|
||||
tags = audio_file.tags.get('TCON').text[0] if "TCON" in audio_file.tags else None # Genre
|
||||
series = audio_file.tags.get('TALB').text[0] if "TALB" in audio_file.tags else None# Album
|
||||
series_id = audio_file.tags.get('TRCK').text[0] if "TRCK" in audio_file.tags else None # track no.
|
||||
publisher = audio_file.tags.get('TPUB').text[0] if "TPUB" in audio_file.tags else None
|
||||
pubdate = str(audio_file.tags.get('TDRL').text[0]) if "TDRL" in audio_file.tags else None
|
||||
if not pubdate:
|
||||
pubdate = str(audio_file.tags.get('TDRC').text[0]) if "TDRC" in audio_file.tags else None
|
||||
if not pubdate:
|
||||
pubdate = str(audio_file.tags.get('TDOR').text[0]) if "TDOR" in audio_file.tags else None
|
||||
if cover_data and not no_cover_processing:
|
||||
cover_info = cover_data[0]
|
||||
for dat in cover_data:
|
||||
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||
cover_info = dat
|
||||
break
|
||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||
elif original_file_extension in [".ogg", ".flac", ".opus", ".ogv"]:
|
||||
title = audio_file.tags.get('TITLE')[0] if "TITLE" in audio_file else None
|
||||
author = audio_file.tags.get('ARTIST')[0] if "ARTIST" in audio_file else None
|
||||
comments = audio_file.tags.get('COMMENTS')[0] if "COMMENTS" in audio_file else None
|
||||
tags = audio_file.tags.get('GENRE')[0] if "GENRE" in audio_file else None # Genre
|
||||
series = audio_file.tags.get('ALBUM')[0] if "ALBUM" in audio_file else None
|
||||
series_id = audio_file.tags.get('TRACKNUMBER')[0] if "TRACKNUMBER" in audio_file else None
|
||||
publisher = audio_file.tags.get('LABEL')[0] if "LABEL" in audio_file else None
|
||||
pubdate = audio_file.tags.get('DATE')[0] if "DATE" in audio_file else None
|
||||
cover_data = audio_file.tags.get('METADATA_BLOCK_PICTURE')
|
||||
if not no_cover_processing:
|
||||
if cover_data:
|
||||
cover_info = mutagen.flac.Picture(base64.b64decode(cover_data[0]))
|
||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||
if hasattr(audio_file, "pictures"):
|
||||
cover_info = audio_file.pictures[0]
|
||||
for dat in audio_file.pictures:
|
||||
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||
cover_info = dat
|
||||
break
|
||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||
elif original_file_extension in [".aac"]:
|
||||
title = audio_file.tags.get('Title').value if "Title" in audio_file else None
|
||||
author = audio_file.tags.get('Artist').value if "Artist" in audio_file else None
|
||||
comments = audio_file.tags.get('Comment').value if "Comment" in audio_file else None
|
||||
tags = audio_file.tags.get('Genre').value if "Genre" in audio_file else None
|
||||
series = audio_file.tags.get('Album').value if "Album" in audio_file else None
|
||||
series_id = audio_file.tags.get('Track').value if "Track" in audio_file else None
|
||||
publisher = audio_file.tags.get('Label').value if "Label" in audio_file else None
|
||||
pubdate = audio_file.tags.get('Year').value if "Year" in audio_file else None
|
||||
cover_data = audio_file.tags['Cover Art (Front)']
|
||||
if cover_data and not no_cover_processing:
|
||||
tmp_cover_name = tmp_file_path + '.jpg'
|
||||
with open(tmp_cover_name, "wb") as cover_file:
|
||||
cover_file.write(cover_data.value.split(b"\x00",1)[1])
|
||||
elif original_file_extension in [".asf"]:
|
||||
title = audio_file.tags.get('Title')[0].value if "Title" in audio_file else None
|
||||
author = audio_file.tags.get('Artist')[0].value if "Artist" in audio_file else None
|
||||
comments = audio_file.tags.get('Comments')[0].value if "Comments" in audio_file else None
|
||||
tags = audio_file.tags.get('Genre')[0].value if "Genre" in audio_file else None
|
||||
series = audio_file.tags.get('Album')[0].value if "Album" in audio_file else None
|
||||
series_id = audio_file.tags.get('Track')[0].value if "Track" in audio_file else None
|
||||
publisher = audio_file.tags.get('Label')[0].value if "Label" in audio_file else None
|
||||
pubdate = audio_file.tags.get('Year')[0].value if "Year" in audio_file else None
|
||||
cover_data = audio_file.tags.get('WM/Picture', None)
|
||||
if cover_data and not no_cover_processing:
|
||||
tmp_cover_name = tmp_file_path + '.jpg'
|
||||
with open(tmp_cover_name, "wb") as cover_file:
|
||||
cover_file.write(cover_data[0].value)
|
||||
elif original_file_extension in [".mp4", ".m4a", ".m4b"]:
|
||||
title = audio_file.tags.get('©nam')[0] if "©nam" in audio_file.tags else None
|
||||
author = audio_file.tags.get('©ART')[0] if "©ART" in audio_file.tags else None
|
||||
comments = audio_file.tags.get('©cmt')[0] if "©cmt" in audio_file.tags else None
|
||||
tags = audio_file.tags.get('©gen')[0] if "©gen" in audio_file.tags else None
|
||||
series = audio_file.tags.get('©alb')[0] if "©alb" in audio_file.tags else None
|
||||
series_id = str(audio_file.tags.get('trkn')[0][0]) if "trkn" in audio_file.tags else None
|
||||
publisher = ""
|
||||
pubdate = audio_file.tags.get('©day')[0] if "©day" in audio_file.tags else None
|
||||
cover_data = audio_file.tags.get('covr', None)
|
||||
if cover_data and not no_cover_processing:
|
||||
cover_type = None
|
||||
for c in cover_data:
|
||||
if c.imageformat == mutagen.mp4.AtomDataType.JPEG:
|
||||
cover_type =".jpg"
|
||||
cover_bin = c
|
||||
break
|
||||
elif c.imageformat == mutagen.mp4.AtomDataType.PNG:
|
||||
cover_type = ".png"
|
||||
cover_bin = c
|
||||
break
|
||||
if cover_type:
|
||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_bin, cover_type)
|
||||
else:
|
||||
logger.error("Unknown covertype in file {} ".format(original_file_name))
|
||||
|
||||
return BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=title or original_file_name ,
|
||||
author="Unknown" if author is None else author,
|
||||
cover=tmp_cover_name,
|
||||
description="" if comments is None else comments,
|
||||
tags="" if tags is None else tags,
|
||||
series="" if series is None else series,
|
||||
series_id="1" if series_id is None else series_id.split("/")[0],
|
||||
languages="",
|
||||
publisher= "" if publisher is None else publisher,
|
||||
pubdate="" if pubdate is None else pubdate,
|
||||
identifiers=[],
|
||||
)
|
||||
90
cps/basic.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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"))
|
||||
48
cps/clean_html.py
Normal file
@@ -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
|
||||
40
cps/cli.py
@@ -29,12 +29,25 @@ from .constants import DEFAULT_SETTINGS_FILE, DEFAULT_GDRIVE_FILE
|
||||
|
||||
def version_info():
|
||||
if _NIGHTLY_VERSION[1].startswith('$Format'):
|
||||
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION['version']
|
||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
||||
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION.replace("b", " Beta")
|
||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION.replace("b", " Beta"), _NIGHTLY_VERSION[1])
|
||||
|
||||
|
||||
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):
|
||||
self.arg_parser()
|
||||
|
||||
@@ -44,21 +57,25 @@ class CliParameter(object):
|
||||
prog='cps.py')
|
||||
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('-c', metavar='path', help='path and name to SSL certfile, e.g. /opt/test.cert, '
|
||||
'works only in combination with keyfile')
|
||||
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, '
|
||||
'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, '
|
||||
'works only in combination with certfile')
|
||||
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())
|
||||
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('-s', metavar='user:pass',
|
||||
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('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
||||
'and exits Calibre-Web')
|
||||
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
||||
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions '
|
||||
'in advance and exits Calibre-Web')
|
||||
parser.add_argument('-r', action='store_true', help='Enable public database reconnect '
|
||||
'route under /reconnect')
|
||||
args = parser.parse_args()
|
||||
|
||||
self.logpath = args.o or ""
|
||||
@@ -98,6 +115,8 @@ class CliParameter(object):
|
||||
if args.k == "":
|
||||
self.keyfilepath = ""
|
||||
|
||||
# overwrite limiter backend
|
||||
self.memory_backend = args.m or None
|
||||
# dry run updater
|
||||
self.dry_run = args.d or None
|
||||
# enable reconnect endpoint for docker database reconnect
|
||||
@@ -127,6 +146,3 @@ class CliParameter(object):
|
||||
if self.user_credentials and ":" not in self.user_credentials:
|
||||
print("No valid 'username:password' format")
|
||||
sys.exit(3)
|
||||
|
||||
if args.f:
|
||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
||||
|
||||
29
cps/comic.py
@@ -90,7 +90,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cover_data = cf.read([name])
|
||||
cover_data = cf.read(name)
|
||||
break
|
||||
except Exception as ex:
|
||||
log.error('Rarfile failed with error: {}'.format(ex))
|
||||
@@ -102,20 +102,20 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
||||
extension = ext[1].lower()
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
try:
|
||||
cover_data = cf.read(name)[name].read()
|
||||
cover_data = cf.read([name])[name].read()
|
||||
except (py7zr.Bad7zFile, OSError) as ex:
|
||||
log.error('7Zip file failed with error: {}'.format(ex))
|
||||
break
|
||||
return cover_data, extension
|
||||
|
||||
|
||||
def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
||||
def _extract_cover(tmp_file_path, original_file_extension, rar_executable):
|
||||
cover_data = extension = None
|
||||
if use_comic_meta:
|
||||
try:
|
||||
archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
|
||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
||||
except TypeError:
|
||||
archive = ComicArchive(tmp_file_name)
|
||||
archive = ComicArchive(tmp_file_path)
|
||||
name_list = archive.getPageNameList if hasattr(archive, "getPageNameList") else archive.get_page_name_list
|
||||
for index, name in enumerate(name_list()):
|
||||
ext = os.path.splitext(name)
|
||||
@@ -126,11 +126,11 @@ def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
||||
cover_data = get_page(index)
|
||||
break
|
||||
else:
|
||||
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
|
||||
return cover.cover_processing(tmp_file_name, cover_data, extension)
|
||||
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_path, rar_executable)
|
||||
return cover.cover_processing(tmp_file_path, cover_data, extension)
|
||||
|
||||
|
||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable):
|
||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable, no_cover_processing):
|
||||
if use_comic_meta:
|
||||
try:
|
||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
||||
@@ -155,14 +155,17 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
|
||||
|
||||
lang = loaded_metadata.language or ""
|
||||
loaded_metadata.language = isoLanguages.get_lang3(lang)
|
||||
|
||||
if not no_cover_processing:
|
||||
cover_file = _extract_cover(tmp_file_path, original_file_extension, rar_executable)
|
||||
else:
|
||||
cover_file = None
|
||||
return BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=loaded_metadata.title or original_file_name,
|
||||
author=" & ".join([credit["person"]
|
||||
for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown',
|
||||
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||
cover=cover_file,
|
||||
description=loaded_metadata.comments or "",
|
||||
tags="",
|
||||
series=loaded_metadata.series or "",
|
||||
@@ -171,13 +174,17 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
|
||||
publisher="",
|
||||
pubdate="",
|
||||
identifiers=[])
|
||||
if not no_cover_processing:
|
||||
cover_file = _extract_cover(tmp_file_path, original_file_extension, rar_executable)
|
||||
else:
|
||||
cover_file = None
|
||||
|
||||
return BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=original_file_name,
|
||||
author='Unknown',
|
||||
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||
cover=cover_file,
|
||||
description="",
|
||||
tags="",
|
||||
series="",
|
||||
|
||||
@@ -34,7 +34,8 @@ except ImportError:
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from . import constants, logger
|
||||
|
||||
from .subproc_wrapper import process_wait
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
_Base = declarative_base()
|
||||
@@ -47,6 +48,7 @@ class _Flask_Settings(_Base):
|
||||
flask_session_key = Column(BLOB, default=b"")
|
||||
|
||||
def __init__(self, key):
|
||||
super().__init__()
|
||||
self.flask_session_key = key
|
||||
|
||||
|
||||
@@ -69,6 +71,8 @@ class _Settings(_Base):
|
||||
|
||||
config_calibre_dir = Column(String)
|
||||
config_calibre_uuid = Column(String)
|
||||
config_calibre_split = Column(Boolean, default=False)
|
||||
config_calibre_split_dir = Column(String)
|
||||
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||
config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||
config_certfile = Column(String)
|
||||
@@ -79,7 +83,9 @@ class _Settings(_Base):
|
||||
config_random_books = Column(Integer, default=4)
|
||||
config_authors_max = 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_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
||||
@@ -111,8 +117,6 @@ class _Settings(_Base):
|
||||
|
||||
config_use_goodreads = Column(Boolean, default=False)
|
||||
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_login_type = Column(Integer, default=0)
|
||||
|
||||
@@ -138,10 +142,12 @@ class _Settings(_Base):
|
||||
|
||||
config_kepubifypath = Column(String, default=None)
|
||||
config_converterpath = Column(String, default=None)
|
||||
config_binariesdir = Column(String, default=None)
|
||||
config_calibre = Column(String)
|
||||
config_rarfile_location = Column(String, default=None)
|
||||
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||
config_unicode_filename = Column(Boolean, default=False)
|
||||
config_embed_metadata = Column(Boolean, default=True)
|
||||
|
||||
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
||||
|
||||
@@ -160,9 +166,13 @@ class _Settings(_Base):
|
||||
config_password_number = Column(Boolean, default=True)
|
||||
config_password_lower = Column(Boolean, default=True)
|
||||
config_password_upper = Column(Boolean, default=True)
|
||||
config_password_character = Column(Boolean, default=True)
|
||||
config_password_special = Column(Boolean, default=True)
|
||||
config_session = Column(Integer, default=1)
|
||||
config_ratelimiter = Column(Boolean, default=True)
|
||||
config_limiter_uri = Column(String, default="")
|
||||
config_limiter_options = Column(String, default="")
|
||||
config_check_extensions = Column(Boolean, default=True)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__class__.__name__
|
||||
@@ -184,15 +194,17 @@ class ConfigSQL(object):
|
||||
self.load()
|
||||
|
||||
change = False
|
||||
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
||||
change = True
|
||||
self.config_converterpath = autodetect_calibre_binary()
|
||||
|
||||
if self.config_kepubifypath == None: # pylint: disable=access-member-before-definition
|
||||
if self.config_binariesdir is None:
|
||||
change = True
|
||||
self.config_binariesdir = autodetect_calibre_binaries()
|
||||
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
|
||||
|
||||
if self.config_kepubifypath is None:
|
||||
change = True
|
||||
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
|
||||
self.config_rarfile_location = autodetect_unrar_binary()
|
||||
if change:
|
||||
@@ -256,19 +268,19 @@ class ConfigSQL(object):
|
||||
|
||||
def list_denied_tags(self):
|
||||
mct = self.config_denied_tags or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_tags(self):
|
||||
mct = self.config_allowed_tags or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_denied_column_values(self):
|
||||
mct = self.config_denied_column_value or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_column_values(self):
|
||||
mct = self.config_allowed_column_value or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def get_log_level(self):
|
||||
return logger.get_level_name(self.config_log_level)
|
||||
@@ -340,7 +352,7 @@ class ConfigSQL(object):
|
||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||
have_metadata_db = os.path.isfile(db_file)
|
||||
self.db_configured = have_metadata_db
|
||||
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||
|
||||
from . import cli_param
|
||||
if os.environ.get('FLASK_DEBUG'):
|
||||
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||
@@ -389,12 +401,17 @@ class ConfigSQL(object):
|
||||
self.db_configured = False
|
||||
self.save()
|
||||
|
||||
def get_book_path(self):
|
||||
return self.config_calibre_split_dir if self.config_calibre_split_dir else self.config_calibre_dir
|
||||
|
||||
def store_calibre_uuid(self, calibre_db, Library_table):
|
||||
from . import app
|
||||
try:
|
||||
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
||||
if self.config_calibre_uuid != calibre_uuid.uuid:
|
||||
self.config_calibre_uuid = calibre_uuid.uuid
|
||||
self.save()
|
||||
with app.app_context():
|
||||
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
||||
if self.config_calibre_uuid != calibre_uuid.uuid:
|
||||
self.config_calibre_uuid = calibre_uuid.uuid
|
||||
self.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -409,23 +426,16 @@ def _encrypt_fields(session, secret_key):
|
||||
except OperationalError:
|
||||
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 'config_goodreads_api_secret_e' String"))
|
||||
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String"))
|
||||
session.commit()
|
||||
crypter = Fernet(secret_key)
|
||||
settings = session.query(_Settings.mail_password, _Settings.config_goodreads_api_secret,
|
||||
_Settings.config_ldap_serv_password).first()
|
||||
settings = session.query(_Settings.mail_password, _Settings.config_ldap_serv_password).first()
|
||||
if settings.mail_password:
|
||||
session.query(_Settings).update(
|
||||
{_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:
|
||||
session.query(_Settings).update(
|
||||
{_Settings.config_ldap_serv_password_e:
|
||||
crypter.encrypt(settings.config_ldap_serv_password.encode())})
|
||||
{_Settings.config_ldap_serv_password_e: crypter.encrypt(settings.config_ldap_serv_password.encode())})
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -469,17 +479,37 @@ def _migrate_table(session, orm_class, secret_key=None):
|
||||
session.rollback()
|
||||
|
||||
|
||||
def autodetect_calibre_binary():
|
||||
def autodetect_calibre_binaries():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\calibre\\ebook-convert.exe",
|
||||
"C:\\program files(x86)\\calibre\\ebook-convert.exe",
|
||||
"C:\\program files(x86)\\calibre2\\ebook-convert.exe",
|
||||
"C:\\program files\\calibre2\\ebook-convert.exe"]
|
||||
calibre_path = ["C:\\program files\\calibre\\",
|
||||
"C:\\program files(x86)\\calibre\\",
|
||||
"C:\\program files(x86)\\calibre2\\",
|
||||
"C:\\program files\\calibre2\\"]
|
||||
elif sys.platform.startswith("freebsd"):
|
||||
calibre_path = ["/usr/local/bin/"]
|
||||
else:
|
||||
calibre_path = ["/opt/calibre/ebook-convert"]
|
||||
calibre_path = ["/opt/calibre/"]
|
||||
for element in calibre_path:
|
||||
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||
return element
|
||||
supported_binary_paths = [os.path.join(element, binary)
|
||||
for binary in constants.SUPPORTED_CALIBRE_BINARIES.values()]
|
||||
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):
|
||||
version = values[0].group(1)
|
||||
log.debug("calibre version %s", version)
|
||||
return element
|
||||
return ""
|
||||
|
||||
|
||||
def autodetect_converter_binary(calibre_path):
|
||||
if sys.platform == "win32":
|
||||
converter_path = os.path.join(calibre_path, "ebook-convert.exe")
|
||||
else:
|
||||
converter_path = os.path.join(calibre_path, "ebook-convert")
|
||||
if calibre_path and os.path.isfile(converter_path) and os.access(converter_path, os.X_OK):
|
||||
return converter_path
|
||||
return ""
|
||||
|
||||
|
||||
@@ -487,6 +517,8 @@ def autodetect_unrar_binary():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||
"C:\\program files(x86)\\WinRar\\unRAR.exe"]
|
||||
elif sys.platform.startswith("freebsd"):
|
||||
calibre_path = ["/usr/local/bin/unrar"]
|
||||
else:
|
||||
calibre_path = ["/usr/bin/unrar"]
|
||||
for element in calibre_path:
|
||||
@@ -499,6 +531,8 @@ def autodetect_kepubify_binary():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||
"C:\\program files(x86)\\kepubify\\kepubify-windows-64Bit.exe"]
|
||||
elif sys.platform.startswith("freebsd"):
|
||||
calibre_path = ["/usr/local/bin/kepubify"]
|
||||
else:
|
||||
calibre_path = ["/opt/kepubify/kepubify-linux-64bit", "/opt/kepubify/kepubify-linux-32bit"]
|
||||
for element in calibre_path:
|
||||
@@ -523,7 +557,7 @@ def load_configuration(session, secret_key):
|
||||
|
||||
def get_flask_session_key(_session):
|
||||
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))
|
||||
_session.add(flask_settings)
|
||||
_session.commit()
|
||||
@@ -534,6 +568,7 @@ def get_encryption_key(key_path):
|
||||
key_file = os.path.join(key_path, ".key")
|
||||
generate = True
|
||||
error = ""
|
||||
key = None
|
||||
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
|
||||
with open(key_file, "rb") as f:
|
||||
key = f.read()
|
||||
|
||||
@@ -19,9 +19,6 @@
|
||||
import sys
|
||||
import os
|
||||
from collections import namedtuple
|
||||
from sqlalchemy import __version__ as sql_version
|
||||
|
||||
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
|
||||
|
||||
# APP_MODE - production, development, or test
|
||||
APP_MODE = os.environ.get('APP_MODE', 'production')
|
||||
@@ -34,6 +31,8 @@ UPDATER_AVAILABLE = True
|
||||
|
||||
# Base dir is parent of current file, necessary if called from different folder
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
|
||||
# if executable file the files should be placed in the parent dir (parallel to the exe file)
|
||||
|
||||
STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
|
||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
||||
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
||||
@@ -49,6 +48,9 @@ if HOME_CONFIG:
|
||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
||||
else:
|
||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
||||
if getattr(sys, 'frozen', False):
|
||||
CONFIG_DIR = os.path.abspath(os.path.join(CONFIG_DIR, os.pardir))
|
||||
|
||||
|
||||
DEFAULT_SETTINGS_FILE = "app.db"
|
||||
DEFAULT_GDRIVE_FILE = "gdrive.db"
|
||||
@@ -144,17 +146,23 @@ del env_CALIBRE_PORT
|
||||
|
||||
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
|
||||
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr']
|
||||
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr', 'prc']
|
||||
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
|
||||
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'cb7', 'djvu', 'djv',
|
||||
'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg',
|
||||
'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||
|
||||
_extension = ""
|
||||
if sys.platform == "win32":
|
||||
_extension = ".exe"
|
||||
SUPPORTED_CALIBRE_BINARIES = {binary: binary + _extension for binary in ["ebook-convert", "calibredb"]}
|
||||
|
||||
|
||||
def has_flag(value, bit_flag):
|
||||
return bit_flag == (bit_flag & (value or 0))
|
||||
|
||||
|
||||
def selected_roles(dictionary):
|
||||
return sum(v for k, v in ALL_ROLES.items() if k in dictionary)
|
||||
|
||||
@@ -163,13 +171,12 @@ def selected_roles(dictionary):
|
||||
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
||||
'series_id, languages, publisher, pubdate, identifiers')
|
||||
|
||||
STABLE_VERSION = {'version': '0.6.21'}
|
||||
# python build process likes to have x.y.zbw -> b for beta and w a counting number
|
||||
STABLE_VERSION = '0.6.25'
|
||||
|
||||
NIGHTLY_VERSION = dict()
|
||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||
NIGHTLY_VERSION[1] = '$Format:%cI$'
|
||||
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
||||
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
|
||||
|
||||
# CACHE
|
||||
CACHE_TYPE_THUMBNAILS = 'thumbnails'
|
||||
@@ -183,7 +190,7 @@ THUMBNAIL_TYPE_AUTHOR = 3
|
||||
COVER_THUMBNAIL_ORIGINAL = 0
|
||||
COVER_THUMBNAIL_SMALL = 1
|
||||
COVER_THUMBNAIL_MEDIUM = 2
|
||||
COVER_THUMBNAIL_LARGE = 3
|
||||
COVER_THUMBNAIL_LARGE = 4
|
||||
|
||||
# clean-up the module namespace
|
||||
del sys, os, namedtuple
|
||||
|
||||
@@ -29,13 +29,14 @@ NO_JPEG_EXTENSIONS = ['.png', '.webp', '.bmp']
|
||||
COVER_EXTENSIONS = ['.png', '.webp', '.bmp', '.jpg', '.jpeg']
|
||||
|
||||
|
||||
def cover_processing(tmp_file_name, img, extension):
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
||||
def cover_processing(tmp_file_path, img, extension):
|
||||
# tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
||||
tmp_cover_name = tmp_file_path + '.jpg'
|
||||
if extension in NO_JPEG_EXTENSIONS:
|
||||
if use_IM:
|
||||
with Image(blob=img) as imgc:
|
||||
imgc.format = 'jpeg'
|
||||
imgc.transform_colorspace('rgb')
|
||||
imgc.transform_colorspace('srgb')
|
||||
imgc.save(filename=tmp_cover_name)
|
||||
return tmp_cover_name
|
||||
else:
|
||||
|
||||
22
cps/cw_advocate/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
|
||||
from .adapters import ValidatingHTTPAdapter
|
||||
from .api import *
|
||||
from .addrvalidator import AddrValidator
|
||||
from .exceptions import UnacceptableAddressException
|
||||
48
cps/cw_advocate/adapters.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK
|
||||
|
||||
from .addrvalidator import AddrValidator
|
||||
from .exceptions import ProxyDisabledException
|
||||
from .poolmanager import ValidatingPoolManager
|
||||
|
||||
|
||||
class ValidatingHTTPAdapter(HTTPAdapter):
|
||||
__attrs__ = HTTPAdapter.__attrs__ + ['_validator']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._validator = kwargs.pop('validator', None)
|
||||
if not self._validator:
|
||||
self._validator = AddrValidator()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK,
|
||||
**pool_kwargs):
|
||||
self._pool_connections = connections
|
||||
self._pool_maxsize = maxsize
|
||||
self._pool_block = block
|
||||
self.poolmanager = ValidatingPoolManager(
|
||||
num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
validator=self._validator,
|
||||
**pool_kwargs
|
||||
)
|
||||
|
||||
def proxy_manager_for(self, proxy, **proxy_kwargs):
|
||||
raise ProxyDisabledException("Proxies cannot be used with Advocate")
|
||||
281
cps/cw_advocate/addrvalidator.py
Normal file
@@ -0,0 +1,281 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
import functools
|
||||
import fnmatch
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
try:
|
||||
import netifaces
|
||||
HAVE_NETIFACES = True
|
||||
except ImportError:
|
||||
netifaces = None
|
||||
HAVE_NETIFACES = False
|
||||
|
||||
from .exceptions import NameserverException, ConfigException
|
||||
|
||||
|
||||
def canonicalize_hostname(hostname):
|
||||
"""Lowercase and punycodify a hostname"""
|
||||
# We do the lowercasing after IDNA encoding because we only want to
|
||||
# lowercase the *ASCII* chars.
|
||||
# TODO: The differences between IDNA2003 and IDNA2008 might be relevant
|
||||
# to us, but both specs are damn confusing.
|
||||
return str(hostname.encode("idna").lower(), 'utf-8')
|
||||
|
||||
|
||||
def determine_local_addresses():
|
||||
"""Get all IPs that refer to this machine according to netifaces"""
|
||||
if not HAVE_NETIFACES:
|
||||
raise ConfigException("Tried to determine local addresses, "
|
||||
"but netifaces module was not importable")
|
||||
ips = []
|
||||
for interface in netifaces.interfaces():
|
||||
if_families = netifaces.ifaddresses(interface)
|
||||
for family_kind in {netifaces.AF_INET, netifaces.AF_INET6}:
|
||||
addrs = if_families.get(family_kind, [])
|
||||
for addr in (x.get("addr", "") for x in addrs):
|
||||
if family_kind == netifaces.AF_INET6:
|
||||
# We can't do anything sensible with the scope here
|
||||
addr = addr.split("%")[0]
|
||||
ips.append(ipaddress.ip_network(addr))
|
||||
return ips
|
||||
|
||||
|
||||
def add_local_address_arg(func):
|
||||
"""Add the "_local_addresses" kwarg if it's missing
|
||||
|
||||
IMO this information shouldn't be cached between calls (what if one of the
|
||||
adapters got a new IP at runtime?,) and we don't want each function to
|
||||
recalculate it. Just recalculate it if the caller didn't provide it for us.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if "_local_addresses" not in kwargs:
|
||||
if self.autodetect_local_addresses:
|
||||
kwargs["_local_addresses"] = determine_local_addresses()
|
||||
else:
|
||||
kwargs["_local_addresses"] = []
|
||||
return func(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class AddrValidator:
|
||||
_6TO4_RELAY_NET = ipaddress.ip_network("192.88.99.0/24")
|
||||
# Just the well known prefix, DNS64 servers can set their own
|
||||
# prefix, but in practice most probably don't.
|
||||
_DNS64_WK_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||
DEFAULT_PORT_WHITELIST = {80, 8080, 443, 8443, 8000}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ip_blacklist=None,
|
||||
ip_whitelist=None,
|
||||
port_whitelist=None,
|
||||
port_blacklist=None,
|
||||
hostname_blacklist=None,
|
||||
allow_ipv6=False,
|
||||
allow_teredo=False,
|
||||
allow_6to4=False,
|
||||
allow_dns64=False,
|
||||
# Must be explicitly set to "False" if you don't want to try
|
||||
# detecting local interface addresses with netifaces.
|
||||
autodetect_local_addresses=True,
|
||||
):
|
||||
if not port_blacklist and not port_whitelist:
|
||||
# An assortment of common HTTPS? ports.
|
||||
port_whitelist = self.DEFAULT_PORT_WHITELIST.copy()
|
||||
self.ip_blacklist = ip_blacklist or set()
|
||||
self.ip_whitelist = ip_whitelist or set()
|
||||
self.port_blacklist = port_blacklist or set()
|
||||
self.port_whitelist = port_whitelist or set()
|
||||
# TODO: ATM this can contain either regexes or globs that are converted
|
||||
# to regexes upon every check. Create a collection that automagically
|
||||
# converts them to regexes on insert?
|
||||
self.hostname_blacklist = hostname_blacklist or set()
|
||||
self.allow_ipv6 = allow_ipv6
|
||||
self.allow_teredo = allow_teredo
|
||||
self.allow_6to4 = allow_6to4
|
||||
self.allow_dns64 = allow_dns64
|
||||
self.autodetect_local_addresses = autodetect_local_addresses
|
||||
|
||||
@add_local_address_arg
|
||||
def is_ip_allowed(self, addr_ip, _local_addresses=None):
|
||||
if not isinstance(addr_ip,
|
||||
(ipaddress.IPv4Address, ipaddress.IPv6Address)):
|
||||
addr_ip = ipaddress.ip_address(addr_ip)
|
||||
|
||||
# The whitelist should take precedence over the blacklist so we can
|
||||
# punch holes in blacklisted ranges
|
||||
if any(addr_ip in net for net in self.ip_whitelist):
|
||||
return True
|
||||
|
||||
if any(addr_ip in net for net in self.ip_blacklist):
|
||||
return False
|
||||
|
||||
if any(addr_ip in net for net in _local_addresses):
|
||||
return False
|
||||
|
||||
if addr_ip.version == 4:
|
||||
if not addr_ip.is_private:
|
||||
# IPs for carrier-grade NAT. Seems weird that it doesn't set
|
||||
# `is_private`, but we need to check `not is_global`
|
||||
if not ipaddress.ip_network(addr_ip).is_global:
|
||||
return False
|
||||
elif addr_ip.version == 6:
|
||||
# You'd better have a good reason for enabling IPv6
|
||||
# because Advocate's techniques don't work well without NAT.
|
||||
if not self.allow_ipv6:
|
||||
return False
|
||||
|
||||
# v6 addresses can also map to IPv4 addresses! Tricky!
|
||||
v4_nested = []
|
||||
if addr_ip.ipv4_mapped:
|
||||
v4_nested.append(addr_ip.ipv4_mapped)
|
||||
# WTF IPv6? Why you gotta have a billion tunneling mechanisms?
|
||||
# XXX: Do we even really care about these? If we're tunneling
|
||||
# through public servers we shouldn't be able to access
|
||||
# addresses on our private network, right?
|
||||
if addr_ip.sixtofour:
|
||||
if not self.allow_6to4:
|
||||
return False
|
||||
v4_nested.append(addr_ip.sixtofour)
|
||||
if addr_ip.teredo:
|
||||
if not self.allow_teredo:
|
||||
return False
|
||||
# Check both the client *and* server IPs
|
||||
v4_nested.extend(addr_ip.teredo)
|
||||
if addr_ip in self._DNS64_WK_PREFIX:
|
||||
if not self.allow_dns64:
|
||||
return False
|
||||
# When using the well-known prefix the last 4 bytes
|
||||
# are the IPv4 addr
|
||||
v4_nested.append(ipaddress.ip_address(addr_ip.packed[-4:]))
|
||||
|
||||
if not all(self.is_ip_allowed(addr_v4) for addr_v4 in v4_nested):
|
||||
return False
|
||||
|
||||
# fec0::*, apparently deprecated?
|
||||
if addr_ip.is_site_local:
|
||||
return False
|
||||
else:
|
||||
raise ValueError("Unsupported IP version(?): %r" % addr_ip)
|
||||
|
||||
# 169.254.XXX.XXX, AWS uses these for autoconfiguration
|
||||
if addr_ip.is_link_local:
|
||||
return False
|
||||
# 127.0.0.1, ::1, etc.
|
||||
if addr_ip.is_loopback:
|
||||
return False
|
||||
if addr_ip.is_multicast:
|
||||
return False
|
||||
# 192.168.XXX.XXX, 10.XXX.XXX.XXX
|
||||
if addr_ip.is_private:
|
||||
return False
|
||||
# 255.255.255.255, ::ffff:XXXX:XXXX (v6->v4) mapping
|
||||
if addr_ip.is_reserved:
|
||||
return False
|
||||
# There's no reason to connect directly to a 6to4 relay
|
||||
if addr_ip in self._6TO4_RELAY_NET:
|
||||
return False
|
||||
# 0.0.0.0
|
||||
if addr_ip.is_unspecified:
|
||||
return False
|
||||
|
||||
# It doesn't look bad, so... it's must be ok!
|
||||
return True
|
||||
|
||||
def _hostname_matches_pattern(self, hostname, pattern):
|
||||
# If they specified a string, just assume they only want basic globbing.
|
||||
# This stops people from not realizing they're dealing in REs and
|
||||
# not escaping their periods unless they specifically pass in an RE.
|
||||
# This has the added benefit of letting us sanely handle globbed
|
||||
# IDNs by default.
|
||||
if isinstance(pattern, str):
|
||||
# convert the glob to a punycode glob, then a regex
|
||||
pattern = fnmatch.translate(canonicalize_hostname(pattern))
|
||||
|
||||
hostname = canonicalize_hostname(hostname)
|
||||
# Down the line the hostname may get treated as a null-terminated string
|
||||
# (as with `socket.getaddrinfo`.) Try to account for that.
|
||||
#
|
||||
# >>> socket.getaddrinfo("example.com\x00aaaa", 80)
|
||||
# [(2, 1, 6, '', ('93.184.216.34', 80)), [...]
|
||||
no_null_hostname = hostname.split("\x00")[0]
|
||||
|
||||
return any(re.match(pattern, x.strip(".")) for x
|
||||
in (no_null_hostname, hostname))
|
||||
|
||||
def is_hostname_allowed(self, hostname):
|
||||
# Sometimes (like with "external" services that your IP has privileged
|
||||
# access to) you might not always know the IP range to blacklist access
|
||||
# to, or the `A` record might change without you noticing.
|
||||
# For e.x.: `foocorp.external.org`.
|
||||
#
|
||||
# Another option is doing something like:
|
||||
#
|
||||
# for addrinfo in socket.getaddrinfo("foocorp.external.org", 80):
|
||||
# global_validator.ip_blacklist.add(ip_address(addrinfo[4][0]))
|
||||
#
|
||||
# but that's not always a good idea if they're behind a third-party lb.
|
||||
for pattern in self.hostname_blacklist:
|
||||
if self._hostname_matches_pattern(hostname, pattern):
|
||||
return False
|
||||
return True
|
||||
|
||||
@add_local_address_arg
|
||||
def is_addrinfo_allowed(self, addrinfo, _local_addresses=None):
|
||||
assert(len(addrinfo) == 5)
|
||||
# XXX: Do we care about any of the other elements? Guessing not.
|
||||
family, socktype, proto, canonname, sockaddr = addrinfo
|
||||
|
||||
# The 4th elem inaddrinfo may either be a touple of two or four items,
|
||||
# depending on whether we're dealing with IPv4 or v6
|
||||
if len(sockaddr) == 2:
|
||||
# v4
|
||||
ip, port = sockaddr
|
||||
elif len(sockaddr) == 4:
|
||||
# v6
|
||||
# XXX: what *are* `flow_info` and `scope_id`? Anything useful?
|
||||
# Seems like we can figure out all we need about the scope from
|
||||
# the `is_<x>` properties.
|
||||
ip, port, flow_info, scope_id = sockaddr
|
||||
else:
|
||||
raise ValueError("Unexpected addrinfo format %r" % sockaddr)
|
||||
|
||||
# Probably won't help protect against SSRF, but might prevent our being
|
||||
# used to attack others' non-HTTP services. See
|
||||
# http://www.remote.org/jochen/sec/hfpa/
|
||||
if self.port_whitelist and port not in self.port_whitelist:
|
||||
return False
|
||||
if port in self.port_blacklist:
|
||||
return False
|
||||
|
||||
if self.hostname_blacklist:
|
||||
if not canonname:
|
||||
raise NameserverException(
|
||||
"addrinfo must contain the canon name to do blacklisting "
|
||||
"based on hostname. Make sure you use the "
|
||||
"`socket.AI_CANONNAME` flag, and that each record contains "
|
||||
"the canon name. Your DNS server might also be garbage."
|
||||
)
|
||||
|
||||
if not self.is_hostname_allowed(canonname):
|
||||
return False
|
||||
|
||||
return self.is_ip_allowed(ip, _local_addresses=_local_addresses)
|
||||
202
cps/cw_advocate/api.py
Normal file
@@ -0,0 +1,202 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
"""
|
||||
advocate.api
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This module implements the Requests API, largely a copy/paste from `requests`
|
||||
itself.
|
||||
|
||||
:copyright: (c) 2015 by Jordan Milne.
|
||||
:license: Apache2, see LICENSE for more details.
|
||||
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import pickle
|
||||
|
||||
from requests import Session as RequestsSession
|
||||
|
||||
# import cw_advocate
|
||||
from .adapters import ValidatingHTTPAdapter
|
||||
from .exceptions import MountDisabledException
|
||||
|
||||
|
||||
class Session(RequestsSession):
|
||||
"""Convenience wrapper around `requests.Session` set up for `advocate`ing"""
|
||||
|
||||
__attrs__ = RequestsSession.__attrs__ + ["validator"]
|
||||
DEFAULT_VALIDATOR = None
|
||||
"""
|
||||
User-replaceable default validator to use for all Advocate sessions,
|
||||
includes sessions created by advocate.get()
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.validator = kwargs.pop("validator", None) or self.DEFAULT_VALIDATOR
|
||||
adapter_kwargs = kwargs.pop("_adapter_kwargs", {})
|
||||
|
||||
# `Session.__init__()` calls `mount()` internally, so we need to allow
|
||||
# it temporarily
|
||||
self.__mount_allowed = True
|
||||
RequestsSession.__init__(self, *args, **kwargs)
|
||||
|
||||
# Drop any existing adapters
|
||||
self.adapters = OrderedDict()
|
||||
|
||||
self.mount("http://", ValidatingHTTPAdapter(validator=self.validator, **adapter_kwargs))
|
||||
self.mount("https://", ValidatingHTTPAdapter(validator=self.validator, **adapter_kwargs))
|
||||
self.__mount_allowed = False
|
||||
|
||||
def mount(self, *args, **kwargs):
|
||||
"""Wrapper around `mount()` to prevent a protection bypass"""
|
||||
if self.__mount_allowed:
|
||||
super().mount(*args, **kwargs)
|
||||
else:
|
||||
raise MountDisabledException(
|
||||
"mount() is disabled to prevent protection bypasses"
|
||||
)
|
||||
|
||||
|
||||
def session(*args, **kwargs):
|
||||
return Session(*args, **kwargs)
|
||||
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
"""Constructs and sends a :class:`Request <Request>`.
|
||||
|
||||
:param method: method for the new :class:`Request` object.
|
||||
:param url: URL for the new :class:`Request` object.
|
||||
:param params: (optional) Dictionary or bytes to be sent in the query string for the :class:`Request`.
|
||||
:param data: (optional) Dictionary, bytes, or file-like object to send in the body of the :class:`Request`.
|
||||
:param json: (optional) json data to send in the body of the :class:`Request`.
|
||||
:param headers: (optional) Dictionary of HTTP Headers to send with the :class:`Request`.
|
||||
:param cookies: (optional) Dict or CookieJar object to send with the :class:`Request`.
|
||||
:param files: (optional) Dictionary of ``'name': file-like-objects`` (or ``{'name': ('filename', fileobj)}``) for multipart encoding upload.
|
||||
:param auth: (optional) Auth tuple to enable Basic/Digest/Custom HTTP Auth.
|
||||
:param timeout: (optional) How long to wait for the server to send data
|
||||
before giving up, as a float, or a (`connect timeout, read timeout
|
||||
<user/advanced.html#timeouts>`_) tuple.
|
||||
:type timeout: float or tuple
|
||||
:param allow_redirects: (optional) Boolean. Set to True if POST/PUT/DELETE redirect following is allowed.
|
||||
:type allow_redirects: bool
|
||||
:param proxies: (optional) Dictionary mapping protocol to the URL of the proxy.
|
||||
:param verify: (optional) if ``True``, the SSL cert will be verified. A CA_BUNDLE path can also be provided.
|
||||
:param stream: (optional) if ``False``, the response content will be immediately downloaded.
|
||||
:param cert: (optional) if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair.
|
||||
:return: :class:`Response <Response>` object
|
||||
:rtype: requests.Response
|
||||
"""
|
||||
|
||||
validator = kwargs.pop("validator", None)
|
||||
with Session(validator=validator) as sess:
|
||||
response = sess.request(method=method, url=url, **kwargs)
|
||||
return response
|
||||
|
||||
|
||||
def get(url, **kwargs):
|
||||
"""Sends a GET request.
|
||||
|
||||
:param url: URL for the new :class:`Request` object.
|
||||
:param **kwargs: Optional arguments that ``request`` takes.
|
||||
:return: :class:`Response <Response>` object
|
||||
:rtype: requests.Response
|
||||
"""
|
||||
|
||||
kwargs.setdefault('allow_redirects', True)
|
||||
return request('get', url, **kwargs)
|
||||
|
||||
|
||||
class RequestsAPIWrapper:
|
||||
"""Provides a `requests.api`-like interface with a specific validator"""
|
||||
|
||||
# Due to how the classes are dynamically constructed pickling may not work
|
||||
# correctly unless loaded within the same interpreter instance.
|
||||
# Enable at your peril.
|
||||
SUPPORT_WRAPPER_PICKLING = False
|
||||
|
||||
def __init__(self, validator):
|
||||
# Do this here to avoid circular import issues
|
||||
try:
|
||||
from .futures import FuturesSession
|
||||
have_requests_futures = True
|
||||
except ImportError as e:
|
||||
have_requests_futures = False
|
||||
|
||||
self.validator = validator
|
||||
outer_self = self
|
||||
|
||||
class _WrappedSession(Session):
|
||||
"""An `advocate.Session` that uses the wrapper's blacklist
|
||||
|
||||
the wrapper is meant to be a transparent replacement for `requests`,
|
||||
so people should be able to subclass `wrapper.Session` and still
|
||||
get the desired validation behaviour
|
||||
"""
|
||||
DEFAULT_VALIDATOR = outer_self.validator
|
||||
|
||||
self._make_wrapper_cls_global(_WrappedSession)
|
||||
|
||||
if have_requests_futures:
|
||||
|
||||
class _WrappedFuturesSession(FuturesSession):
|
||||
"""Like _WrappedSession, but for `FuturesSession`s"""
|
||||
DEFAULT_VALIDATOR = outer_self.validator
|
||||
self._make_wrapper_cls_global(_WrappedFuturesSession)
|
||||
|
||||
self.FuturesSession = _WrappedFuturesSession
|
||||
|
||||
self.request = self._default_arg_wrapper(request)
|
||||
self.get = self._default_arg_wrapper(get)
|
||||
self.Session = _WrappedSession
|
||||
|
||||
def __getattr__(self, item):
|
||||
# This class is meant to mimic the requests base module, so if we don't
|
||||
# have this attribute, it might be on the base module (like the Request
|
||||
# class, etc.)
|
||||
try:
|
||||
return object.__getattribute__(self, item)
|
||||
except AttributeError:
|
||||
from . import cw_advocate
|
||||
return getattr(cw_advocate, item)
|
||||
|
||||
def _default_arg_wrapper(self, fun):
|
||||
def wrapped_func(*args, **kwargs):
|
||||
kwargs.setdefault("validator", self.validator)
|
||||
return fun(*args, **kwargs)
|
||||
return wrapped_func
|
||||
|
||||
def _make_wrapper_cls_global(self, cls):
|
||||
if not self.SUPPORT_WRAPPER_PICKLING:
|
||||
return
|
||||
# Gnarly, but necessary to give pickle a consistent module-level
|
||||
# reference for each wrapper.
|
||||
wrapper_hash = hashlib.sha256(pickle.dumps(self)).hexdigest()
|
||||
cls.__name__ = "_".join((cls.__name__, wrapper_hash))
|
||||
cls.__qualname__ = ".".join((__name__, cls.__name__))
|
||||
if not globals().get(cls.__name__):
|
||||
globals()[cls.__name__] = cls
|
||||
|
||||
|
||||
__all__ = (
|
||||
"get",
|
||||
"request",
|
||||
"session",
|
||||
"Session",
|
||||
"RequestsAPIWrapper",
|
||||
)
|
||||
201
cps/cw_advocate/connection.py
Normal file
@@ -0,0 +1,201 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from socket import timeout as SocketTimeout
|
||||
|
||||
from urllib3.connection import HTTPSConnection, HTTPConnection
|
||||
from urllib3.exceptions import ConnectTimeoutError
|
||||
from urllib3.util.connection import _set_socket_options
|
||||
from urllib3.util.connection import create_connection as old_create_connection
|
||||
|
||||
from . import addrvalidator
|
||||
from .exceptions import UnacceptableAddressException
|
||||
|
||||
|
||||
def advocate_getaddrinfo(host, port, get_canonname=False):
|
||||
addrinfo = socket.getaddrinfo(
|
||||
host,
|
||||
port,
|
||||
0,
|
||||
socket.SOCK_STREAM,
|
||||
0,
|
||||
# We need what the DNS client sees the hostname as, correctly handles
|
||||
# IDNs and tricky things like `private.foocorp.org\x00.google.com`.
|
||||
# All IDNs will be converted to punycode.
|
||||
socket.AI_CANONNAME if get_canonname else 0,
|
||||
)
|
||||
return fix_addrinfo(addrinfo)
|
||||
|
||||
|
||||
def fix_addrinfo(records):
|
||||
"""
|
||||
Propagate the canonname across records and parse IPs
|
||||
|
||||
I'm not sure if this is just the behaviour of `getaddrinfo` on Linux, but
|
||||
it seems like only the first record in the set has the canonname field
|
||||
populated.
|
||||
"""
|
||||
def fix_record(record, canonname):
|
||||
sa = record[4]
|
||||
sa = (ipaddress.ip_address(sa[0]),) + sa[1:]
|
||||
return record[0], record[1], record[2], canonname, sa
|
||||
|
||||
canonname = None
|
||||
if records:
|
||||
# Apparently the canonical name is only included in the first record?
|
||||
# Add it to all of them.
|
||||
assert(len(records[0]) == 5)
|
||||
canonname = records[0][3]
|
||||
return tuple(fix_record(x, canonname) for x in records)
|
||||
|
||||
|
||||
# Lifted from requests' urllib3, which in turn lifted it from `socket.py`. Oy!
|
||||
def validating_create_connection(address,
|
||||
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
|
||||
source_address=None, socket_options=None,
|
||||
validator=None):
|
||||
"""Connect to *address* and return the socket object.
|
||||
|
||||
Convenience function. Connect to *address* (a 2-tuple ``(host,
|
||||
port)``) and return the socket object. Passing the optional
|
||||
*timeout* parameter will set the timeout on the socket instance
|
||||
before attempting to connect. If no *timeout* is supplied, the
|
||||
global default timeout setting returned by :func:`getdefaulttimeout`
|
||||
is used. If *source_address* is set it must be a tuple of (host, port)
|
||||
for the socket to bind as a source address before making the connection.
|
||||
An host of '' or port 0 tells the OS to use the default.
|
||||
"""
|
||||
|
||||
host, port = address
|
||||
# We can skip asking for the canon name if we're not doing hostname-based
|
||||
# blacklisting.
|
||||
need_canonname = False
|
||||
if validator.hostname_blacklist:
|
||||
need_canonname = True
|
||||
# We check both the non-canonical and canonical hostnames so we can
|
||||
# catch both of these:
|
||||
# CNAME from nonblacklisted.com -> blacklisted.com
|
||||
# CNAME from blacklisted.com -> nonblacklisted.com
|
||||
if not validator.is_hostname_allowed(host):
|
||||
raise UnacceptableAddressException(host)
|
||||
|
||||
err = None
|
||||
addrinfo = advocate_getaddrinfo(host, port, get_canonname=need_canonname)
|
||||
if addrinfo:
|
||||
if validator.autodetect_local_addresses:
|
||||
local_addresses = addrvalidator.determine_local_addresses()
|
||||
else:
|
||||
local_addresses = []
|
||||
for res in addrinfo:
|
||||
# Are we allowed to connect with this result?
|
||||
if not validator.is_addrinfo_allowed(
|
||||
res,
|
||||
_local_addresses=local_addresses,
|
||||
):
|
||||
continue
|
||||
af, socktype, proto, canonname, sa = res
|
||||
# Unparse the validated IP
|
||||
sa = (sa[0].exploded,) + sa[1:]
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
|
||||
# If provided, set socket level options before connecting.
|
||||
# This is the only addition urllib3 makes to this function.
|
||||
_set_socket_options(sock, socket_options)
|
||||
|
||||
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT:
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sa)
|
||||
return sock
|
||||
|
||||
except socket.error as _:
|
||||
err = _
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
sock = None
|
||||
|
||||
if err is None:
|
||||
# If we got here, none of the results were acceptable
|
||||
err = UnacceptableAddressException(address)
|
||||
if err is not None:
|
||||
raise err
|
||||
else:
|
||||
raise socket.error("getaddrinfo returns an empty list")
|
||||
|
||||
|
||||
# TODO: Is there a better way to add this to multiple classes with different
|
||||
# base classes? I tried a mixin, but it used the base method instead.
|
||||
def _validating_new_conn(self):
|
||||
""" Establish a socket connection and set nodelay settings on it.
|
||||
|
||||
:return: New socket connection.
|
||||
"""
|
||||
extra_kw = {}
|
||||
if self.source_address:
|
||||
extra_kw['source_address'] = self.source_address
|
||||
|
||||
if self.socket_options:
|
||||
extra_kw['socket_options'] = self.socket_options
|
||||
|
||||
try:
|
||||
# Hack around HTTPretty's patched sockets
|
||||
# TODO: some better method of hacking around it that checks if we
|
||||
# _would have_ connected to a private addr?
|
||||
conn_func = validating_create_connection
|
||||
if socket.getaddrinfo.__module__.startswith("httpretty"):
|
||||
conn_func = old_create_connection
|
||||
else:
|
||||
extra_kw["validator"] = self._validator
|
||||
|
||||
conn = conn_func(
|
||||
(self.host, self.port),
|
||||
self.timeout,
|
||||
**extra_kw
|
||||
)
|
||||
|
||||
except SocketTimeout:
|
||||
raise ConnectTimeoutError(
|
||||
self, "Connection to %s timed out. (connect timeout=%s)" %
|
||||
(self.host, self.timeout))
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
# Don't silently break if the private API changes across urllib3 versions
|
||||
assert(hasattr(HTTPConnection, '_new_conn'))
|
||||
assert(hasattr(HTTPSConnection, '_new_conn'))
|
||||
|
||||
|
||||
class ValidatingHTTPConnection(HTTPConnection):
|
||||
_new_conn = _validating_new_conn
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._validator = kwargs.pop("validator")
|
||||
HTTPConnection.__init__(self, *args, **kwargs)
|
||||
|
||||
|
||||
class ValidatingHTTPSConnection(HTTPSConnection):
|
||||
_new_conn = _validating_new_conn
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._validator = kwargs.pop("validator")
|
||||
HTTPSConnection.__init__(self, *args, **kwargs)
|
||||
39
cps/cw_advocate/connectionpool.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool
|
||||
|
||||
from .connection import (
|
||||
ValidatingHTTPConnection,
|
||||
ValidatingHTTPSConnection,
|
||||
)
|
||||
|
||||
# Don't silently break if the private API changes across urllib3 versions
|
||||
assert(hasattr(HTTPConnectionPool, 'ConnectionCls'))
|
||||
assert(hasattr(HTTPSConnectionPool, 'ConnectionCls'))
|
||||
assert(hasattr(HTTPConnectionPool, 'scheme'))
|
||||
assert(hasattr(HTTPSConnectionPool, 'scheme'))
|
||||
|
||||
|
||||
class ValidatingHTTPConnectionPool(HTTPConnectionPool):
|
||||
scheme = 'http'
|
||||
ConnectionCls = ValidatingHTTPConnection
|
||||
|
||||
|
||||
class ValidatingHTTPSConnectionPool(HTTPSConnectionPool):
|
||||
scheme = 'https'
|
||||
ConnectionCls = ValidatingHTTPSConnection
|
||||
39
cps/cw_advocate/exceptions.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
class AdvocateException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnacceptableAddressException(AdvocateException):
|
||||
pass
|
||||
|
||||
|
||||
class NameserverException(AdvocateException):
|
||||
pass
|
||||
|
||||
|
||||
class MountDisabledException(AdvocateException):
|
||||
pass
|
||||
|
||||
|
||||
class ProxyDisabledException(NotImplementedError, AdvocateException):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigException(AdvocateException):
|
||||
pass
|
||||
61
cps/cw_advocate/poolmanager.py
Normal file
@@ -0,0 +1,61 @@
|
||||
#
|
||||
# Copyright 2015 Jordan Milne
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Source: https://github.com/JordanMilne/Advocate
|
||||
|
||||
import collections
|
||||
import functools
|
||||
|
||||
from urllib3 import PoolManager
|
||||
from urllib3.poolmanager import _default_key_normalizer, PoolKey
|
||||
|
||||
from .connectionpool import (
|
||||
ValidatingHTTPSConnectionPool,
|
||||
ValidatingHTTPConnectionPool,
|
||||
)
|
||||
|
||||
pool_classes_by_scheme = {
|
||||
"http": ValidatingHTTPConnectionPool,
|
||||
"https": ValidatingHTTPSConnectionPool,
|
||||
}
|
||||
|
||||
AdvocatePoolKey = collections.namedtuple('AdvocatePoolKey',
|
||||
PoolKey._fields + ('key_validator',))
|
||||
|
||||
|
||||
def key_normalizer(key_class, request_context):
|
||||
request_context = request_context.copy()
|
||||
# TODO: add ability to serialize validator rules to dict,
|
||||
# allowing pool to be shared between sessions with the same
|
||||
# rules.
|
||||
request_context["validator"] = id(request_context["validator"])
|
||||
return _default_key_normalizer(key_class, request_context)
|
||||
|
||||
|
||||
key_fn_by_scheme = {
|
||||
'http': functools.partial(key_normalizer, AdvocatePoolKey),
|
||||
'https': functools.partial(key_normalizer, AdvocatePoolKey),
|
||||
}
|
||||
|
||||
|
||||
class ValidatingPoolManager(PoolManager):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make sure the API hasn't changed
|
||||
assert (hasattr(self, 'pool_classes_by_scheme'))
|
||||
|
||||
self.pool_classes_by_scheme = pool_classes_by_scheme
|
||||
self.key_fn_by_scheme = key_fn_by_scheme.copy()
|
||||
@@ -2,7 +2,7 @@ from babel import negotiate_locale
|
||||
from flask_babel import Babel, Locale
|
||||
from babel.core import UnknownLocaleError
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
|
||||
from . import logger
|
||||
|
||||
@@ -10,6 +10,7 @@ log = logger.create()
|
||||
|
||||
babel = Babel()
|
||||
|
||||
|
||||
def get_locale():
|
||||
# if a user is logged in, use the locale from the user settings
|
||||
if current_user is not None and hasattr(current_user, "locale"):
|
||||
@@ -33,7 +34,7 @@ def get_user_locale_language(user_language):
|
||||
|
||||
|
||||
def get_available_locale():
|
||||
return [Locale('en')] + babel.list_translations()
|
||||
return sorted(babel.list_translations(), key=lambda x: x.display_name.lower())
|
||||
|
||||
|
||||
def get_available_translations():
|
||||
98
cps/cw_login/__init__.py
Normal file
@@ -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)
|
||||
55
cps/cw_login/config.py
Normal file
@@ -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
|
||||
555
cps/cw_login/login_manager.py
Normal file
@@ -0,0 +1,555 @@
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
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.now(timezone.utc) + 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
|
||||
65
cps/cw_login/mixins.py
Normal file
@@ -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
|
||||
61
cps/cw_login/signals.py
Normal file
@@ -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)
|
||||
424
cps/cw_login/utils.py
Normal file
@@ -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
|
||||
242
cps/db.py
@@ -20,9 +20,11 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote
|
||||
import unidecode
|
||||
# from weakref import WeakSet
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlite3 import OperationalError as sqliteOperationalError
|
||||
from sqlalchemy import create_engine
|
||||
@@ -40,16 +42,14 @@ except ImportError:
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
|
||||
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 get_locale
|
||||
from flask import flash
|
||||
from flask import flash, g, Flask
|
||||
|
||||
from . import logger, ub, isoLanguages
|
||||
from .pagination import Pagination
|
||||
|
||||
from weakref import WeakSet
|
||||
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@@ -102,8 +102,19 @@ class Identifiers(Base):
|
||||
type = Column(String(collation='NOCASE'), nullable=False, default="isbn")
|
||||
val = Column(String(collation='NOCASE'), nullable=False)
|
||||
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||
amazon = {
|
||||
"jp": "co.jp",
|
||||
"uk": "co.uk",
|
||||
"us": "com",
|
||||
"au": "com.au",
|
||||
"be": "com.be",
|
||||
"br": "com.br",
|
||||
"tr": "com.tr",
|
||||
"mx": "com.mx",
|
||||
}
|
||||
|
||||
def __init__(self, val, id_type, book):
|
||||
super().__init__()
|
||||
self.val = val
|
||||
self.type = id_type
|
||||
self.book = book
|
||||
@@ -113,7 +124,11 @@ class Identifiers(Base):
|
||||
if format_type == 'amazon':
|
||||
return "Amazon"
|
||||
elif format_type.startswith("amazon_"):
|
||||
return "Amazon.{0}".format(format_type[7:])
|
||||
label_amazon = "Amazon.{0}"
|
||||
country_code = format_type[7:].lower()
|
||||
if country_code not in self.amazon:
|
||||
return label_amazon.format(country_code)
|
||||
return label_amazon.format(self.amazon[country_code])
|
||||
elif format_type == "isbn":
|
||||
return "ISBN"
|
||||
elif format_type == "doi":
|
||||
@@ -128,12 +143,20 @@ class Identifiers(Base):
|
||||
return "Google Books"
|
||||
elif format_type == "kobo":
|
||||
return "Kobo"
|
||||
elif format_type == "barnesnoble":
|
||||
return "Barnes & Noble"
|
||||
elif format_type == "litres":
|
||||
return "ЛитРес"
|
||||
elif format_type == "issn":
|
||||
return "ISSN"
|
||||
elif format_type == "isfdb":
|
||||
return "ISFDB"
|
||||
elif format_type == "storygraph":
|
||||
return "StoryGraph"
|
||||
elif format_type == "ebooks":
|
||||
return "eBooks.com"
|
||||
elif format_type == "smashwords":
|
||||
return "Smashwords"
|
||||
if format_type == "lubimyczytac":
|
||||
return "Lubimyczytac"
|
||||
if format_type == "databazeknih":
|
||||
@@ -146,7 +169,11 @@ class Identifiers(Base):
|
||||
if format_type == "amazon" or format_type == "asin":
|
||||
return "https://amazon.com/dp/{0}".format(self.val)
|
||||
elif format_type.startswith('amazon_'):
|
||||
return "https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
|
||||
link_amazon = "https://amazon.{0}/dp/{1}"
|
||||
country_code = format_type[7:].lower()
|
||||
if country_code not in self.amazon:
|
||||
return link_amazon.format(country_code, self.val)
|
||||
return link_amazon.format(self.amazon[country_code], self.val)
|
||||
elif format_type == "isbn":
|
||||
return "https://www.worldcat.org/isbn/{0}".format(self.val)
|
||||
elif format_type == "doi":
|
||||
@@ -161,6 +188,8 @@ class Identifiers(Base):
|
||||
return "https://books.google.com/books?id={0}".format(self.val)
|
||||
elif format_type == "kobo":
|
||||
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":
|
||||
return "https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
|
||||
elif format_type == "litres":
|
||||
@@ -168,13 +197,19 @@ class Identifiers(Base):
|
||||
elif format_type == "issn":
|
||||
return "https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
||||
elif format_type == "isfdb":
|
||||
return "http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
||||
return "https://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
||||
elif format_type == "databazeknih":
|
||||
return "https://www.databazeknih.cz/knihy/{0}".format(self.val)
|
||||
elif format_type == "storygraph":
|
||||
return "https://app.thestorygraph.com/books/{0}".format(self.val)
|
||||
elif format_type == "ebooks":
|
||||
return "https://www.ebooks.com/en-us/book/{0}".format(self.val)
|
||||
elif format_type == "smashwords":
|
||||
return "https://www.smashwords.com/books/view/{0}".format(self.val)
|
||||
elif self.val.lower().startswith("javascript:"):
|
||||
return quote(self.val)
|
||||
elif self.val.lower().startswith("data:"):
|
||||
link , __, __ = str.partition(self.val, ",")
|
||||
link, __, __ = str.partition(self.val, ",")
|
||||
return link
|
||||
else:
|
||||
return "{0}".format(self.val)
|
||||
@@ -188,6 +223,7 @@ class Comments(Base):
|
||||
text = Column(String(collation='NOCASE'), nullable=False)
|
||||
|
||||
def __init__(self, comment, book):
|
||||
super().__init__()
|
||||
self.text = comment
|
||||
self.book = book
|
||||
|
||||
@@ -205,6 +241,7 @@ class Tags(Base):
|
||||
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
|
||||
def get(self):
|
||||
@@ -226,6 +263,7 @@ class Authors(Base):
|
||||
link = Column(String, nullable=False, default="")
|
||||
|
||||
def __init__(self, name, sort, link=""):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.sort = sort
|
||||
self.link = link
|
||||
@@ -248,6 +286,7 @@ class Series(Base):
|
||||
sort = Column(String(collation='NOCASE'))
|
||||
|
||||
def __init__(self, name, sort):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.sort = sort
|
||||
|
||||
@@ -268,6 +307,7 @@ class Ratings(Base):
|
||||
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
|
||||
|
||||
def __init__(self, rating):
|
||||
super().__init__()
|
||||
self.rating = rating
|
||||
|
||||
def get(self):
|
||||
@@ -287,6 +327,7 @@ class Languages(Base):
|
||||
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
|
||||
|
||||
def __init__(self, lang_code):
|
||||
super().__init__()
|
||||
self.lang_code = lang_code
|
||||
|
||||
def get(self):
|
||||
@@ -310,6 +351,7 @@ class Publishers(Base):
|
||||
sort = Column(String(collation='NOCASE'))
|
||||
|
||||
def __init__(self, name, sort):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.sort = sort
|
||||
|
||||
@@ -334,6 +376,7 @@ class Data(Base):
|
||||
name = Column(String, nullable=False)
|
||||
|
||||
def __init__(self, book, book_format, uncompressed_size, name):
|
||||
super().__init__()
|
||||
self.book = book
|
||||
self.format = book_format
|
||||
self.uncompressed_size = uncompressed_size
|
||||
@@ -353,6 +396,7 @@ class Metadata_Dirtied(Base):
|
||||
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
|
||||
|
||||
def __init__(self, book):
|
||||
super().__init__()
|
||||
self.book = book
|
||||
|
||||
|
||||
@@ -365,10 +409,10 @@ class Books(Base):
|
||||
title = Column(String(collation='NOCASE'), nullable=False, default='Unknown')
|
||||
sort = Column(String(collation='NOCASE'))
|
||||
author_sort = Column(String(collation='NOCASE'))
|
||||
timestamp = Column(TIMESTAMP, default=datetime.utcnow)
|
||||
timestamp = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
|
||||
pubdate = Column(TIMESTAMP, default=DEFAULT_PUBDATE)
|
||||
series_index = Column(String, nullable=False, default="1.0")
|
||||
last_modified = Column(TIMESTAMP, default=datetime.utcnow)
|
||||
last_modified = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
|
||||
path = Column(String, default="", nullable=False)
|
||||
has_cover = Column(Integer, default=0)
|
||||
uuid = Column(String)
|
||||
@@ -387,6 +431,7 @@ class Books(Base):
|
||||
|
||||
def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover,
|
||||
authors, tags, languages=None):
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self.sort = sort
|
||||
self.author_sort = author_sort
|
||||
@@ -395,12 +440,12 @@ class Books(Base):
|
||||
self.series_index = series_index
|
||||
self.last_modified = last_modified
|
||||
self.path = path
|
||||
self.has_cover = (has_cover != None)
|
||||
self.has_cover = (has_cover is not None)
|
||||
|
||||
def __repr__(self):
|
||||
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.last_modified, self.path, self.has_cover)
|
||||
self.timestamp, self.pubdate, self.series_index,
|
||||
self.last_modified, self.path, self.has_cover)
|
||||
|
||||
@property
|
||||
def atom_timestamp(self):
|
||||
@@ -444,11 +489,13 @@ class CustomColumns(Base):
|
||||
content['is_editable'] = self.editable
|
||||
content['rec_index'] = sequence + 22 # toDo why ??
|
||||
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:
|
||||
content['#value#'] = value
|
||||
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)
|
||||
|
||||
|
||||
@@ -493,35 +540,25 @@ class AlchemyEncoder(json.JSONEncoder):
|
||||
|
||||
|
||||
class CalibreDB:
|
||||
_init = False
|
||||
engine = None
|
||||
config = None
|
||||
session_factory = None
|
||||
# This is a WeakSet so that references here don't keep other CalibreDB
|
||||
# instances alive once they reach the end of their respective scopes
|
||||
instances = WeakSet()
|
||||
config_calibre_dir = None
|
||||
app_db_path = None
|
||||
|
||||
def __init__(self, expire_on_commit=True, init=False):
|
||||
def __init__(self, _app: Flask=None): # , expire_on_commit=True, init=False):
|
||||
""" Initialize a new CalibreDB session
|
||||
"""
|
||||
self.session = None
|
||||
if init:
|
||||
self.init_db(expire_on_commit)
|
||||
self.Session = None
|
||||
#if init:
|
||||
# self.init_db(expire_on_commit)
|
||||
if _app is not None and not _app._got_first_request:
|
||||
self.init_app(_app)
|
||||
|
||||
|
||||
def init_db(self, expire_on_commit=True):
|
||||
if self._init:
|
||||
self.init_session(expire_on_commit)
|
||||
|
||||
self.instances.add(self)
|
||||
|
||||
def init_session(self, expire_on_commit=True):
|
||||
self.session = self.session_factory()
|
||||
self.session.expire_on_commit = expire_on_commit
|
||||
self.update_title_sort(self.config)
|
||||
def init_app(self, _app):
|
||||
_app.teardown_appcontext(self.teardown)
|
||||
|
||||
@classmethod
|
||||
def setup_db_cc_classes(cls, cc):
|
||||
global cc_classes
|
||||
cc_ids = []
|
||||
books_custom_column_links = {}
|
||||
for row in cc:
|
||||
@@ -589,8 +626,6 @@ class CalibreDB:
|
||||
secondary=books_custom_column_links[cc_id[0]],
|
||||
backref='books'))
|
||||
|
||||
return cc_classes
|
||||
|
||||
@classmethod
|
||||
def check_valid_db(cls, config_calibre_dir, app_db_path, config_calibre_uuid):
|
||||
if not config_calibre_dir:
|
||||
@@ -610,7 +645,6 @@ class CalibreDB:
|
||||
local_session = scoped_session(sessionmaker())
|
||||
local_session.configure(bind=connection)
|
||||
database_uuid = local_session().query(Library_Id).one_or_none()
|
||||
# local_session.dispose()
|
||||
|
||||
check_engine.connect()
|
||||
db_change = config_calibre_uuid != database_uuid.uuid
|
||||
@@ -618,13 +652,30 @@ class CalibreDB:
|
||||
return False, False
|
||||
return True, db_change
|
||||
|
||||
def teardown(self, exception):
|
||||
ctx = g.get("lib_sql")
|
||||
if ctx:
|
||||
ctx.close()
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
# connect or get active connection
|
||||
if not g.get("lib_sql"):
|
||||
g.lib_sql = self.connect()
|
||||
return g.lib_sql
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, config):
|
||||
def update_config(cls, config, config_calibre_dir, app_db_path):
|
||||
cls.config = config
|
||||
cls.config_calibre_dir = config_calibre_dir
|
||||
cls.app_db_path = app_db_path
|
||||
|
||||
|
||||
def connect(self):
|
||||
return self.setup_db(self.config_calibre_dir, self.app_db_path)
|
||||
|
||||
@classmethod
|
||||
def setup_db(cls, config_calibre_dir, app_db_path):
|
||||
cls.dispose()
|
||||
|
||||
if not config_calibre_dir:
|
||||
cls.config.invalidate()
|
||||
@@ -636,16 +687,17 @@ class CalibreDB:
|
||||
return None
|
||||
|
||||
try:
|
||||
cls.engine = create_engine('sqlite://',
|
||||
engine = create_engine('sqlite://',
|
||||
echo=False,
|
||||
isolation_level="SERIALIZABLE",
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool)
|
||||
with cls.engine.begin() as connection:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text('PRAGMA cache_size = 10000;'))
|
||||
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||
|
||||
conn = cls.engine.connect()
|
||||
conn = engine.connect()
|
||||
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
|
||||
except Exception as ex:
|
||||
cls.config.invalidate(ex)
|
||||
@@ -661,13 +713,10 @@ class CalibreDB:
|
||||
log.error_or_exception(e)
|
||||
return None
|
||||
|
||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||
autoflush=True,
|
||||
bind=cls.engine, future=True))
|
||||
for inst in cls.instances:
|
||||
inst.init_session()
|
||||
return scoped_session(sessionmaker(autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine, future=True))
|
||||
|
||||
cls._init = True
|
||||
|
||||
def get_book(self, book_id):
|
||||
return self.session.query(Books).filter(Books.id == book_id).first()
|
||||
@@ -718,8 +767,8 @@ class CalibreDB:
|
||||
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
||||
if not allow_show_archived:
|
||||
archived_books = (ub.session.query(ub.ArchivedBook)
|
||||
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
||||
.filter(ub.ArchivedBook.is_archived == True)
|
||||
.filter(ub.ArchivedBook.user_id==int(current_user.id))
|
||||
.filter(ub.ArchivedBook.is_archived==True)
|
||||
.all())
|
||||
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
||||
archived_filter = Books.id.notin_(archived_book_ids)
|
||||
@@ -839,8 +888,7 @@ class CalibreDB:
|
||||
entries = list()
|
||||
pagination = list()
|
||||
try:
|
||||
pagination = Pagination(page, pagesize,
|
||||
len(query.all()))
|
||||
pagination = Pagination(page, pagesize, query.count())
|
||||
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
||||
except Exception as ex:
|
||||
log.error_or_exception(ex)
|
||||
@@ -861,10 +909,12 @@ class CalibreDB:
|
||||
authors_ordered = list()
|
||||
# error = False
|
||||
for auth in sort_authors:
|
||||
results = self.session.query(Authors).filter(Authors.sort == auth.lstrip().strip()).all()
|
||||
auth = strip_whitespaces(auth)
|
||||
results = self.session.query(Authors).filter(Authors.sort == auth).all()
|
||||
# ToDo: How to handle not found author name
|
||||
if not len(results):
|
||||
log.error("Author {} not found to display name in right order".format(auth.strip()))
|
||||
book_id = entry.id if isinstance(entry, Books) else entry[0].id
|
||||
log.error("Author '{}' of book {} not found to display name in right order".format(auth, book_id))
|
||||
# error = True
|
||||
break
|
||||
for r in results:
|
||||
@@ -886,7 +936,8 @@ class CalibreDB:
|
||||
|
||||
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
|
||||
query = query or ''
|
||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
self.create_functions()
|
||||
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
entries = self.session.query(database).filter(tag_filter). \
|
||||
filter(func.lower(database.name).ilike("%" + query + "%")).all()
|
||||
# json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries])
|
||||
@@ -894,7 +945,8 @@ class CalibreDB:
|
||||
return json_dumps
|
||||
|
||||
def check_exists_book(self, authr, title):
|
||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
self.create_functions()
|
||||
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
q = list()
|
||||
author_terms = re.split(r'\s*&\s*', authr)
|
||||
for author_term in author_terms:
|
||||
@@ -904,8 +956,9 @@ class CalibreDB:
|
||||
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
|
||||
|
||||
def search_query(self, term, config, *join):
|
||||
term.strip().lower()
|
||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
strip_whitespaces(term).lower()
|
||||
self.create_functions()
|
||||
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
q = list()
|
||||
author_terms = re.split("[, ]+", term)
|
||||
for author_term in author_terms:
|
||||
@@ -956,7 +1009,7 @@ class CalibreDB:
|
||||
pagination = None
|
||||
result = self.search_query(term, config, *join).order_by(*order).all()
|
||||
result_count = len(result)
|
||||
if offset != None and limit != None:
|
||||
if offset is not None and limit is not None:
|
||||
offset = int(offset)
|
||||
limit_all = offset + int(limit)
|
||||
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||
@@ -986,7 +1039,7 @@ class CalibreDB:
|
||||
if not return_all_languages:
|
||||
no_lang_count = (self.session.query(Books)
|
||||
.outerjoin(books_languages_link).outerjoin(Languages)
|
||||
.filter(Languages.lang_code == None)
|
||||
.filter(Languages.lang_code==None)
|
||||
.filter(self.common_filters())
|
||||
.count())
|
||||
if no_lang_count:
|
||||
@@ -1003,7 +1056,7 @@ class CalibreDB:
|
||||
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
|
||||
return sorted(languages, key=lambda x: x.name, reverse=reverse_order)
|
||||
|
||||
def update_title_sort(self, config, conn=None):
|
||||
def create_functions(self, config=None):
|
||||
# user defined sort function for calibre databases (Series, etc.)
|
||||
def _title_sort(title):
|
||||
# calibre sort stuff
|
||||
@@ -1012,56 +1065,27 @@ class CalibreDB:
|
||||
if match:
|
||||
prep = match.group(1)
|
||||
title = title[len(prep):] + ', ' + prep
|
||||
return title.strip()
|
||||
return strip_whitespaces(title)
|
||||
|
||||
try:
|
||||
# sqlalchemy <1.4.24
|
||||
conn = conn or self.session.connection().connection.driver_connection
|
||||
# sqlalchemy <1.4.24 and sqlalchemy 2.0
|
||||
conn = self.session.connection().connection.driver_connection
|
||||
except AttributeError:
|
||||
# sqlalchemy >1.4.24 and sqlalchemy 2.0
|
||||
conn = conn or self.session.connection().connection.connection
|
||||
# sqlalchemy >1.4.24
|
||||
conn = self.session.connection().connection.connection
|
||||
try:
|
||||
conn.create_function("title_sort", 1, _title_sort)
|
||||
if config:
|
||||
conn.create_function("title_sort", 1, _title_sort)
|
||||
conn.create_function('uuid4', 0, lambda: str(uuid4()))
|
||||
conn.create_function("lower", 1, lcase)
|
||||
except sqliteOperationalError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def dispose(cls):
|
||||
# global session
|
||||
|
||||
for inst in cls.instances:
|
||||
old_session = inst.session
|
||||
inst.session = None
|
||||
if old_session:
|
||||
try:
|
||||
old_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
if old_session.bind:
|
||||
try:
|
||||
old_session.bind.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for attr in list(Books.__dict__.keys()):
|
||||
if attr.startswith("custom_column_"):
|
||||
setattr(Books, attr, None)
|
||||
|
||||
for db_class in cc_classes.values():
|
||||
Base.metadata.remove(db_class.__table__)
|
||||
cc_classes.clear()
|
||||
|
||||
for table in reversed(Base.metadata.sorted_tables):
|
||||
name = table.key
|
||||
if name.startswith("custom_column_") or name.startswith("books_custom_column_"):
|
||||
if table is not None:
|
||||
Base.metadata.remove(table)
|
||||
|
||||
def reconnect_db(self, config, app_db_path):
|
||||
self.dispose()
|
||||
self.engine.dispose()
|
||||
# self.dispose()
|
||||
# self.engine.dispose()
|
||||
self.setup_db(config.config_calibre_dir, app_db_path)
|
||||
self.update_config(config)
|
||||
self.update_config(config, config.config_calibre_dir, app_db_path)
|
||||
|
||||
|
||||
def lcase(s):
|
||||
@@ -1084,9 +1108,3 @@ class Category:
|
||||
self.id = cat_id
|
||||
self.rating = rating
|
||||
self.count = 1
|
||||
|
||||
'''class Count:
|
||||
count = None
|
||||
|
||||
def __init__(self, count):
|
||||
self.count = count'''
|
||||
|
||||
@@ -23,16 +23,17 @@ import zipfile
|
||||
import json
|
||||
from io import BytesIO
|
||||
from flask_babel.speaklater import LazyString
|
||||
|
||||
from importlib.metadata import metadata
|
||||
import os
|
||||
|
||||
from flask import send_file, __version__
|
||||
from flask import send_file
|
||||
|
||||
from . import logger, config
|
||||
from .about import collect_stats
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class lazyEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, LazyString):
|
||||
@@ -40,6 +41,7 @@ class lazyEncoder(json.JSONEncoder):
|
||||
# Let the base class default method raise the TypeError
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def assemble_logfiles(file_name):
|
||||
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
|
||||
wfd = BytesIO()
|
||||
@@ -47,7 +49,8 @@ def assemble_logfiles(file_name):
|
||||
with open(f, 'rb') as fd:
|
||||
shutil.copyfileobj(fd, wfd)
|
||||
wfd.seek(0)
|
||||
if int(__version__.split('.')[0]) < 2:
|
||||
version = metadata("flask")["Version"]
|
||||
if int(version.split('.')[0]) < 2:
|
||||
return send_file(wfd,
|
||||
as_attachment=True,
|
||||
attachment_filename=os.path.basename(file_name))
|
||||
@@ -70,7 +73,8 @@ def send_debug():
|
||||
for fp in file_list:
|
||||
zf.write(fp, os.path.basename(fp))
|
||||
memory_zip.seek(0)
|
||||
if int(__version__.split('.')[0]) < 2:
|
||||
version = metadata("flask")["Version"]
|
||||
if int(version.split('.')[0]) < 2:
|
||||
return send_file(memory_zip,
|
||||
as_attachment=True,
|
||||
attachment_filename="Calibre-Web-debug-pack.zip")
|
||||
|
||||
@@ -39,8 +39,24 @@ def load_dependencies(optional=False):
|
||||
with open(req_path, 'r') as f:
|
||||
for line in f:
|
||||
if not line.startswith('#') and not line == '\n' and not line.startswith('git'):
|
||||
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
|
||||
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?(?:\s?;\s?'
|
||||
r'(?:(python_version)\s?([<=>]+)\s?\'([\d\.]+)\'|'
|
||||
r'(sys_platform)\s?([\!=]+)\s?\'([\w]+)\'))?', line.strip())
|
||||
try:
|
||||
if res.group(7) and res.group(8):
|
||||
val = res.group(8).split(".")
|
||||
if not eval(str(sys.version_info[0]) + "." + "{:02d}".format(sys.version_info[1]) +
|
||||
res.group(7) + val[0] + "." + "{:02d}".format(int(val[1]))):
|
||||
continue
|
||||
elif res.group(10) and res.group(11):
|
||||
# only installed if platform is eqal, don't check if platform is not equal
|
||||
if res.group(10) == "==":
|
||||
if sys.platform != res.group(11):
|
||||
continue
|
||||
# installed if platform is not eqal, don't check if platform is equal
|
||||
elif res.group(10) == "!=":
|
||||
if sys.platform == res.group(11):
|
||||
continue
|
||||
if getattr(sys, 'frozen', False):
|
||||
dep_version = exe_deps[res.group(1).lower().replace('_', '-')]
|
||||
else:
|
||||
@@ -58,10 +74,12 @@ def load_dependencies(optional=False):
|
||||
|
||||
def dependency_check(optional=False):
|
||||
d = list()
|
||||
dep_version_int = None
|
||||
low_check = None
|
||||
deps = load_dependencies(optional)
|
||||
for dep in deps:
|
||||
try:
|
||||
dep_version_int = [int(x) for x in dep[0].split('.')]
|
||||
dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')[:3]]
|
||||
low_check = [int(x) for x in dep[3].split('.')]
|
||||
high_check = [int(x) for x in dep[5].split('.')]
|
||||
except AttributeError:
|
||||
|
||||
1190
cps/editbooks.py
Executable file → Normal file
61
cps/embed_helper.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2024 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 uuid import uuid4
|
||||
import os
|
||||
|
||||
from .file_helper import get_temp_dir
|
||||
from .subproc_wrapper import process_open
|
||||
from . import logger, config
|
||||
from .constants import SUPPORTED_CALIBRE_BINARIES
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
def do_calibre_export(book_id, book_format):
|
||||
try:
|
||||
quotes = [4, 6]
|
||||
tmp_dir = get_temp_dir()
|
||||
calibredb_binarypath = get_calibre_binarypath("calibredb")
|
||||
temp_file_name = str(uuid4())
|
||||
my_env = os.environ.copy()
|
||||
if config.config_calibre_split:
|
||||
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
|
||||
library_path = config.get_book_path()
|
||||
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path,
|
||||
'--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name),
|
||||
str(book_id)]
|
||||
p = process_open(opf_command, quotes, my_env)
|
||||
_, err = p.communicate()
|
||||
if err:
|
||||
log.error('Metadata embedder encountered an error: %s', err)
|
||||
return tmp_dir, temp_file_name
|
||||
except OSError as ex:
|
||||
# ToDo real error handling
|
||||
log.error_or_exception(ex)
|
||||
return None, None
|
||||
|
||||
|
||||
def get_calibre_binarypath(binary):
|
||||
binariesdir = config.config_binariesdir
|
||||
if binariesdir:
|
||||
try:
|
||||
return os.path.join(binariesdir, SUPPORTED_CALIBRE_BINARIES[binary])
|
||||
except KeyError as ex:
|
||||
log.error("Binary not supported by Calibre-Web: %s", SUPPORTED_CALIBRE_BINARIES[binary])
|
||||
pass
|
||||
return ""
|
||||
43
cps/epub.py
@@ -23,10 +23,13 @@ from lxml import etree
|
||||
from . import isoLanguages, cover
|
||||
from . import config, logger
|
||||
from .helper import split_authors
|
||||
from .epub_helper import get_content_opf, default_ns
|
||||
from .constants import BookMeta
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||
if cover_file is None:
|
||||
return None
|
||||
@@ -43,25 +46,17 @@ def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||
cf = zip_file.read(zip_cover_path)
|
||||
return cover.cover_processing(tmp_file_name, cf, extension)
|
||||
|
||||
|
||||
def get_epub_layout(book, book_data):
|
||||
ns = {
|
||||
'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
|
||||
'pkg': 'http://www.idpf.org/2007/opf',
|
||||
}
|
||||
file_path = os.path.normpath(os.path.join(config.config_calibre_dir, book.path, book_data.name + "." + book_data.format.lower()))
|
||||
file_path = os.path.normpath(os.path.join(config.get_book_path(),
|
||||
book.path, book_data.name + "." + book_data.format.lower()))
|
||||
|
||||
try:
|
||||
epubZip = zipfile.ZipFile(file_path)
|
||||
txt = epubZip.read('META-INF/container.xml')
|
||||
tree = etree.fromstring(txt)
|
||||
cfname = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||
cf = epubZip.read(cfname)
|
||||
tree, __ = get_content_opf(file_path, default_ns)
|
||||
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
||||
|
||||
tree = etree.fromstring(cf)
|
||||
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0]
|
||||
|
||||
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=ns)
|
||||
except (etree.XMLSyntaxError, KeyError, IndexError) as e:
|
||||
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=default_ns)
|
||||
except (etree.XMLSyntaxError, KeyError, IndexError, OSError, UnicodeDecodeError) as e:
|
||||
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
|
||||
layout = []
|
||||
|
||||
@@ -71,20 +66,14 @@ def get_epub_layout(book, book_data):
|
||||
return layout[0]
|
||||
|
||||
|
||||
def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
def get_epub_info(tmp_file_path, original_file_name, original_file_extension, no_cover_processing):
|
||||
ns = {
|
||||
'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
|
||||
'pkg': 'http://www.idpf.org/2007/opf',
|
||||
'dc': 'http://purl.org/dc/elements/1.1/'
|
||||
}
|
||||
|
||||
epub_zip = zipfile.ZipFile(tmp_file_path)
|
||||
|
||||
txt = epub_zip.read('META-INF/container.xml')
|
||||
tree = etree.fromstring(txt)
|
||||
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||
cf = epub_zip.read(cf_name)
|
||||
tree = etree.fromstring(cf)
|
||||
tree, cf_name = get_content_opf(tmp_file_path, ns)
|
||||
|
||||
cover_path = os.path.dirname(cf_name)
|
||||
|
||||
@@ -102,7 +91,7 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
elif s == 'date':
|
||||
epub_metadata[s] = tmp[0][:10]
|
||||
else:
|
||||
epub_metadata[s] = tmp[0]
|
||||
epub_metadata[s] = strip_whitespaces(tmp[0])
|
||||
else:
|
||||
epub_metadata[s] = 'Unknown'
|
||||
|
||||
@@ -127,7 +116,11 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
|
||||
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
|
||||
|
||||
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
||||
epub_zip = zipfile.ZipFile(tmp_file_path)
|
||||
if not no_cover_processing:
|
||||
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
||||
else:
|
||||
cover_file = None
|
||||
|
||||
identifiers = []
|
||||
for node in p.xpath('dc:identifier', namespaces=ns):
|
||||
|
||||
169
cps/epub_helper.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018 lemmsh, Kennyl, Kyosfonica, matthazinski
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
|
||||
from . import isoLanguages
|
||||
|
||||
default_ns = {
|
||||
'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
|
||||
'pkg': 'http://www.idpf.org/2007/opf',
|
||||
}
|
||||
|
||||
OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
|
||||
PURL_NAMESPACE = "http://purl.org/dc/elements/1.1/"
|
||||
|
||||
OPF = "{%s}" % OPF_NAMESPACE
|
||||
PURL = "{%s}" % PURL_NAMESPACE
|
||||
|
||||
etree.register_namespace("opf", OPF_NAMESPACE)
|
||||
etree.register_namespace("dc", PURL_NAMESPACE)
|
||||
|
||||
OPF_NS = {None: OPF_NAMESPACE} # the default namespace (no prefix)
|
||||
NSMAP = {'dc': PURL_NAMESPACE, 'opf': OPF_NAMESPACE}
|
||||
|
||||
|
||||
def updateEpub(src, dest, filename, data, ):
|
||||
# create a temp copy of the archive without filename
|
||||
with zipfile.ZipFile(src, 'r') as zin:
|
||||
with zipfile.ZipFile(dest, 'w') as zout:
|
||||
zout.comment = zin.comment # preserve the comment
|
||||
for item in zin.infolist():
|
||||
if item.filename != filename:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
|
||||
# now add filename with its new data
|
||||
with zipfile.ZipFile(dest, mode='a', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(filename, data)
|
||||
|
||||
|
||||
def get_content_opf(file_path, ns=None):
|
||||
if ns is None:
|
||||
ns = default_ns
|
||||
epubZip = zipfile.ZipFile(file_path)
|
||||
txt = epubZip.read('META-INF/container.xml')
|
||||
tree = etree.fromstring(txt)
|
||||
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||
cf = epubZip.read(cf_name)
|
||||
|
||||
return etree.fromstring(cf), cf_name
|
||||
|
||||
|
||||
def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3):
|
||||
# generate root package element
|
||||
package = etree.Element(OPF + "package", nsmap=OPF_NS)
|
||||
package.set("unique-identifier", "uuid_id")
|
||||
package.set("version", "2.0")
|
||||
|
||||
# generate metadata element and all sub elements of it
|
||||
metadata = etree.SubElement(package, "metadata", nsmap=NSMAP)
|
||||
identifier = etree.SubElement(metadata, PURL + "identifier", id="calibre_id", nsmap=NSMAP)
|
||||
identifier.set(OPF + "scheme", "calibre")
|
||||
identifier.text = str(book.id)
|
||||
identifier2 = etree.SubElement(metadata, PURL + "identifier", id="uuid_id", nsmap=NSMAP)
|
||||
identifier2.set(OPF + "scheme", "uuid")
|
||||
identifier2.text = book.uuid
|
||||
for i in book.identifiers:
|
||||
identifier = etree.SubElement(metadata, PURL + "identifier", nsmap=NSMAP)
|
||||
identifier.set(OPF + "scheme", i.format_type())
|
||||
identifier.text = str(i.val)
|
||||
title = etree.SubElement(metadata, PURL + "title", nsmap=NSMAP)
|
||||
title.text = book.title
|
||||
for author in book.authors:
|
||||
creator = etree.SubElement(metadata, PURL + "creator", nsmap=NSMAP)
|
||||
creator.text = str(author.name)
|
||||
creator.set(OPF + "file-as", book.author_sort) # ToDo Check
|
||||
creator.set(OPF + "role", "aut")
|
||||
contributor = etree.SubElement(metadata, PURL + "contributor", nsmap=NSMAP)
|
||||
contributor.text = "calibre (5.7.2) [https://calibre-ebook.com]"
|
||||
contributor.set(OPF + "file-as", "calibre") # ToDo Check
|
||||
contributor.set(OPF + "role", "bkp")
|
||||
|
||||
date = etree.SubElement(metadata, PURL + "date", nsmap=NSMAP)
|
||||
date.text = '{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(d=book.pubdate)
|
||||
if book.comments and book.comments[0].text:
|
||||
for b in book.comments:
|
||||
description = etree.SubElement(metadata, PURL + "description", nsmap=NSMAP)
|
||||
description.text = b.text
|
||||
for b in book.publishers:
|
||||
publisher = etree.SubElement(metadata, PURL + "publisher", nsmap=NSMAP)
|
||||
publisher.text = str(b.name)
|
||||
if not book.languages:
|
||||
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
|
||||
language.text = export_language
|
||||
else:
|
||||
for b in book.languages:
|
||||
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
|
||||
language.text = str(b.lang_code) if lang_type == 3 else isoLanguages.get(part3=b.lang_code).part1
|
||||
for b in book.tags:
|
||||
subject = etree.SubElement(metadata, PURL + "subject", nsmap=NSMAP)
|
||||
subject.text = str(b.name)
|
||||
etree.SubElement(metadata, "meta", name="calibre:author_link_map",
|
||||
content="{" + ", ".join(['"' + str(a.name) + '": ""' for a in book.authors]) + "}",
|
||||
nsmap=NSMAP)
|
||||
for b in book.series:
|
||||
etree.SubElement(metadata, "meta", name="calibre:series",
|
||||
content=str(str(b.name)),
|
||||
nsmap=NSMAP)
|
||||
if book.series:
|
||||
etree.SubElement(metadata, "meta", name="calibre:series_index",
|
||||
content=str(book.series_index),
|
||||
nsmap=NSMAP)
|
||||
if len(book.ratings) and book.ratings[0].rating > 0:
|
||||
etree.SubElement(metadata, "meta", name="calibre:rating",
|
||||
content=str(book.ratings[0].rating),
|
||||
nsmap=NSMAP)
|
||||
etree.SubElement(metadata, "meta", name="calibre:timestamp",
|
||||
content='{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(
|
||||
d=book.timestamp),
|
||||
nsmap=NSMAP)
|
||||
etree.SubElement(metadata, "meta", name="calibre:title_sort",
|
||||
content=book.sort,
|
||||
nsmap=NSMAP)
|
||||
sequence = 0
|
||||
for cc in custom_columns:
|
||||
value = None
|
||||
extra = None
|
||||
cc_entry = getattr(book, "custom_column_" + str(cc.id))
|
||||
if cc_entry.__len__():
|
||||
value = [c.value for c in cc_entry] if cc.is_multiple else cc_entry[0].value
|
||||
extra = cc_entry[0].extra if hasattr(cc_entry[0], "extra") else None
|
||||
etree.SubElement(metadata, "meta", name="calibre:user_metadata:#{}".format(cc.label),
|
||||
content=cc.to_json(value, extra, sequence),
|
||||
nsmap=NSMAP)
|
||||
sequence += 1
|
||||
|
||||
# generate guide element and all sub elements of it
|
||||
# Title is translated from default export language
|
||||
guide = etree.SubElement(package, "guide")
|
||||
etree.SubElement(guide, "reference", type="cover", title=translated_cover_name, href="cover.jpg")
|
||||
|
||||
return package
|
||||
|
||||
|
||||
def replace_metadata(tree, package):
|
||||
rep_element = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
||||
new_element = package.xpath('//metadata', namespaces=default_ns)[0]
|
||||
tree.replace(rep_element, new_element)
|
||||
return etree.tostring(tree,
|
||||
xml_declaration=True,
|
||||
encoding='utf-8',
|
||||
pretty_print=True).decode('utf-8')
|
||||
|
||||
|
||||
@@ -31,27 +31,43 @@ from . import config, app, logger, services
|
||||
log = logger.create()
|
||||
|
||||
# custom error page
|
||||
|
||||
def error_http(error):
|
||||
return render_template('http_error.html',
|
||||
error_code="Error {0}".format(error.code),
|
||||
error_name=error.name,
|
||||
issue=False,
|
||||
goto_admin=False,
|
||||
unconfigured=not config.db_configured,
|
||||
instance=config.config_calibre_web_title
|
||||
), error.code
|
||||
|
||||
|
||||
def internal_error(error):
|
||||
if (isinstance(error.original_exception, AttributeError) and
|
||||
error.original_exception.args[0] == "'NoneType' object has no attribute 'query'"
|
||||
and error.original_exception.name == "query"):
|
||||
return render_template('http_error.html',
|
||||
error_code="Database Error",
|
||||
error_name='The library used is invalid or has permission errors',
|
||||
issue=False,
|
||||
goto_admin=True,
|
||||
unconfigured=False,
|
||||
error_stack="",
|
||||
instance=config.config_calibre_web_title
|
||||
), 500
|
||||
return render_template('http_error.html',
|
||||
error_code="500 Internal Server Error",
|
||||
error_name='The server encountered an internal error and was unable to complete your '
|
||||
'request. There is an error in the application.',
|
||||
issue=True,
|
||||
goto_admin=False,
|
||||
unconfigured=False,
|
||||
error_stack=traceback.format_exc().split("\n"),
|
||||
instance=config.config_calibre_web_title
|
||||
), 500
|
||||
|
||||
|
||||
def init_errorhandler():
|
||||
# http error handling
|
||||
for ex in default_exceptions:
|
||||
@@ -60,7 +76,6 @@ def init_errorhandler():
|
||||
elif ex == 500:
|
||||
app.register_error_handler(ex, internal_error)
|
||||
|
||||
|
||||
if services.ldap:
|
||||
# Only way of catching the LDAPException upon logging in with LDAP server down
|
||||
@app.errorhandler(services.ldap.LDAPException)
|
||||
|
||||
83
cps/file_helper.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2023 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 tempfile import gettempdir
|
||||
import os
|
||||
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_mimetype(ext):
|
||||
# overwrite some mimetypes for proper file detection
|
||||
mimes = {".fb2": "text/xml",
|
||||
".cbz": "application/zip",
|
||||
".cbr": "application/x-rar"
|
||||
}
|
||||
return mimes.get(ext, mimetypes.types_map[ext])
|
||||
|
||||
|
||||
def get_temp_dir():
|
||||
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||
if not os.path.isdir(tmp_dir):
|
||||
os.mkdir(tmp_dir)
|
||||
return tmp_dir
|
||||
|
||||
|
||||
def del_temp_dir():
|
||||
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||
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(get_mimetype("." + 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
|
||||
@@ -23,17 +23,17 @@
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
from uuid import uuid4
|
||||
from time import time
|
||||
from shutil import move, copyfile
|
||||
|
||||
from flask import Blueprint, flash, request, redirect, url_for, abort
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required
|
||||
|
||||
from . import logger, gdriveutils, config, ub, calibre_db, csrf
|
||||
from .admin import admin_required
|
||||
from .file_helper import get_temp_dir
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
||||
log = logger.create()
|
||||
@@ -45,11 +45,11 @@ except ImportError as err:
|
||||
|
||||
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")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def authenticate_google_drive():
|
||||
try:
|
||||
@@ -76,7 +76,7 @@ def google_drive_callback():
|
||||
|
||||
|
||||
@gdrive.route("/watch/subscribe")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def watch_gdrive():
|
||||
if not config.config_google_drive_watch_changes_response:
|
||||
@@ -86,11 +86,12 @@ def watch_gdrive():
|
||||
notification_id = str(uuid4())
|
||||
try:
|
||||
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.save()
|
||||
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':
|
||||
flash(_('Callback domain is not verified, '
|
||||
'please follow steps to verify domain in google developer console'), category="error")
|
||||
@@ -101,7 +102,7 @@ def watch_gdrive():
|
||||
|
||||
|
||||
@gdrive.route("/watch/revoke")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def revoke_watch_gdrive():
|
||||
last_watch_response = config.config_google_drive_watch_changes_response
|
||||
@@ -115,6 +116,7 @@ def revoke_watch_gdrive():
|
||||
config.save()
|
||||
return redirect(url_for('admin.db_configuration'))
|
||||
|
||||
|
||||
try:
|
||||
@csrf.exempt
|
||||
@gdrive.route("/watch/callback", methods=['GET', 'POST'])
|
||||
@@ -138,10 +140,8 @@ try:
|
||||
if response:
|
||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
||||
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
||||
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
||||
tmp_dir = os.path.join(tempfile.gettempdir(), 'calibre_web')
|
||||
if not os.path.isdir(tmp_dir):
|
||||
os.mkdir(tmp_dir)
|
||||
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
||||
tmp_dir = get_temp_dir()
|
||||
|
||||
log.info('Database file updated')
|
||||
copyfile(dbpath, os.path.join(tmp_dir, "metadata.db_" + str(current_milli_time())))
|
||||
|
||||
@@ -21,7 +21,10 @@ import json
|
||||
import shutil
|
||||
import chardet
|
||||
import ssl
|
||||
import sqlite3
|
||||
import mimetypes
|
||||
|
||||
from werkzeug.datastructures import Headers
|
||||
from flask import Response, stream_with_context
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import Column, UniqueConstraint
|
||||
@@ -34,7 +37,6 @@ except ImportError:
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
from sqlalchemy.sql.expression import text
|
||||
|
||||
try:
|
||||
from httplib2 import __version__ as httplib2_version
|
||||
@@ -64,7 +66,7 @@ except ImportError as err:
|
||||
importError = err
|
||||
gdrive_support = False
|
||||
|
||||
from . import logger, cli_param, config
|
||||
from . import logger, cli_param, config, db
|
||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||
|
||||
|
||||
@@ -208,6 +210,7 @@ def getDrive(drive=None, gauth=None):
|
||||
log.error("Google Drive error: {}".format(e))
|
||||
return drive
|
||||
|
||||
|
||||
def listRootFolders():
|
||||
try:
|
||||
drive = getDrive(Gdrive.Instance().drive)
|
||||
@@ -225,7 +228,7 @@ def getEbooksFolder(drive):
|
||||
|
||||
def getFolderInFolder(parentId, folderName, drive):
|
||||
# drive = getDrive(drive)
|
||||
query=""
|
||||
query = ""
|
||||
if folderName:
|
||||
query = "title = '%s' and " % folderName.replace("'", r"\'")
|
||||
folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder'" \
|
||||
@@ -236,6 +239,7 @@ def getFolderInFolder(parentId, folderName, drive):
|
||||
else:
|
||||
return fileList[0]
|
||||
|
||||
|
||||
# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database
|
||||
def getEbooksFolderId(drive=None):
|
||||
storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first()
|
||||
@@ -257,17 +261,20 @@ def getEbooksFolderId(drive=None):
|
||||
return gDriveId.gdrive_id
|
||||
|
||||
|
||||
def getFile(pathId, fileName, drive):
|
||||
metaDataFile = "'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", r"\'"))
|
||||
def getFile(pathId, fileName, drive, nocase):
|
||||
metaDataFile = "'%s' in parents and trashed = false and title contains '%s'" % (pathId, fileName.replace("'", r"\'"))
|
||||
fileList = drive.ListFile({'q': metaDataFile}).GetList()
|
||||
if fileList.__len__() == 0:
|
||||
return None
|
||||
else:
|
||||
return fileList[0]
|
||||
if nocase:
|
||||
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):
|
||||
# drive = getDrive(drive)
|
||||
currentFolderId = None
|
||||
try:
|
||||
currentFolderId = getEbooksFolderId(drive)
|
||||
@@ -301,7 +308,7 @@ def getFolderId(path, drive):
|
||||
session.commit()
|
||||
else:
|
||||
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))
|
||||
session.rollback()
|
||||
except ApiRequestError as ex:
|
||||
@@ -312,7 +319,7 @@ def getFolderId(path, drive):
|
||||
return currentFolderId
|
||||
|
||||
|
||||
def getFileFromEbooksFolder(path, fileName):
|
||||
def getFileFromEbooksFolder(path, fileName, nocase=False):
|
||||
drive = getDrive(Gdrive.Instance().drive)
|
||||
if path:
|
||||
# sqlCheckPath=path if path[-1] =='/' else path + '/'
|
||||
@@ -320,7 +327,7 @@ def getFileFromEbooksFolder(path, fileName):
|
||||
else:
|
||||
folderId = getEbooksFolderId(drive)
|
||||
if folderId:
|
||||
return getFile(folderId, fileName, drive)
|
||||
return getFile(folderId, fileName, drive, nocase)
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -336,33 +343,37 @@ def downloadFile(path, filename, output):
|
||||
f.GetContentFile(output)
|
||||
|
||||
|
||||
def moveGdriveFolderRemote(origin_file, target_folder):
|
||||
def moveGdriveFolderRemote(origin_file, target_folder, single_book=False):
|
||||
drive = getDrive(Gdrive.Instance().drive)
|
||||
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')])
|
||||
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
||||
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
||||
if not gFileTargetDir:
|
||||
gFileTargetDir = drive.CreateFile(
|
||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||
"mimeType": "application/vnd.google-apps.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 gFileTargetDir['title'] != target_folder:
|
||||
if single_book:
|
||||
gFileTargetDir = getFileFromEbooksFolder(None, target_folder, nocase=True)
|
||||
if gFileTargetDir:
|
||||
# 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()
|
||||
else:
|
||||
gFileTargetDir = drive.CreateFile(
|
||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||
"mimeType": "application/vnd.google-apps.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
|
||||
drive.auth.service.files().patch(fileId=origin_file['id'],
|
||||
body={'title': target_folder},
|
||||
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 len(children['items']) == 1:
|
||||
deleteDatabaseEntry(previous_parents)
|
||||
@@ -370,20 +381,20 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
||||
|
||||
|
||||
def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||
ignoreFiles=None,
|
||||
parent=None, prevDir=''):
|
||||
ignoreFiles=None,
|
||||
parent=None, prevDir=''):
|
||||
ignoreFiles = ignoreFiles or []
|
||||
drive = getDrive(drive)
|
||||
isInitial = not bool(parent)
|
||||
if not parent:
|
||||
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" %
|
||||
(os.path.basename(uploadFile).replace("'", r"\'"), parent['id'])}).GetList()
|
||||
if len(existingFolder) == 0 and (not isInitial or createRoot):
|
||||
parent = drive.CreateFile({'title': os.path.basename(uploadFile),
|
||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||
"mimeType": "application/vnd.google-apps.folder"})
|
||||
"mimeType": "application/vnd.google-apps.folder"})
|
||||
parent.Upload()
|
||||
else:
|
||||
if (not isInitial or createRoot) and len(existingFolder) > 0:
|
||||
@@ -399,7 +410,7 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||
driveFile = existingFiles[0]
|
||||
else:
|
||||
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.Upload()
|
||||
|
||||
@@ -411,7 +422,7 @@ def uploadFileToEbooksFolder(destFile, f, string=False):
|
||||
for i, x in enumerate(splitDir):
|
||||
if i == len(splitDir)-1:
|
||||
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:
|
||||
driveFile = existing_Files[0]
|
||||
else:
|
||||
@@ -424,17 +435,17 @@ def uploadFileToEbooksFolder(destFile, f, string=False):
|
||||
driveFile.Upload()
|
||||
else:
|
||||
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:
|
||||
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()
|
||||
else:
|
||||
parent = existing_Folder[0]
|
||||
|
||||
|
||||
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.
|
||||
# Args:
|
||||
# service: Drive API service instance.
|
||||
@@ -505,7 +516,7 @@ def stopChannel(drive, channel_id, resource_id):
|
||||
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.
|
||||
#
|
||||
# Args:
|
||||
@@ -539,8 +550,9 @@ def updateGdriveCalibreFromLocal():
|
||||
if os.path.isdir(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
|
||||
def updateDatabaseOnEdit(ID,newPath):
|
||||
def updateDatabaseOnEdit(ID, newPath):
|
||||
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/'
|
||||
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
||||
if storedPathName:
|
||||
@@ -561,6 +573,14 @@ def deleteDatabaseEntry(ID):
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
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
|
||||
# ToDo: Check is this right everyone get read permissions on cover files?
|
||||
@@ -579,13 +599,17 @@ def get_cover_via_gdrive(cover_path):
|
||||
session.add(permissionAdded)
|
||||
try:
|
||||
session.commit()
|
||||
except OperationalError as ex:
|
||||
except (OperationalError, IntegrityError) as ex:
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
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:
|
||||
return None
|
||||
|
||||
|
||||
# Gets cover file from gdrive
|
||||
def get_metadata_backup_via_gdrive(metadata_path):
|
||||
df = getFileFromEbooksFolder(metadata_path, 'metadata.opf')
|
||||
@@ -609,6 +633,7 @@ def get_metadata_backup_via_gdrive(metadata_path):
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# Creates chunks for downloading big files
|
||||
def partial(total_byte_len, part_size_limit):
|
||||
s = []
|
||||
@@ -617,6 +642,7 @@ def partial(total_byte_len, part_size_limit):
|
||||
s.append([p, last])
|
||||
return s
|
||||
|
||||
|
||||
# downloads files in chunks from gdrive
|
||||
def do_gdrive_download(df, headers, convert_encoding=False):
|
||||
total_size = int(df.metadata.get('fileSize'))
|
||||
@@ -656,6 +682,7 @@ oauth_scope:
|
||||
- https://www.googleapis.com/auth/drive
|
||||
"""
|
||||
|
||||
|
||||
def update_settings(client_id, client_secret, redirect_uri):
|
||||
if redirect_uri.endswith('/'):
|
||||
redirect_uri = redirect_uri[:-1]
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
# 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 datetime import datetime
|
||||
from gevent.pywsgi import WSGIHandler
|
||||
|
||||
|
||||
class MyWSGIHandler(WSGIHandler):
|
||||
def get_environ(self):
|
||||
env = super().get_environ()
|
||||
@@ -26,4 +27,26 @@ class MyWSGIHandler(WSGIHandler):
|
||||
env['RAW_URI'] = path
|
||||
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)
|
||||
|
||||
|
||||
520
cps/helper.py
@@ -22,18 +22,19 @@ import random
|
||||
import io
|
||||
import mimetypes
|
||||
import re
|
||||
import regex
|
||||
import shutil
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from tempfile import gettempdir
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import requests
|
||||
import unidecode
|
||||
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, request
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import lazy_gettext as N_
|
||||
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.exc import InvalidRequestError, OperationalError
|
||||
from werkzeug.datastructures import Headers
|
||||
@@ -42,24 +43,29 @@ from markupsafe import escape
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
import advocate
|
||||
from advocate.exceptions import UnacceptableAddressException
|
||||
from . import cw_advocate
|
||||
from .cw_advocate.exceptions import UnacceptableAddressException
|
||||
use_advocate = True
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
use_advocate = False
|
||||
advocate = requests
|
||||
UnacceptableAddressException = MissingSchema = BaseException
|
||||
|
||||
from . import calibre_db, cli_param
|
||||
from .string_helper import strip_whitespaces
|
||||
from .tasks.convert import TaskConvert
|
||||
from . import logger, config, db, ub, fs
|
||||
from . import gdriveutils as gd
|
||||
from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES
|
||||
from .constants import (STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES,
|
||||
SUPPORTED_CALIBRE_BINARIES)
|
||||
from .subproc_wrapper import process_wait
|
||||
from .services.worker import WorkerThread
|
||||
from .tasks.mail import TaskEmail
|
||||
from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails
|
||||
from .tasks.metadata_backup import TaskBackupMetadata
|
||||
from .file_helper import get_temp_dir
|
||||
from .epub_helper import get_content_opf, create_new_metadata_backup, updateEpub, replace_metadata
|
||||
from .embed_helper import do_calibre_export
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@@ -112,9 +118,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
|
||||
def send_test_mail(ereader_mail, user_name):
|
||||
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
|
||||
config.get_mail_settings(), ereader_mail, N_("Test Email"),
|
||||
_('This Email has been sent via Calibre-Web.')))
|
||||
for email in ereader_mail.split(','):
|
||||
email = strip_whitespaces(email)
|
||||
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
|
||||
|
||||
|
||||
@@ -191,7 +199,7 @@ def check_send_to_ereader(entry):
|
||||
# Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
|
||||
# list with supported formats
|
||||
def check_read_formats(entry):
|
||||
extensions_reader = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR', 'DJVU', 'DJV'}
|
||||
extensions_reader = {'TXT', 'PDF', 'EPUB', 'KEPUB', 'CBZ', 'CBT', 'CBR', 'DJVU', 'DJV'}
|
||||
book_formats = list()
|
||||
if len(entry.data):
|
||||
for ele in iter(entry.data):
|
||||
@@ -220,14 +228,16 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
|
||||
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))
|
||||
email_text = N_("%(book)s send to eReader", book=link)
|
||||
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
|
||||
config.get_mail_settings(), ereader_mail,
|
||||
email_text, _('This Email has been sent via Calibre-Web.')))
|
||||
for email in ereader_mail.split(','):
|
||||
email = strip_whitespaces(email)
|
||||
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 _("The requested file could not be read. Maybe wrong permissions?")
|
||||
|
||||
|
||||
def get_valid_filename(value, replace_whitespace=True, chars=128):
|
||||
def get_valid_filename(value, replace_whitespace=True, chars=128, force_unidecode=False):
|
||||
"""
|
||||
Returns the given string converted to a string that can be used for a clean
|
||||
filename. Limits num characters to 128 max.
|
||||
@@ -235,7 +245,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
|
||||
if value[-1:] == '.':
|
||||
value = value[:-1]+'_'
|
||||
value = value.replace("/", "_").replace(":", "_").strip('\0')
|
||||
if config.config_unicode_filename:
|
||||
if config.config_unicode_filename or force_unidecode:
|
||||
value = (unidecode.unidecode(value))
|
||||
if replace_whitespace:
|
||||
# *+:\"/<>? are replaced by _
|
||||
@@ -243,7 +253,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
|
||||
# pipe has to be replaced with comma
|
||||
value = re.sub(r'[|]+', ',', value, flags=re.U)
|
||||
|
||||
value = value.encode('utf-8')[:chars].decode('utf-8', errors='ignore').strip()
|
||||
value = strip_whitespaces(value.encode('utf-8')[:chars].decode('utf-8', errors='ignore'))
|
||||
|
||||
if not value:
|
||||
raise ValueError("Filename cannot be empty")
|
||||
@@ -258,11 +268,11 @@ def split_authors(values):
|
||||
commas = author.count(',')
|
||||
if commas == 1:
|
||||
author_split = author.split(',')
|
||||
authors_list.append(author_split[1].strip() + ' ' + author_split[0].strip())
|
||||
authors_list.append(strip_whitespaces(author_split[1]) + ' ' + strip_whitespaces(author_split[0]))
|
||||
elif commas > 1:
|
||||
authors_list.extend([x.strip() for x in author.split(',')])
|
||||
authors_list.extend([strip_whitespaces(x) for x in author.split(',')])
|
||||
else:
|
||||
authors_list.append(author.strip())
|
||||
authors_list.append(strip_whitespaces(author))
|
||||
return authors_list
|
||||
|
||||
|
||||
@@ -318,7 +328,7 @@ def edit_book_read_status(book_id, read_status=None):
|
||||
ub.session_commit("Book {} readbit toggled".format(book_id))
|
||||
else:
|
||||
try:
|
||||
calibre_db.update_title_sort(config)
|
||||
calibre_db.create_functions(config)
|
||||
book = calibre_db.get_filtered_book(book_id)
|
||||
book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column))
|
||||
if len(book_read_status):
|
||||
@@ -383,75 +393,54 @@ def delete_book_file(book, calibrepath, book_format=None):
|
||||
id=book.id,
|
||||
path=book.path)
|
||||
|
||||
|
||||
def clean_author_database(renamed_author, calibre_path="", local_book=None, gdrive=None):
|
||||
valid_filename_authors = [get_valid_filename(r, chars=96) for r in renamed_author]
|
||||
for r in renamed_author:
|
||||
if local_book:
|
||||
all_books = [local_book]
|
||||
def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdrive=False):
|
||||
for file_format in one_book.data:
|
||||
if not gdrive:
|
||||
if not os.path.exists(new_path):
|
||||
os.makedirs(new_path)
|
||||
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:
|
||||
all_books = calibre_db.session.query(db.Books) \
|
||||
.filter(db.Books.authors.any(db.Authors.name == r)).all()
|
||||
for book in all_books:
|
||||
book_author_path = book.path.split('/')[0]
|
||||
if book_author_path in valid_filename_authors or local_book:
|
||||
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
|
||||
all_new_authordir = get_valid_filename(new_author.name, chars=96)
|
||||
all_titledir = book.path.split('/')[1]
|
||||
all_new_path = os.path.join(calibre_path, all_new_authordir, all_titledir)
|
||||
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
|
||||
+ get_valid_filename(new_author.name, chars=42)
|
||||
# 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
|
||||
g_file = gd.getFileFromEbooksFolder(old_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(old_path, file_format.name + '.' + file_format.format.lower()))
|
||||
|
||||
# change name in Database
|
||||
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 title_dir from database and add id
|
||||
if first_author:
|
||||
new_authordir = get_valid_filename(first_author, chars=96)
|
||||
for r in renamed_author:
|
||||
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
|
||||
old_author_dir = get_valid_filename(r, chars=96)
|
||||
new_author_rename_dir = get_valid_filename(new_author.name, chars=96)
|
||||
if gdrive:
|
||||
g_file = gd.getFileFromEbooksFolder(None, old_author_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))
|
||||
new_authordir = get_valid_filename(first_author, chars=96)
|
||||
new_author_rename_dir = get_valid_filename(renamed_author, chars=96)
|
||||
if gdrive:
|
||||
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
|
||||
if g_file:
|
||||
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
local_book = calibre_db.get_book(book_id)
|
||||
if original_filepath:
|
||||
@@ -463,15 +452,11 @@ def update_dir_structure_file(book_id, calibre_path, first_author, original_file
|
||||
author_dir = local_book.path.split('/')[0]
|
||||
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) + ")"
|
||||
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:
|
||||
error = move_files_on_change(calibre_path,
|
||||
@@ -481,36 +466,39 @@ def update_dir_structure_file(book_id, calibre_path, first_author, original_file
|
||||
db_filename,
|
||||
original_filepath,
|
||||
path)
|
||||
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:
|
||||
return error
|
||||
|
||||
# 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)
|
||||
file_name = get_valid_filename(title, chars=42) + ' - ' + \
|
||||
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),
|
||||
title_dir + " (" + str(book_id) + ")")
|
||||
book.path = gdrive_path.replace("\\", "/")
|
||||
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
|
||||
|
||||
|
||||
|
||||
def update_dir_structure_gdrive(book_id, first_author, renamed_author):
|
||||
def update_dir_structure_gdrive(book_id, first_author):
|
||||
book = calibre_db.get_book(book_id)
|
||||
|
||||
authordir = book.path.split('/')[0]
|
||||
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) + ")"
|
||||
|
||||
if titledir != new_titledir:
|
||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir)
|
||||
g_file = gd.getFileFromEbooksFolder(authordir, titledir)
|
||||
if g_file:
|
||||
gd.moveGdriveFileRemote(g_file, new_titledir)
|
||||
book.path = book.path.split('/')[0] + '/' + new_titledir
|
||||
@@ -518,44 +506,53 @@ def update_dir_structure_gdrive(book_id, first_author, renamed_author):
|
||||
else:
|
||||
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:
|
||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir)
|
||||
if authordir != new_authordir:
|
||||
g_file = gd.getFileFromEbooksFolder(authordir, new_titledir)
|
||||
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]
|
||||
gd.updateDatabaseOnEdit(g_file['id'], book.path)
|
||||
else:
|
||||
return _('File %(file)s not found on Google Drive', file=authordir) # file not found
|
||||
|
||||
# change location in database to new author/title path
|
||||
book.path = os.path.join(new_authordir, new_titledir).replace('\\', '/')
|
||||
return rename_files_on_change(first_author, renamed_author, book, gdrive=True)
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
def move_files_on_change(calibre_path, new_authordir, new_titledir, localbook, db_filename, original_filepath, path):
|
||||
new_path = os.path.join(calibre_path, new_authordir, new_titledir)
|
||||
new_name = get_valid_filename(localbook.title, chars=96) + ' - ' + new_authordir
|
||||
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_author_dir, new_titledir)
|
||||
try:
|
||||
if original_filepath:
|
||||
if not os.path.isdir(new_path):
|
||||
os.makedirs(new_path)
|
||||
shutil.move(os.path.normcase(original_filepath), os.path.normcase(os.path.join(new_path, db_filename)))
|
||||
log.debug("Moving title: %s to %s/%s", original_filepath, new_path, new_name)
|
||||
try:
|
||||
shutil.move(original_filepath, os.path.join(new_path, db_filename))
|
||||
except OSError:
|
||||
log.error("Rename title from {} to {} failed with error, trying to "
|
||||
"move without metadata".format(path, new_path))
|
||||
shutil.move(original_filepath, os.path.join(new_path, db_filename), copy_function=shutil.copy)
|
||||
log.debug("Moving title: %s to %s", original_filepath, new_path)
|
||||
else:
|
||||
# Check new path is not valid path
|
||||
if not os.path.exists(new_path):
|
||||
# move original path to 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)
|
||||
log.info("Moving title: %s into existing: %s", path, new_path)
|
||||
# Take all files and subfolder from old path (strange command)
|
||||
for dir_name, __, file_list in os.walk(path):
|
||||
for file in file_list:
|
||||
shutil.move(os.path.normcase(os.path.join(dir_name, file)),
|
||||
os.path.normcase(os.path.join(new_path + dir_name[len(path):], file)))
|
||||
shutil.move(os.path.join(dir_name, file), 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
|
||||
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:
|
||||
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",
|
||||
@@ -571,15 +568,15 @@ def rename_files_on_change(first_author,
|
||||
calibre_path="",
|
||||
gdrive=False):
|
||||
# Rename all files from old names to new names
|
||||
try:
|
||||
clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
|
||||
if first_author and first_author not in renamed_author:
|
||||
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:
|
||||
shutil.rmtree(os.path.dirname(path))
|
||||
except (OSError, FileNotFoundError) as ex:
|
||||
log.error_or_exception("Error in rename file in path {}".format(ex))
|
||||
return _("Error in rename file in path: {}".format(str(ex)))
|
||||
#try:
|
||||
#clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
|
||||
#if first_author and first_author not in renamed_author:
|
||||
# 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:
|
||||
# shutil.rmtree(os.path.dirname(path))
|
||||
#except (OSError, FileNotFoundError) as ex:
|
||||
# log.error_or_exception("Error in rename file in path {}".format(ex))
|
||||
# return _("Error in rename file in path: {}".format(str(ex)))
|
||||
return False
|
||||
|
||||
|
||||
@@ -590,7 +587,7 @@ def delete_book_gdrive(book, book_format):
|
||||
for entry in book.data:
|
||||
if entry.format.upper() == book_format:
|
||||
name = entry.name + '.' + book_format
|
||||
g_file = gd.getFileFromEbooksFolder(book.path, name)
|
||||
g_file = gd.getFileFromEbooksFolder(book.path, name, nocase=True)
|
||||
else:
|
||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), book.path.split('/')[1])
|
||||
if g_file:
|
||||
@@ -618,6 +615,7 @@ def reset_password(user_id):
|
||||
ub.session.rollback()
|
||||
return 0, None
|
||||
|
||||
|
||||
def generate_random_password(min_length):
|
||||
min_length = max(8, min_length) - 4
|
||||
random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
|
||||
@@ -643,12 +641,6 @@ def generate_random_password(min_length):
|
||||
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):
|
||||
output = []
|
||||
inpt = [" ".join(inp.split()) for inp in inpt]
|
||||
@@ -667,38 +659,44 @@ def check_email(email):
|
||||
|
||||
|
||||
def check_username(username):
|
||||
username = username.strip()
|
||||
username = strip_whitespaces(username)
|
||||
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
|
||||
log.error("This username is already taken")
|
||||
raise Exception(_("This username is already taken"))
|
||||
return username
|
||||
|
||||
|
||||
def valid_email(email):
|
||||
email = email.strip()
|
||||
# if email is not deleted
|
||||
if email:
|
||||
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
||||
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
||||
email):
|
||||
log.error("Invalid Email address format")
|
||||
raise Exception(_("Invalid Email address format"))
|
||||
return email
|
||||
def valid_email(emails):
|
||||
valid_emails = []
|
||||
for email in emails.split(','):
|
||||
email = strip_whitespaces(email)
|
||||
# if email is not deleted
|
||||
if email:
|
||||
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
||||
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
||||
email):
|
||||
log.error("Invalid Email address format for {}".format(email))
|
||||
raise Exception(_("Invalid Email address format"))
|
||||
valid_emails.append(email)
|
||||
return ",".join(valid_emails)
|
||||
|
||||
|
||||
def valid_password(check_password):
|
||||
if config.config_password_policy:
|
||||
verify = ""
|
||||
if config.config_password_min_length > 0:
|
||||
verify += "^(?=.{" + str(config.config_password_min_length) + ",}$)"
|
||||
verify += r"^(?=.{" + str(config.config_password_min_length) + ",}$)"
|
||||
if config.config_password_number:
|
||||
verify += "(?=.*?\d)"
|
||||
verify += r"(?=.*?\d)"
|
||||
if config.config_password_lower:
|
||||
verify += "(?=.*?[a-z])"
|
||||
verify += r"(?=.*?[\p{Ll}])"
|
||||
if config.config_password_upper:
|
||||
verify += "(?=.*?[A-Z])"
|
||||
verify += r"(?=.*?[\p{Lu}])"
|
||||
if config.config_password_character:
|
||||
verify += r"(?=.*?[\p{Letter}])"
|
||||
if config.config_password_special:
|
||||
verify += "(?=.*?[^A-Za-z\s0-9])"
|
||||
match = re.match(verify, check_password)
|
||||
verify += r"(?=.*?[^\p{Letter}\s0-9])"
|
||||
match = regex.match(verify, check_password)
|
||||
if not match:
|
||||
raise Exception(_("Password doesn't comply with password validation rules"))
|
||||
return check_password
|
||||
@@ -709,22 +707,20 @@ def update_dir_structure(book_id,
|
||||
calibre_path,
|
||||
first_author=None, # change author of book to this author
|
||||
original_filepath=None,
|
||||
db_filename=None,
|
||||
renamed_author=None):
|
||||
renamed_author = renamed_author or []
|
||||
db_filename=None):
|
||||
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:
|
||||
return update_dir_structure_file(book_id,
|
||||
calibre_path,
|
||||
first_author,
|
||||
original_filepath,
|
||||
db_filename, renamed_author)
|
||||
first_author,
|
||||
db_filename)
|
||||
|
||||
|
||||
def delete_book(book, calibrepath, 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)
|
||||
if config.config_use_google_drive:
|
||||
return delete_book_gdrive(book, book_format)
|
||||
@@ -769,9 +765,9 @@ def get_book_cover_internal(book, resolution=None):
|
||||
try:
|
||||
if not gd.is_gdrive_ready():
|
||||
return get_cover_on_failure()
|
||||
path = gd.get_cover_via_gdrive(book.path)
|
||||
if path:
|
||||
return redirect(path)
|
||||
cover_file = gd.get_cover_via_gdrive(book.path)
|
||||
if cover_file:
|
||||
return Response(cover_file, mimetype='image/jpeg')
|
||||
else:
|
||||
log.error('{}/cover.jpg not found on Google Drive'.format(book.path))
|
||||
return get_cover_on_failure()
|
||||
@@ -781,7 +777,7 @@ def get_book_cover_internal(book, resolution=None):
|
||||
|
||||
# Send the book cover from the Calibre directory
|
||||
else:
|
||||
cover_file_path = os.path.join(config.config_calibre_dir, book.path)
|
||||
cover_file_path = os.path.join(config.get_book_path(), book.path)
|
||||
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
|
||||
return send_from_directory(cover_file_path, "cover.jpg")
|
||||
else:
|
||||
@@ -792,24 +788,23 @@ def get_book_cover_internal(book, resolution=None):
|
||||
|
||||
def get_book_cover_thumbnail(book, resolution):
|
||||
if book and book.has_cover:
|
||||
return ub.session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \
|
||||
.filter(ub.Thumbnail.entity_id == book.id) \
|
||||
.filter(ub.Thumbnail.resolution == resolution) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.first()
|
||||
return (ub.session
|
||||
.query(ub.Thumbnail)
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER)
|
||||
.filter(ub.Thumbnail.entity_id == book.id)
|
||||
.filter(ub.Thumbnail.resolution == resolution)
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
|
||||
.first())
|
||||
|
||||
|
||||
def get_series_thumbnail_on_failure(series_id, resolution):
|
||||
book = calibre_db.session \
|
||||
.query(db.Books) \
|
||||
.join(db.books_series_link) \
|
||||
.join(db.Series) \
|
||||
.filter(db.Series.id == series_id) \
|
||||
.filter(db.Books.has_cover == 1) \
|
||||
.first()
|
||||
|
||||
book = (calibre_db.session
|
||||
.query(db.Books)
|
||||
.join(db.books_series_link)
|
||||
.join(db.Series)
|
||||
.filter(db.Series.id == series_id)
|
||||
.filter(db.Books.has_cover == 1)
|
||||
.first())
|
||||
return get_book_cover_internal(book, resolution=resolution)
|
||||
|
||||
|
||||
@@ -831,13 +826,13 @@ def get_series_cover_internal(series_id, resolution=None):
|
||||
|
||||
|
||||
def get_series_thumbnail(series_id, resolution):
|
||||
return ub.session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \
|
||||
.filter(ub.Thumbnail.entity_id == series_id) \
|
||||
.filter(ub.Thumbnail.resolution == resolution) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.first()
|
||||
return (ub.session
|
||||
.query(ub.Thumbnail)
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES)
|
||||
.filter(ub.Thumbnail.entity_id == series_id)
|
||||
.filter(ub.Thumbnail.resolution == resolution)
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
|
||||
.first())
|
||||
|
||||
|
||||
# saves book cover from url
|
||||
@@ -846,7 +841,7 @@ def save_cover_from_url(url, book_path):
|
||||
if cli_param.allow_localhost:
|
||||
img = requests.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
||||
elif use_advocate:
|
||||
img = advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
||||
img = cw_advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
||||
else:
|
||||
log.error("python module advocate is not installed but is needed")
|
||||
return False, _("Python module 'advocate' is not installed but is needed for cover uploads")
|
||||
@@ -877,7 +872,7 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
|
||||
log.error("Failed to create path for cover")
|
||||
return False, _("Failed to create path for cover")
|
||||
try:
|
||||
# upload of jgp file without wand
|
||||
# upload of jpg file without wand
|
||||
if isinstance(img, requests.Response):
|
||||
with open(os.path.join(filepath, saved_filename), 'wb') as f:
|
||||
f.write(img.content)
|
||||
@@ -910,7 +905,7 @@ def save_cover(img, book_path):
|
||||
else:
|
||||
imgc = Image(blob=io.BytesIO(img.content))
|
||||
imgc.format = 'jpeg'
|
||||
imgc.transform_colorspace("rgb")
|
||||
imgc.transform_colorspace("srgb")
|
||||
img = imgc
|
||||
except (BlobError, MissingDelegateError):
|
||||
log.error("Invalid cover file content")
|
||||
@@ -921,10 +916,7 @@ def save_cover(img, book_path):
|
||||
return False, _("Only jpg/jpeg files are supported as coverfile")
|
||||
|
||||
if config.config_use_google_drive:
|
||||
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||
|
||||
if not os.path.isdir(tmp_dir):
|
||||
os.mkdir(tmp_dir)
|
||||
tmp_dir = get_temp_dir()
|
||||
ret, message = save_cover_from_filestorage(tmp_dir, "uploaded_cover.jpg", img)
|
||||
if ret is True:
|
||||
gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg').replace("\\", "/"),
|
||||
@@ -934,33 +926,74 @@ def save_cover(img, book_path):
|
||||
else:
|
||||
return False, message
|
||||
else:
|
||||
return save_cover_from_filestorage(os.path.join(config.config_calibre_dir, book_path), "cover.jpg", img)
|
||||
return save_cover_from_filestorage(os.path.join(config.get_book_path(), book_path), "cover.jpg", img)
|
||||
|
||||
|
||||
def do_download_file(book, book_format, client, data, headers):
|
||||
book_name = data.name
|
||||
download_name = filename = None
|
||||
if config.config_use_google_drive:
|
||||
# startTime = time.time()
|
||||
df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format)
|
||||
# log.debug('%s', time.time() - startTime)
|
||||
if df:
|
||||
return gd.do_gdrive_download(df, headers)
|
||||
if config.config_embed_metadata and (
|
||||
(book_format == "kepub" and config.config_kepubifypath) or
|
||||
(book_format != "kepub" and config.config_binariesdir)):
|
||||
output_path = os.path.join(config.config_calibre_dir, book.path)
|
||||
if not os.path.exists(output_path):
|
||||
os.makedirs(output_path)
|
||||
output = os.path.join(config.config_calibre_dir, book.path, book_name + "." + book_format)
|
||||
gd.downloadFile(book.path, book_name + "." + book_format, output)
|
||||
if book_format == "kepub" and config.config_kepubifypath:
|
||||
filename, download_name = do_kepubify_metadata_replace(book, output)
|
||||
elif book_format != "kepub" and config.config_binariesdir:
|
||||
filename, download_name = do_calibre_export(book.id, book_format)
|
||||
else:
|
||||
return gd.do_gdrive_download(df, headers)
|
||||
else:
|
||||
abort(404)
|
||||
else:
|
||||
filename = os.path.join(config.config_calibre_dir, book.path)
|
||||
if not os.path.isfile(os.path.join(filename, data.name + "." + book_format)):
|
||||
filename = os.path.join(config.get_book_path(), book.path)
|
||||
if not os.path.isfile(os.path.join(filename, book_name + "." + book_format)):
|
||||
# ToDo: improve error handling
|
||||
log.error('File not found: %s', os.path.join(filename, data.name + "." + book_format))
|
||||
log.error('File not found: %s', os.path.join(filename, book_name + "." + book_format))
|
||||
|
||||
if client == "kobo" and book_format == "kepub":
|
||||
headers["Content-Disposition"] = headers["Content-Disposition"].replace(".kepub", ".kepub.epub")
|
||||
|
||||
response = make_response(send_from_directory(filename, data.name + "." + book_format))
|
||||
# ToDo Check headers parameter
|
||||
for element in headers:
|
||||
response.headers[element[0]] = element[1]
|
||||
log.info('Downloading file: {}'.format(os.path.join(filename, data.name + "." + book_format)))
|
||||
return response
|
||||
if book_format == "kepub" and config.config_kepubifypath and config.config_embed_metadata:
|
||||
filename, download_name = do_kepubify_metadata_replace(book, os.path.join(filename,
|
||||
book_name + "." + book_format))
|
||||
elif book_format != "kepub" and config.config_binariesdir and config.config_embed_metadata:
|
||||
filename, download_name = do_calibre_export(book.id, book_format)
|
||||
else:
|
||||
download_name = book_name
|
||||
|
||||
response = make_response(send_from_directory(filename, download_name + "." + book_format))
|
||||
# ToDo Check headers parameter
|
||||
for element in headers:
|
||||
response.headers[element[0]] = element[1]
|
||||
log.info('Downloading file: \'%s\' by %s - %s', format(os.path.join(filename, book_name + "." + book_format)),
|
||||
current_user.name, request.headers.get('X-Forwarded-For', request.remote_addr))
|
||||
return response
|
||||
|
||||
|
||||
def do_kepubify_metadata_replace(book, file_path):
|
||||
custom_columns = (calibre_db.session.query(db.CustomColumns)
|
||||
.filter(db.CustomColumns.mark_for_delete == 0)
|
||||
.filter(db.CustomColumns.datatype.notin_(db.cc_exceptions))
|
||||
.order_by(db.CustomColumns.label).all())
|
||||
|
||||
tree, cf_name = get_content_opf(file_path)
|
||||
package = create_new_metadata_backup(book, custom_columns, current_user.locale, _("Cover"), lang_type=2)
|
||||
content = replace_metadata(tree, package)
|
||||
tmp_dir = get_temp_dir()
|
||||
temp_file_name = str(uuid4())
|
||||
# open zipfile and replace metadata block in content.opf
|
||||
updateEpub(file_path, os.path.join(tmp_dir, temp_file_name + ".kepub"), cf_name, content)
|
||||
return tmp_dir, temp_file_name
|
||||
|
||||
|
||||
##################################
|
||||
|
||||
@@ -970,20 +1003,61 @@ def check_unrar(unrar_location):
|
||||
return
|
||||
|
||||
if not os.path.exists(unrar_location):
|
||||
return _('Unrar binary file not found')
|
||||
return _('UnRar binary file not found')
|
||||
|
||||
try:
|
||||
unrar_location = [unrar_location]
|
||||
value = process_wait(unrar_location, pattern='UNRAR (.*) freeware')
|
||||
if value:
|
||||
version = value.group(1)
|
||||
log.debug("unrar version %s", version)
|
||||
log.debug("UnRar version %s", version)
|
||||
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
log.error_or_exception(err)
|
||||
return _('Error executing UnRar')
|
||||
|
||||
|
||||
def check_calibre(calibre_location):
|
||||
if not calibre_location:
|
||||
return
|
||||
|
||||
if not os.path.exists(calibre_location):
|
||||
return _('Could not find the specified directory')
|
||||
|
||||
if not os.path.isdir(calibre_location):
|
||||
return _('Please specify a directory, not a file')
|
||||
|
||||
try:
|
||||
supported_binary_paths = [os.path.join(calibre_location, binary)
|
||||
for binary in SUPPORTED_CALIBRE_BINARIES.values()]
|
||||
binaries_available = [os.path.isfile(binary_path) for binary_path in supported_binary_paths]
|
||||
binaries_executable = [os.access(binary_path, os.X_OK) for binary_path in supported_binary_paths]
|
||||
if all(binaries_available) and all(binaries_executable):
|
||||
values = [process_wait([binary_path, "--version"], pattern=r'\(calibre (.*)\)')
|
||||
for binary_path in supported_binary_paths]
|
||||
if all(values):
|
||||
version = values[0].group(1)
|
||||
log.debug("calibre version %s", version)
|
||||
else:
|
||||
return _('Calibre binaries not viable')
|
||||
else:
|
||||
ret_val = []
|
||||
missing_binaries = [path for path, available in
|
||||
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_available) if not available]
|
||||
|
||||
missing_perms = [path for path, available in
|
||||
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_executable) if not available]
|
||||
if missing_binaries:
|
||||
ret_val.append(_('Missing calibre binaries: %(missing)s', missing=", ".join(missing_binaries)))
|
||||
if missing_perms:
|
||||
ret_val.append(_('Missing executable permissions: %(missing)s', missing=", ".join(missing_perms)))
|
||||
return ", ".join(ret_val)
|
||||
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
log.error_or_exception(err)
|
||||
return _('Error executing Calibre')
|
||||
|
||||
|
||||
def json_serial(obj):
|
||||
"""JSON serializer for objects not serializable by default json code"""
|
||||
|
||||
@@ -1008,43 +1082,41 @@ def tags_filters():
|
||||
|
||||
|
||||
# checks if domain is in database (including wildcards)
|
||||
# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name;
|
||||
# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name;
|
||||
# from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/
|
||||
# in all calls the email address is checked for validity
|
||||
def check_valid_domain(domain_text):
|
||||
sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 1);"
|
||||
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()
|
||||
if not len(result):
|
||||
if not len(ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()):
|
||||
return False
|
||||
sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 0);"
|
||||
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()
|
||||
return not len(result)
|
||||
return not len(ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all())
|
||||
|
||||
|
||||
def get_download_link(book_id, book_format, client):
|
||||
book_format = book_format.split(".")[0]
|
||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||
data1= ""
|
||||
if book:
|
||||
data1 = calibre_db.get_book_format(book.id, book_format.upper())
|
||||
if data1:
|
||||
# collect downloaded books only for registered user and not for anonymous user
|
||||
if current_user.is_authenticated:
|
||||
ub.update_download(book_id, int(current_user.id))
|
||||
file_name = book.title
|
||||
if len(book.authors) > 0:
|
||||
file_name = file_name + ' - ' + book.authors[0].name
|
||||
if client == "kindle":
|
||||
file_name = get_valid_filename(file_name, replace_whitespace=False, force_unidecode=True)
|
||||
else:
|
||||
file_name = quote(get_valid_filename(file_name, replace_whitespace=False))
|
||||
headers = Headers()
|
||||
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
|
||||
headers["Content-Disposition"] = ('attachment; filename="{}.{}"; filename*=UTF-8\'\'{}.{}').format(
|
||||
file_name, book_format, file_name, book_format)
|
||||
return do_download_file(book, book_format, client, data1, headers)
|
||||
else:
|
||||
log.error("Book id {} not found for downloading".format(book_id))
|
||||
abort(404)
|
||||
if data1:
|
||||
# collect downloaded books only for registered user and not for anonymous user
|
||||
if current_user.is_authenticated:
|
||||
ub.update_download(book_id, int(current_user.id))
|
||||
file_name = book.title
|
||||
if len(book.authors) > 0:
|
||||
file_name = file_name + ' - ' + book.authors[0].name
|
||||
file_name = get_valid_filename(file_name, replace_whitespace=False)
|
||||
headers = Headers()
|
||||
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
|
||||
headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % (
|
||||
quote(file_name), book_format, quote(file_name), book_format)
|
||||
return do_download_file(book, book_format, client, data1, headers)
|
||||
else:
|
||||
abort(404)
|
||||
abort(404)
|
||||
|
||||
|
||||
def clear_cover_thumbnail_cache(book_id):
|
||||
|
||||
@@ -15,24 +15,17 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import sys
|
||||
|
||||
from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES
|
||||
from . import logger
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
try:
|
||||
from iso639 import languages, __version__
|
||||
get = languages.get
|
||||
except ImportError:
|
||||
from pycountry import languages as pyc_languages
|
||||
try:
|
||||
import pkg_resources
|
||||
__version__ = pkg_resources.get_distribution('pycountry').version + ' (PyCountry)'
|
||||
del pkg_resources
|
||||
except (ImportError, Exception):
|
||||
__version__ = "? (PyCountry)"
|
||||
|
||||
def _copy_fields(l):
|
||||
l.part1 = getattr(l, 'alpha_2', None)
|
||||
@@ -46,35 +39,48 @@ except ImportError:
|
||||
return _copy_fields(pyc_languages.get(alpha_2=part1))
|
||||
if name is not None:
|
||||
return _copy_fields(pyc_languages.get(name=name))
|
||||
except ImportError as ex:
|
||||
if sys.version_info >= (3, 12):
|
||||
print("Python 3.12 isn't compatible with iso-639. Please install pycountry.")
|
||||
from iso639 import languages
|
||||
get = languages.get
|
||||
|
||||
|
||||
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):
|
||||
try:
|
||||
return get_language_names(locale)[lang_code]
|
||||
except KeyError:
|
||||
log.error('Missing translation for language name: {}'.format(lang_code))
|
||||
return "Unknown"
|
||||
UNKNOWN_TRANSLATION = "Unknown"
|
||||
names = get_language_names(locale)
|
||||
if names is None:
|
||||
log.error(f"Missing language names for locale: {str(locale)}/{locale.language}")
|
||||
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):
|
||||
language_names = set(x.strip().lower() for x in language_names if x)
|
||||
def get_language_code_from_name(locale, language_names, remainder=None):
|
||||
language_names = set(strip_whitespaces(x).lower() for x in language_names if x)
|
||||
lang = list()
|
||||
for k, v in get_language_names(locale).items():
|
||||
v = v.lower()
|
||||
if v in language_names:
|
||||
lang.append(k)
|
||||
language_names.remove(v)
|
||||
for key, val in get_language_names(locale).items():
|
||||
val = val.lower()
|
||||
if val in language_names:
|
||||
lang.append(key)
|
||||
language_names.remove(val)
|
||||
if remainder is not None and language_names:
|
||||
remainder.extend(language_names)
|
||||
return lang
|
||||
|
||||
|
||||
|
||||
def get_valid_language_codes(locale, language_names, remainder=None):
|
||||
def get_valid_language_codes_from_code(locale, language_names, remainder=None):
|
||||
lang = list()
|
||||
if "" in language_names:
|
||||
language_names.remove("")
|
||||
@@ -95,6 +101,6 @@ def get_lang3(lang):
|
||||
ret_value = lang
|
||||
else:
|
||||
ret_value = ""
|
||||
except KeyError:
|
||||
except (KeyError, AttributeError):
|
||||
ret_value = lang
|
||||
return ret_value
|
||||
|
||||
@@ -27,10 +27,9 @@ import datetime
|
||||
import mimetypes
|
||||
from uuid import uuid4
|
||||
|
||||
# from babel.dates import format_date
|
||||
from flask import Blueprint, request, url_for
|
||||
from flask import Blueprint, request, url_for, g
|
||||
from flask_babel import format_date
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
|
||||
from . import constants, logger
|
||||
|
||||
@@ -44,6 +43,8 @@ def url_for_other_page(page):
|
||||
args = request.view_args.copy()
|
||||
args['page'] = page
|
||||
for get, val in request.args.items():
|
||||
if get == "page":
|
||||
continue
|
||||
args[get] = val
|
||||
return url_for(request.endpoint, **args)
|
||||
|
||||
@@ -112,21 +113,12 @@ def yesno(value, yes, no):
|
||||
|
||||
@jinjia.app_template_filter('formatfloat')
|
||||
def formatfloat(value, decimals=1):
|
||||
value = 0 if not value else value
|
||||
return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.')
|
||||
|
||||
|
||||
@jinjia.app_template_filter('formatseriesindex')
|
||||
def formatseriesindex_filter(series_index):
|
||||
if series_index:
|
||||
try:
|
||||
if int(series_index) - series_index == 0:
|
||||
return int(series_index)
|
||||
else:
|
||||
return series_index
|
||||
except ValueError:
|
||||
return series_index
|
||||
return 0
|
||||
if not value or (isinstance(value, str) and not value.is_numeric()):
|
||||
return value
|
||||
formated_value = ('{0:.' + str(decimals) + 'f}').format(value)
|
||||
if formated_value.endswith('.' + "0" * decimals):
|
||||
formated_value = formated_value.rstrip('0').rstrip('.')
|
||||
return formated_value
|
||||
|
||||
|
||||
@jinjia.app_template_filter('escapedlink')
|
||||
@@ -180,3 +172,12 @@ def get_cover_srcset(series):
|
||||
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
|
||||
srcset.append(f'{url} {resolution}x')
|
||||
return ', '.join(srcset)
|
||||
|
||||
|
||||
@jinjia.app_template_filter('music')
|
||||
def contains_music(book_formats):
|
||||
result = False
|
||||
for format in book_formats:
|
||||
if format.format.lower() in g.constants.EXTENSIONS_AUDIO:
|
||||
result = True
|
||||
return result
|
||||
|
||||
235
cps/kobo.py
@@ -18,7 +18,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
@@ -36,7 +36,7 @@ from flask import (
|
||||
redirect,
|
||||
abort
|
||||
)
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from werkzeug.datastructures import Headers
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.sql.expression import and_, or_
|
||||
@@ -44,11 +44,10 @@ from sqlalchemy.exc import StatementError
|
||||
from sqlalchemy.sql import select
|
||||
import requests
|
||||
|
||||
|
||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
||||
from . import isoLanguages
|
||||
from .epub import get_epub_layout
|
||||
from .constants import COVER_THUMBNAIL_SMALL #, sqlalchemy_version2
|
||||
from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE
|
||||
from .helper import get_download_link
|
||||
from .services import SyncToken as SyncToken
|
||||
from .web import download_required
|
||||
@@ -56,7 +55,7 @@ from .kobo_auth import requires_kobo_auth, get_auth_token
|
||||
|
||||
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
|
||||
KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
|
||||
KOBO_IMAGEHOST_URL = "https://kbimages1-a.akamaihd.net"
|
||||
KOBO_IMAGEHOST_URL = "https://cdn.kobo.com/book-images"
|
||||
|
||||
SYNC_ITEM_LIMIT = 100
|
||||
|
||||
@@ -107,24 +106,29 @@ def make_request_to_kobo_store(sync_token=None):
|
||||
return store_response
|
||||
|
||||
|
||||
def redirect_or_proxy_request():
|
||||
def redirect_or_proxy_request(auth=False):
|
||||
if config.config_kobo_proxy:
|
||||
if request.method == "GET":
|
||||
return redirect(get_store_url_for_current_request(), 307)
|
||||
else:
|
||||
# The Kobo device turns other request types into GET requests on redirects,
|
||||
# so we instead proxy to the Kobo store ourselves.
|
||||
store_response = make_request_to_kobo_store()
|
||||
try:
|
||||
if request.method == "GET":
|
||||
alfa = redirect(get_store_url_for_current_request(), 307)
|
||||
return alfa
|
||||
else:
|
||||
# The Kobo device turns other request types into GET requests on redirects,
|
||||
# so we instead proxy to the Kobo store ourselves.
|
||||
store_response = make_request_to_kobo_store()
|
||||
|
||||
response_headers = store_response.headers
|
||||
for header_key in CONNECTION_SPECIFIC_HEADERS:
|
||||
response_headers.pop(header_key, default=None)
|
||||
response_headers = store_response.headers
|
||||
for header_key in CONNECTION_SPECIFIC_HEADERS:
|
||||
response_headers.pop(header_key, default=None)
|
||||
|
||||
return make_response(
|
||||
store_response.content, store_response.status_code, response_headers.items()
|
||||
)
|
||||
else:
|
||||
return make_response(jsonify({}))
|
||||
return make_response(
|
||||
store_response.content, store_response.status_code, response_headers.items()
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Failed to receive or parse response from Kobo's endpoint: {}".format(e))
|
||||
if auth:
|
||||
return make_calibre_web_auth_response()
|
||||
return make_response(jsonify({}))
|
||||
|
||||
|
||||
def convert_to_kobo_timestamp_string(timestamp):
|
||||
@@ -132,31 +136,34 @@ def convert_to_kobo_timestamp_string(timestamp):
|
||||
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
except AttributeError as exc:
|
||||
log.debug("Timestamp not valid: {}".format(exc))
|
||||
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
@kobo.route("/v1/library/sync")
|
||||
@requires_kobo_auth
|
||||
@download_required
|
||||
# @download_required
|
||||
def HandleSyncRequest():
|
||||
if not current_user.role_download():
|
||||
log.info("Users need download permissions for syncing library to Kobo reader")
|
||||
return abort(403)
|
||||
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("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:
|
||||
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
||||
|
||||
# if no books synced don't respect sync_token
|
||||
if not ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.user_id == current_user.id).count():
|
||||
sync_token.books_last_modified = datetime.datetime.min
|
||||
sync_token.books_last_created = datetime.datetime.min
|
||||
sync_token.reading_state_last_modified = datetime.datetime.min
|
||||
sync_token.books_last_modified = datetime.min
|
||||
sync_token.books_last_created = datetime.min
|
||||
sync_token.reading_state_last_modified = datetime.min
|
||||
|
||||
new_books_last_modified = sync_token.books_last_modified # needed for sync selected shelfs only
|
||||
new_books_last_created = sync_token.books_last_created # needed to distinguish between new and changed entitlement
|
||||
new_reading_state_last_modified = sync_token.reading_state_last_modified
|
||||
|
||||
new_archived_last_modified = datetime.datetime.min
|
||||
new_archived_last_modified = datetime.min
|
||||
sync_results = []
|
||||
|
||||
# We reload the book database so that the user gets a fresh view of the library
|
||||
@@ -205,11 +212,11 @@ def HandleSyncRequest():
|
||||
for book in books:
|
||||
formats = [data.format for data in book.Books.data]
|
||||
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||
helper.convert_book_format(book.Books.id, config.get_book_path(), 'EPUB', 'KEPUB', current_user.name)
|
||||
|
||||
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -315,13 +322,13 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
|
||||
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
|
||||
|
||||
except Exception as ex:
|
||||
log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
|
||||
log.error_or_exception("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
|
||||
if set_cont:
|
||||
extra_headers["x-kobo-sync"] = "continue"
|
||||
sync_token.to_headers(extra_headers)
|
||||
|
||||
# log.debug("Kobo Sync Content: {}".format(sync_results))
|
||||
# jsonify decodes the unicode string different to what kobo expects
|
||||
# jsonify decodes the Unicode string different to what kobo expects
|
||||
response = make_response(json.dumps(sync_results), extra_headers)
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return response
|
||||
@@ -373,7 +380,7 @@ def create_book_entitlement(book, archived):
|
||||
book_uuid = str(book.uuid)
|
||||
return {
|
||||
"Accessibility": "Full",
|
||||
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.datetime.utcnow())},
|
||||
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.now(timezone.utc))},
|
||||
"Created": convert_to_kobo_timestamp_string(book.timestamp),
|
||||
"CrossRevisionId": book_uuid,
|
||||
"Id": book_uuid,
|
||||
@@ -421,7 +428,7 @@ def get_series(book):
|
||||
|
||||
|
||||
def get_seriesindex(book):
|
||||
return book.series_index or 1
|
||||
return book.series_index if isinstance(book.series_index, float) else 1
|
||||
|
||||
|
||||
def get_language(book):
|
||||
@@ -484,14 +491,16 @@ def get_metadata(book):
|
||||
|
||||
if get_series(book):
|
||||
name = get_series(book)
|
||||
metadata["Series"] = {
|
||||
"Name": get_series(book),
|
||||
"Number": get_seriesindex(book), # ToDo Check int() ?
|
||||
"NumberFloat": float(get_seriesindex(book)),
|
||||
# Get a deterministic id based on the series name.
|
||||
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
|
||||
}
|
||||
|
||||
try:
|
||||
metadata["Series"] = {
|
||||
"Name": get_series(book),
|
||||
"Number": get_seriesindex(book), # ToDo Check int() ?
|
||||
"NumberFloat": float(get_seriesindex(book)),
|
||||
# Get a deterministic id based on the series name.
|
||||
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
|
||||
}
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -723,7 +732,7 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
||||
ub.session_commit()
|
||||
|
||||
|
||||
# Creates a Kobo "Tag" object from a ub.Shelf object
|
||||
# Creates a Kobo "Tag" object from an ub.Shelf object
|
||||
def create_kobo_tag(shelf):
|
||||
tag = {
|
||||
"Created": convert_to_kobo_timestamp_string(shelf.created),
|
||||
@@ -793,7 +802,7 @@ def HandleStateRequest(book_uuid):
|
||||
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
|
||||
and new_book_read_status != book_read.read_status:
|
||||
book_read.times_started_reading += 1
|
||||
book_read.last_time_started_reading = datetime.datetime.utcnow()
|
||||
book_read.last_time_started_reading = datetime.now(timezone.utc)
|
||||
book_read.read_status = new_book_read_status
|
||||
update_results_response["StatusInfoResult"] = {"Result": "Success"}
|
||||
except (KeyError, TypeError, ValueError, StatementError):
|
||||
@@ -901,7 +910,12 @@ def get_current_bookmark_response(current_bookmark):
|
||||
@requires_kobo_auth
|
||||
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||
try:
|
||||
resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL
|
||||
if int(height) > 1000:
|
||||
resolution = COVER_THUMBNAIL_LARGE
|
||||
elif int(height) > 500:
|
||||
resolution = COVER_THUMBNAIL_MEDIUM
|
||||
else:
|
||||
resolution = COVER_THUMBNAIL_SMALL
|
||||
except ValueError:
|
||||
log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
|
||||
resolution = COVER_THUMBNAIL_SMALL
|
||||
@@ -918,8 +932,8 @@ def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||
log.debug("Redirecting request for cover image of unknown book %s to Kobo" % book_uuid)
|
||||
return redirect(KOBO_IMAGEHOST_URL +
|
||||
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
|
||||
width=width,
|
||||
height=height), 307)
|
||||
width=width,
|
||||
height=height), 307)
|
||||
|
||||
|
||||
@kobo.route("")
|
||||
@@ -946,9 +960,11 @@ def HandleBookDeletionRequest(book_uuid):
|
||||
|
||||
# TODO: Implement the following routes
|
||||
@csrf.exempt
|
||||
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
|
||||
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET", "POST"])
|
||||
@kobo.route("/v1/library/<dummy>/preview", methods=["POST"])
|
||||
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()
|
||||
|
||||
|
||||
@@ -959,6 +975,7 @@ def HandleUnimplementedRequest(dummy=None):
|
||||
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/assets", methods=["GET"])
|
||||
def HandleUserRequest(dummy=None):
|
||||
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
|
||||
return redirect_or_proxy_request()
|
||||
@@ -1000,7 +1017,8 @@ def handle_getests():
|
||||
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/deals", methods=["GET", "POST"])
|
||||
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()
|
||||
|
||||
|
||||
@@ -1017,7 +1035,7 @@ def make_calibre_web_auth_response():
|
||||
"RefreshToken": RefreshToken,
|
||||
"TokenType": "Bearer",
|
||||
"TrackingId": str(uuid.uuid4()),
|
||||
"UserKey": content.get('UserKey',""),
|
||||
"UserKey": content.get('UserKey', ""),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1030,7 +1048,7 @@ def HandleAuthRequest():
|
||||
log.debug('Kobo Auth request')
|
||||
if config.config_kobo_proxy:
|
||||
try:
|
||||
return redirect_or_proxy_request()
|
||||
return redirect_or_proxy_request(auth=True)
|
||||
except Exception:
|
||||
log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.")
|
||||
return make_calibre_web_auth_response()
|
||||
@@ -1114,25 +1132,37 @@ def download_book(book_id, book_format):
|
||||
|
||||
def NATIVE_KOBO_RESOURCES():
|
||||
return {
|
||||
"account_page": "https://secure.kobobooks.com/profile",
|
||||
"account_page": "https://www.kobo.com/account/settings",
|
||||
"account_page_rakuten": "https://my.rakuten.co.jp/",
|
||||
"add_device": "https://storeapi.kobo.com/v1/user/add-device",
|
||||
"add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}",
|
||||
"affiliaterequest": "https://storeapi.kobo.com/v1/affiliate",
|
||||
"assets": "https://storeapi.kobo.com/v1/assets",
|
||||
"audiobook": "https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}",
|
||||
"audiobook_detail_page": "https://www.kobo.com/{region}/{language}/audiobook/{slug}",
|
||||
"audiobook_landing_page": "https://www.kobo.com/{region}/{language}/audiobooks",
|
||||
"audiobook_preview": "https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview",
|
||||
"audiobook_purchase_withcredit": "https://storeapi.kobo.com/v1/store/audiobook/{Id}",
|
||||
"audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion",
|
||||
"authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations",
|
||||
"autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete",
|
||||
"blackstone_header": {"key": "x-amz-request-payer", "value": "requester"},
|
||||
"blackstone_header": {
|
||||
"key": "x-amz-request-payer",
|
||||
"value": "requester"
|
||||
},
|
||||
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
|
||||
"book_detail_page": "https://store.kobobooks.com/{culture}/ebook/{slug}",
|
||||
"book_detail_page_rakuten": "https://books.rakuten.co.jp/rk/{crossrevisionid}",
|
||||
"book_landing_page": "https://store.kobobooks.com/ebooks",
|
||||
"book_detail_page": "https://www.kobo.com/{region}/{language}/ebook/{slug}",
|
||||
"book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}",
|
||||
"book_landing_page": "https://www.kobo.com/ebooks",
|
||||
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
|
||||
"browse_history": "https://storeapi.kobo.com/v1/user/browsehistory",
|
||||
"categories": "https://storeapi.kobo.com/v1/categories",
|
||||
"categories_page": "https://store.kobobooks.com/ebooks/categories",
|
||||
"categories_page": "https://www.kobo.com/ebooks/categories",
|
||||
"category": "https://storeapi.kobo.com/v1/categories/{CategoryId}",
|
||||
"category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured",
|
||||
"category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products",
|
||||
"checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow",
|
||||
"client_authd_referral": "https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink",
|
||||
"configuration_data": "https://storeapi.kobo.com/v1/configuration",
|
||||
"content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access",
|
||||
"customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO",
|
||||
@@ -1143,92 +1173,109 @@ def NATIVE_KOBO_RESOURCES():
|
||||
"delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete",
|
||||
"device_auth": "https://storeapi.kobo.com/v1/auth/device",
|
||||
"device_refresh": "https://storeapi.kobo.com/v1/auth/refresh",
|
||||
"dictionary_host": "https://kbdownload1-a.akamaihd.net",
|
||||
"dictionary_host": "https://ereaderfiles.kobo.com",
|
||||
"discovery_host": "https://discovery.kobobooks.com",
|
||||
"ereaderdevices": "https://storeapi.kobo.com/v2/products/EReaderDeviceFeeds",
|
||||
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
||||
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
||||
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
||||
"facebook_sso_page":
|
||||
"https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/",
|
||||
"facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://kobo.com/",
|
||||
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
||||
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
||||
"free_books_page": {
|
||||
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
||||
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
||||
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
||||
"NL": "https://www.kobo.com/{region}/{language}/"
|
||||
"List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
||||
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis",
|
||||
"NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
||||
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis"
|
||||
},
|
||||
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
||||
"funnel_metrics": "https://storeapi.kobo.com/v1/funnelmetrics",
|
||||
"get_download_keys": "https://storeapi.kobo.com/v1/library/downloadkeys",
|
||||
"get_download_link": "https://storeapi.kobo.com/v1/library/downloadlink",
|
||||
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
|
||||
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
|
||||
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
|
||||
"help_page": "https://www.kobo.com/help",
|
||||
"kobo_audiobooks_enabled": "False",
|
||||
"gpb_flow_enabled": "False",
|
||||
"help_page": "http://www.kobo.com/help",
|
||||
"image_host": "//cdn.kobo.com/book-images/",
|
||||
"image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg",
|
||||
"image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg",
|
||||
"kobo_audiobooks_credit_redemption": "False",
|
||||
"kobo_audiobooks_enabled": "True",
|
||||
"kobo_audiobooks_orange_deal_enabled": "False",
|
||||
"kobo_audiobooks_subscriptions_enabled": "False",
|
||||
"kobo_nativeborrow_enabled": "True",
|
||||
"kobo_display_price": "True",
|
||||
"kobo_dropbox_link_account_enabled": "False",
|
||||
"kobo_google_tax": "False",
|
||||
"kobo_googledrive_link_account_enabled": "False",
|
||||
"kobo_nativeborrow_enabled": "False",
|
||||
"kobo_onedrive_link_account_enabled": "False",
|
||||
"kobo_onestorelibrary_enabled": "False",
|
||||
"kobo_privacyCentre_url": "https://www.kobo.com/privacy",
|
||||
"kobo_redeem_enabled": "True",
|
||||
"kobo_shelfie_enabled": "False",
|
||||
"kobo_subscriptions_enabled": "False",
|
||||
"kobo_superpoints_enabled": "False",
|
||||
"kobo_subscriptions_enabled": "True",
|
||||
"kobo_superpoints_enabled": "True",
|
||||
"kobo_wishlist_enabled": "True",
|
||||
"library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}",
|
||||
"library_items": "https://storeapi.kobo.com/v1/user/library",
|
||||
"library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata",
|
||||
"library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices",
|
||||
"library_stack": "https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}",
|
||||
"library_search": "https://storeapi.kobo.com/v1/library/search",
|
||||
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
||||
"love_dashboard_page": "https://store.kobobooks.com/{culture}/kobosuperpoints",
|
||||
"love_points_redemption_page":
|
||||
"https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}",
|
||||
"magazine_landing_page": "https://store.kobobooks.com/emagazines",
|
||||
"love_dashboard_page": "https://www.kobo.com/{region}/{language}/kobosuperpoints",
|
||||
"love_points_redemption_page": "https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}",
|
||||
"magazine_landing_page": "https://www.kobo.com/emagazines",
|
||||
"more_sign_in_options": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/#allProviders",
|
||||
"notebooks": "https://storeapi.kobo.com/api/internal/notebooks",
|
||||
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
||||
"oauth_host": "https://oauth.kobo.com",
|
||||
"overdrive_account": "https://auth.overdrive.com/account",
|
||||
"overdrive_library": "https://{libraryKey}.auth.overdrive.com/library",
|
||||
"overdrive_library_finder_host": "https://libraryfinder.api.overdrive.com",
|
||||
"overdrive_thunder_host": "https://thunder.api.overdrive.com",
|
||||
"password_retrieval_page": "https://www.kobobooks.com/passwordretrieval.html",
|
||||
"password_retrieval_page": "https://www.kobo.com/passwordretrieval.html",
|
||||
"personalizedrecommendations": "https://storeapi.kobo.com/v2/users/personalizedrecommendations",
|
||||
"pocket_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkpocket",
|
||||
"post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event",
|
||||
"ppx_purchasing_url": "https://purchasing.kobo.com",
|
||||
"privacy_page": "https://www.kobo.com/privacypolicy?style=onestore",
|
||||
"product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread",
|
||||
"product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices",
|
||||
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
||||
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
||||
"products": "https://storeapi.kobo.com/v1/products",
|
||||
"provider_external_sign_in_page":
|
||||
"https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://store.kobobooks.com/",
|
||||
"purchase_buy": "https://www.kobo.com/checkout/createpurchase/",
|
||||
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
||||
"productsv2": "https://storeapi.kobo.com/v2/products",
|
||||
"provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://kobo.com/",
|
||||
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
||||
"quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase",
|
||||
"rakuten_token_exchange": "https://storeapi.kobo.com/v1/auth/rakuten_token_exchange",
|
||||
"rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}",
|
||||
"reading_services_host": "https://readingservices.kobo.com",
|
||||
"reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state",
|
||||
"redeem_interstitial_page": "https://store.kobobooks.com",
|
||||
"registration_page": "https://authorize.kobo.com/signup?returnUrl=http://store.kobobooks.com/",
|
||||
"redeem_interstitial_page": "https://www.kobo.com",
|
||||
"registration_page": "https://authorize.kobo.com/signup?returnUrl=http://kobo.com/",
|
||||
"related_items": "https://storeapi.kobo.com/v1/products/{Id}/related",
|
||||
"remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}",
|
||||
"rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
|
||||
"review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}",
|
||||
"review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}",
|
||||
"shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie",
|
||||
"sign_in_page": "https://authorize.kobo.com/signin?returnUrl=http://store.kobobooks.com/",
|
||||
"sign_in_page": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/",
|
||||
"social_authorization_host": "https://social.kobobooks.com:8443",
|
||||
"social_host": "https://social.kobobooks.com",
|
||||
"stacks_host_productId": "https://store.kobobooks.com/collections/byproductid/",
|
||||
"store_home": "www.kobo.com/{region}/{language}",
|
||||
"store_host": "store.kobobooks.com",
|
||||
"store_newreleases": "https://store.kobobooks.com/{culture}/List/new-releases/961XUjtsU0qxkFItWOutGA",
|
||||
"store_search": "https://store.kobobooks.com/{culture}/Search?Query={query}",
|
||||
"store_top50": "https://store.kobobooks.com/{culture}/ebooks/Top",
|
||||
"store_host": "www.kobo.com",
|
||||
"store_newreleases": "https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA",
|
||||
"store_search": "https://www.kobo.com/{region}/{language}/Search?Query={query}",
|
||||
"store_top50": "https://www.kobo.com/{region}/{language}/ebooks/Top",
|
||||
"subs_landing_page": "https://www.kobo.com/{region}/{language}/plus",
|
||||
"subs_management_page": "https://www.kobo.com/{region}/{language}/account/subscriptions",
|
||||
"subs_plans_page": "https://www.kobo.com/{region}/{language}/plus/plans",
|
||||
"subs_purchase_buy_templated": "https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}",
|
||||
"tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items",
|
||||
"tags": "https://storeapi.kobo.com/v1/library/tags",
|
||||
"taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile",
|
||||
"terms_of_sale_page": "https://authorize.kobo.com/{region}/{language}/terms/termsofsale",
|
||||
"update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview",
|
||||
"use_one_store": "False",
|
||||
"use_one_store": "True",
|
||||
"user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits",
|
||||
"user_platform": "https://storeapi.kobo.com/v1/user/platform",
|
||||
"user_profile": "https://storeapi.kobo.com/v1/user/profile",
|
||||
@@ -1236,6 +1283,6 @@ def NATIVE_KOBO_RESOURCES():
|
||||
"user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations",
|
||||
"user_reviews": "https://storeapi.kobo.com/v1/user/reviews",
|
||||
"user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist",
|
||||
"userguide_host": "https://kbdownload1-a.akamaihd.net",
|
||||
"wishlist_page": "https://store.kobobooks.com/{region}/{language}/account/wishlist",
|
||||
"userguide_host": "https://ereaderfiles.kobo.com",
|
||||
"wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
This module also includes research notes into the auth protocol used by Kobo devices.
|
||||
|
||||
Log-in:
|
||||
When first booting a Kobo device the user must sign into a Kobo (or affiliate) account.
|
||||
When first booting a Kobo device the user must log in to a Kobo (or affiliate) account.
|
||||
Upon successful sign-in, the user is redirected to
|
||||
https://auth.kobobooks.com/CrossDomainSignIn?id=<some id>
|
||||
which serves the following response:
|
||||
@@ -41,7 +41,7 @@ issue for a few years now https://www.mobileread.com/forums/showpost.php?p=34768
|
||||
will still grant access given the userkey.)
|
||||
|
||||
Official Kobo Store Api authorization:
|
||||
* For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is
|
||||
* For most of the endpoints we care about (sync, metadata, tags, etc.), the userKey is
|
||||
passed in the x-kobo-userkey header, and is sufficient to authorize the API call.
|
||||
* Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through
|
||||
an authorization header. To get a BearerToken, the device makes a POST request to the
|
||||
@@ -65,12 +65,14 @@ from os import urandom
|
||||
from functools import wraps
|
||||
|
||||
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_limiter import RateLimitExceeded
|
||||
|
||||
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
|
||||
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>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def generate_auth_token(user_id):
|
||||
warning = False
|
||||
host_list = request.host.rsplit(':')
|
||||
@@ -115,12 +117,12 @@ def generate_auth_token(user_id):
|
||||
"generate_kobo_auth_url.html",
|
||||
title=_("Kobo Setup"),
|
||||
auth_token=auth_token.auth_token,
|
||||
warning = warning
|
||||
warning=warning
|
||||
)
|
||||
|
||||
|
||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def delete_auth_token(user_id):
|
||||
# Invalidate any previously generated Kobo Auth token for this user
|
||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||
@@ -156,6 +158,9 @@ def requires_kobo_auth(f):
|
||||
limiter.check()
|
||||
except RateLimitExceeded:
|
||||
return abort(429)
|
||||
except (ConnectionError, Exception) as e:
|
||||
log.error("Connection error to limiter backend: %s", e)
|
||||
return abort(429)
|
||||
user = (
|
||||
ub.session.query(ub.User)
|
||||
.join(ub.RemoteAuthToken)
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
# 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
|
||||
import datetime
|
||||
from datetime import datetime, timezone
|
||||
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,
|
||||
# do nothing (safety precaution)
|
||||
@@ -50,7 +51,6 @@ def remove_synced_book(book_id, all=False, session=None):
|
||||
ub.session_commit(_session=session)
|
||||
|
||||
|
||||
|
||||
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),
|
||||
ub.ArchivedBook.book_id == book_id)).first()
|
||||
@@ -58,7 +58,7 @@ def change_archived_books(book_id, state=None, message=None):
|
||||
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
|
||||
|
||||
archived_book.is_archived = state if state else not archived_book.is_archived
|
||||
archived_book.last_modified = datetime.datetime.utcnow() # toDo. Check utc timestamp
|
||||
archived_book.last_modified = datetime.now(timezone.utc) # toDo. Check utc timestamp
|
||||
|
||||
ub.session.merge(archived_book)
|
||||
ub.session_commit(message)
|
||||
@@ -71,7 +71,7 @@ def update_on_sync_shelfs(user_id):
|
||||
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
||||
.join(ub.BookShelf, ub.KoboSyncedBooks.book_id == ub.BookShelf.book_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())
|
||||
for b in books_to_archive:
|
||||
change_archived_books(b.book_id, True)
|
||||
|
||||
@@ -29,7 +29,7 @@ from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||
ACCESS_FORMATTER_GEVENT = Formatter("%(message)s")
|
||||
ACCESS_FORMATTER_TORNADO = Formatter("[%(asctime)s] %(message)s")
|
||||
|
||||
FORMATTER = Formatter("[%(asctime)s] %(levelname)5s {%(name)s:%(lineno)d} %(message)s")
|
||||
FORMATTER = Formatter("[%(asctime)s] %(levelname)5s {%(filename)s:%(lineno)d} %(message)s")
|
||||
DEFAULT_LOG_LEVEL = logging.INFO
|
||||
DEFAULT_LOG_FILE = os.path.join(_CONFIG_DIR, "calibre-web.log")
|
||||
DEFAULT_ACCESS_LOG = os.path.join(_CONFIG_DIR, "access.log")
|
||||
@@ -42,17 +42,12 @@ logging.addLevelName(logging.CRITICAL, "CRIT")
|
||||
|
||||
class _Logger(logging.Logger):
|
||||
|
||||
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
||||
if sys.version_info > (3, 7):
|
||||
if is_debug_enabled():
|
||||
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||
else:
|
||||
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
||||
def error_or_exception(self, message, stacklevel=1, *args, **kwargs):
|
||||
is_debug = self.getEffectiveLevel() <= logging.DEBUG
|
||||
if not is_debug:
|
||||
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||
else:
|
||||
if is_debug_enabled():
|
||||
self.exception(message, stack_info=True, *args, **kwargs)
|
||||
else:
|
||||
self.error(message, *args, **kwargs)
|
||||
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
||||
|
||||
def debug_no_auth(self, message, *args, **kwargs):
|
||||
message = message.strip("\r\n")
|
||||
@@ -150,7 +145,7 @@ def setup(log_file, log_level=None):
|
||||
else:
|
||||
try:
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||
except IOError:
|
||||
except (IOError, PermissionError):
|
||||
if log_file == DEFAULT_LOG_FILE:
|
||||
raise
|
||||
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||
@@ -177,7 +172,7 @@ def create_access_log(log_file, log_name, formatter):
|
||||
access_log.setLevel(logging.INFO)
|
||||
try:
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||
except IOError:
|
||||
except (IOError, PermissionError):
|
||||
if log_file == DEFAULT_ACCESS_LOG:
|
||||
raise
|
||||
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||
|
||||
@@ -20,17 +20,18 @@ import sys
|
||||
|
||||
from . import create_app, limiter
|
||||
from .jinjia import jinjia
|
||||
from .remotelogin import remotelogin
|
||||
from flask import request
|
||||
|
||||
|
||||
def request_username():
|
||||
return request.authorization.username
|
||||
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
|
||||
from .web import web
|
||||
from .basic import basic
|
||||
from .opds import opds
|
||||
from .admin import admi
|
||||
from .gdrive import gdrive
|
||||
@@ -41,6 +42,7 @@ def main():
|
||||
from .shelf import shelf
|
||||
from .tasks_status import tasks
|
||||
from .error_handler import init_errorhandler
|
||||
from .remotelogin import remotelogin
|
||||
try:
|
||||
from .kobo import kobo, get_kobo_activated
|
||||
from .kobo_auth import kobo_auth
|
||||
@@ -48,12 +50,14 @@ def main():
|
||||
kobo_available = get_kobo_activated()
|
||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||
kobo_available = False
|
||||
kobo = kobo_auth = get_remote_address = None
|
||||
|
||||
try:
|
||||
from .oauth_bb import oauth
|
||||
oauth_available = True
|
||||
except ImportError:
|
||||
oauth_available = False
|
||||
oauth = None
|
||||
|
||||
from . import web_server
|
||||
init_errorhandler()
|
||||
@@ -61,8 +65,9 @@ def main():
|
||||
app.register_blueprint(search)
|
||||
app.register_blueprint(tasks)
|
||||
app.register_blueprint(web)
|
||||
app.register_blueprint(basic)
|
||||
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(about)
|
||||
app.register_blueprint(shelf)
|
||||
|
||||
@@ -25,7 +25,7 @@ try:
|
||||
import cchardet #optional for better speed
|
||||
except ImportError:
|
||||
pass
|
||||
from cps import logger
|
||||
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
import cps.logger as logger
|
||||
|
||||
@@ -33,21 +33,21 @@ import cps.logger as logger
|
||||
from operator import itemgetter
|
||||
log = logger.create()
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class Amazon(Metadata):
|
||||
__name__ = "Amazon"
|
||||
__id__ = "amazon"
|
||||
headers = {'upgrade-insecure-requests': '1',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36',
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
||||
'sec-gpc': '1',
|
||||
'sec-fetch-site': 'none',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-user': '?1',
|
||||
'sec-fetch-dest': 'document',
|
||||
'accept-encoding': 'gzip, deflate, br',
|
||||
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0',
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Alt-Used' : 'www.amazon.com',
|
||||
'Priority' : 'u=0, i',
|
||||
'accept-encoding': 'gzip, deflate, br, zstd',
|
||||
'accept-language': 'en-US,en;q=0.9'}
|
||||
session = requests.Session()
|
||||
session.headers=headers
|
||||
@@ -55,7 +55,6 @@ class Amazon(Metadata):
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
#timer=time()
|
||||
def inner(link, index) -> [dict, int]:
|
||||
with self.session as session:
|
||||
try:
|
||||
@@ -63,11 +62,11 @@ class Amazon(Metadata):
|
||||
r.raise_for_status()
|
||||
except Exception as ex:
|
||||
log.warning(ex)
|
||||
return None
|
||||
return []
|
||||
long_soup = BS(r.text, "lxml") #~4sec :/
|
||||
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
||||
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-ppd_csm_instrumentation_wrapper"})
|
||||
if soup2 is None:
|
||||
return None
|
||||
return []
|
||||
try:
|
||||
match = MetaRecord(
|
||||
title = "",
|
||||
@@ -90,7 +89,7 @@ class Amazon(Metadata):
|
||||
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
|
||||
.replace("\xa0"," ")[:-9].strip().strip("\n")
|
||||
except (AttributeError, TypeError):
|
||||
return None # if there is no description it is not a book and therefore should be ignored
|
||||
return [] # if there is no description it is not a book and therefore should be ignored
|
||||
try:
|
||||
match.title = soup2.find("span", attrs={"id": "productTitle"}).text
|
||||
except (AttributeError, TypeError):
|
||||
@@ -109,13 +108,13 @@ class Amazon(Metadata):
|
||||
except (AttributeError, ValueError):
|
||||
match.rating = 0
|
||||
try:
|
||||
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image frontImage"})["src"]
|
||||
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image"})["src"]
|
||||
except (AttributeError, TypeError):
|
||||
match.cover = ""
|
||||
return match, index
|
||||
except Exception as e:
|
||||
log.error_or_exception(e)
|
||||
return None
|
||||
return []
|
||||
|
||||
val = list()
|
||||
if self.active:
|
||||
@@ -135,7 +134,7 @@ class Amazon(Metadata):
|
||||
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
||||
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
|
||||
val = list(map(lambda x : x.result() ,concurrent.futures.as_completed(fut)))
|
||||
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:3])}
|
||||
val = list(map(lambda x : x.result(), concurrent.futures.as_completed(fut)))
|
||||
result = list(filter(lambda x: x, val))
|
||||
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
|
||||
|
||||
@@ -169,7 +169,8 @@ class Douban(Metadata):
|
||||
),
|
||||
)
|
||||
|
||||
html = etree.HTML(r.content.decode("utf8"))
|
||||
decode_content = r.content.decode("utf8")
|
||||
html = etree.HTML(decode_content)
|
||||
|
||||
match.title = html.xpath(self.TITTLE_XPATH)[0].text
|
||||
match.cover = html.xpath(
|
||||
@@ -184,7 +185,7 @@ class Douban(Metadata):
|
||||
if len(tag_elements):
|
||||
match.tags = [tag_element.text for tag_element in tag_elements]
|
||||
else:
|
||||
match.tags = self._get_tags(html.text)
|
||||
match.tags = self._get_tags(decode_content)
|
||||
|
||||
description_element = html.xpath(self.DESCRIPTION_XPATH)
|
||||
if len(description_element):
|
||||
@@ -216,7 +217,8 @@ class Douban(Metadata):
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class Google(Metadata):
|
||||
results.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
return []
|
||||
for result in results.json().get("items", []):
|
||||
val.append(
|
||||
self._parse_search_result(
|
||||
|
||||
@@ -97,12 +97,14 @@ class LubimyCzytac(Metadata):
|
||||
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
||||
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
||||
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']"
|
||||
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{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"
|
||||
COVER = "//meta[@property='og:image']/@content"
|
||||
@@ -135,7 +137,7 @@ class LubimyCzytac(Metadata):
|
||||
|
||||
def _prepare_query(self, title: str) -> str:
|
||||
query = ""
|
||||
characters_to_remove = "\?()\/"
|
||||
characters_to_remove = r"\?()\/"
|
||||
pattern = "[" + characters_to_remove + "]"
|
||||
title = re.sub(pattern, "", title)
|
||||
title = title.replace("_", " ")
|
||||
@@ -158,6 +160,7 @@ class LubimyCzytac(Metadata):
|
||||
|
||||
class LubimyCzytacParser:
|
||||
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_PL_TEMPLATE = (
|
||||
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
||||
@@ -282,11 +285,13 @@ class LubimyCzytacParser:
|
||||
|
||||
def _parse_tags(self) -> List[str]:
|
||||
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
||||
return [
|
||||
strip_accents(w.replace(", itd.", " itd."))
|
||||
for w in tags
|
||||
if isinstance(w, str)
|
||||
]
|
||||
if tags:
|
||||
return [
|
||||
strip_accents(w.replace(", itd.", " itd."))
|
||||
for w in tags
|
||||
if isinstance(w, str)
|
||||
]
|
||||
return None
|
||||
|
||||
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
||||
value = None
|
||||
@@ -346,5 +351,9 @@ class LubimyCzytacParser:
|
||||
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ class OAuthBackend(SQLAlchemyBackend):
|
||||
Stores and retrieves OAuth tokens using a relational database through
|
||||
the `SQLAlchemy`_ ORM.
|
||||
|
||||
.. _SQLAlchemy: https://www.sqlalchemy.org/
|
||||
_SQLAlchemy: https://www.sqlalchemy.org/
|
||||
"""
|
||||
def __init__(self, model, session, provider_id,
|
||||
user=None, user_id=None, user_required=None, anon_user=None,
|
||||
|
||||
@@ -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.google import make_google_blueprint, google
|
||||
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 .usermanagement import user_login_required
|
||||
|
||||
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:
|
||||
login_user(oauth_entry.user)
|
||||
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")
|
||||
return redirect(url_for('web.index'))
|
||||
else:
|
||||
@@ -205,6 +206,7 @@ def unlink_oauth(provider):
|
||||
flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
|
||||
return redirect(url_for('web.profile'))
|
||||
|
||||
|
||||
def generate_oauth_blueprints():
|
||||
if not ub.session.query(ub.OAuthProvider).count():
|
||||
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)
|
||||
|
||||
|
||||
|
||||
# notify on OAuth provider error
|
||||
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
||||
def github_error(blueprint, error, error_description=None, error_uri=None):
|
||||
@@ -338,7 +341,7 @@ def github_login():
|
||||
|
||||
|
||||
@oauth.route('/unlink/github', methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def github_login_unlink():
|
||||
return unlink_oauth(oauthblueprints[0]['id'])
|
||||
|
||||
@@ -362,6 +365,6 @@ def google_login():
|
||||
|
||||
|
||||
@oauth.route('/unlink/google', methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def google_login_unlink():
|
||||
return unlink_oauth(oauthblueprints[1]['id'])
|
||||
|
||||
74
cps/opds.py
@@ -21,18 +21,19 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import json
|
||||
# import json
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from flask import Blueprint, request, render_template, make_response, abort, Response
|
||||
from flask_login import current_user
|
||||
from flask import Blueprint, request, render_template, make_response, abort, g, jsonify
|
||||
from flask_babel import get_locale
|
||||
from flask_babel import gettext as _
|
||||
|
||||
|
||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
|
||||
from . import logger, config, db, calibre_db, ub, isoLanguages
|
||||
from .usermanagement import requires_basic_auth_if_no_ano
|
||||
from . import logger, config, db, calibre_db, ub, isoLanguages, constants
|
||||
from .usermanagement import requires_basic_auth_if_no_ano, auth
|
||||
from .helper import get_download_link, get_book_cover
|
||||
from .pagination import Pagination
|
||||
from .web import render_read_books
|
||||
@@ -94,6 +95,8 @@ def feed_letter_books(book_id):
|
||||
@opds.route("/opds/new")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_new():
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_RECENT):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books, True, [db.Books.timestamp.desc()],
|
||||
@@ -104,6 +107,8 @@ def feed_new():
|
||||
@opds.route("/opds/discover")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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)
|
||||
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))
|
||||
@@ -113,6 +118,8 @@ def feed_discover():
|
||||
@opds.route("/opds/rated")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_best_rated():
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_BEST_RATED):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 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),
|
||||
@@ -124,6 +131,8 @@ def feed_best_rated():
|
||||
@opds.route("/opds/hot")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_hot():
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_HOT):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
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)
|
||||
@@ -146,12 +155,16 @@ def feed_hot():
|
||||
@opds.route("/opds/author")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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')
|
||||
|
||||
|
||||
@opds.route("/opds/author/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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
|
||||
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)\
|
||||
@@ -173,6 +186,8 @@ def feed_author(book_id):
|
||||
@opds.route("/opds/publisher")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_publisherindex():
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_PUBLISHER):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries = calibre_db.session.query(db.Publishers)\
|
||||
.join(db.books_publishers_link)\
|
||||
@@ -194,12 +209,16 @@ def feed_publisher(book_id):
|
||||
@opds.route("/opds/category")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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')
|
||||
|
||||
|
||||
@opds.route("/opds/category/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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
|
||||
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
||||
entries = calibre_db.session.query(db.Tags)\
|
||||
@@ -223,12 +242,16 @@ def feed_category(book_id):
|
||||
@opds.route("/opds/series")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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')
|
||||
|
||||
|
||||
@opds.route("/opds/series/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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
|
||||
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
||||
entries = calibre_db.session.query(db.Series)\
|
||||
@@ -258,6 +281,8 @@ def feed_series(book_id):
|
||||
@opds.route("/opds/ratings")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_ratingindex():
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_RATING):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
||||
(db.Ratings.rating / 2).label('name')) \
|
||||
@@ -284,6 +309,8 @@ def feed_ratings(book_id):
|
||||
@opds.route("/opds/formats")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_formatindex():
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_FORMAT):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
||||
.filter(calibre_db.common_filters()) \
|
||||
@@ -291,7 +318,6 @@ def feed_formatindex():
|
||||
.order_by(db.Data.format).all()
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(entries))
|
||||
|
||||
element = list()
|
||||
for entry in entries:
|
||||
element.append(FeedObject(entry.format, entry.format))
|
||||
@@ -314,12 +340,14 @@ def feed_format(book_id):
|
||||
@opds.route("/opds/language/")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_languagesindex():
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_LANGUAGE):
|
||||
abort(404)
|
||||
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()
|
||||
else:
|
||||
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)
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(languages))
|
||||
@@ -341,9 +369,11 @@ def feed_languages(book_id):
|
||||
@opds.route("/opds/shelfindex")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_shelfindex():
|
||||
if not (auth.current_user().is_authenticated or g.allow_anonymous):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
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)
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
number)
|
||||
@@ -353,16 +383,19 @@ def feed_shelfindex():
|
||||
@opds.route("/opds/shelf/<int:book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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
|
||||
if current_user.is_anonymous:
|
||||
if auth.current_user().is_anonymous:
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
||||
ub.Shelf.id == book_id).first()
|
||||
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),
|
||||
and_(ub.Shelf.is_public == 1,
|
||||
ub.Shelf.id == book_id))).first()
|
||||
result = list()
|
||||
pagination = list()
|
||||
# user is allowed to access shelf
|
||||
if shelf:
|
||||
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
||||
@@ -390,12 +423,9 @@ def feed_shelf(book_id):
|
||||
@opds.route("/opds/download/<book_id>/<book_format>/")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def opds_download_link(book_id, book_format):
|
||||
if not current_user.role_download():
|
||||
return abort(403)
|
||||
if "Kobo" in request.headers.get('User-Agent'):
|
||||
client = "kobo"
|
||||
else:
|
||||
client = ""
|
||||
if not auth.current_user().role_download():
|
||||
return abort(401)
|
||||
client = "kobo" if "Kobo" in request.headers.get('User-Agent') else ""
|
||||
return get_download_link(book_id, book_format.lower(), client)
|
||||
|
||||
|
||||
@@ -421,7 +451,7 @@ def get_database_stats():
|
||||
stat['authors'] = calibre_db.session.query(db.Authors).count()
|
||||
stat['categories'] = calibre_db.session.query(db.Tags).count()
|
||||
stat['series'] = calibre_db.session.query(db.Series).count()
|
||||
return Response(json.dumps(stat), mimetype="application/json")
|
||||
return make_response(jsonify(stat))
|
||||
|
||||
|
||||
@opds.route("/opds/thumb_240_240/<book_id>")
|
||||
@@ -436,6 +466,8 @@ def feed_get_cover(book_id):
|
||||
@opds.route("/opds/readbooks")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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
|
||||
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)
|
||||
@@ -444,6 +476,8 @@ def feed_read_books():
|
||||
@opds.route("/opds/unreadbooks")
|
||||
@requires_basic_auth_if_no_ano
|
||||
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
|
||||
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)
|
||||
@@ -477,7 +511,7 @@ def feed_search(term):
|
||||
def render_xml_template(*args, **kwargs):
|
||||
# ToDo: return time in current timezone similar to %z
|
||||
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.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
||||
return response
|
||||
@@ -502,7 +536,7 @@ def render_element_index(database_column, linked_table, folder):
|
||||
entries = entries.join(linked_table).join(db.Books)
|
||||
entries = entries.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(database_column, 1, 1))).all()
|
||||
elements = []
|
||||
if off == 0:
|
||||
if off == 0 and entries:
|
||||
elements.append({'id': "00", 'name': _("All")})
|
||||
shift = 1
|
||||
for entry in entries[
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from flask import request, url_for, redirect
|
||||
from flask import request, url_for, redirect, current_app
|
||||
|
||||
|
||||
def is_safe_url(target):
|
||||
@@ -38,16 +38,15 @@ def is_safe_url(target):
|
||||
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
|
||||
|
||||
|
||||
def get_redirect_target():
|
||||
for target in request.values.get('next'), request.referrer:
|
||||
if not target:
|
||||
continue
|
||||
if is_safe_url(target):
|
||||
return target
|
||||
def remove_prefix(text, prefix):
|
||||
if text.startswith(prefix):
|
||||
return text[len(prefix):]
|
||||
return ""
|
||||
|
||||
|
||||
def redirect_back(endpoint, **values):
|
||||
target = request.form['next']
|
||||
if not target or not is_safe_url(target):
|
||||
def get_redirect_location(next, endpoint, **values):
|
||||
target = next or url_for(endpoint, **values)
|
||||
adapter = current_app.url_map.bind(urlparse(request.host_url).netloc)
|
||||
if not len(adapter.allowed_methods(remove_prefix(target, request.environ.get('HTTP_X_SCRIPT_NAME',"")))):
|
||||
target = url_for(endpoint, **values)
|
||||
return redirect(target)
|
||||
return target
|
||||
|
||||
@@ -25,12 +25,13 @@ from datetime import datetime
|
||||
from functools import wraps
|
||||
|
||||
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 sqlalchemy.sql.expression import true
|
||||
|
||||
from . import config, logger, ub
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
|
||||
remotelogin = Blueprint('remotelogin', __name__)
|
||||
@@ -65,7 +66,7 @@ def remote_login():
|
||||
|
||||
@remotelogin.route('/verify/<token>')
|
||||
@remote_login_required
|
||||
@login_required
|
||||
@user_login_required
|
||||
def verify_token(token):
|
||||
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_babel import gettext as _
|
||||
from werkzeug.local import LocalProxy
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from sqlalchemy.sql.expression import or_
|
||||
|
||||
from . import config, constants, logger, ub
|
||||
from .ub import User
|
||||
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def get_sidebar_config(kwargs=None):
|
||||
@@ -112,7 +111,7 @@ def render_title_template(*args, **kwargs):
|
||||
sidebar, simple = get_sidebar_config(kwargs)
|
||||
try:
|
||||
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)
|
||||
except PermissionError:
|
||||
log.error("No permission to access {} file.".format(args[0]))
|
||||
|
||||
@@ -41,9 +41,9 @@ class ReverseProxied(object):
|
||||
"""Wrap the application in this middleware and configure the
|
||||
front-end server to add these headers, to let you quietly bind
|
||||
this to a URL other than / and to an HTTP scheme that is
|
||||
different than what is used locally.
|
||||
different from what is used locally.
|
||||
|
||||
Code courtesy of: http://flask.pocoo.org/snippets/35/
|
||||
Code courtesy of: https://flask.pocoo.org/snippets/35/
|
||||
|
||||
In nginx:
|
||||
location /myprefix {
|
||||
|
||||
@@ -21,6 +21,7 @@ import datetime
|
||||
from . import config, constants
|
||||
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
|
||||
from .tasks.database import TaskReconnectDatabase
|
||||
from .tasks.clean import TaskClean
|
||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||
from .services.worker import WorkerThread
|
||||
from .tasks.metadata_backup import TaskBackupMetadata
|
||||
@@ -31,6 +32,9 @@ def get_scheduled_tasks(reconnect=True):
|
||||
if reconnect:
|
||||
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||
|
||||
# Delete temp folder
|
||||
tasks.append([lambda: TaskClean(), 'delete temp', True])
|
||||
|
||||
# Generate metadata.opf file for each changed book
|
||||
if config.schedule_metadata_backup:
|
||||
tasks.append([lambda: TaskBackupMetadata("en"), 'backup metadata', False])
|
||||
@@ -65,9 +69,12 @@ def register_scheduled_tasks(reconnect=True):
|
||||
duration = config.schedule_duration
|
||||
|
||||
# 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)
|
||||
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")
|
||||
|
||||
# Kick-off tasks, if they should currently be running
|
||||
@@ -86,6 +93,8 @@ def register_startup_tasks():
|
||||
# Ignore tasks that should currently be running, as these will be added when registering scheduled tasks
|
||||
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||
else:
|
||||
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskClean(), 'delete temp', True]])
|
||||
|
||||
|
||||
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 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 gettext as _
|
||||
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
|
||||
from sqlalchemy.sql.functions import coalesce
|
||||
|
||||
from . import logger, db, calibre_db, config, ub
|
||||
from .string_helper import strip_whitespaces
|
||||
from .usermanagement import login_required_if_no_ano
|
||||
from .render_template import render_title_template
|
||||
from .pagination import Pagination
|
||||
|
||||
|
||||
search = Blueprint('search', __name__)
|
||||
|
||||
log = logger.create()
|
||||
@@ -80,16 +82,27 @@ def adv_search_custom_columns(cc, term, q):
|
||||
if custom_end:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
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:
|
||||
custom_query = term.get('custom_column_' + str(c.id))
|
||||
if custom_query != '' and custom_query is not None:
|
||||
if c.datatype == 'bool':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == (custom_query == "True")))
|
||||
elif c.datatype == 'int' or c.datatype == 'float':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == custom_query))
|
||||
elif c.datatype == 'rating':
|
||||
if c.datatype == 'bool':
|
||||
if custom_query != "Any":
|
||||
if custom_query == "":
|
||||
q = q.filter(~getattr(db.Books, 'custom_column_' + str(c.id)).
|
||||
any(db.cc_classes[c.id].value >= 0))
|
||||
else:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
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(
|
||||
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
||||
else:
|
||||
@@ -128,10 +141,10 @@ def adv_search_read_status(read_status):
|
||||
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
|
||||
else:
|
||||
try:
|
||||
if read_status == "True":
|
||||
db_filter = db.cc_classes[config.config_read_column].value == True
|
||||
if read_status == "":
|
||||
db_filter = coalesce(db.cc_classes[config.config_read_column].value, 2) == 2
|
||||
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):
|
||||
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",
|
||||
@@ -217,8 +230,8 @@ def extend_search_term(searchterm,
|
||||
searchterm.extend([_("Rating <= %(rating)s", rating=rating_high)])
|
||||
if rating_low:
|
||||
searchterm.extend([_("Rating >= %(rating)s", rating=rating_low)])
|
||||
if read_status:
|
||||
searchterm.extend([_("Read Status = %(status)s", status=read_status)])
|
||||
if read_status != "Any":
|
||||
searchterm.extend([_("Read Status = '%(status)s'", status=read_status)])
|
||||
searchterm.extend(ext for ext in tags['include_extension'])
|
||||
searchterm.extend(ext for ext in tags['exclude_extension'])
|
||||
# handle custom columns
|
||||
@@ -231,7 +244,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
pagination = None
|
||||
|
||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
||||
calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
|
||||
calibre_db.create_functions()
|
||||
# calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
|
||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
|
||||
.outerjoin(db.Series)\
|
||||
@@ -244,21 +258,21 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
tags['include_' + element] = term.get('include_' + element)
|
||||
tags['exclude_' + element] = term.get('exclude_' + element)
|
||||
|
||||
author_name = term.get("author_name")
|
||||
book_title = term.get("book_title")
|
||||
author_name = term.get("authors")
|
||||
book_title = term.get("title")
|
||||
publisher = term.get("publisher")
|
||||
pub_start = term.get("publishstart")
|
||||
pub_end = term.get("publishend")
|
||||
rating_low = term.get("ratinghigh")
|
||||
rating_high = term.get("ratinglow")
|
||||
description = term.get("comment")
|
||||
description = term.get("comments")
|
||||
read_status = term.get("read_status")
|
||||
if author_name:
|
||||
author_name = author_name.strip().lower().replace(',', '|')
|
||||
author_name = strip_whitespaces(author_name).lower().replace(',', '|')
|
||||
if book_title:
|
||||
book_title = book_title.strip().lower()
|
||||
book_title = strip_whitespaces(book_title).lower()
|
||||
if publisher:
|
||||
publisher = publisher.strip().lower()
|
||||
publisher = strip_whitespaces(publisher).lower()
|
||||
|
||||
search_term = []
|
||||
cc_present = False
|
||||
@@ -274,16 +288,29 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
cc_present = True
|
||||
if column_end:
|
||||
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')
|
||||
)])
|
||||
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)):
|
||||
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||
cc_present = True
|
||||
|
||||
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \
|
||||
or rating_high or description or cc_present or read_status:
|
||||
or rating_high or description or cc_present or read_status != "Any":
|
||||
search_term, pub_start, pub_end = extend_search_term(search_term,
|
||||
author_name,
|
||||
book_title,
|
||||
@@ -302,7 +329,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
|
||||
if pub_end:
|
||||
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
|
||||
q = q.filter(adv_search_read_status(read_status))
|
||||
if read_status != "Any":
|
||||
q = q.filter(adv_search_read_status(read_status))
|
||||
if publisher:
|
||||
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%")))
|
||||
q = adv_search_tag(q, tags['include_tag'], tags['exclude_tag'])
|
||||
|
||||
@@ -23,17 +23,16 @@ import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, Response, request, url_for
|
||||
from flask_login import current_user
|
||||
from flask_login import login_required
|
||||
from flask import Blueprint, request, url_for, make_response, jsonify
|
||||
from .cw_login import current_user
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from cps.services.Metadata import Metadata
|
||||
from . import constants, logger, ub, web_server
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
# current_milli_time = lambda: int(round(time() * 1000))
|
||||
|
||||
meta = Blueprint("metadata", __name__)
|
||||
|
||||
@@ -81,7 +80,7 @@ cl = list_classes(new_list)
|
||||
|
||||
|
||||
@meta.route("/metadata/provider")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def metadata_provider():
|
||||
active = current_user.view_settings.get("metadata", {})
|
||||
provider = list()
|
||||
@@ -90,12 +89,12 @@ def metadata_provider():
|
||||
provider.append(
|
||||
{"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__}
|
||||
)
|
||||
return Response(json.dumps(provider), mimetype="application/json")
|
||||
return make_response(jsonify(provider))
|
||||
|
||||
|
||||
@meta.route("/metadata/provider", methods=["POST"])
|
||||
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def metadata_change_active_provider(prov_name):
|
||||
new_state = request.get_json()
|
||||
active = current_user.view_settings.get("metadata", {})
|
||||
@@ -115,14 +114,12 @@ def metadata_change_active_provider(prov_name):
|
||||
provider = next((c for c in cl if c.__id__ == prov_name), None)
|
||||
if provider is not None:
|
||||
data = provider.search(new_state.get("query", ""))
|
||||
return Response(
|
||||
json.dumps([asdict(x) for x in data]), mimetype="application/json"
|
||||
)
|
||||
return make_response(jsonify([asdict(x) for x in data]))
|
||||
return ""
|
||||
|
||||
|
||||
@meta.route("/metadata/search", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def metadata_search():
|
||||
query = request.form.to_dict().get("query")
|
||||
data = list()
|
||||
@@ -130,7 +127,7 @@ def metadata_search():
|
||||
locale = get_locale()
|
||||
if query:
|
||||
static_cover = url_for("static", filename="generic_cover.jpg")
|
||||
# start = current_milli_time()
|
||||
# ret = cl[0].search(query, static_cover, locale)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
meta = {
|
||||
executor.submit(c.search, query, static_cover, locale): c
|
||||
@@ -139,5 +136,4 @@ def metadata_search():
|
||||
}
|
||||
for future in concurrent.futures.as_completed(meta):
|
||||
data.extend([asdict(x) for x in future.result() if x])
|
||||
# log.info({'Time elapsed {}'.format(current_milli_time()-start)})
|
||||
return Response(json.dumps(data), mimetype="application/json")
|
||||
return make_response(jsonify(data))
|
||||
|
||||
@@ -21,12 +21,12 @@ import os
|
||||
import errno
|
||||
import signal
|
||||
import socket
|
||||
import subprocess # nosec
|
||||
|
||||
try:
|
||||
from gevent.pywsgi import WSGIServer
|
||||
from .gevent_wsgi import MyWSGIHandler
|
||||
from gevent.pool import Pool
|
||||
from gevent.socket import socket as GeventSocket
|
||||
from gevent import __version__ as _version
|
||||
from greenlet import GreenletExit
|
||||
import ssl
|
||||
@@ -36,6 +36,7 @@ except ImportError:
|
||||
from .tornado_wsgi import MyWSGIContainer
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado import netutil
|
||||
from tornado import version as _version
|
||||
VERSION = 'Tornado ' + _version
|
||||
_GEVENT = False
|
||||
@@ -95,7 +96,13 @@ class WebServer(object):
|
||||
log.warning('Cert path: %s', certfile_path)
|
||||
log.warning('Key path: %s', keyfile_path)
|
||||
|
||||
def _make_gevent_unix_socket(self, socket_file):
|
||||
@staticmethod
|
||||
def _make_gevent_socket_activated():
|
||||
# Reuse an already open socket on fd=SD_LISTEN_FDS_START
|
||||
SD_LISTEN_FDS_START = 3
|
||||
return GeventSocket(fileno=SD_LISTEN_FDS_START)
|
||||
|
||||
def _prepare_unix_socket(self, socket_file):
|
||||
# the socket file must not exist prior to bind()
|
||||
if os.path.exists(socket_file):
|
||||
# avoid nuking regular files and symbolic links (could be a mistype or security issue)
|
||||
@@ -103,35 +110,41 @@ class WebServer(object):
|
||||
raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file)
|
||||
os.remove(socket_file)
|
||||
|
||||
unix_sock = WSGIServer.get_listener(socket_file, family=socket.AF_UNIX)
|
||||
self.unix_socket_file = socket_file
|
||||
|
||||
# ensure current user and group have r/w permissions, no permissions for other users
|
||||
# this way the socket can be shared in a semi-secure manner
|
||||
# between the user running calibre-web and the user running the fronting webserver
|
||||
os.chmod(socket_file, 0o660)
|
||||
|
||||
return unix_sock
|
||||
|
||||
def _make_gevent_socket(self):
|
||||
def _make_gevent_listener(self):
|
||||
if os.name != 'nt':
|
||||
socket_activated = os.environ.get("LISTEN_FDS")
|
||||
if socket_activated:
|
||||
sock = self._make_gevent_socket_activated()
|
||||
sock_info = sock.getsockname()
|
||||
return sock, "systemd-socket:" + _readable_listen_address(sock_info[0], sock_info[1])
|
||||
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
|
||||
if unix_socket_file:
|
||||
return self._make_gevent_unix_socket(unix_socket_file), "unix:" + unix_socket_file
|
||||
self._prepare_unix_socket(unix_socket_file)
|
||||
unix_sock = WSGIServer.get_listener(unix_socket_file, family=socket.AF_UNIX)
|
||||
# ensure current user and group have r/w permissions, no permissions for other users
|
||||
# this way the socket can be shared in a semi-secure manner
|
||||
# between the user running calibre-web and the user running the fronting webserver
|
||||
os.chmod(unix_socket_file, 0o660)
|
||||
|
||||
return unix_sock, "unix:" + unix_socket_file
|
||||
|
||||
if self.listen_address:
|
||||
return (self.listen_address, self.listen_port), None
|
||||
return ((self.listen_address, self.listen_port),
|
||||
_readable_listen_address(self.listen_address, self.listen_port))
|
||||
|
||||
if os.name == 'nt':
|
||||
self.listen_address = '0.0.0.0'
|
||||
return (self.listen_address, self.listen_port), None
|
||||
return ((self.listen_address, self.listen_port),
|
||||
_readable_listen_address(self.listen_address, self.listen_port))
|
||||
|
||||
address = ('::', self.listen_port)
|
||||
try:
|
||||
address = ('::', self.listen_port)
|
||||
sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
|
||||
except socket.error as ex:
|
||||
log.error('%s', ex)
|
||||
log.warning('Unable to listen on "", trying on IPv4 only...')
|
||||
log.warning('Unable to listen on {}, trying on IPv4 only...'.format(address))
|
||||
address = ('', self.listen_port)
|
||||
sock = WSGIServer.get_listener(address, family=socket.AF_INET)
|
||||
|
||||
@@ -201,9 +214,7 @@ class WebServer(object):
|
||||
ssl_args = self.ssl_args or {}
|
||||
|
||||
try:
|
||||
sock, output = self._make_gevent_socket()
|
||||
if output is None:
|
||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||
sock, output = self._make_gevent_listener()
|
||||
log.info('Starting Gevent server on %s', output)
|
||||
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
|
||||
error_log=log,
|
||||
@@ -228,17 +239,42 @@ class WebServer(object):
|
||||
if os.name == 'nt' and sys.version_info > (3, 7):
|
||||
import asyncio
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
log.info('Starting Tornado server on %s', _readable_listen_address(self.listen_address, self.listen_port))
|
||||
try:
|
||||
# Max Buffersize set to 200MB
|
||||
http_server = HTTPServer(MyWSGIContainer(self.app),
|
||||
max_buffer_size=209700000,
|
||||
ssl_options=self.ssl_args)
|
||||
|
||||
# Max Buffersize set to 200MB
|
||||
http_server = HTTPServer(MyWSGIContainer(self.app),
|
||||
max_buffer_size=209700000,
|
||||
ssl_options=self.ssl_args)
|
||||
http_server.listen(self.listen_port, self.listen_address)
|
||||
self.wsgiserver = IOLoop.current()
|
||||
self.wsgiserver.start()
|
||||
# wait for stop signal
|
||||
self.wsgiserver.close(True)
|
||||
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
|
||||
if os.environ.get("LISTEN_FDS") and os.name != 'nt':
|
||||
SD_LISTEN_FDS_START = 3
|
||||
sock = socket.socket(fileno=SD_LISTEN_FDS_START)
|
||||
http_server.add_socket(sock)
|
||||
sock.setblocking(0)
|
||||
socket_name =sock.getsockname()
|
||||
output = "systemd-socket:" + _readable_listen_address(socket_name[0], socket_name[1])
|
||||
elif unix_socket_file and os.name != 'nt':
|
||||
self._prepare_unix_socket(unix_socket_file)
|
||||
output = "unix:" + unix_socket_file
|
||||
unix_socket = netutil.bind_unix_socket(self.unix_socket_file)
|
||||
http_server.add_socket(unix_socket)
|
||||
# ensure current user and group have r/w permissions, no permissions for other users
|
||||
# this way the socket can be shared in a semi-secure manner
|
||||
# between the user running calibre-web and the user running the fronting webserver
|
||||
os.chmod(self.unix_socket_file, 0o660)
|
||||
else:
|
||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||
http_server.listen(self.listen_port, self.listen_address)
|
||||
log.info('Starting Tornado server on %s', output)
|
||||
|
||||
self.wsgiserver = IOLoop.current()
|
||||
self.wsgiserver.start()
|
||||
# wait for stop signal
|
||||
self.wsgiserver.close(True)
|
||||
finally:
|
||||
if self.unix_socket_file:
|
||||
os.remove(self.unix_socket_file)
|
||||
self.unix_socket_file = None
|
||||
|
||||
def start(self):
|
||||
try:
|
||||
@@ -265,7 +301,6 @@ class WebServer(object):
|
||||
log.info("Performing restart of Calibre-Web")
|
||||
args = self._get_args_for_reloading()
|
||||
os.execv(args[0].lstrip('"').rstrip('"'), args)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def shutdown_scheduler():
|
||||
@@ -291,4 +326,5 @@ class WebServer(object):
|
||||
if restart:
|
||||
self.wsgiserver.call_later(1.0, self.wsgiserver.stop)
|
||||
else:
|
||||
self.wsgiserver.add_callback_from_signal(self.wsgiserver.stop)
|
||||
self.wsgiserver.asyncio_loop.call_soon_threadsafe(self.wsgiserver.stop)
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ log = logger.create()
|
||||
|
||||
|
||||
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):
|
||||
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
||||
|
||||
@@ -47,7 +47,7 @@ def get_datetime_from_json(json_object, field_name):
|
||||
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -173,8 +173,8 @@ class SyncToken:
|
||||
|
||||
def __str__(self):
|
||||
return "{},{},{},{},{},{}".format(self.books_last_created,
|
||||
self.books_last_modified,
|
||||
self.archive_last_modified,
|
||||
self.reading_state_last_modified,
|
||||
self.tags_last_modified,
|
||||
self.raw_kobo_store_token)
|
||||
self.books_last_modified,
|
||||
self.archive_last_modified,
|
||||
self.reading_state_last_modified,
|
||||
self.tags_last_modified,
|
||||
self.raw_kobo_store_token)
|
||||
|
||||
@@ -42,6 +42,7 @@ class BackgroundScheduler:
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
|
||||
cls.log = logger.create()
|
||||
logger.logging.getLogger('tzlocal').setLevel(logger.logging.WARNING)
|
||||
cls.scheduler = BScheduler()
|
||||
cls.scheduler.start()
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.g
|
||||
def setup_gmail(token):
|
||||
# If there are no (valid) credentials available, let the user log in.
|
||||
creds = None
|
||||
user_info = None
|
||||
if "token" in token:
|
||||
creds = Credentials(
|
||||
token=token['token'],
|
||||
@@ -91,7 +92,7 @@ def send_messsage(token, msg):
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
service = build('gmail', 'v1', credentials=creds)
|
||||
message_as_bytes = msg.as_bytes() # the message should converted from string to bytes.
|
||||
message_as_bytes = msg.as_bytes() # the message should be converted from string to bytes.
|
||||
message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding)
|
||||
raw = message_as_base64.decode() # convert to something JSON serializable
|
||||
body = {'raw': raw}
|
||||
|
||||
@@ -18,16 +18,51 @@
|
||||
|
||||
import time
|
||||
from functools import reduce
|
||||
import requests
|
||||
|
||||
from goodreads.client import GoodreadsClient
|
||||
from goodreads.request import GoodreadsRequest
|
||||
import xmltodict
|
||||
|
||||
try:
|
||||
from goodreads.client import GoodreadsClient
|
||||
import Levenshtein
|
||||
except ImportError:
|
||||
from betterreads.client import GoodreadsClient
|
||||
|
||||
try: import Levenshtein
|
||||
except ImportError: Levenshtein = False
|
||||
Levenshtein = False
|
||||
|
||||
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()
|
||||
@@ -38,20 +73,20 @@ _CACHE_TIMEOUT = 23 * 60 * 60 # 23 hours (in seconds)
|
||||
_AUTHORS_CACHE = {}
|
||||
|
||||
|
||||
def connect(key=None, secret=None, enabled=True):
|
||||
def connect(key=None, enabled=True):
|
||||
global _client
|
||||
|
||||
if not enabled or not key or not secret:
|
||||
if not enabled or not key:
|
||||
_client = None
|
||||
return
|
||||
|
||||
if _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
|
||||
|
||||
if not _client:
|
||||
_client = GoodreadsClient(key, secret)
|
||||
_client = my_GoodreadsClient(key, None)
|
||||
|
||||
|
||||
def get_author_info(author_name):
|
||||
@@ -76,12 +111,13 @@ def get_author_info(author_name):
|
||||
|
||||
if author_info:
|
||||
author_info._timestamp = now
|
||||
author_info.safe_about = clean_string(author_info.about)
|
||||
_AUTHORS_CACHE[author_name] = author_info
|
||||
return author_info
|
||||
|
||||
|
||||
def get_other_books(author_info, library_books=None):
|
||||
# Get all identifiers (ISBN, Goodreads, etc) and filter author's books by that list so we show fewer duplicates
|
||||
# Get all identifiers (ISBN, Goodreads, etc.) and filter author's books by that list so we show fewer duplicates
|
||||
# Note: Not all images will be shown, even though they're available on Goodreads.com.
|
||||
# See https://www.goodreads.com/topic/show/18213769-goodreads-book-images
|
||||
|
||||
@@ -91,7 +127,8 @@ def get_other_books(author_info, library_books=None):
|
||||
identifiers = []
|
||||
library_titles = []
|
||||
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]
|
||||
|
||||
for book in author_info.books:
|
||||
|
||||
@@ -30,9 +30,11 @@ except ImportError:
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class LDAPLogger(object):
|
||||
|
||||
def write(self, message):
|
||||
@staticmethod
|
||||
def write(message):
|
||||
try:
|
||||
log.debug(message.strip("\n").replace("\n", ""))
|
||||
except Exception:
|
||||
@@ -71,6 +73,7 @@ class mySimpleLDap(LDAP):
|
||||
|
||||
_ldap = mySimpleLDap()
|
||||
|
||||
|
||||
def init_app(app, config):
|
||||
if config.config_login_type != constants.LOGIN_LDAP:
|
||||
return
|
||||
@@ -124,7 +127,7 @@ def init_app(app, config):
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ class CalibreTask:
|
||||
self.run(*args)
|
||||
except Exception as ex:
|
||||
self._handleError(str(ex))
|
||||
log.error_or_exception(ex)
|
||||
log.exception(ex)
|
||||
|
||||
self.end_time = datetime.now()
|
||||
|
||||
@@ -235,7 +235,7 @@ class CalibreTask:
|
||||
|
||||
@property
|
||||
def dead(self):
|
||||
"""Determines whether or not this task can be garbage collected
|
||||
"""Determines whether this task can be garbage collected
|
||||
|
||||
We have a separate dictating this because there may be certain tasks that want to override this
|
||||
"""
|
||||
@@ -266,3 +266,6 @@ class CalibreTask:
|
||||
def _handleSuccess(self):
|
||||
self.stat = STAT_FINISH_SUCCESS
|
||||
self.progress = 1
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
92
cps/shelf.py
@@ -21,17 +21,17 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, flash, redirect, request, url_for, abort
|
||||
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.sql.expression import func, true
|
||||
|
||||
from . import calibre_db, config, db, logger, ub
|
||||
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()
|
||||
|
||||
@@ -39,7 +39,7 @@ shelf = Blueprint('shelf', __name__)
|
||||
|
||||
|
||||
@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):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
@@ -71,8 +71,16 @@ def add_to_shelf(shelf_id, book_id):
|
||||
else:
|
||||
maxOrder = maxOrder[0]
|
||||
|
||||
if not calibre_db.session.query(db.Books).filter(db.Books.id == book_id).one_or_none():
|
||||
log.error("Invalid Book Id: %s. Could not be added to shelf %s", book_id, shelf.name)
|
||||
if not xhr:
|
||||
flash(_("%(book_id)s is a invalid Book Id. Could not be added to Shelf", book_id=book_id),
|
||||
category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400
|
||||
|
||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
|
||||
shelf.last_modified = datetime.utcnow()
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
@@ -95,7 +103,7 @@ def add_to_shelf(shelf_id, book_id):
|
||||
|
||||
|
||||
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def search_to_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf is None:
|
||||
@@ -131,7 +139,7 @@ def search_to_shelf(shelf_id):
|
||||
for book in books_for_shelf:
|
||||
maxOrder += 1
|
||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
|
||||
shelf.last_modified = datetime.utcnow()
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
@@ -147,7 +155,7 @@ def search_to_shelf(shelf_id):
|
||||
|
||||
|
||||
@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):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
@@ -177,7 +185,7 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
|
||||
try:
|
||||
ub.session.delete(book_shelf)
|
||||
shelf.last_modified = datetime.utcnow()
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
@@ -204,14 +212,14 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
|
||||
|
||||
@shelf.route("/shelf/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def create_shelf():
|
||||
shelf = ub.Shelf()
|
||||
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
|
||||
|
||||
|
||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def edit_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
@@ -221,7 +229,7 @@ def edit_shelf(shelf_id):
|
||||
|
||||
|
||||
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def delete_shelf(shelf_id):
|
||||
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
try:
|
||||
@@ -242,7 +250,7 @@ def show_simpleshelf(shelf_id):
|
||||
return render_show_shelf(2, shelf_id, 1, None)
|
||||
|
||||
|
||||
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "order", 'page': 1})
|
||||
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "stored", 'page': 1})
|
||||
@shelf.route("/shelf/<int:shelf_id>/<sort_param>", defaults={'page': 1})
|
||||
@shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>")
|
||||
@login_required_if_no_ano
|
||||
@@ -251,7 +259,7 @@ def show_shelf(shelf_id, sort_param, page):
|
||||
|
||||
|
||||
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def order_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf and check_shelf_view_permissions(shelf):
|
||||
@@ -263,7 +271,7 @@ def order_shelf(shelf_id):
|
||||
for book in books_in_shelf:
|
||||
setattr(book, 'order', to_save[str(book.book_id)])
|
||||
counter += 1
|
||||
# if order different from before -> shelf.last_modified = datetime.utcnow()
|
||||
# if order different from before -> shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
@@ -410,29 +418,37 @@ def change_shelf_order(shelf_id, order):
|
||||
|
||||
def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
|
||||
status = current_user.get_view_property("shelf", 'man')
|
||||
# check user is allowed to access shelf
|
||||
if shelf and check_shelf_view_permissions(shelf):
|
||||
if shelf_type == 1:
|
||||
# order = [ub.BookShelf.order.asc()]
|
||||
if sort_param == 'pubnew':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
||||
if sort_param == 'pubold':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate])
|
||||
if sort_param == 'abc':
|
||||
change_shelf_order(shelf_id, [db.Books.sort])
|
||||
if sort_param == 'zyx':
|
||||
change_shelf_order(shelf_id, [db.Books.sort.desc()])
|
||||
if sort_param == 'new':
|
||||
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
|
||||
if sort_param == 'old':
|
||||
change_shelf_order(shelf_id, [db.Books.timestamp])
|
||||
if sort_param == 'authaz':
|
||||
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
|
||||
if sort_param == 'authza':
|
||||
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
|
||||
db.Series.name.desc(),
|
||||
db.Books.series_index.desc()])
|
||||
if status != 'on':
|
||||
if sort_param == 'stored':
|
||||
sort_param = current_user.get_view_property("shelf", 'stored')
|
||||
else:
|
||||
current_user.set_view_property("shelf", 'stored', sort_param)
|
||||
if sort_param == 'pubnew':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
||||
if sort_param == 'pubold':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate])
|
||||
if sort_param == 'shelfnew':
|
||||
change_shelf_order(shelf_id, [ub.BookShelf.date_added.desc()])
|
||||
if sort_param == 'shelfold':
|
||||
change_shelf_order(shelf_id, [ub.BookShelf.date_added])
|
||||
if sort_param == 'abc':
|
||||
change_shelf_order(shelf_id, [db.Books.sort])
|
||||
if sort_param == 'zyx':
|
||||
change_shelf_order(shelf_id, [db.Books.sort.desc()])
|
||||
if sort_param == 'new':
|
||||
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
|
||||
if sort_param == 'old':
|
||||
change_shelf_order(shelf_id, [db.Books.timestamp])
|
||||
if sort_param == 'authaz':
|
||||
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
|
||||
if sort_param == 'authza':
|
||||
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
|
||||
db.Series.name.desc(),
|
||||
db.Books.series_index.desc()])
|
||||
page = "shelf.html"
|
||||
pagesize = 0
|
||||
else:
|
||||
@@ -445,7 +461,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
[ub.BookShelf.order.asc()],
|
||||
True, config.config_read_column,
|
||||
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
|
||||
# delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||
# delete shelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||
wrong_entries = calibre_db.session.query(ub.BookShelf) \
|
||||
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
|
||||
.filter(db.Books.id == None).all()
|
||||
@@ -464,7 +480,9 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
pagination=pagination,
|
||||
title=_("Shelf: '%(name)s'", name=shelf.name),
|
||||
shelf=shelf,
|
||||
page="shelf")
|
||||
page="shelf",
|
||||
status=status,
|
||||
order=sort_param)
|
||||
else:
|
||||
flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
108
cps/static/css/basic.css
Normal file
@@ -0,0 +1,108 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
height: 75px;
|
||||
padding: 5px 20px;
|
||||
width: 100%;
|
||||
display: table;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
nav > * {
|
||||
display: inline-block;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
float: none;
|
||||
text-align: center;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
nav > *:first-child {
|
||||
text-align: left;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.theme{
|
||||
text-align: center;
|
||||
margin: 10px;
|
||||
}
|
||||
nav > *:last-child {
|
||||
text-align: right;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
nav > a {
|
||||
color: black;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.search {
|
||||
margin: auto auto;
|
||||
}
|
||||
|
||||
form > input {
|
||||
width: 18ch;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
form > * {
|
||||
height: 50px;
|
||||
background-color: white;
|
||||
border-radius: 0;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
form > span {
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
width: 160px;
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 5px 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: black;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 150px;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.listing {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
padding: 10px 0;
|
||||
height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pagination > div {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.pagination > div:last-child {
|
||||
float: right;
|
||||
}
|
||||
@@ -3268,6 +3268,10 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] > div.
|
||||
left: auto !important
|
||||
}
|
||||
|
||||
ul.dropdown-menu.offscreen {
|
||||
margin: 2px -160px 0
|
||||
}
|
||||
|
||||
div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropdown-menu.offscreen {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
@@ -3296,6 +3300,7 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
|
||||
left: 0 !important;
|
||||
}
|
||||
#add-to-shelves {
|
||||
min-height: 48px;
|
||||
max-height: calc(100% - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -4332,6 +4337,7 @@ body.advanced_search > div.container-fluid > div.row-fluid > div.col-sm-10 > div
|
||||
|
||||
.navbar-right > li > ul.dropdown-menu.offscreen {
|
||||
right: -10px
|
||||
|
||||
}
|
||||
|
||||
.login .plexBack, body.login > div.container-fluid > div.row-fluid > div.col-sm-2, body.login > div.navbar.navbar-default.navbar-static-top > div > form {
|
||||
@@ -4812,8 +4818,14 @@ body.advsearch:not(.blur) > div.container-fluid > div.row-fluid > div.col-sm-10
|
||||
z-index: 999999999999999999999999999999999999
|
||||
}
|
||||
|
||||
.search #shelf-actions, body.login .home-btn {
|
||||
display: none
|
||||
body.search #shelf-actions button#add-to-shelf {
|
||||
height: 40px;
|
||||
}
|
||||
@media screen and (max-width: 767px) {
|
||||
body.search .discover, body.advsearch .discover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
body.read:not(.blur) a[href*=readbooks] {
|
||||
@@ -5134,7 +5146,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
||||
right: 5px
|
||||
}
|
||||
|
||||
#shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] {
|
||||
body:not(.search) #shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] {
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
@@ -5151,7 +5163,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
||||
color: var(--color-primary)
|
||||
}
|
||||
|
||||
#shelf-actions, #shelf-actions > .btn-group, #shelf-actions > .btn-group > .empty-ul {
|
||||
body:not(.search) #shelf-actions, body:not(.search) #shelf-actions > .btn-group, body:not(.search) #shelf-actions > .btn-group > .empty-ul {
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
@@ -7141,12 +7153,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||
}
|
||||
|
||||
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 {
|
||||
max-width: 130px;
|
||||
width: 130px;
|
||||
height: 180px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
max-width: unset;
|
||||
width: 100%;
|
||||
height: unset;
|
||||
padding: 15px;
|
||||
position: absolute
|
||||
}
|
||||
|
||||
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 {
|
||||
@@ -7155,10 +7166,6 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||
width: 100%
|
||||
}
|
||||
|
||||
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(1), body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(2), body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(1), body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(2) {
|
||||
padding-left: 120px
|
||||
}
|
||||
|
||||
#deleteButton, body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 > div.text-center > #delete, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 > div.text-center > #delete {
|
||||
top: 48px;
|
||||
height: 42px
|
||||
@@ -7944,3 +7951,5 @@ div.comments[data-readmore] {
|
||||
transition: height 300ms;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.dropdown-menu > .offscreen
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
|
||||
.blackTheme {
|
||||
background: #000;
|
||||
color: #fff
|
||||
color: #fff;
|
||||
}
|
||||
3
cps/static/css/libs/images/altText_add.svg
Normal file
@@ -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 |
3
cps/static/css/libs/images/altText_done.svg
Normal file
@@ -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 |
6
cps/static/css/libs/images/annotation-paperclip.svg
Normal file
@@ -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 |
7
cps/static/css/libs/images/annotation-pushpin.svg
Normal file
@@ -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 |
3
cps/static/css/libs/images/cursor-editorFreeText.svg
Normal file
@@ -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 |
4
cps/static/css/libs/images/cursor-editorInk.svg
Normal file
@@ -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 |
5
cps/static/css/libs/images/editor-toolbar-delete.svg
Normal file
@@ -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
|
||||
- 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"><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>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</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
|
||||
- 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"><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>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 431 B After Width: | Height: | Size: 578 B |
3
cps/static/css/libs/images/gv-toolbarButton-download.svg
Normal file
@@ -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
|
||||
- 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">
|
||||
<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 width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</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
|
||||
- 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"><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>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</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 |