Compare commits
467 commits
Author | SHA1 | Date | |
---|---|---|---|
5348dc9f82 | |||
e36ad27d49 | |||
d21d4bda83 | |||
8d8aa4749b | |||
3baf75e59e | |||
880b899e81 | |||
6061d6f600 | |||
a680a6501c | |||
625b5d963a | |||
9d11cc7576 | |||
c49e17db90 | |||
7696ff45db | |||
731f177d18 | |||
0061111ad8 | |||
6b53c00d7c | |||
1490a95735 | |||
c398b0d3f4 | |||
95cca433bc | |||
4f081c8d34 | |||
8386f77d72 | |||
03956637be | |||
60bdaa27a0 | |||
a6fa7ebb3a | |||
d0bd6c1231 | |||
960e64963f | |||
0b1a548ee4 | |||
04bd6dd35d | |||
2e7d12b3e6 | |||
cd990e4e2f | |||
fe187da491 | |||
636b470001 | |||
e5cf94d488 | |||
c5458c2d06 | |||
7af8636687 | |||
5ac46dad63 | |||
d4c814c79a | |||
db0d6e28a3 | |||
2f8d62649e | |||
683adc1b46 | |||
cfeb206154 | |||
c5c0f4258b | |||
73addc2f75 | |||
0ca50252dd | |||
8d79be07da | |||
37d5a7a20d | |||
76496e7169 | |||
7fcc3c8788 | |||
4436db7d83 | |||
d017c642eb | |||
7c5f311af9 | |||
73155f399b | |||
e540f7b784 | |||
0e8f816d0e | |||
1bf0d8478a | |||
594947852f | |||
b318ed5b06 | |||
012aed42b1 | |||
5c10bafb7d | |||
e660221265 | |||
e23ca7d215 | |||
95b02269bb | |||
ce07ba8cdc | |||
17e5c2c1b4 | |||
4fd2ff826a | |||
6efcc450a3 | |||
dc7442cfb6 | |||
9c708b8c89 | |||
40f0bd858b | |||
639e1ec9c6 | |||
a35072bbc3 | |||
da5ca5edea | |||
1e4df2d1b5 | |||
d68dda85ad | |||
065619772e | |||
7d17a92793 | |||
1d4be082cf | |||
2d643b48c6 | |||
bab7097fa3 | |||
fa8419976d | |||
427dcde672 | |||
580c61e924 | |||
6c9b6eb061 | |||
6d7b5db482 | |||
8a0c24a9b5 | |||
c8e0b9c5fb | |||
556329d5fa | |||
ac22c826cb | |||
0adc7a0d5e | |||
f7d7936499 | |||
c8faa30724 | |||
0d1d102f47 | |||
cf0264b5a6 | |||
6054accc54 | |||
778a9c870d | |||
dee64f130e | |||
bc8d7923b4 | |||
dec5ef153b | |||
5cf566251a | |||
7edc5d0165 | |||
35ced9a451 | |||
446029ce84 | |||
bb91d3c6b6 | |||
b32412f4fd | |||
ce0bf28725 | |||
77816b6c5d | |||
b145f4ada9 | |||
e4aa5c6e6e | |||
fa66fbbf1e | |||
bc433f235f | |||
2a38c8d21b | |||
4bc7fde36b | |||
5042f3bda7 | |||
fca5b3259d | |||
ee12c15d1c | |||
741c2eb234 | |||
9c843ee145 | |||
8f416cb5d7 | |||
5f9aca20c4 | |||
59e40b551b | |||
9586f59592 | |||
a585ab038b | |||
81baf59085 | |||
d7abc468b9 | |||
67f8ec6fae | |||
24bc7816f1 | |||
f0678c3379 | |||
e94a856a08 | |||
2c90114b9d | |||
0860f37ac0 | |||
8932317f08 | |||
f551a5214a | |||
d91676289b | |||
121789febe | |||
276ce34ae9 | |||
5ce0af0731 | |||
4c0be4ce8b | |||
f7fbf49e1e | |||
c9f66eb91c | |||
142e3eff2b | |||
8d4444cfb9 | |||
b59962a119 | |||
335db51ffc | |||
a62522d36a | |||
15e5219e3a | |||
a0bc5bacd8 | |||
4aba1034e7 | |||
a35a1283da | |||
382a79e6c7 | |||
24843cc31b | |||
|
ffd367c6ca | ||
73f0d4a7c3 | |||
42f96b0ead | |||
|
42c7b9d854 | ||
|
7cd5e19c1e | ||
bbb860148d | |||
c405fc0dbb | |||
d65757f7df | |||
|
9e57d04ad5 | ||
e5c02b1fc6 | |||
742296d5dd | |||
4a208a91b6 | |||
4dd7a6dcc4 | |||
aec98120ab | |||
03b2668969 | |||
46c2224a4f | |||
9f3cbac3c4 | |||
39d0a64c34 | |||
9fb2e8552f | |||
eb20cc1d21 | |||
86f4da306c | |||
04b3b7f806 | |||
dd0951cc82 | |||
4945b40810 | |||
c55f437885 | |||
b32cefe762 | |||
30c4c8ec8f | |||
4d974a5364 | |||
0d5387823d | |||
467ba19704 | |||
9edb0571d8 | |||
e5bad72e36 | |||
18ca8545e6 | |||
d4b8581793 | |||
1b660d8af5 | |||
2fc4a5e23e | |||
30634f9ec2 | |||
0239f7e031 | |||
70e57e4155 | |||
e9c46f23db | |||
0b43aad50e | |||
70d4579448 | |||
c40372a020 | |||
ffd0d3384e | |||
6f3f613cc8 | |||
34ea2441bb | |||
8c9977439e | |||
8cbe51277e | |||
b649a79afc | |||
8dd5fc0f50 | |||
1d079abd19 | |||
092cd5ca18 | |||
6f84023f8c | |||
78e6d76693 | |||
0936bcb311 | |||
01d5745fa7 | |||
0f95cfa0bc | |||
9d30534d1d | |||
23ab0c4329 | |||
132da5d69b | |||
1654ceecf3 | |||
025910029e | |||
236a32ebc1 | |||
d3c79a4cc3 | |||
3142c6073c | |||
1cf0449371 | |||
5252c59910 | |||
fceda5c698 | |||
7cc173420d | |||
2d6751cf0c | |||
baeae5cacd | |||
93c45f8a90 | |||
418f501afa | |||
7e50300942 | |||
43348a89da | |||
6fb289727c | |||
0fb62f5962 | |||
365110544c | |||
452dd3f47d | |||
098284a617 | |||
27e0cb9a34 | |||
f0cf3b3a68 | |||
169f0687cb | |||
b3fb0a8600 | |||
480658fefe | |||
91f649fcb2 | |||
d5f36bcfbe | |||
039b6a1914 | |||
756e3478d8 | |||
7d677734f3 | |||
9580068c5b | |||
c359b7640e | |||
cf0aea4f73 | |||
20fb7dbc6e | |||
920c938200 | |||
dcb9833b0d | |||
345c9f9885 | |||
58d8ca2a25 | |||
6ceb800723 | |||
247bce0996 | |||
1759be4d8c | |||
ea0ef22b16 | |||
227c43c81e | |||
65cba59a72 | |||
9d1c9646d9 | |||
52ffbab671 | |||
45daf529f8 | |||
cbc24d4774 | |||
380afe9831 | |||
39b2e40e32 | |||
dffa2d9d50 | |||
1cfab95201 | |||
9befd27a26 | |||
70703c5ceb | |||
b0bde64882 | |||
5c50eadb20 | |||
b5604ac8d1 | |||
9f733125a7 | |||
a6a5264477 | |||
e5b7d0fe95 | |||
b2b017c1bd | |||
f6a0adfb56 | |||
e5a44fd38c | |||
75e08aa1b2 | |||
957fc4ada7 | |||
870cdf5c25 | |||
40ead1bbe1 | |||
3e4f55fa9c | |||
501c8c3ee3 | |||
ce46abeb3d | |||
0c9adb0288 | |||
0fd65d3c2b | |||
6072bf64f3 | |||
c4b7e56c35 | |||
e7b577ef87 | |||
2add0c3d15 | |||
e15e4c72fe | |||
447e91f1f1 | |||
777878610f | |||
5e524cb4f2 | |||
778bd7d872 | |||
17f3779596 | |||
cd075a8ce2 | |||
7b4f5d3ac1 | |||
ff96b732af | |||
afc3b45edd | |||
d30f1bc334 | |||
29a3f740a9 | |||
14723b03ff | |||
87f04ce988 | |||
b89405ed88 | |||
e5f2e9d537 | |||
a0db1bfb47 | |||
4033837b91 | |||
f9e6f1dde3 | |||
2a65644813 | |||
2d2159ee58 | |||
ea241577f1 | |||
b8a74443c9 | |||
acb5bc97a9 | |||
5fa4066d7a | |||
ddf4099639 | |||
1e15a82403 | |||
e0bc184c12 | |||
b45661fa41 | |||
97f52a713b | |||
2231c3644c | |||
2add38b71e | |||
6d912de376 | |||
bb18194b56 | |||
458da087d0 | |||
9e71c3d35d | |||
342ca6ac76 | |||
ba7e838db8 | |||
d95422ea45 | |||
6313664e53 | |||
b63e2db584 | |||
8fdaad5367 | |||
931568db26 | |||
de141bb697 | |||
8b00cf8a1a | |||
829bb4a20f | |||
3ca2af74bb | |||
2413a8aa96 | |||
1a6ec02664 | |||
e72a6b01f0 | |||
fb9e9a24c9 | |||
f3bb90fffd | |||
b446c7072c | |||
cedae16456 | |||
adb302ebe3 | |||
28e4a71cae | |||
5d031f9e84 | |||
6481e8d9bc | |||
fa3a6ec362 | |||
a518d72a7d | |||
85d95fb5d7 | |||
a7f6824334 | |||
b8a8cd62cf | |||
b47716249e | |||
e4e8beb958 | |||
6b51531cb4 | |||
5349fc4c96 | |||
5308e28d98 | |||
78b2f8220d | |||
3cc2fe798f | |||
5c643299c7 | |||
1762b102fb | |||
e7df63d6f8 | |||
475302eb07 | |||
45298abb98 | |||
e853e22362 | |||
382b7ee4a9 | |||
3394eb72e8 | |||
9a98fcdf4f | |||
a3f23e3b4d | |||
cfe0f47d0f | |||
5b70c59f83 | |||
179f5753ed | |||
9add6be8e4 | |||
3d5b537369 | |||
ab810a8f94 | |||
43a56e865e | |||
40810d6310 | |||
6f6bb4e534 | |||
6b1cd896ea | |||
b658bf5c79 | |||
92cd38cbb0 | |||
1c09be1b1c | |||
41d490ea80 | |||
1e56d5a09a | |||
4b4ab324cc | |||
06278935b6 | |||
0a202a215d | |||
387e7d859c | |||
5555cdfd1e | |||
730a2bcb9d | |||
c34ec965a1 | |||
ee9992603c | |||
5aa4eed816 | |||
7090db3c37 | |||
371401d441 | |||
a86188fcb6 | |||
91fc632703 | |||
b053ba3c88 | |||
8a9f41759e | |||
76305543fa | |||
71295e30f0 | |||
52106f1d3f | |||
65ff5f947a | |||
93e3fa5412 | |||
e0dfdafdc0 | |||
3f4c5bbc11 | |||
bfa7f68edc | |||
d87d49e67b | |||
e8214b45ef | |||
75ed4503c8 | |||
63b0ec45e9 | |||
53b7b86515 | |||
e5d3af1b51 | |||
3c95eeeefb | |||
486eec2448 | |||
360063845e | |||
acce72e90e | |||
8b4a14ffa3 | |||
221d548e4a | |||
6bdcce1844 | |||
54bed15585 | |||
7027c74035 | |||
21786d6e6c | |||
88bf1e580c | |||
c354830653 | |||
7429d43280 | |||
b172d9f139 | |||
55731c63ce | |||
5987e54105 | |||
1f6a587329 | |||
e2e21f4afa | |||
5690e4bfab | |||
85be02c7d2 | |||
6678c4cdbb | |||
ba34ee034a | |||
9df4cb1eca | |||
d2b7ed5d02 | |||
08b44ccee6 | |||
8853e42508 | |||
d8a6dbf2bf | |||
286caadf40 | |||
8814e60624 | |||
4ac0bcde43 | |||
ecdd63b9d7 | |||
9bd6bc3d1c | |||
aec22e813d | |||
1912251801 | |||
e26dad6137 | |||
a14d31e9d3 | |||
1a9582213a | |||
00d7a29b2d | |||
0419a844ce | |||
9d91cec6f9 | |||
29da1e52d7 | |||
67308911fb | |||
824aaa1e57 | |||
eaf54a4e83 | |||
8e974e3745 | |||
a7e71525f4 | |||
8f8e53bb27 | |||
93be2f5a32 | |||
d234fd942d | |||
09bf6b8596 | |||
d48591bbae | |||
159528c603 | |||
c4f7ea7fc6 | |||
f39782346f | |||
dc99e7a39b | |||
9d6cf902d1 | |||
747c053c89 | |||
0d520c0fd8 |
182 changed files with 6281 additions and 1116 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
# Created by https://www.gitignore.io/api/django
|
# Created by https://www.gitignore.io/api/django
|
||||||
|
|
||||||
### Django ###
|
### Django ###
|
||||||
|
@ -15,5 +14,10 @@ media
|
||||||
# <django-project-name>/staticfiles/
|
# <django-project-name>/staticfiles/
|
||||||
|
|
||||||
# End of https://www.gitignore.io/api/django
|
# End of https://www.gitignore.io/api/django
|
||||||
|
|
||||||
|
/.pdm-python
|
||||||
|
/.env
|
||||||
|
/.mypy_cache
|
||||||
|
/.pytest_cache
|
||||||
/static
|
/static
|
||||||
node_modules
|
/node_modules
|
||||||
|
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "lemoncurry/static/base16-materialtheme-scheme"]
|
||||||
|
path = lemoncurry/static/base16-materialtheme-scheme
|
||||||
|
url = https://github.com/ntpeters/base16-materialtheme-scheme
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017 - 2024 Danielle McLean
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
27
Pipfile
27
Pipfile
|
@ -1,27 +0,0 @@
|
||||||
[[source]]
|
|
||||||
|
|
||||||
url = "https://pypi.python.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
name = "pypi"
|
|
||||||
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
|
|
||||||
django = "*"
|
|
||||||
django-compressor = "*"
|
|
||||||
gunicorn = "*"
|
|
||||||
"psycopg2" = "*"
|
|
||||||
pillow = "*"
|
|
||||||
python-memcached = "*"
|
|
||||||
django-favicon-plus = "*"
|
|
||||||
django-meta = "*"
|
|
||||||
django-redis-cache = "*"
|
|
||||||
django-activeurl = "*"
|
|
||||||
django-otp = "*"
|
|
||||||
qrcode = "*"
|
|
||||||
django-otp-agents = "*"
|
|
||||||
python-slugify = "*"
|
|
||||||
django-markdown-deux = "*"
|
|
||||||
|
|
||||||
|
|
||||||
[dev-packages]
|
|
325
Pipfile.lock
generated
325
Pipfile.lock
generated
|
@ -1,325 +0,0 @@
|
||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"hash": {
|
|
||||||
"sha256": "c32114b4f91b4ef6f4023a180c8db8c0567a6f9206da1af2136af17dd6026b93"
|
|
||||||
},
|
|
||||||
"host-environment-markers": {
|
|
||||||
"implementation_name": "cpython",
|
|
||||||
"implementation_version": "3.6.3",
|
|
||||||
"os_name": "posix",
|
|
||||||
"platform_machine": "x86_64",
|
|
||||||
"platform_python_implementation": "CPython",
|
|
||||||
"platform_release": "17.2.0",
|
|
||||||
"platform_system": "Darwin",
|
|
||||||
"platform_version": "Darwin Kernel Version 17.2.0: Sun Oct 1 00:46:50 PDT 2017; root:xnu-4570.20.62~10/RELEASE_X86_64",
|
|
||||||
"python_full_version": "3.6.3",
|
|
||||||
"python_version": "3.6",
|
|
||||||
"sys_platform": "darwin"
|
|
||||||
},
|
|
||||||
"pipfile-spec": 6,
|
|
||||||
"requires": {},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"name": "pypi",
|
|
||||||
"url": "https://pypi.python.org/simple",
|
|
||||||
"verify_ssl": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"default": {
|
|
||||||
"django": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7ab6a9c798a5f9f359ee6da3677211f883fb02ef32cebe9b29751eb7a871febf",
|
|
||||||
"sha256:c3b42ca1efa1c0a129a9e863134cc3fe705c651dea3a04a7998019e522af0c60"
|
|
||||||
],
|
|
||||||
"version": "==1.11.6"
|
|
||||||
},
|
|
||||||
"django-activeurl": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7ebc4a34f91e18f29eb02bfac503057d69b4e1b6f9e8dd1297798387876e54da"
|
|
||||||
],
|
|
||||||
"version": "==0.1.11"
|
|
||||||
},
|
|
||||||
"django-agent-trust": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:962653c4eeab63715a6efd27649a00302082c9fb1d931e3df959e57605eb8c25",
|
|
||||||
"sha256:b262db89410b9901c32f27f7dd6697bf61bfcfdc01651fe40699d0b81ebc4fcc"
|
|
||||||
],
|
|
||||||
"version": "==0.3.0"
|
|
||||||
},
|
|
||||||
"django-appconf": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:ddab987d14b26731352c01ee69c090a4ebfc9141ed223bef039d79587f22acd9",
|
|
||||||
"sha256:6a4d9aea683b4c224d97ab8ee11ad2d29a37072c0c6c509896dd9857466fb261"
|
|
||||||
],
|
|
||||||
"version": "==1.0.2"
|
|
||||||
},
|
|
||||||
"django-classy-tags": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:f6d12f5a4df3e387795a0d9ef2836af389cae9a1fbebda035dac043d4722b1f7",
|
|
||||||
"sha256:792f9161d0e22d55b4fab6fc297bab8ab072ffaa3075b227613a6d8473624db8"
|
|
||||||
],
|
|
||||||
"version": "==0.8.0"
|
|
||||||
},
|
|
||||||
"django-compat": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:b20fb26d15bbedbf26fb274eb400d6fad2a23655eb5741ae258d39557b5fc5a3"
|
|
||||||
],
|
|
||||||
"version": "==1.0.14"
|
|
||||||
},
|
|
||||||
"django-compressor": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7732676cfb9d58498dfb522b036f75f3f253f72ea1345ac036434fdc418c2e57",
|
|
||||||
"sha256:9616570e5b08e92fa9eadc7a1b1b49639cce07ef392fc27c74230ab08075b30f"
|
|
||||||
],
|
|
||||||
"version": "==2.2"
|
|
||||||
},
|
|
||||||
"django-favicon-plus": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:824da4ecd3501a157d9538ed1b0672227b2a8a5a3d940bd075ba5b5c636fb400"
|
|
||||||
],
|
|
||||||
"version": "==0.0.7"
|
|
||||||
},
|
|
||||||
"django-markdown-deux": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:5b4a3cd9454af5b4cec0e19151b41d98d09400ddae0688afb81dbf62a4edafff"
|
|
||||||
],
|
|
||||||
"version": "==1.0.5"
|
|
||||||
},
|
|
||||||
"django-meta": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:2a5b8d95099f69fb9736630c4fbf4fcc2972a1fcd9c708a5bb72dde22e84d8dd",
|
|
||||||
"sha256:4a7dc51c40fd6a097825040af29ee0e049f1fce29b006e39f266f80ba988bac6"
|
|
||||||
],
|
|
||||||
"version": "==1.4"
|
|
||||||
},
|
|
||||||
"django-otp": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:54f35d7a84d8c46f35d20b969f38ef1afc0fa7627e44c481e4ab5f66a8da187e",
|
|
||||||
"sha256:46fa6f2ae30a69a09bdc448b06a370c88d95fb0c3a9ba5771ca4d0d7740d56d7"
|
|
||||||
],
|
|
||||||
"version": "==0.4.1.1"
|
|
||||||
},
|
|
||||||
"django-otp-agents": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:4ca8fae30418e0a813840cee5068d2fb96e3759787a5820d54921b90c7beaa7a",
|
|
||||||
"sha256:8d9f26d5a186b059251bd03e1ab509b5861a678e463c49de9b0766080b2c16a5"
|
|
||||||
],
|
|
||||||
"version": "==0.3.0"
|
|
||||||
},
|
|
||||||
"django-redis-cache": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:2b4e3510bbcaf3d331975717afd6f15a36fbaf7622504599d2727dc99f90c64d"
|
|
||||||
],
|
|
||||||
"version": "==1.7.1"
|
|
||||||
},
|
|
||||||
"gunicorn": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6",
|
|
||||||
"sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622"
|
|
||||||
],
|
|
||||||
"version": "==19.7.1"
|
|
||||||
},
|
|
||||||
"lxml": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7a8715539adb41c78129983ba69d852e0102a3f51d559eeb91dce1f6290c4ad0",
|
|
||||||
"sha256:d3a98dda9831a37ef7f55c5e69c0d276c278f24978f5b36b9fad7eac05a22bfc",
|
|
||||||
"sha256:1deacd52638da2d7fcb864c3949f0285638ec10e6aace93ce15c6a2e0ed91b95",
|
|
||||||
"sha256:1548247ea3b50014a3ea55ad9446108df191b6a6e51aa8f5953c95b663f382ff",
|
|
||||||
"sha256:5da6f5b31ea2b573cb20e88aefc6b49d849d07588ba60871342cae42f569b0d7",
|
|
||||||
"sha256:12e348eb57fb79ccf91a49b7b937c49a5bbe1d73ba75589674b76a56d064bda0",
|
|
||||||
"sha256:b9e1735918fc1e83c522b9f1048e6bc5af38af958e4efc843046e4b0075a021b",
|
|
||||||
"sha256:fafeb4b190bd63ba2bcee2496d99cb7345fafbace6b999403010abdff8c05b72",
|
|
||||||
"sha256:9007da6fb1b96fb1c9d7bd65e97bbbad60295abc19833d7e67e05314c1868f58",
|
|
||||||
"sha256:cfbf0b956f33cda3af2a1438a2541549b69a7a240e71de7d8ca819b8f1547aac",
|
|
||||||
"sha256:0a103253a94cdad86028d273aaebb8b30c75fdf009c23e52cdc8ce88429fd326",
|
|
||||||
"sha256:b3d5a0ecf0c2c31c404246b6706b2e477159ee07b73be5102389ab250dd67701",
|
|
||||||
"sha256:e92af0fd08c7d2176ad4be4a7c47fd800d6ee05046b41e36ed579c01fb106c25",
|
|
||||||
"sha256:feb2144c2ae4035ad57165dd22bdc93b1389158a985c0497a096d39e2b2cd67b",
|
|
||||||
"sha256:a4433655219b84a360dbdf2c34d9625c3988a272e6fc028222d528ad5902f6a2",
|
|
||||||
"sha256:db98287cb1488eb103930a64444542f6ffe83694ef392f801aa56d648d905663",
|
|
||||||
"sha256:307d325ee143b60b9c82912e96e9f4345200c33c8ae00b04b001e4c85fb5f146",
|
|
||||||
"sha256:5caec9b174dbf927034d588669c62d2a9d0ce447365b20a3463f4daab1e4f03b",
|
|
||||||
"sha256:fb816595494ce21191764572215f56edfbc6d9fbebd1491c8466502892989689",
|
|
||||||
"sha256:10399bececdb67f0d9251ecf2dda2abf6ddeee6096741754356f1a3715c8c830",
|
|
||||||
"sha256:c263fd15d27f3be93485fcd83a495cbbc35352512d9e31644d49a54504a1be2a",
|
|
||||||
"sha256:c10ad53216d5af2b3ba63e65db793cb7dd7e598e17826938045e32f38b0e4814",
|
|
||||||
"sha256:d42a5182d4b0953d02e5f46c9f0dc304be736fbaa1c0d2f11326182b9684b5f4",
|
|
||||||
"sha256:dd7c22bf890d266e72c5e5c8c44555ffbfe4ca2a329da785e7d8b1972fc3ff74",
|
|
||||||
"sha256:93df9805146980e83834ea9320baa6a56d8aea45f63d7d3cc721f71eb1a1bac6",
|
|
||||||
"sha256:7ba1b62fe9414d73d493241011df952b72074808debc3a2d6d8a64fb9944edf6",
|
|
||||||
"sha256:d2c121f5f77bed1e1eddeee23ee76fee8a3d48fa7a3aab589d12942f87778a9e",
|
|
||||||
"sha256:be3aaeb5f468a49f523f16736ccff7d82af2b4b303292ba3d052b5b28f3fbe47"
|
|
||||||
],
|
|
||||||
"version": "==4.1.0"
|
|
||||||
},
|
|
||||||
"markdown2": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:264731e7625402227ff6fb01f2d814882da7705432659a18a419c508e8bfccb1"
|
|
||||||
],
|
|
||||||
"version": "==2.3.4"
|
|
||||||
},
|
|
||||||
"olefile": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:61f2ca0cd0aa77279eb943c07f607438edf374096b66332fae1ee64a6f0f73ad"
|
|
||||||
],
|
|
||||||
"version": "==0.44"
|
|
||||||
},
|
|
||||||
"pillow": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:cc6a5ed5b8f9d2f25e4e42d562e0ec4df3ce838f9e9b9d9d9b65fac6fe93a4cc",
|
|
||||||
"sha256:54898190b538a6c8fa4228e866ff2e7609da1ba9fd1d9cc5dc8ca591d37ce0a8",
|
|
||||||
"sha256:a336596b06e062b92eb8201a3b5dff07ae01c3a5d08ce5539d2da49b123f2be6",
|
|
||||||
"sha256:922aeb050bd52d8ce9531ab57fd2440bfe975900e8700fec385fb741c3c557c7",
|
|
||||||
"sha256:6d814aa655d94c63547fc3208cb6ab886ff1a64c543b31f52658663b1bb3f011",
|
|
||||||
"sha256:e66080685863444738f08e13081c287e340b6e4f8bd674a2e0da967776ac6f46",
|
|
||||||
"sha256:575a9b3468c82f38be0419cd39d35001ae95a0cc5226534e45430035fecef583",
|
|
||||||
"sha256:4fb8ab0f8895fb946454ef6ffe806f49ee387095f2d6112ae24670e5fb8fbcd9",
|
|
||||||
"sha256:1d742642d01914b7e0cf6fd597a51f57d21fd68f794cf84803e03e72db78a261",
|
|
||||||
"sha256:59cef683d79b85d55a950c1e61dc7b6be0c45a5074692746354cd9a8ace1cd17",
|
|
||||||
"sha256:822e4fc261d12fa44d88dadee0e93d59663db94d962d4ffffbf09b1fe5e5be51",
|
|
||||||
"sha256:a6f43511c79bed431ec2b56e55150b5222c732cd9e5f80e77a44e068e94c71fc",
|
|
||||||
"sha256:2046a2001e2c413998951cc28aa0dbfd4cff846a12e24c2145d42630d5104094",
|
|
||||||
"sha256:39c7c9dcf64430091e30ef14d4191b4cae9b7b5ff29762357730aac4866fb189",
|
|
||||||
"sha256:f2d71951f473744ac617b645b62d0c4df5372ef4618c425646bfe5e2e8878e61",
|
|
||||||
"sha256:9adcfa2477b7e279ebeee75b49f535518201bbd7d26ca2ef1cf6751cb6e658e8",
|
|
||||||
"sha256:0e3b56364a2c772c961a8faad8a835d3f24d8848310de035c9e07cc006035cbc",
|
|
||||||
"sha256:92087cb92a968421f42235f7d8153f4766b6ba213a6efb36b8060f3c9d294569",
|
|
||||||
"sha256:53eaec751151b5713a15b1cd62b06d0fc16d72f56623c15448728c554c30770b",
|
|
||||||
"sha256:e595312f67962d6b4fde3b7dffaaaca4becefa522d677676bb57b0ec5f8f921a",
|
|
||||||
"sha256:dc32362d0cadf18c3aef7040455760106cafe7dd3c211dc27c507e746376bb56",
|
|
||||||
"sha256:759e5e3e99c4ac87b99e9288a75236c63173d1bb24c8d3f9d9d2c8332fceeb0a",
|
|
||||||
"sha256:b13106cb83a3b7d1a02fafb94bfafbc980465ba948b76ea1996245959c6783d2",
|
|
||||||
"sha256:9184b9788a9cf677e53626a4dc141136a22d349a5480479b98defd3cfb5015a4",
|
|
||||||
"sha256:be803fae6af36639524a0f6861a8cface67bbec66c3416c3eaf592f1d45b8b20",
|
|
||||||
"sha256:effa82e72f5064439a3d2c7ff615b999eb1c4d65bb1f1e6ee6e2ddb345b3e81e",
|
|
||||||
"sha256:9dc002a914cefa710dcb9fb204d34f6cd822662047a6038178f5fc9bfa7be961",
|
|
||||||
"sha256:7b3cf7a80608ed661b77793f64e1f2bd1e77136ad0b750aa2c81fac9c7e2c785",
|
|
||||||
"sha256:a9bad3405a642649e68568fe9832e8f6ae585354ab0b4ae250816ead11a553a2",
|
|
||||||
"sha256:4d3dbd93b131013a71b2e98530dd4945a03c7994d42381e44a921dd8bec300bc",
|
|
||||||
"sha256:9a1514bee2e32e0d4c0f55ba7a20f4387f883e37c7d2db64ca50449ffebe86cc",
|
|
||||||
"sha256:a9721fe1f6fdfe0c108ea81b1a05dc216f1ec5bb65ef1de1d85fd00494d019e0",
|
|
||||||
"sha256:e75d745306ec8aac0e6903358fdfc7fb6854febe551ed753ee7a1cad058b61bb",
|
|
||||||
"sha256:ccc9c1f5ba413fc5ee09bc78de7dd2ad8e189edb48f3bc38acedd04a7f43a0c1",
|
|
||||||
"sha256:150e24462fd106074a9a63417a55fbb0c633716cef9511f1bd7a773972de14f4",
|
|
||||||
"sha256:250d8470661fd657c2583672ab5139f40e7f2ef28ecdc90f87563af0b27f6fba",
|
|
||||||
"sha256:a97c715d44efd5b4aa8d739b8fad88b93ed79f1b33fc2822d5802043f3b1b527",
|
|
||||||
"sha256:dbefe5aa0882f00f12eceb3fb7df57105cd87fae767ca025db4685b7577c2390",
|
|
||||||
"sha256:62a7bbf0a1120ff07a99ddedd383779a8d80bd9d363f3964b2b43a26cef6ea50",
|
|
||||||
"sha256:42b4a67949085ddd4559c3c716a00a275fb45cb2c3a3aeec95c4b94419b7c243",
|
|
||||||
"sha256:0ac037e6c1746d63a1ea354f0d5974d8f3f984fc0333be373ad193711a89b1e9",
|
|
||||||
"sha256:8989cbf10ea07fc9982ec86116f6234bb3e44da481874ac94650d6176f60106f",
|
|
||||||
"sha256:77834551d3e928f3da922ce9dfb5c8db46758ea2f2922d4c5835a5b67a222aff",
|
|
||||||
"sha256:c00301e807084706bd46a1c56694ee235debe68eaf482c0186edfe07b93a9f6a",
|
|
||||||
"sha256:0163bd681d3488e2e9c26f4fbbfefcfb7f32259c431bfd2c3bc25574708a8b8c",
|
|
||||||
"sha256:223b06c337d8d60fb65af3b540ab1fa4644931d61d1fddf6e32f7a0e496685f2",
|
|
||||||
"sha256:1ab641cb7daf88e88ede8d3b89b7bd68a7099d8671160492d5e6845e24426080"
|
|
||||||
],
|
|
||||||
"version": "==4.3.0"
|
|
||||||
},
|
|
||||||
"psycopg2": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:594aa9a095de16614f703d759e10c018bdffeafce2921b8e80a0e8a0ebbc12e5",
|
|
||||||
"sha256:1cf5d84290c771eeecb734abe2c6c3120e9837eb12f99474141a862b9061ac51",
|
|
||||||
"sha256:0344b181e1aea37a58c218ccb0f0f771295de9aa25a625ed076e6996c6530f9e",
|
|
||||||
"sha256:25250867a4cd1510fb755ef9cb38da3065def999d8e92c44e49a39b9b76bc893",
|
|
||||||
"sha256:317612d5d0ca4a9f7e42afb2add69b10be360784d21ce4ecfbca19f1f5eadf43",
|
|
||||||
"sha256:9d6266348b15b4a48623bf4d3e50445d8e581da413644f365805b321703d0fac",
|
|
||||||
"sha256:ddca39cc55877653b5fcf59976d073e3d58c7c406ef54ae8e61ddf8782867182",
|
|
||||||
"sha256:988d2ec7560d42ef0ac34b3b97aad14c4f068792f00e1524fa1d3749fe4e4b64",
|
|
||||||
"sha256:7a9c6c62e6e05df5406e9b5235c31c376a22620ef26715a663cee57083b3c2ea",
|
|
||||||
"sha256:7a75565181e75ba0b9fb174b58172bf6ea9b4331631cfe7bafff03f3641f5d73",
|
|
||||||
"sha256:94e4128ba1ea56f02522fffac65520091a9de3f5c00da31539e085e13db4771b",
|
|
||||||
"sha256:92179bd68c2efe72924a99b6745a9172471931fc296f9bfdf9645b75eebd6344",
|
|
||||||
"sha256:b9358e203168fef7bfe9f430afaed3a2a624717a1d19c7afa7dfcbd76e3cd95c",
|
|
||||||
"sha256:009e0bc09a57dbef4b601cb8b46a2abad51f5274c8be4bba276ff2884cd4cc53",
|
|
||||||
"sha256:d3ac07240e2304181ffdb13c099840b5eb555efc7be9344503c0c03aa681de79",
|
|
||||||
"sha256:40fa5630cd7d237cd93c4d4b64b9e5ed9273d1cfce55241c7f9066f5db70629d",
|
|
||||||
"sha256:6c2f1a76a9ebd9ecf7825b9e20860139ca502c2bf1beabf6accf6c9e66a7e0c3",
|
|
||||||
"sha256:37f54452c7787dbdc0a634ca9773362b91709917f0b365ed14b831f03cbd34ba",
|
|
||||||
"sha256:8f5942a4daf1ffac42109dc4a72f786af4baa4fa702ede1d7c57b4b696c2e7d6",
|
|
||||||
"sha256:bf708455cd1e9fa96c05126e89a0c59b200d086c7df7bbafc7d9be769e4149a3",
|
|
||||||
"sha256:82c40ea3ac1555e0462803380609fbe8b26f52620f3d4f8eb480cfd8ceed8a14",
|
|
||||||
"sha256:207ba4f9125a0a4200691e82d5eee7ea1485708eabe99a07fc7f08696fae62f4",
|
|
||||||
"sha256:0cd4c848f0e9d805d531e44973c8f48962e20eb7fc0edac3db4f9dbf9ed5ab82",
|
|
||||||
"sha256:57baf63aeb2965ca4b52613ce78e968b6d2bde700c97f6a7e8c6c236b51ab83e",
|
|
||||||
"sha256:2954557393cfc9a5c11a5199c7a78cd9c0c793a047552d27b1636da50d013916",
|
|
||||||
"sha256:7c31dade89634807196a6b20ced831fbd5bec8a21c4e458ea950c9102c3aa96f",
|
|
||||||
"sha256:1286dd16d0e46d59fa54582725986704a7a3f3d9aca6c5902a7eceb10c60cb7e",
|
|
||||||
"sha256:697ff63bc5451e0b0db48ad205151123d25683b3754198be7ab5fcb44334e519",
|
|
||||||
"sha256:fc993c9331d91766d54757bbc70231e29d5ceb2d1ac08b1570feaa0c38ab9582",
|
|
||||||
"sha256:9d64fed2681552ed642e9c0cc831a9e95ab91de72b47d0cb68b5bf506ba88647",
|
|
||||||
"sha256:5c3213be557d0468f9df8fe2487eaf2990d9799202c5ff5cb8d394d09fad9b2a"
|
|
||||||
],
|
|
||||||
"version": "==2.7.3.2"
|
|
||||||
},
|
|
||||||
"python-memcached": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:2775829cb54b9e4c5b3bbd8028680f0c0ab695db154b9c46f0f074ff97540eb6"
|
|
||||||
],
|
|
||||||
"version": "==1.58"
|
|
||||||
},
|
|
||||||
"python-slugify": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:c3733135d3b184196fdb8844f6a74bbfb9cf6720d1dcce3254bdc434353f938f",
|
|
||||||
"sha256:57a385df7a1c6dbd15f7666eaff0ff29d3f60363b228b1197c5308ed3ba5f824"
|
|
||||||
],
|
|
||||||
"version": "==1.2.4"
|
|
||||||
},
|
|
||||||
"pytz": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:c883c2d6670042c7bc1688645cac73dd2b03193d1f7a6847b6154e96890be06d",
|
|
||||||
"sha256:03c9962afe00e503e2d96abab4e8998a0f84d4230fa57afe1e0528473698cdd9",
|
|
||||||
"sha256:487e7d50710661116325747a9cd1744d3323f8e49748e287bc9e659060ec6bf9",
|
|
||||||
"sha256:43f52d4c6a0be301d53ebd867de05e2926c35728b3260157d274635a0a947f1c",
|
|
||||||
"sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67",
|
|
||||||
"sha256:54a935085f7bf101f86b2aff75bd9672b435f51c3339db2ff616e66845f2b8f9",
|
|
||||||
"sha256:39504670abb5dae77f56f8eb63823937ce727d7cdd0088e6909e6dcac0f89043",
|
|
||||||
"sha256:ddc93b6d41cfb81266a27d23a79e13805d4a5521032b512643af8729041a81b4",
|
|
||||||
"sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589"
|
|
||||||
],
|
|
||||||
"version": "==2017.2"
|
|
||||||
},
|
|
||||||
"qrcode": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:60222a612b83231ed99e6cb36e55311227c395d0d0f62e41bb51ebbb84a9a22b",
|
|
||||||
"sha256:4115ccee832620df16b659d4653568331015c718a754855caf5930805d76924e"
|
|
||||||
],
|
|
||||||
"version": "==5.3"
|
|
||||||
},
|
|
||||||
"rcssmin": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
|
|
||||||
],
|
|
||||||
"version": "==1.0.6"
|
|
||||||
},
|
|
||||||
"redis": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb",
|
|
||||||
"sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"
|
|
||||||
],
|
|
||||||
"version": "==2.10.6"
|
|
||||||
},
|
|
||||||
"rjsmin": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:dd9591aa73500b08b7db24367f8d32c6470021f39d5ab4e50c7c02e4401386f1"
|
|
||||||
],
|
|
||||||
"version": "==1.0.12"
|
|
||||||
},
|
|
||||||
"six": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb",
|
|
||||||
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9"
|
|
||||||
],
|
|
||||||
"version": "==1.11.0"
|
|
||||||
},
|
|
||||||
"unidecode": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:61f807220eda0203a774a09f84b4304a3f93b5944110cc132af29ddb81366883",
|
|
||||||
"sha256:280a6ab88e1f2eb5af79edff450021a0d3f0448952847cd79677e55e58bad051"
|
|
||||||
],
|
|
||||||
"version": "==0.4.21"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"develop": {}
|
|
||||||
}
|
|
90
README.md
Normal file
90
README.md
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
lemoncurry (always all-lowercase) is a Django-based personal site designed to
|
||||||
|
operate as part of the [IndieWeb][]. It currently supports the following
|
||||||
|
IndieWeb specifications natively.
|
||||||
|
|
||||||
|
- All content is exposed using standard [microformats2][] markup, making it
|
||||||
|
easy for other sites and applications across the IndieWeb to consume.
|
||||||
|
- Additionally, the site owner's profiles are exposed using [rel-me][],
|
||||||
|
enabling independent verification of their identity across various services.
|
||||||
|
This permits [IndieAuth.com][] to authenticate the site's owner using a
|
||||||
|
social profile, such as a Twitter account. However, this functionality is not
|
||||||
|
necessary because lemoncurry also fully implements…
|
||||||
|
- [IndieAuth][], an protocol derived from OAuth 2.0 which enables the site's
|
||||||
|
owner to authorise access to their domain directly from the lemoncurry site
|
||||||
|
itself. Additionally, tokens for further access to the lemoncurry site may be
|
||||||
|
requested and issued, including customisable token scope as in OAuth.
|
||||||
|
- [Micropub][] is *partially* supported - using a token obtained through
|
||||||
|
IndieAuth, clients may post new content to the lemoncurry site using either
|
||||||
|
the form-encoded or JSON request formats. There is currently no support for
|
||||||
|
updating or deleting existing content through Micropub, although this is of
|
||||||
|
course planned.
|
||||||
|
- [Webmention][], used to enable rich commenting and social interaction between
|
||||||
|
separate IndieWeb sites, is partially supported. lemoncurry will correctly
|
||||||
|
*send* webmentions to all URLs mentioned in a published entry. However, it
|
||||||
|
currently does not expose an endpoint for *receiving* webmentions.
|
||||||
|
- [WebSub][] is also partially supported. When content is posted through
|
||||||
|
Micropub, WebSub is pinged as it should be - however, since only creating
|
||||||
|
*new* content through Micropub is supported, updates do not currently cause a
|
||||||
|
WebSub ping.
|
||||||
|
|
||||||
|
[IndieAuth]: https://www.w3.org/TR/indieauth/
|
||||||
|
[IndieAuth.com]: https://indieauth.com/
|
||||||
|
[IndieWeb]: https://indieweb.org/
|
||||||
|
[microformats2]: http://microformats.org/wiki/microformats2
|
||||||
|
[Micropub]: https://www.w3.org/TR/micropub/
|
||||||
|
[rel-me]: http://microformats.org/wiki/rel-me
|
||||||
|
[Webmention]: https://www.w3.org/TR/webmention/
|
||||||
|
[WebSub]: https://www.w3.org/TR/websub/
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
lemoncurry uses the following tools:
|
||||||
|
|
||||||
|
* [Pipenv][] - developed with Pipenv 2018.5.18, but should work with most versions.
|
||||||
|
* [Yarn][] - again, developed with Yarn 1.7.0, but should work with most versions.
|
||||||
|
|
||||||
|
As well as the following services:
|
||||||
|
|
||||||
|
* [PostgreSQL][] - create a database named `lemoncurry`. Socket auth is
|
||||||
|
recommended, so ensure the UNIX user you'll be running lemoncurry with has
|
||||||
|
access to that database. Alternatively, set the `POSTGRES_PASSWORD`
|
||||||
|
environment variable to use password auth.
|
||||||
|
* [Redis][] - lemoncurry expects to find Redis on port 6380, rather than the
|
||||||
|
standard port of 6379. Sorry about that.
|
||||||
|
|
||||||
|
If you're running in production, I'd recommend [Gunicorn][], which is already part
|
||||||
|
of lemoncurry's Pipfile. Ensure you run Gunicorn behind a secure reverse proxy,
|
||||||
|
such as [Nginx][].
|
||||||
|
|
||||||
|
If you're running in development, the usual Django `runserver` command should
|
||||||
|
be fine.
|
||||||
|
|
||||||
|
[Gunicorn]: https://gunicorn.org/
|
||||||
|
[Nginx]: https://nginx.org/en/
|
||||||
|
[Pipenv]: https://docs.pipenv.org/
|
||||||
|
[PostgreSQL]: https://www.postgresql.org/
|
||||||
|
[Redis]: https://redis.io/
|
||||||
|
[Yarn]: https://yarnpkg.org/
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Clone the repo recursively - since it uses Git submodules - and then install
|
||||||
|
both Python and Node dependencies.
|
||||||
|
|
||||||
|
```shellsession
|
||||||
|
$ git clone --recursive https://git.00dani.me/00dani/lemoncurry
|
||||||
|
$ cd lemoncurry
|
||||||
|
$ pipenv install --dev
|
||||||
|
$ yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
Once those steps complete, you should be able to perform the usual Django steps
|
||||||
|
to get a development server up and running. (If you'd prefer, you can use
|
||||||
|
`pipenv shell` to activate lemoncurry's virtualenv, rather than prefacing each
|
||||||
|
command with `pipenv run`. I like being explicit.)
|
||||||
|
|
||||||
|
```shellsession
|
||||||
|
$ pipenv run ./manage.py migrate
|
||||||
|
$ pipenv run ./manage.py collectstatic
|
||||||
|
$ pipenv run ./manage.py runserver 3000
|
||||||
|
```
|
|
@ -1 +0,0 @@
|
||||||
default_app_config = 'entries.apps.EntriesConfig'
|
|
|
@ -1,5 +1,5 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Entry, Syndication
|
from .models import Cat, Entry, Syndication
|
||||||
|
|
||||||
|
|
||||||
class SyndicationInline(admin.TabularInline):
|
class SyndicationInline(admin.TabularInline):
|
||||||
|
@ -8,9 +8,11 @@ class SyndicationInline(admin.TabularInline):
|
||||||
|
|
||||||
|
|
||||||
class EntryAdmin(admin.ModelAdmin):
|
class EntryAdmin(admin.ModelAdmin):
|
||||||
inlines = (
|
date_hierarchy = "created"
|
||||||
SyndicationInline,
|
list_display = ("title", "id", "kind", "created")
|
||||||
)
|
list_filter = ("kind",)
|
||||||
|
inlines = (SyndicationInline,)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Cat)
|
||||||
admin.site.register(Entry, EntryAdmin)
|
admin.site.register(Entry, EntryAdmin)
|
||||||
|
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class EntriesConfig(AppConfig):
|
class EntriesConfig(AppConfig):
|
||||||
name = 'entries'
|
name = "entries"
|
||||||
|
|
34
entries/from_url.py
Normal file
34
entries/from_url.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.urls import resolve, Resolver404
|
||||||
|
from micropub import error
|
||||||
|
|
||||||
|
from .models import Entry
|
||||||
|
|
||||||
|
|
||||||
|
def from_url(url: str) -> Entry:
|
||||||
|
domain = Site.objects.get_current().domain
|
||||||
|
if not url:
|
||||||
|
raise error.bad_req("url parameter required")
|
||||||
|
if "//" not in url:
|
||||||
|
url = "//" + url
|
||||||
|
parts = urlparse(url, scheme="https")
|
||||||
|
if parts.scheme not in ("http", "https") or parts.netloc != domain:
|
||||||
|
raise error.bad_req("url does not point to this site")
|
||||||
|
|
||||||
|
try:
|
||||||
|
match = resolve(parts.path)
|
||||||
|
except Resolver404:
|
||||||
|
raise error.bad_req("url does not point to a valid page on this site")
|
||||||
|
|
||||||
|
if match.view_name != "entries:entry":
|
||||||
|
raise error.bad_req("url does not point to an entry on this site")
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry = Entry.objects.get(pk=match.kwargs["id"])
|
||||||
|
except Entry.DoesNotExist:
|
||||||
|
raise error.bad_req("url does not point to an existing entry")
|
||||||
|
|
||||||
|
return entry
|
15
entries/jinja2/entries/entry.html
Normal file
15
entries/jinja2/entries/entry.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends 'lemoncurry/layout.html' %}
|
||||||
|
{% block head %}
|
||||||
|
<link rel="shortlink" href="{{ entry.short_url }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% import 'entries/h-entry.html' as h %}
|
||||||
|
{% block main %}
|
||||||
|
<div class="entry">
|
||||||
|
{{ h.hEntry(entry, indent_width=8) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
55
entries/jinja2/entries/h-entry.html
Normal file
55
entries/jinja2/entries/h-entry.html
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
{% macro hEntry(entry, indent_width) -%}
|
||||||
|
{%- set i = ' ' * indent_width -%}
|
||||||
|
<article class="h-entry media">
|
||||||
|
{{i}}<aside class="info">
|
||||||
|
{{i}}<a class="p-author h-card" href="{{ entry.author.url }}">
|
||||||
|
{{i}}{% if entry.author.avatar %}<img class="u-photo img-fluid" src="{{ entry.author.avatar.url }}" alt="{{ entry.author.name }}" />{% endif %}
|
||||||
|
{{i}}<span class="p-name sr-only">{{ entry.author.name }}</span>
|
||||||
|
{{i}}</a>
|
||||||
|
{{i}}<a class="u-uid u-url" href="{{ entry.url }}">
|
||||||
|
{{i}}<time class="dt-published media" datetime="{{ entry.published.isoformat() }}" title="{{ entry.published.isoformat() }}">
|
||||||
|
{{i}}<i class="fas fa-fw fa-calendar" aria-hidden="true"></i>
|
||||||
|
{{i}}<div class="media-body">{{ entry.published | ago }}</div>
|
||||||
|
{{i}}</time>
|
||||||
|
{{i}}</a>
|
||||||
|
{{i}}<time class="dt-updated media" datetime="{{ entry.updated.isoformat() }}" title="{{ entry.updated.isoformat() }}"{% if (entry.updated | ago) == (entry.published | ago) %} hidden{% endif %}>
|
||||||
|
{{i}}<i class="fas fa-fw fa-pencil-alt" aria-hidden="true"></i>
|
||||||
|
{{i}}<div class="media-body">{{ entry.updated | ago }}</div>
|
||||||
|
{{i}}</time>
|
||||||
|
{{i}}<a class="u-url media" href="{{ entry.short_url }}">
|
||||||
|
{{i}}<i class="fas fa-fw fa-link" aria-hidden="true"></i>
|
||||||
|
{{i}}<div class="media-body">{{ entry.short_url | friendly_url }}</div>
|
||||||
|
{{i}}</a>
|
||||||
|
{{i}}</aside>
|
||||||
|
|
||||||
|
{{i}}<div class="card media-body">
|
||||||
|
{% if entry.photo %}
|
||||||
|
{{i}}<img class="card-img-top u-photo" src="{{ entry.photo.url }}" />
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{{i}}<div class="card-body">
|
||||||
|
{% if entry.name %}
|
||||||
|
{{i}}<h4 class="card-title p-name">{{ entry.name }}</h4>
|
||||||
|
{% endif %}
|
||||||
|
{{i}}<div class="e-content">
|
||||||
|
{{i}}{{ entry.content | markdown }}
|
||||||
|
{{i}}</div>
|
||||||
|
|
||||||
|
{% for c in entry.cats.all() %}
|
||||||
|
{{i}}<a class="p-category card-link" href="{{ c.url }}">
|
||||||
|
{{i}}<i class="fas fa-paw" aria-hidden="true"></i>
|
||||||
|
{{i}}{{ c.name }}
|
||||||
|
{{i}}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% for s in entry.syndications.all() %}
|
||||||
|
{{i}}<a class="u-syndication card-link" href="{{ s.url }}">
|
||||||
|
{{i}}<i class="{{ s.site.icon }}" aria-hidden="true"></i>
|
||||||
|
{{i}}{{ s.site.domain }}
|
||||||
|
{{i}}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{{i}}</div>
|
||||||
|
{{i}}</div>
|
||||||
|
|
||||||
|
{{i}}<script class="p-json-ld" type="application/ld+json">{{ entry.json_ld | tojson }}</script>
|
||||||
|
{{i}}</article>
|
||||||
|
{%- endmacro %}
|
20
entries/jinja2/entries/index.html
Normal file
20
entries/jinja2/entries/index.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends 'lemoncurry/layout.html' %}
|
||||||
|
|
||||||
|
{% block html_attr %}
|
||||||
|
class="h-feed"{{ super() }}
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% import 'entries/h-entry.html' as h %}
|
||||||
|
{% block main %}
|
||||||
|
<ol class="list-unstyled entries">
|
||||||
|
{% for entry in entries %}
|
||||||
|
<li>
|
||||||
|
{{ h.hEntry(entry, indent_width=10) }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% endblock %}
|
26
entries/jobs.py
Normal file
26
entries/jobs.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django_rq import job
|
||||||
|
from ronkyuu import webmention
|
||||||
|
|
||||||
|
|
||||||
|
@job
|
||||||
|
def ping_hub(*urls):
|
||||||
|
for url in urls:
|
||||||
|
requests.post(
|
||||||
|
settings.PUSH_HUB,
|
||||||
|
data={
|
||||||
|
"hub.mode": "publish",
|
||||||
|
"hub.url": url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@job
|
||||||
|
def send_mentions(source, targets=None):
|
||||||
|
if targets is None:
|
||||||
|
targets = webmention.findMentions(source)["refs"]
|
||||||
|
for target in targets:
|
||||||
|
status, endpoint = webmention.discoverEndpoint(target)
|
||||||
|
if endpoint is not None and status == 200:
|
||||||
|
webmention.sendWebmention(source, target, endpoint)
|
|
@ -1,24 +1,88 @@
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
class Entry:
|
class Entry:
|
||||||
fields = ()
|
def __init__(self, id, plural, icon, on_home=True, slug=False):
|
||||||
|
self.id = id
|
||||||
|
self.plural = plural
|
||||||
|
self.icon = icon
|
||||||
|
self.on_home = on_home
|
||||||
|
self.slug = slug
|
||||||
|
|
||||||
@classmethod
|
@property
|
||||||
def has(cls, field):
|
def index(self):
|
||||||
return field in cls.fields
|
return self.index_page()
|
||||||
|
|
||||||
|
def index_page(self, page=0):
|
||||||
|
kwargs = {"kind": self}
|
||||||
|
if page > 1:
|
||||||
|
kwargs["page"] = page
|
||||||
|
return reverse("entries:index", kwargs=kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entry(self):
|
||||||
|
return self.plural + "_entry"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def atom(self):
|
||||||
|
return reverse("entries:atom_by_kind", kwargs={"kind": self})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rss(self):
|
||||||
|
return reverse("entries:rss_by_kind", kwargs={"kind": self})
|
||||||
|
|
||||||
|
|
||||||
class Note(Entry):
|
Note = Entry(
|
||||||
id = 'note'
|
id="note",
|
||||||
icon = 'fa fa-paper-plane'
|
icon="fas fa-paper-plane",
|
||||||
plural = 'notes'
|
plural="notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Article(Entry):
|
Article = Entry(
|
||||||
id = 'article'
|
id="article",
|
||||||
icon = 'fa fa-file-text'
|
icon="fas fa-file-alt",
|
||||||
plural = 'articles'
|
plural="articles",
|
||||||
fields = ('slug', 'name')
|
slug=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
Photo = Entry(
|
||||||
|
id="photo",
|
||||||
|
icon="fas fa-camera",
|
||||||
|
plural="photos",
|
||||||
|
)
|
||||||
|
|
||||||
all = (Note, Article)
|
Reply = Entry(
|
||||||
|
id="reply",
|
||||||
|
icon="fas fa-comment",
|
||||||
|
plural="replies",
|
||||||
|
on_home=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
Like = Entry(
|
||||||
|
id="like",
|
||||||
|
icon="fas fa-heart",
|
||||||
|
plural="likes",
|
||||||
|
on_home=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
Repost = Entry(
|
||||||
|
id="repost",
|
||||||
|
icon="fas fa-retweet",
|
||||||
|
plural="reposts",
|
||||||
|
)
|
||||||
|
|
||||||
|
all = (Note, Article, Photo)
|
||||||
|
on_home = {k.id for k in all if k.on_home}
|
||||||
from_id = {k.id: k for k in all}
|
from_id = {k.id: k for k in all}
|
||||||
from_plural = {k.plural: k for k in all}
|
from_plural = {k.plural: k for k in all}
|
||||||
|
|
||||||
|
|
||||||
|
class EntryKindConverter:
|
||||||
|
regex = "|".join(k.plural for k in all)
|
||||||
|
|
||||||
|
def to_python(self, plural):
|
||||||
|
return from_plural[plural]
|
||||||
|
|
||||||
|
def to_url(self, k):
|
||||||
|
return k.plural
|
||||||
|
|
|
@ -8,7 +8,6 @@ import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
@ -17,20 +16,41 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Entry',
|
name="Entry",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('kind', models.CharField(choices=[('note', 'Note'), ('article', 'Article')], default='note', max_length=30)),
|
"id",
|
||||||
('name', models.CharField(blank=True, max_length=100)),
|
models.AutoField(
|
||||||
('summary', models.TextField(blank=True)),
|
auto_created=True,
|
||||||
('content', models.TextField()),
|
primary_key=True,
|
||||||
('published', models.DateTimeField()),
|
serialize=False,
|
||||||
('updated', models.DateTimeField()),
|
verbose_name="ID",
|
||||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"kind",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("note", "Note"), ("article", "Article")],
|
||||||
|
default="note",
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(blank=True, max_length=100)),
|
||||||
|
("summary", models.TextField(blank=True)),
|
||||||
|
("content", models.TextField()),
|
||||||
|
("published", models.DateTimeField()),
|
||||||
|
("updated", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"author",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name_plural': 'entries',
|
"verbose_name_plural": "entries",
|
||||||
'ordering': ['-published'],
|
"ordering": ["-published"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -7,23 +7,42 @@ import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('users', '0005_auto_20171023_0158'),
|
("users", "0005_auto_20171023_0158"),
|
||||||
('entries', '0001_initial'),
|
("entries", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Syndication',
|
name="Syndication",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('url', models.CharField(max_length=255)),
|
"id",
|
||||||
('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='syndications', to='entries.Entry')),
|
models.AutoField(
|
||||||
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.Profile')),
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("url", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"entry",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="syndications",
|
||||||
|
to="entries.Entry",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"profile",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="users.Profile"
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['profile'],
|
"ordering": ["profile"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
18
entries/migrations/0003_remove_entry_summary.py
Normal file
18
entries/migrations/0003_remove_entry_summary.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-10-25 03:03
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0002_syndication"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="entry",
|
||||||
|
name="summary",
|
||||||
|
),
|
||||||
|
]
|
35
entries/migrations/0004_auto_20171027_0846.py
Normal file
35
entries/migrations/0004_auto_20171027_0846.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-10-26 21:46
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0003_remove_entry_summary"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="author",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="entries",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("note", "note"), ("article", "article")],
|
||||||
|
db_index=True,
|
||||||
|
default="note",
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
29
entries/migrations/0005_auto_20171027_1557.py
Normal file
29
entries/migrations/0005_auto_20171027_1557.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-10-27 04:57
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0004_auto_20171027_0846"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="entry",
|
||||||
|
name="photo",
|
||||||
|
field=models.ImageField(blank=True, upload_to=""),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("note", "note"), ("article", "article"), ("photo", "photo")],
|
||||||
|
db_index=True,
|
||||||
|
default="note",
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
48
entries/migrations/0006_auto_20171102_1200.py
Normal file
48
entries/migrations/0006_auto_20171102_1200.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-11-02 01:00
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import django.utils.timezone
|
||||||
|
import model_utils.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0005_auto_20171027_1557"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="entry",
|
||||||
|
options={"ordering": ["-created"], "verbose_name_plural": "entries"},
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="entry",
|
||||||
|
old_name="published",
|
||||||
|
new_name="created",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="entry",
|
||||||
|
old_name="updated",
|
||||||
|
new_name="modified",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="created",
|
||||||
|
field=model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="modified",
|
||||||
|
field=model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
36
entries/migrations/0007_auto_20171113_0841.py
Normal file
36
entries/migrations/0007_auto_20171113_0841.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-12 21:41
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0006_auto_20171102_1200"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="entry",
|
||||||
|
name="cite",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("note", "note"),
|
||||||
|
("article", "article"),
|
||||||
|
("photo", "photo"),
|
||||||
|
("reply", "reply"),
|
||||||
|
("like", "like"),
|
||||||
|
("repost", "repost"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="note",
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
29
entries/migrations/0008_auto_20171116_2116.py
Normal file
29
entries/migrations/0008_auto_20171116_2116.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-16 10:16
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0007_auto_20171113_0841"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="entry",
|
||||||
|
old_name="cite",
|
||||||
|
new_name="in_reply_to",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="entry",
|
||||||
|
name="like_of",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="entry",
|
||||||
|
name="repost_of",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
]
|
33
entries/migrations/0009_tag.py
Normal file
33
entries/migrations/0009_tag.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-19 23:43
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0008_auto_20171116_2116"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Tag",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255, unique=True)),
|
||||||
|
("slug", models.CharField(max_length=255, unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ("name",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
19
entries/migrations/0010_entry_tags.py
Normal file
19
entries/migrations/0010_entry_tags.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-19 23:46
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0009_tag"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="entry",
|
||||||
|
name="tags",
|
||||||
|
field=models.ManyToManyField(related_name="entries", to="entries.Tag"),
|
||||||
|
),
|
||||||
|
]
|
25
entries/migrations/0011_auto_20171120_1108.py
Normal file
25
entries/migrations/0011_auto_20171120_1108.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-20 00:08
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0010_entry_tags"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="Tag",
|
||||||
|
new_name="Cat",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="entry",
|
||||||
|
old_name="tags",
|
||||||
|
new_name="cats",
|
||||||
|
),
|
||||||
|
]
|
29
entries/migrations/0012_auto_20180628_2044.py
Normal file
29
entries/migrations/0012_auto_20180628_2044.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 2.0.6 on 2018-06-28 10:44
|
||||||
|
|
||||||
|
import computed_property.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0011_auto_20171120_1108"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="syndication",
|
||||||
|
options={"ordering": ["domain"]},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="syndication",
|
||||||
|
name="profile",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="syndication",
|
||||||
|
name="domain",
|
||||||
|
field=computed_property.fields.ComputedCharField(
|
||||||
|
compute_from="calc_domain", default="", editable=False, max_length=255
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
22
entries/migrations/0013_alter_entry_kind.py
Normal file
22
entries/migrations/0013_alter_entry_kind.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-12 04:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("entries", "0012_auto_20180628_2044"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("note", "note"), ("article", "article"), ("photo", "photo")],
|
||||||
|
db_index=True,
|
||||||
|
default="note",
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,63 +1,226 @@
|
||||||
|
from computed_property import ComputedCharField
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.sites.models import Site as DjangoSite
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from itertools import groupby
|
||||||
|
from mf2util import interpret
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
|
from textwrap import shorten
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
from lemonshort.short_url import short_url
|
||||||
|
from meta.models import ModelMeta
|
||||||
|
from model_utils.models import TimeStampedModel
|
||||||
|
from users.models import Site
|
||||||
|
|
||||||
from users.models import Profile
|
|
||||||
from . import kinds
|
from . import kinds
|
||||||
ENTRY_KINDS = [(k.id, k.__name__) for k in kinds.all]
|
from lemoncurry import requests, utils
|
||||||
|
|
||||||
|
ENTRY_KINDS = [(k.id, k.id) for k in kinds.all]
|
||||||
|
|
||||||
|
|
||||||
class Entry(models.Model):
|
class CatManager(models.Manager):
|
||||||
|
def from_name(self, name):
|
||||||
|
cat, created = self.get_or_create(name=name, slug=slugify(name))
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
class Cat(models.Model):
|
||||||
|
objects = CatManager()
|
||||||
|
name = models.CharField(max_length=255, unique=True)
|
||||||
|
slug = models.CharField(max_length=255, unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "#" + self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return reverse("entries:cat", args=(self.slug,))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
class EntryManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super(EntryManager, self).get_queryset()
|
||||||
|
return qs.select_related("author").prefetch_related("cats", "syndications")
|
||||||
|
|
||||||
|
|
||||||
|
class Entry(ModelMeta, TimeStampedModel):
|
||||||
|
objects = EntryManager()
|
||||||
kind = models.CharField(
|
kind = models.CharField(
|
||||||
max_length=30,
|
max_length=30, choices=ENTRY_KINDS, db_index=True, default=ENTRY_KINDS[0][0]
|
||||||
choices=ENTRY_KINDS,
|
|
||||||
default=ENTRY_KINDS[0][0]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(max_length=100, blank=True)
|
name = models.CharField(max_length=100, blank=True)
|
||||||
summary = models.TextField(blank=True)
|
photo = models.ImageField(blank=True)
|
||||||
content = models.TextField()
|
content = models.TextField()
|
||||||
|
|
||||||
author = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
cats = models.ManyToManyField(Cat, related_name="entries")
|
||||||
|
|
||||||
published = models.DateTimeField()
|
in_reply_to = models.CharField(max_length=255, blank=True)
|
||||||
updated = models.DateTimeField()
|
like_of = models.CharField(max_length=255, blank=True)
|
||||||
|
repost_of = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
author = models.ForeignKey(
|
||||||
|
get_user_model(),
|
||||||
|
related_name="entries",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reply_context(self):
|
||||||
|
if not self.in_reply_to:
|
||||||
|
return None
|
||||||
|
return interpret(requests.mf2(self.in_reply_to).to_dict(), self.in_reply_to)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def published(self):
|
||||||
|
return self.created
|
||||||
|
|
||||||
|
@property
|
||||||
|
def updated(self):
|
||||||
|
return self.modified
|
||||||
|
|
||||||
|
_metadata = {
|
||||||
|
"description": "excerpt",
|
||||||
|
"image": "image_url",
|
||||||
|
"twitter_creator": "twitter_creator",
|
||||||
|
"og_profile_id": "og_profile_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self):
|
||||||
|
if self.name:
|
||||||
|
return self.name
|
||||||
|
return shorten(utils.to_plain(self.paragraphs[0]), width=100, placeholder="…")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def excerpt(self):
|
||||||
|
try:
|
||||||
|
return utils.to_plain(self.paragraphs[0 if self.name else 1])
|
||||||
|
except IndexError:
|
||||||
|
return " "
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paragraphs(self):
|
||||||
|
lines = self.content.splitlines()
|
||||||
|
return ["\n".join(para) for k, para in groupby(lines, key=bool) if k]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def twitter_creator(self):
|
||||||
|
return self.author.twitter_username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def og_profile_id(self):
|
||||||
|
return self.author.facebook_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_url(self):
|
||||||
|
return self.photo.url if self.photo else self.author.avatar_url
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{kind} {id}: {content}'.format(
|
return "{0} {1}: {2}".format(self.kind, self.id, self.title)
|
||||||
kind=self.kind,
|
|
||||||
id=self.id,
|
def get_absolute_url(self):
|
||||||
content=self.content
|
return self.absolute_url
|
||||||
)
|
|
||||||
|
@property
|
||||||
|
def absolute_url(self):
|
||||||
|
base = "https://" + DjangoSite.objects.get_current().domain
|
||||||
|
return urljoin(base, self.url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def affected_urls(self):
|
||||||
|
base = "https://" + DjangoSite.objects.get_current().domain
|
||||||
|
kind = kinds.from_id[self.kind]
|
||||||
|
urls = {
|
||||||
|
self.url,
|
||||||
|
reverse("entries:index", kwargs={"kind": kind}),
|
||||||
|
reverse("entries:atom_by_kind", kwargs={"kind": kind}),
|
||||||
|
reverse("entries:rss_by_kind", kwargs={"kind": kind}),
|
||||||
|
} | {cat.url for cat in self.cats.all()}
|
||||||
|
if kind.on_home:
|
||||||
|
urls |= {
|
||||||
|
reverse("home:index"),
|
||||||
|
reverse("entries:atom"),
|
||||||
|
reverse("entries:rss"),
|
||||||
|
}
|
||||||
|
return {urljoin(base, u) for u in urls}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self):
|
def url(self):
|
||||||
kind = kinds.from_id[self.kind]
|
kind = kinds.from_id[self.kind]
|
||||||
route = 'entries:{kind}_entry'.format(kind=kind.plural)
|
args = [kind, self.id]
|
||||||
args = [self.id]
|
if kind.slug:
|
||||||
if kind.has('slug'):
|
|
||||||
route += '_slug'
|
|
||||||
args.append(self.slug)
|
args.append(self.slug)
|
||||||
return reverse(route, args=args)
|
return reverse("entries:entry", args=args)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_url(self):
|
||||||
|
return short_url(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def slug(self):
|
def slug(self):
|
||||||
return slugify(self.name)
|
return slugify(self.name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def json_ld(self):
|
||||||
|
base = "https://" + DjangoSite.objects.get_current().domain
|
||||||
|
url = urljoin(base, self.url)
|
||||||
|
|
||||||
|
posting = {
|
||||||
|
"@context": "http://schema.org",
|
||||||
|
"@type": "BlogPosting",
|
||||||
|
"@id": url,
|
||||||
|
"url": url,
|
||||||
|
"mainEntityOfPage": url,
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"url": urljoin(base, self.author.url),
|
||||||
|
"name": self.author.name,
|
||||||
|
},
|
||||||
|
"headline": self.title,
|
||||||
|
"description": self.excerpt,
|
||||||
|
"datePublished": self.created.isoformat(),
|
||||||
|
"dateModified": self.modified.isoformat(),
|
||||||
|
}
|
||||||
|
if self.photo:
|
||||||
|
posting["image"] = (urljoin(base, self.photo.url),)
|
||||||
|
return posting
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = 'entries'
|
verbose_name_plural = "entries"
|
||||||
ordering = ['-published']
|
ordering = ["-created"]
|
||||||
|
|
||||||
|
|
||||||
class Syndication(models.Model):
|
class Syndication(models.Model):
|
||||||
entry = models.ForeignKey(
|
entry = models.ForeignKey(
|
||||||
Entry,
|
Entry, related_name="syndications", on_delete=models.CASCADE
|
||||||
related_name='syndications',
|
|
||||||
on_delete=models.CASCADE
|
|
||||||
)
|
)
|
||||||
profile = models.ForeignKey(Profile, on_delete=models.CASCADE)
|
|
||||||
url = models.CharField(max_length=255)
|
url = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
domain = ComputedCharField(
|
||||||
|
compute_from="calc_domain",
|
||||||
|
max_length=255,
|
||||||
|
)
|
||||||
|
|
||||||
|
def calc_domain(self):
|
||||||
|
domain = urlparse(self.url).netloc
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
return domain
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def site(self):
|
||||||
|
d = self.domain
|
||||||
|
try:
|
||||||
|
return Site.objects.get(domain=d)
|
||||||
|
except Site.DoesNotExist:
|
||||||
|
return Site(name=d, domain=d, icon="fas fa-newspaper")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['profile']
|
ordering = ["domain"]
|
||||||
|
|
32
entries/pagination.py
Normal file
32
entries/pagination.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from django.core.paginator import Page, Paginator
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
|
from lemoncurry.middleware import ResponseException
|
||||||
|
|
||||||
|
|
||||||
|
def paginate(queryset, reverse: Callable[[int], str], page: int | None) -> Page:
|
||||||
|
def redirect_to_page(i: int):
|
||||||
|
raise ResponseException(redirect(reverse(i)))
|
||||||
|
|
||||||
|
def reversible(p: Page) -> Page:
|
||||||
|
p.reverse = reverse
|
||||||
|
return p
|
||||||
|
|
||||||
|
paginator = Paginator(queryset, 10)
|
||||||
|
|
||||||
|
# If no page number was specified, return page one.
|
||||||
|
if page is None:
|
||||||
|
return reversible(paginator.page(1))
|
||||||
|
|
||||||
|
# If the first page was explicitly requested, or the page number was negative, redirect to page one with no URL suffix.
|
||||||
|
if page <= 1:
|
||||||
|
redirect_to_page(1)
|
||||||
|
|
||||||
|
# If the page requested is larger than the last page, then redirect to the last page.
|
||||||
|
if page > paginator.num_pages:
|
||||||
|
redirect_to_page(paginator.num_pages)
|
||||||
|
|
||||||
|
# Just return the current page! Hooray!
|
||||||
|
return reversible(paginator.page(page))
|
13
entries/sitemaps.py
Normal file
13
entries/sitemaps.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from django.contrib import sitemaps
|
||||||
|
from .models import Entry
|
||||||
|
|
||||||
|
|
||||||
|
class EntriesSitemap(sitemaps.Sitemap):
|
||||||
|
def items(self):
|
||||||
|
return Entry.objects.all()
|
||||||
|
|
||||||
|
def lastmod(self, entry):
|
||||||
|
return entry.updated
|
||||||
|
|
||||||
|
def location(self, entry):
|
||||||
|
return entry.url
|
|
@ -1,4 +1,5 @@
|
||||||
ol.entries
|
ol.entries, div.entry
|
||||||
|
max-width 100%
|
||||||
display flex
|
display flex
|
||||||
margin-bottom 0
|
margin-bottom 0
|
||||||
flex 1
|
flex 1
|
||||||
|
@ -9,13 +10,52 @@ ol.entries
|
||||||
&:last-child
|
&:last-child
|
||||||
margin-bottom 0
|
margin-bottom 0
|
||||||
|
|
||||||
.card.h-entry
|
.h-entry.media
|
||||||
.e-content > :last-child
|
> aside.info
|
||||||
margin-bottom 0
|
|
||||||
.card-footer
|
|
||||||
display flex
|
display flex
|
||||||
justify-content space-evenly
|
flex-direction column
|
||||||
flex-wrap wrap
|
align-items flex-start
|
||||||
.h-card > img
|
font-size 0.8rem
|
||||||
height 1em
|
margin-right 0.4rem
|
||||||
vertical-align baseline
|
flex-basis 7rem
|
||||||
|
max-width 10%
|
||||||
|
|
||||||
|
a.p-author
|
||||||
|
align-self center
|
||||||
|
text-align center
|
||||||
|
img.u-photo
|
||||||
|
border-radius .25rem
|
||||||
|
max-height 3em
|
||||||
|
> *
|
||||||
|
margin-bottom .25rem
|
||||||
|
.media
|
||||||
|
align-items baseline
|
||||||
|
max-width 10rem
|
||||||
|
> :first-child
|
||||||
|
margin-right 2px
|
||||||
|
display none
|
||||||
|
@media (min-width $sm)
|
||||||
|
display inline-block
|
||||||
|
> .card
|
||||||
|
flex 1
|
||||||
|
.e-content
|
||||||
|
ul
|
||||||
|
list-style-type disc
|
||||||
|
ul, ol
|
||||||
|
margin-bottom 1rem
|
||||||
|
padding-left 1.1rem
|
||||||
|
ul
|
||||||
|
list-style-type circle
|
||||||
|
ul, ol
|
||||||
|
margin-bottom 0
|
||||||
|
ul
|
||||||
|
list-style-type square
|
||||||
|
img
|
||||||
|
max-width 100%
|
||||||
|
> :last-child
|
||||||
|
margin-bottom 0
|
||||||
|
.card-link
|
||||||
|
display inline-block
|
||||||
|
font-size 0.8rem
|
||||||
|
margin-left 0
|
||||||
|
margin-right 1.25rem
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
{% extends 'lemoncurry/layout.html' %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block styles %}
|
|
||||||
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="container">
|
|
||||||
{% include 'entries/h-entry.html' %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,30 +0,0 @@
|
||||||
{% load humanize markdown_deux_tags %}<article class="card h-entry">
|
|
||||||
<div class="card-body">
|
|
||||||
{% if entry.name %}<h4 class="card-title p-name">{{ entry.name }}</h4>{% endif %}
|
|
||||||
<div class="e-content{% if not entry.name %} p-name{% endif %}">{{ entry.content | markdown }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<a class="p-author h-card" href="{{ entry.author.url }}">
|
|
||||||
<img class="u-photo" src="{{ entry.author.avatar.url }}" />
|
|
||||||
{{ entry.author.first_name }} {{ entry.author.last_name }}
|
|
||||||
</a>
|
|
||||||
<a class="u-url" href="{{ entry.url }}">
|
|
||||||
<time class="dt-published" datetime="{{ entry.published.isoformat }}">
|
|
||||||
<i class="fa fa-calendar"></i>
|
|
||||||
{{ entry.published | naturaltime }}
|
|
||||||
</time>
|
|
||||||
</a>
|
|
||||||
{% if entry.updated != entry.published %}
|
|
||||||
<time class="dt-updated" datetime="{{ entry.updated.isoformat }}">
|
|
||||||
<i class="fa fa-pencil"></i>
|
|
||||||
{{ entry.updated | naturaltime }}
|
|
||||||
</time>
|
|
||||||
{% endif %}
|
|
||||||
{% for s in entry.syndications.all %}
|
|
||||||
<a class="u-syndication" href="{{ s.url }}">
|
|
||||||
<i class="{{ s.profile.site.icon }}"></i>
|
|
||||||
{{ s.profile.name }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
|
@ -1,15 +0,0 @@
|
||||||
{% extends 'lemoncurry/layout.html' %}
|
|
||||||
{% load static %}
|
|
||||||
{% block html_class %}h-feed{% endblock %}
|
|
||||||
{% block styles %}
|
|
||||||
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
|
|
||||||
{% endblock %}
|
|
||||||
{% block main %}
|
|
||||||
<ol class="container list-unstyled">
|
|
||||||
{% for entry in entries %}
|
|
||||||
<li>
|
|
||||||
{% include 'entries/h-entry.html' %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
{% endblock %}
|
|
0
entries/tests/views/__init__.py
Normal file
0
entries/tests/views/__init__.py
Normal file
29
entries/tests/views/feeds.py
Normal file
29
entries/tests/views/feeds.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_atom(client):
|
||||||
|
res = client.get("/atom")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res["Content-Type"] == "application/atom+xml; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_rss(client):
|
||||||
|
res = client.get("/rss")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res["Content-Type"] == "application/rss+xml; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_atom_by_kind(client):
|
||||||
|
res = client.get("/notes/atom")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res["Content-Type"] == "application/atom+xml; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_rss_by_kind(client):
|
||||||
|
res = client.get("/notes/rss")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res["Content-Type"] == "application/rss+xml; charset=utf-8"
|
|
@ -1,24 +1,58 @@
|
||||||
from django.conf.urls import url
|
from django.urls import path, register_converter, reverse
|
||||||
from . import kinds, views
|
from . import kinds
|
||||||
from lemoncurry import breadcrumbs
|
from .views import feeds, lists, perma
|
||||||
|
from lemoncurry import breadcrumbs as crumbs
|
||||||
|
|
||||||
app_name = 'entries'
|
register_converter(kinds.EntryKindConverter, "kind")
|
||||||
urlpatterns = []
|
|
||||||
for k in kinds.all:
|
|
||||||
index = k.plural + '_index'
|
|
||||||
urlpatterns.append(
|
|
||||||
url(r'^{k}$'.format(k=k.plural), views.index, name=index, kwargs={'kind': k})
|
|
||||||
)
|
|
||||||
breadcrumbs.add(app_name + ':' + index, label=k.plural, parent='home:index')
|
|
||||||
|
|
||||||
entry = k.plural + '_entry'
|
|
||||||
pattern = r'^{k}/(?P<id>\d+)'.format(k=k.plural)
|
def to_pat(*args):
|
||||||
urlpatterns.append(
|
return "^{0}$".format("".join(args))
|
||||||
url(pattern + '$', views.entry, name=entry)
|
|
||||||
)
|
|
||||||
breadcrumbs.add(app_name + ':' + entry, parent=app_name + ':' + index)
|
def prefix(route):
|
||||||
if k.has('slug'):
|
return app_name + ":" + route
|
||||||
urlpatterns.append(
|
|
||||||
url(pattern + r'/(?P<slug>.+)$', views.entry, name=entry + '_slug')
|
|
||||||
)
|
id = r"/(?P<id>\d+)"
|
||||||
breadcrumbs.add(app_name + ':' + entry + '_slug', parent=app_name + ':' + index)
|
kind = r"(?P<kind>{0})".format("|".join(k.plural for k in kinds.all))
|
||||||
|
page = r"(?:/page/(?P<page>\d+))?"
|
||||||
|
slug = r"/(?P<slug>[^/]+)"
|
||||||
|
|
||||||
|
slug_opt = "(?:" + slug + ")?"
|
||||||
|
|
||||||
|
app_name = "entries"
|
||||||
|
urlpatterns = (
|
||||||
|
path("atom", feeds.AtomHomeEntries(), name="atom"),
|
||||||
|
path("rss", feeds.RssHomeEntries(), name="rss"),
|
||||||
|
path("cats/<slug:slug>", lists.by_cat, name="cat"),
|
||||||
|
path("cats/<slug:slug>/page/<int:page>", lists.by_cat, name="cat"),
|
||||||
|
path("<kind:kind>", lists.by_kind, name="index"),
|
||||||
|
path("<kind:kind>/page/<int:page>", lists.by_kind, name="index"),
|
||||||
|
path("<kind:kind>/atom", feeds.AtomByKind(), name="atom_by_kind"),
|
||||||
|
path("<kind:kind>/rss", feeds.RssByKind(), name="rss_by_kind"),
|
||||||
|
path("<kind:kind>/<int:id>", perma.entry, name="entry"),
|
||||||
|
path("<kind:kind>/<int:id>/<slug:slug>", perma.entry, name="entry"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IndexCrumb(crumbs.Crumb):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix("index"), parent="home:index")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kind(self):
|
||||||
|
return self.match.kwargs["kind"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
return self.kind.plural
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return reverse(prefix("index"), kwargs={"kind": self.kind})
|
||||||
|
|
||||||
|
|
||||||
|
crumbs.add(prefix("cat"), parent="home:index")
|
||||||
|
crumbs.add(IndexCrumb())
|
||||||
|
crumbs.add(prefix("entry"), parent=prefix("index"))
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
from django.shortcuts import render
|
|
||||||
from .models import Entry
|
|
||||||
|
|
||||||
|
|
||||||
def index(request, kind):
|
|
||||||
entries = Entry.objects.filter(kind=kind.id)
|
|
||||||
return render(request, 'entries/index.html', {
|
|
||||||
'entries': entries,
|
|
||||||
'title': kind.plural
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def entry(request, id, slug=None):
|
|
||||||
entry = Entry.objects.get(pk=id)
|
|
||||||
return render(request, 'entries/entry.html', {
|
|
||||||
'entry': entry,
|
|
||||||
'title': entry.name or entry.content
|
|
||||||
})
|
|
0
entries/views/__init__.py
Normal file
0
entries/views/__init__.py
Normal file
95
entries/views/feeds.py
Normal file
95
entries/views/feeds.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.contrib.syndication.views import Feed
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.feedgenerator import Atom1Feed
|
||||||
|
from lemoncurry.templatetags.markdown import markdown
|
||||||
|
from ..kinds import on_home
|
||||||
|
from ..models import Entry
|
||||||
|
|
||||||
|
|
||||||
|
class Atom1FeedWithHub(Atom1Feed):
|
||||||
|
def add_root_elements(self, handler):
|
||||||
|
super().add_root_elements(handler)
|
||||||
|
handler.startElement("link", {"rel": "hub", "href": settings.PUSH_HUB})
|
||||||
|
handler.endElement("link")
|
||||||
|
|
||||||
|
|
||||||
|
class EntriesFeed(Feed):
|
||||||
|
item_guid_is_permalink = True
|
||||||
|
|
||||||
|
def item_link(self, entry):
|
||||||
|
return entry.absolute_url
|
||||||
|
|
||||||
|
def item_title(self, entry):
|
||||||
|
return entry.title
|
||||||
|
|
||||||
|
def item_description(self, entry):
|
||||||
|
return markdown(entry.content)
|
||||||
|
|
||||||
|
def item_author_name(self, entry):
|
||||||
|
return entry.author.name
|
||||||
|
|
||||||
|
def item_author_email(self, entry):
|
||||||
|
return entry.author.email
|
||||||
|
|
||||||
|
def item_author_link(self, entry):
|
||||||
|
return entry.author.absolute_url
|
||||||
|
|
||||||
|
def item_pubdate(self, entry):
|
||||||
|
return entry.published
|
||||||
|
|
||||||
|
def item_updateddate(self, entry):
|
||||||
|
return entry.updated
|
||||||
|
|
||||||
|
def item_categories(self, entry):
|
||||||
|
return (cat.name for cat in entry.cats.all())
|
||||||
|
|
||||||
|
|
||||||
|
class RssByKind(EntriesFeed):
|
||||||
|
def get_object(self, request, kind):
|
||||||
|
return kind
|
||||||
|
|
||||||
|
def title(self, kind):
|
||||||
|
return "{0} ~ {1}".format(
|
||||||
|
kind.plural,
|
||||||
|
Site.objects.get_current().name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def link(self, kind):
|
||||||
|
return kind.index
|
||||||
|
|
||||||
|
def description(self, kind):
|
||||||
|
return "all {0} at {1}".format(
|
||||||
|
kind.plural,
|
||||||
|
Site.objects.get_current().name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def items(self, kind):
|
||||||
|
return Entry.objects.filter(kind=kind.id)
|
||||||
|
|
||||||
|
|
||||||
|
class AtomByKind(RssByKind):
|
||||||
|
feed_type = Atom1FeedWithHub
|
||||||
|
subtitle = RssByKind.description
|
||||||
|
|
||||||
|
|
||||||
|
class RssHomeEntries(EntriesFeed):
|
||||||
|
def title(self):
|
||||||
|
return Site.objects.get_current().name
|
||||||
|
|
||||||
|
def link(self):
|
||||||
|
return reverse("home:index")
|
||||||
|
|
||||||
|
def description(self):
|
||||||
|
return "content from {0}".format(
|
||||||
|
Site.objects.get_current().name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
return Entry.objects.filter(kind__in=on_home)
|
||||||
|
|
||||||
|
|
||||||
|
class AtomHomeEntries(RssHomeEntries):
|
||||||
|
feed_type = Atom1FeedWithHub
|
||||||
|
subtitle = RssHomeEntries.description
|
36
entries/views/lists.py
Normal file
36
entries/views/lists.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
from annoying.decorators import render_to
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
|
from ..models import Entry, Cat
|
||||||
|
from ..pagination import paginate
|
||||||
|
|
||||||
|
|
||||||
|
@render_to("entries/index.html")
|
||||||
|
def by_kind(request, kind, page=None):
|
||||||
|
entries = Entry.objects.filter(kind=kind.id)
|
||||||
|
entries = paginate(queryset=entries, reverse=kind.index_page, page=page)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries": entries,
|
||||||
|
"atom": kind.atom,
|
||||||
|
"rss": kind.rss,
|
||||||
|
"title": kind.plural,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@render_to("entries/index.html")
|
||||||
|
def by_cat(request, slug, page=None):
|
||||||
|
def url(page):
|
||||||
|
kwargs = {"slug": slug}
|
||||||
|
if page > 1:
|
||||||
|
kwargs["page"] = page
|
||||||
|
return reverse("entries:cat", kwargs=kwargs)
|
||||||
|
|
||||||
|
cat = get_object_or_404(Cat, slug=slug)
|
||||||
|
entries = cat.entries.all()
|
||||||
|
entries = paginate(queryset=entries, reverse=url, page=page)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries": entries,
|
||||||
|
"title": "#" + cat.name,
|
||||||
|
}
|
14
entries/views/perma.py
Normal file
14
entries/views/perma.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
from annoying.decorators import render_to
|
||||||
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from ..models import Entry
|
||||||
|
|
||||||
|
|
||||||
|
@render_to("entries/entry.html")
|
||||||
|
def entry(request, kind, id, slug=None):
|
||||||
|
entry = get_object_or_404(Entry, pk=id)
|
||||||
|
if request.path != entry.url:
|
||||||
|
return redirect(entry.url, permanent=True)
|
||||||
|
return {
|
||||||
|
"entry": entry,
|
||||||
|
"title": entry.title,
|
||||||
|
}
|
5
gunicorn.py
Normal file
5
gunicorn.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
proc_name = "lemoncurry"
|
||||||
|
worker_class = "gevent"
|
||||||
|
workers = multiprocessing.cpu_count() * 2 + 1
|
107
home/jinja2/home/index.html
Normal file
107
home/jinja2/home/index.html
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
{% extends 'lemoncurry/layout.html' %}
|
||||||
|
|
||||||
|
{% block html_attr %}
|
||||||
|
class="h-feed"{{ super() }}
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" type="text/stylus" href="{{ static('home/css/index.styl') }}" />
|
||||||
|
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% for key in user.keys.all() %}
|
||||||
|
<link rel="pgpkey" href="{{ key.file.url }}" />
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<aside class="author">
|
||||||
|
<article class="h-card p-author card">
|
||||||
|
<a class="u-uid u-url" href="{{ user.full_url }}">
|
||||||
|
{% if user.avatar %}
|
||||||
|
<img class="u-photo card-img-top" src="{{ user.avatar.url }}" alt="{{ user.name }}" />
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="p-name card-title">
|
||||||
|
<span class="p-given-name">{{ user.first_name }}</span> <span class="p-family-name">{{ user.last_name }}</span>
|
||||||
|
</h4>
|
||||||
|
{% if user.note %}
|
||||||
|
<div class="p-note">
|
||||||
|
{{ user.note | markdown }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<ul class="profiles">
|
||||||
|
<li>
|
||||||
|
<a class="u-email" rel="me" href="mailto:{{ user.email }}">
|
||||||
|
<i class="fas fa-envelope" aria-hidden="true"></i>
|
||||||
|
{{ user.email }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if user.xmpp %}
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a class="u-impp" rel="me" href="xmpp:{{ user.xmpp }}">
|
||||||
|
<i class="openwebicons-xmpp" aria-hidden="true"></i>
|
||||||
|
{{ user.xmpp }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% if user.keys.exists() %}
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<ul class="profiles">
|
||||||
|
{% for key in user.keys.all() %}
|
||||||
|
<a class="u-key" href="{{ key.file.url }}">
|
||||||
|
<i class="fas fa-key" aria-hidden="true"></i>
|
||||||
|
{{ key.pretty_print() }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if user.profiles.exists() %}
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<ul class="profiles">
|
||||||
|
{% for profile in user.profiles.all() %}
|
||||||
|
<a class="u-url" rel="me" href="{{ profile.url }}" title="{{ profile }}">
|
||||||
|
<i class="{{ profile.site.icon }}" aria-hidden="true"></i>
|
||||||
|
<span class="sr-only">{{ profile }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script class="p-json-ld" type="application/ld+json">{{ user.json_ld | tojson }}</script>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{% import 'entries/h-entry.html' as h %}
|
||||||
|
<ol class="list-unstyled entries">
|
||||||
|
{% for entry in entries %}
|
||||||
|
<li>
|
||||||
|
{{ h.hEntry(entry, indent_width=10) }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block foot %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
tippy('.profiles [title]', {
|
||||||
|
arrow: true,
|
||||||
|
content: function(element) {
|
||||||
|
return element.getAttribute('title');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -3,10 +3,10 @@ from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
class HomeSitemap(sitemaps.Sitemap):
|
class HomeSitemap(sitemaps.Sitemap):
|
||||||
changefreq = 'daily'
|
changefreq = "daily"
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return ('home:index',)
|
return ("home:index",)
|
||||||
|
|
||||||
def location(self, item):
|
def location(self, item):
|
||||||
return reverse(item)
|
return reverse(item)
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
$sm = 576px
|
|
||||||
$md = 768px
|
|
||||||
$lg = 992px
|
|
||||||
$xl = 1200px
|
|
||||||
|
|
||||||
main
|
main
|
||||||
flex-direction column
|
flex-direction column
|
||||||
align-items center
|
align-items center
|
||||||
|
@ -10,11 +5,18 @@ main
|
||||||
margin-bottom 1rem
|
margin-bottom 1rem
|
||||||
.p-note > :last-child
|
.p-note > :last-child
|
||||||
margin-bottom 0
|
margin-bottom 0
|
||||||
|
li.list-group-item
|
||||||
|
background-color $base01
|
||||||
|
text-align center
|
||||||
|
> a
|
||||||
|
margin-right 1rem
|
||||||
|
&:last-child
|
||||||
|
margin-right 0
|
||||||
@media (min-width $md)
|
@media (min-width $md)
|
||||||
flex-direction row-reverse
|
flex-direction row-reverse
|
||||||
align-items unset
|
align-items unset
|
||||||
aside.author
|
aside.author
|
||||||
margin-bottom 0
|
margin-bottom 0
|
||||||
ol.entries
|
ol.entries
|
||||||
margin-right 2rem
|
|
||||||
justify-content flex-start
|
justify-content flex-start
|
||||||
|
margin-right 1rem
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
{% extends 'lemoncurry/layout.html' %}
|
|
||||||
{% load markdown_deux_tags static %}
|
|
||||||
{% block html_class %}h-feed{% endblock %}
|
|
||||||
{% block styles %}
|
|
||||||
<link rel="stylesheet" type="text/stylus" href="{% static 'home/css/index.styl' %}" />
|
|
||||||
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
|
|
||||||
{% endblock %}
|
|
||||||
{% block main %}
|
|
||||||
<aside class="author">
|
|
||||||
<article class="h-card card p-author">
|
|
||||||
<a class="u-uid u-url" href="{{ uri }}" hidden></a>
|
|
||||||
{% if user.avatar %}<img class="card-img-top u-photo" src="{{ user.avatar.url }}" alt="{{ user.first_name }} {{ user.last_name }}" />{% endif %}
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="card-title p-name">
|
|
||||||
<span class="p-given-name">{{ user.first_name }}</span> <span class="p-family-name">{{ user.last_name }}</span>
|
|
||||||
</h4>
|
|
||||||
{% for key in user.keys.all %}
|
|
||||||
<h6 class="card-subtitle">
|
|
||||||
<a class="u-key" href="{{ key.file.url }}">
|
|
||||||
<i class="fa fa-key"></i>
|
|
||||||
{{ key.pretty_print }}
|
|
||||||
</a>
|
|
||||||
</h6>
|
|
||||||
{% endfor %}
|
|
||||||
{% if user.note %}<div class="p-note">{{ user.note | markdown:'trusted' }}</div>{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer">
|
|
||||||
<ul class="profiles">
|
|
||||||
<li>
|
|
||||||
<a class="u-email" rel="me" href="mailto:{{ user.email }}"><i class="fa fa-envelope"></i> {{ user.email }}</a>
|
|
||||||
</li>
|
|
||||||
{% for profile in user.profiles.all %}<li>
|
|
||||||
<a class="u-url" rel="me" href="{{ profile.url }}"><i class="{{ profile.site.icon }}"></i> {{ profile.name }}</a>
|
|
||||||
</li>{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</aside>
|
|
||||||
<ol class="list-unstyled entries">
|
|
||||||
{% for entry in entries %}
|
|
||||||
<li>
|
|
||||||
{% include 'entries/h-entry.html' %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
{% endblock %}
|
|
|
@ -1,8 +1,10 @@
|
||||||
from django.conf.urls import url
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = 'home'
|
app_name = "home"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^$', views.index, name='index'),
|
path("", views.index, name="index"),
|
||||||
|
path("page/<int:page>", views.index, name="index"),
|
||||||
|
path("robots.txt", views.robots, name="robots.txt"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,16 +1,38 @@
|
||||||
from django.shortcuts import get_object_or_404, render
|
from annoying.decorators import render_to
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from entries.models import Entry
|
from urllib.parse import urljoin
|
||||||
from lemoncurry import breadcrumbs
|
|
||||||
|
|
||||||
breadcrumbs.add('home:index', 'home')
|
from entries import kinds, pagination
|
||||||
|
from lemoncurry import breadcrumbs, utils
|
||||||
|
|
||||||
|
breadcrumbs.add("home:index", "home")
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
@render_to("home/index.html")
|
||||||
user = get_object_or_404(User, pk=1)
|
def index(request, page=None):
|
||||||
entries = Entry.objects.filter(author=user)
|
def url(page):
|
||||||
return render(request, 'home/index.html', {
|
kwargs = {"page": page} if page != 1 else {}
|
||||||
'user': user,
|
return reverse("home:index", kwargs=kwargs)
|
||||||
'entries': entries,
|
|
||||||
'meta': user.as_meta(request),
|
user = request.user
|
||||||
})
|
if not hasattr(user, "entries"):
|
||||||
|
user = get_object_or_404(User, pk=1)
|
||||||
|
|
||||||
|
entries = user.entries.filter(kind__in=kinds.on_home)
|
||||||
|
entries = pagination.paginate(queryset=entries, reverse=url, page=page)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user,
|
||||||
|
"entries": entries,
|
||||||
|
"atom": reverse("entries:atom"),
|
||||||
|
"rss": reverse("entries:rss"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def robots(request):
|
||||||
|
base = utils.origin(request)
|
||||||
|
lines = ("User-agent: *", "Sitemap: {0}".format(urljoin(base, reverse("sitemap"))))
|
||||||
|
return HttpResponse("\n".join(lines) + "\n", content_type="text/plain")
|
||||||
|
|
83
lemonauth/jinja2/lemonauth/indie.html
Normal file
83
lemonauth/jinja2/lemonauth/indie.html
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
{% extends 'lemoncurry/layout.html' %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" type="text/stylus" href="{{ static('lemonauth/css/indie.styl') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container">
|
||||||
|
<form class="card" method="post" action="{{ url('lemonauth:indie_approve') }}">
|
||||||
|
<h4 class="card-header h-x-app">
|
||||||
|
{% if app %}
|
||||||
|
{% if app.logo is defined %}
|
||||||
|
<img class="u-logo" src="{{ app.logo[0] }}" alt="{{ app.name[0] }}" />
|
||||||
|
{% endif %}
|
||||||
|
sign in to <span class="p-name">{{ app.name[0] }}</span> (<a class="u-url code" href="{{ params.client_id }}">{{ params.client_id }}</a>)?
|
||||||
|
{% else %}
|
||||||
|
sign in to <a class="u-url p-name code" href="{{ params.client_id }}">{{ params.client_id }}</a>?
|
||||||
|
{% endif %}
|
||||||
|
{% if verified %}
|
||||||
|
<span data-tooltip data-tippy-theme="dark success" data-tippy-html="#verified-success">
|
||||||
|
<i class="fas fa-check-circle verified-success"></i>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span data-tooltip data-tippy-theme="dark warning" data-tippy-html="#verified-warning">
|
||||||
|
<i class="fas fa-question-circle verified-warning"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">do you want to confirm your identity, <a class="code" href="{{ me }}">{{ me }}</a>, with this app?</p>
|
||||||
|
{% if params.response_type == 'code' %}
|
||||||
|
<p class="card-text">additionally, this app is requesting the following <i>scopes</i> - you can edit the scopes that will be granted to the app, if you wish</p>
|
||||||
|
<div class="card-text form-group">
|
||||||
|
{% for scope in scopes %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" id="scopes-{{ scope }}" name="scope" type="checkbox" checked value="{{ scope }}" />
|
||||||
|
<label class="form-check-label" for="scopes-{{ scope }}">{{ scope }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="card-text"><small>you will be redirected to <a class="code" href="{{ redirect_uri }}">{{ redirect_uri }}</a> after authorising this app</small></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<button class="btn btn-success" type="submit">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ csrf_input }}
|
||||||
|
<input type="hidden" name="me" value="{{ me }}">
|
||||||
|
<input type="hidden" name="client_id" value="{{ params.client_id }}">
|
||||||
|
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||||
|
{% if params.state %}
|
||||||
|
<input type="hidden" name="state" value="{{ params.state }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="response_type" value="{{ params.response_type }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="verified-success" hidden>
|
||||||
|
this client has been <strong>verified</strong> using <code>{{ '<link rel="redirect_uri">' | escape }}</code> <br/>- they are who they claim to be!
|
||||||
|
</div>
|
||||||
|
<div id="verified-warning" hidden>
|
||||||
|
this client could <strong>not</strong> be verified using <code>{{ '<link rel="redirect_uri">' | escape }}</code> <br/>- check the redirect uri carefully yourself!
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block foot %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
tippy('[data-tippy-html]', {
|
||||||
|
arrow: true,
|
||||||
|
allowHTML: true,
|
||||||
|
maxWidth: 500,
|
||||||
|
content: function(element) {
|
||||||
|
return document.querySelector(element.getAttribute('data-tippy-html')).innerHTML;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
61
lemonauth/jinja2/lemonauth/login.html
Normal file
61
lemonauth/jinja2/lemonauth/login.html
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
{% extends 'lemoncurry/layout.html' %}
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" type="text/stylus" href="{{ static('lemonauth/css/login.styl') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container">
|
||||||
|
{% if form.errors %}
|
||||||
|
<p class="alert alert-danger">
|
||||||
|
<strong>uh oh!</strong> your login details didn't match, please try again
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% elif next %}
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<p class="alert alert-warning">
|
||||||
|
<strong>hang on!</strong> your account doesn't have access to this page :( to proceed, please log in to an account that does have access!
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="alert alert-warning">
|
||||||
|
<strong>oops!</strong> please log in to see this page
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
<form class="card" method="post" action="{{ url('lemonauth:login') }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.username.id_for_label }}">username</label>
|
||||||
|
<input class="form-control" type="text" autocomplete="username" required id="{{ form.username.auto_id }}" name="{{ form.username.name }}" value="{{ form.username.value() or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.password.id_for_label }}">password</label>
|
||||||
|
<input class="form-control" type="password" autocomplete="current-password" required id="{{ form.password.auto_id }}" name="{{ form.password.name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.otp_token.id_for_label }}">otp token</label>
|
||||||
|
<input class="form-control" type="text" required id="{{ form.otp_token.auto_id }}" name="{{ form.otp_token.name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="{{ form.otp_trust_agent.auto_id }}" name="{{ form.otp_trust_agent.name }}">
|
||||||
|
<label for="{{ form.otp_trust_agent.id_for_label }}" class="form-check-label">
|
||||||
|
remember this browser (don't tick this on a public computer!)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
|
||||||
|
log in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ csrf_input }}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
68
lemonauth/jinja2/lemonauth/tokens.html
Normal file
68
lemonauth/jinja2/lemonauth/tokens.html
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
{% extends 'lemoncurry/layout.html' %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="card-columns">
|
||||||
|
{% for _, c in clients | dictsort %}
|
||||||
|
<div class="h-x-app card">
|
||||||
|
{% if c.app.logo %}
|
||||||
|
<img class="u-photo card-img-top" src="{{ c.app.logo[0] }}" alt="{{ c.app.name[0] }}" />
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">
|
||||||
|
<span class="p-name">{{ c.app.name[0] if c.app else (c.id | friendly_url) }}</span>
|
||||||
|
<span class="badge badge-light">
|
||||||
|
<span class="p-count">{{ c.count }}</span>
|
||||||
|
{{ 'tokens' if c.count > 1 else 'token' }}
|
||||||
|
</span>
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<h6 class="card-subtitle mb-2">
|
||||||
|
<a class="u-url" href="{{ c.id }}">{{ c.id }}</a>
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<p class="card-text">this client has access to the scopes:</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for scope in c.scopes %}
|
||||||
|
<li class="p-scope list-group-item">{{ scope }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form class="card-footer text-right" action="{{ url('lemonauth:tokens_revoke', kwargs={'client_id': c.id}) }}">
|
||||||
|
{{ csrf_input }}
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="fas fa-ban" aria-hidden="true"></i>
|
||||||
|
revoke access
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block foot %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
$('form').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var $f = $(this);
|
||||||
|
if ($f.data('deleting')) return;
|
||||||
|
$f.data('deleting', true);
|
||||||
|
$f.find('button').prop({disabled: true})
|
||||||
|
.find('[data-fa-i2svg]').removeClass('fa-ban').addClass('fa-circle-notch fa-spin');
|
||||||
|
$.ajax({
|
||||||
|
headers: {'X-CSRFToken': $f.find('[name="csrfmiddlewaretoken"]').val()},
|
||||||
|
method: 'DELETE',
|
||||||
|
url: $f.attr('action'),
|
||||||
|
}).then(function() {
|
||||||
|
var $app = $f.parent('.h-x-app');
|
||||||
|
return $app.hide(function() {
|
||||||
|
$app.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
42
lemonauth/migrations/0001_initial.py
Normal file
42
lemonauth/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-10-29 05:05
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [] # type: List[Tuple[str, str]]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="IndieAuthCode",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("code", models.CharField(max_length=64, unique=True)),
|
||||||
|
("me", models.CharField(max_length=255)),
|
||||||
|
("client_id", models.CharField(max_length=255)),
|
||||||
|
("redirect_uri", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"response_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("id", "id"), ("code", "code")],
|
||||||
|
default="id",
|
||||||
|
max_length=4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("scope", models.CharField(blank=True, max_length=200)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
17
lemonauth/migrations/0002_delete_indieauthcode.py
Normal file
17
lemonauth/migrations/0002_delete_indieauthcode.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-02 05:35
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("lemonauth", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="IndieAuthCode",
|
||||||
|
),
|
||||||
|
]
|
120
lemonauth/migrations/0003_indieauthcode_token.py
Normal file
120
lemonauth/migrations/0003_indieauthcode_token.py
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
# Generated by Django 2.0.6 on 2018-06-12 04:51
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import model_utils.fields
|
||||||
|
import randomslugfield.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("lemonauth", "0002_delete_indieauthcode"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="IndieAuthCode",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
randomslugfield.fields.RandomSlugField(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
length=30,
|
||||||
|
max_length=30,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("client_id", models.URLField()),
|
||||||
|
("scope", models.TextField(blank=True)),
|
||||||
|
("redirect_uri", models.URLField()),
|
||||||
|
(
|
||||||
|
"response_type",
|
||||||
|
model_utils.fields.StatusField(
|
||||||
|
choices=[("id", "id"), ("code", "code")],
|
||||||
|
default="id",
|
||||||
|
max_length=100,
|
||||||
|
no_check_for_status=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Token",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
randomslugfield.fields.RandomSlugField(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
length=30,
|
||||||
|
max_length=30,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("client_id", models.URLField()),
|
||||||
|
("scope", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
0
lemonauth/migrations/__init__.py
Normal file
0
lemonauth/migrations/__init__.py
Normal file
62
lemonauth/models.py
Normal file
62
lemonauth/models.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from randomslugfield import RandomSlugField
|
||||||
|
from model_utils import Choices
|
||||||
|
from model_utils.fields import StatusField
|
||||||
|
from model_utils.models import TimeStampedModel
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSecret(TimeStampedModel):
|
||||||
|
"""
|
||||||
|
An AuthSecret is a model with an unguessable primary key, suitable for
|
||||||
|
sharing with external sites for secure authentication.
|
||||||
|
|
||||||
|
AuthSecret is primarily used to factor out the many similarities between
|
||||||
|
authorisation codes and tokens in IndieAuth - the two contain many
|
||||||
|
identical fields, but just a few differences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = RandomSlugField(primary_key=True, length=30)
|
||||||
|
client_id = models.URLField()
|
||||||
|
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||||
|
scope = models.TextField(blank=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def me(self):
|
||||||
|
return self.user.full_url
|
||||||
|
|
||||||
|
def __contains__(self, scope):
|
||||||
|
return scope in self.scope.split(" ")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class IndieAuthCode(AuthSecret):
|
||||||
|
"""
|
||||||
|
An IndieAuthCode is an authorisation code that a client must provide to us
|
||||||
|
to complete the IndieAuth process.
|
||||||
|
|
||||||
|
Codes are single-use, and if unused will be expired automatically after
|
||||||
|
thirty seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
redirect_uri = models.URLField()
|
||||||
|
|
||||||
|
RESPONSE_TYPE = Choices("id", "code")
|
||||||
|
response_type = StatusField(choices_name="RESPONSE_TYPE")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expired(self):
|
||||||
|
return self.created + timedelta(seconds=30) < now()
|
||||||
|
|
||||||
|
|
||||||
|
class Token(AuthSecret):
|
||||||
|
"""
|
||||||
|
A Token grants a client long-term authorisation - it will not expire unless
|
||||||
|
explicitly revoked by the user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
13
lemonauth/static/lemonauth/css/indie.styl
Normal file
13
lemonauth/static/lemonauth/css/indie.styl
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
.h-x-app
|
||||||
|
img
|
||||||
|
height 2em
|
||||||
|
margin-right .5em
|
||||||
|
.tippy-box
|
||||||
|
&[data-theme~='success']
|
||||||
|
color $base0B
|
||||||
|
&[data-theme~='warning']
|
||||||
|
color $base0A
|
||||||
|
.verified-success
|
||||||
|
color $base0B
|
||||||
|
.verified-warning
|
||||||
|
color $base0A
|
|
@ -1,54 +0,0 @@
|
||||||
{% extends 'lemoncurry/layout.html' %}
|
|
||||||
{% load lemonauth_tags static %}
|
|
||||||
|
|
||||||
{% block styles %}
|
|
||||||
<link rel="stylesheet" type="text/stylus" href="{% static 'lemonauth/css/login.styl' %}" />
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="container">
|
|
||||||
{% if form.errors %}
|
|
||||||
<p class="alert alert-danger">
|
|
||||||
<strong>Uh oh!</strong> Your username and password didn't match. Please try again.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if next %}
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<p class="alert alert-warning">
|
|
||||||
<strong>Hang on!</strong> Your account doesn't have access to this page. To proceed, please log in to an account that does have access.
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="alert alert-warning">
|
|
||||||
<strong>Oops!</strong> Please log in to see this page.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form class="card" method="post" action="{% url 'lemonauth:login' %}">
|
|
||||||
<div class="card-body">
|
|
||||||
{% form_field form.username %}
|
|
||||||
{% form_field form.password %}
|
|
||||||
{% form_field form.otp_token %}
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="custom-control custom-checkbox">
|
|
||||||
<input name="{{ form.otp_agent_trust.name }}" class="custom-control-input" type="checkbox" />
|
|
||||||
<span class="custom-control-indicator"></span>
|
|
||||||
<span class="custom-control-description">remember this browser (don't tick this on a public computer!)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer">
|
|
||||||
<button class="btn btn-primary" type="submit">
|
|
||||||
<i class="fa fa-sign-in"></i>
|
|
||||||
log in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="next" value="{{ next }}" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,6 +0,0 @@
|
||||||
<div class="form-group">
|
|
||||||
<label for="{{ field.id_for_label }}">{{ field.label | lower }}</label>
|
|
||||||
<input id="{{ field.id_for_label }}" class="form-control{% if field.errors %}is-invalid{% endif %}" type="{{ field.field.widget.input_type }}" required
|
|
||||||
name="{{ field.html_name }}"{% if field.value %} value="{{ field.value }}"{% endif %}{% if field.help_text %} aria-describedby="{{ field.id_for_label }}-help"{% endif %} />
|
|
||||||
{% if field.help_text %}<p id="{{ field.id_for_label }}-help" class="form-text">{{ field.help_text }}</p>{% endif %}
|
|
||||||
</div>
|
|
|
@ -1,8 +0,0 @@
|
||||||
from django import template
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('lemonauth/tags/form_field.html')
|
|
||||||
def form_field(field):
|
|
||||||
return {'field': field}
|
|
46
lemonauth/tokens.py
Normal file
46
lemonauth/tokens.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
from micropub import error
|
||||||
|
from .models import IndieAuthCode, Token
|
||||||
|
|
||||||
|
|
||||||
|
def auth(request) -> Token:
|
||||||
|
if "HTTP_AUTHORIZATION" in request.META:
|
||||||
|
auth = request.META.get("HTTP_AUTHORIZATION").split(" ")
|
||||||
|
if auth[0] != "Bearer":
|
||||||
|
raise error.bad_req("auth type {0} not supported".format(auth[0]))
|
||||||
|
if len(auth) != 2:
|
||||||
|
raise error.bad_req("invalid Bearer auth format, must be Bearer <token>")
|
||||||
|
token = auth[1]
|
||||||
|
elif "access_token" in request.POST:
|
||||||
|
token = request.POST.get("access_token")
|
||||||
|
elif "access_token" in request.GET:
|
||||||
|
token = request.GET.get("access_token")
|
||||||
|
else:
|
||||||
|
raise error.unauthorized()
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = Token.objects.get(pk=token)
|
||||||
|
except Token.DoesNotExist:
|
||||||
|
raise error.forbidden()
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def gen_auth_code(req):
|
||||||
|
code = IndieAuthCode()
|
||||||
|
code.user = req.user
|
||||||
|
code.client_id = req.POST["client_id"]
|
||||||
|
code.redirect_uri = req.POST["redirect_uri"]
|
||||||
|
code.response_type = req.POST.get("response_type", "id")
|
||||||
|
if "scope" in req.POST:
|
||||||
|
code.scope = " ".join(req.POST.getlist("scope"))
|
||||||
|
code.save()
|
||||||
|
return code.id
|
||||||
|
|
||||||
|
|
||||||
|
def gen_token(code):
|
||||||
|
tok = Token()
|
||||||
|
tok.user = code.user
|
||||||
|
tok.client_id = code.client_id
|
||||||
|
tok.scope = code.scope
|
||||||
|
tok.save()
|
||||||
|
return tok.id
|
|
@ -1,8 +1,17 @@
|
||||||
from django.conf.urls import url
|
from django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = 'lemonauth'
|
app_name = "lemonauth"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url('^login$', views.login, name='login'),
|
path("login", views.login, name="login"),
|
||||||
url('^logout$', views.logout, name='logout'),
|
path("logout", views.logout, name="logout"),
|
||||||
|
path("indie", views.IndieView.as_view(), name="indie"),
|
||||||
|
path("indie/approve", views.indie_approve, name="indie_approve"),
|
||||||
|
path("token", views.TokenView.as_view(), name="token"),
|
||||||
|
path("tokens", views.TokensListView.as_view(), name="tokens"),
|
||||||
|
path(
|
||||||
|
"tokens/<path:client_id>",
|
||||||
|
views.TokensRevokeView.as_view(),
|
||||||
|
name="tokens_revoke",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
from django.contrib.auth import views as auth_views
|
|
||||||
from otp_agents.forms import OTPAuthenticationForm
|
|
||||||
from lemoncurry import breadcrumbs
|
|
||||||
|
|
||||||
breadcrumbs.add(route='lemonauth:login', label='log in', parent='home:index')
|
|
||||||
|
|
||||||
login = auth_views.LoginView.as_view(
|
|
||||||
authentication_form=OTPAuthenticationForm,
|
|
||||||
extra_context={'title': 'log in'},
|
|
||||||
template_name='lemonauth/login.html',
|
|
||||||
redirect_authenticated_user=True,
|
|
||||||
)
|
|
||||||
logout = auth_views.LogoutView.as_view()
|
|
5
lemonauth/views/__init__.py
Normal file
5
lemonauth/views/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from .login import login
|
||||||
|
from .logout import logout
|
||||||
|
from .indie import IndieView, approve as indie_approve
|
||||||
|
from .token import TokenView
|
||||||
|
from .tokens import TokensListView, TokensRevokeView
|
125
lemonauth/views/indie.py
Normal file
125
lemonauth/views/indie.py
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
from annoying.decorators import render_to
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from lemoncurry import breadcrumbs, requests, utils
|
||||||
|
from urllib.parse import urlencode, urljoin, urlunparse, urlparse
|
||||||
|
|
||||||
|
from .. import tokens
|
||||||
|
from ..models import IndieAuthCode
|
||||||
|
|
||||||
|
breadcrumbs.add("lemonauth:indie", parent="home:index")
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(url):
|
||||||
|
if "//" not in url:
|
||||||
|
url = "//" + url
|
||||||
|
(scheme, netloc, path, params, query, fragment) = urlparse(url)
|
||||||
|
if not scheme or scheme == "http":
|
||||||
|
scheme = "https"
|
||||||
|
if not path:
|
||||||
|
path = "/"
|
||||||
|
return urlunparse((scheme, netloc, path, params, query, fragment))
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
class IndieView(TemplateView):
|
||||||
|
template_name = "lemonauth/indie.html"
|
||||||
|
required_params = ("client_id", "redirect_uri")
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
@method_decorator(render_to(template_name))
|
||||||
|
def get(self, request):
|
||||||
|
params = request.GET.dict()
|
||||||
|
params.setdefault("response_type", "id")
|
||||||
|
|
||||||
|
for param in self.required_params:
|
||||||
|
if param not in params:
|
||||||
|
return utils.bad_req("parameter {0} is required".format(param))
|
||||||
|
|
||||||
|
me = request.user.full_url
|
||||||
|
if "me" in params:
|
||||||
|
param_me = canonical(params["me"])
|
||||||
|
if me != param_me:
|
||||||
|
return utils.forbid(
|
||||||
|
"you are logged in as {}, not as {}".format(me, param_me)
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_uri = urljoin(params["client_id"], params["redirect_uri"])
|
||||||
|
|
||||||
|
type = params["response_type"]
|
||||||
|
if type not in ("id", "code"):
|
||||||
|
return utils.bad_req("unknown response_type: {0}".format(type))
|
||||||
|
|
||||||
|
scopes = ()
|
||||||
|
if type == "code":
|
||||||
|
if "scope" not in params:
|
||||||
|
return utils.bad_req("scopes required for code type")
|
||||||
|
scopes = params["scope"].split(" ")
|
||||||
|
|
||||||
|
client = requests.mf2(params["client_id"])
|
||||||
|
rels = client.to_dict()["rel-urls"].get(redirect_uri, {}).get("rels", ())
|
||||||
|
verified = "redirect_uri" in rels
|
||||||
|
|
||||||
|
try:
|
||||||
|
app = client.to_dict(filter_by_type="h-x-app")[0]["properties"]
|
||||||
|
except IndexError:
|
||||||
|
app = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app": app,
|
||||||
|
"me": me,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"verified": verified,
|
||||||
|
"params": params,
|
||||||
|
"scopes": scopes,
|
||||||
|
"title": "indieauth from {client_id}".format(**params),
|
||||||
|
}
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
post = request.POST.dict()
|
||||||
|
try:
|
||||||
|
code = IndieAuthCode.objects.get(pk=post.get("code"))
|
||||||
|
except IndieAuthCode.DoesNotExist:
|
||||||
|
# if anything at all goes wrong when decoding the auth code, bail
|
||||||
|
# out immediately.
|
||||||
|
return utils.forbid("invalid auth code")
|
||||||
|
code.delete()
|
||||||
|
if code.expired:
|
||||||
|
return utils.forbid("invalid auth code")
|
||||||
|
|
||||||
|
if code.response_type != "id":
|
||||||
|
return utils.bad_req("this endpoint only supports response_type=id")
|
||||||
|
if code.client_id != post.get("client_id"):
|
||||||
|
return utils.forbid("client id did not match")
|
||||||
|
if code.redirect_uri != post.get("redirect_uri"):
|
||||||
|
return utils.forbid("redirect uri did not match")
|
||||||
|
|
||||||
|
# If we got here, it's valid! Yay!
|
||||||
|
return utils.choose_type(
|
||||||
|
request,
|
||||||
|
{"me": code.me},
|
||||||
|
{
|
||||||
|
"application/x-www-form-urlencoded": utils.form_encoded_response,
|
||||||
|
"application/json": JsonResponse,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def approve(request):
|
||||||
|
params = {
|
||||||
|
"me": urljoin(utils.origin(request), request.user.url),
|
||||||
|
"code": tokens.gen_auth_code(request),
|
||||||
|
}
|
||||||
|
if "state" in request.POST:
|
||||||
|
params["state"] = request.POST["state"]
|
||||||
|
|
||||||
|
uri = request.POST["redirect_uri"]
|
||||||
|
sep = "&" if "?" in uri else "?"
|
||||||
|
return redirect(uri + sep + urlencode(params))
|
12
lemonauth/views/login.py
Normal file
12
lemonauth/views/login.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import django.contrib.auth.views
|
||||||
|
from otp_agents.forms import OTPAuthenticationForm
|
||||||
|
from lemoncurry import breadcrumbs
|
||||||
|
|
||||||
|
breadcrumbs.add(route="lemonauth:login", label="log in", parent="home:index")
|
||||||
|
|
||||||
|
login = django.contrib.auth.views.LoginView.as_view(
|
||||||
|
authentication_form=OTPAuthenticationForm,
|
||||||
|
extra_context={"title": "log in"},
|
||||||
|
template_name="lemonauth/login.html",
|
||||||
|
redirect_authenticated_user=True,
|
||||||
|
)
|
3
lemonauth/views/logout.py
Normal file
3
lemonauth/views/logout.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import django.contrib.auth.views
|
||||||
|
|
||||||
|
logout = django.contrib.auth.views.LogoutView.as_view()
|
48
lemonauth/views/token.py
Normal file
48
lemonauth/views/token.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
from django.views import View
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
from .. import tokens
|
||||||
|
from ..models import IndieAuthCode
|
||||||
|
from lemoncurry import utils
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
class TokenView(View):
|
||||||
|
def get(self, req):
|
||||||
|
token = tokens.auth(req)
|
||||||
|
res = {
|
||||||
|
"me": token.me,
|
||||||
|
"client_id": token.client_id,
|
||||||
|
"scope": token.scope,
|
||||||
|
}
|
||||||
|
return utils.choose_type(req, res)
|
||||||
|
|
||||||
|
def post(self, req):
|
||||||
|
post = req.POST
|
||||||
|
try:
|
||||||
|
code = IndieAuthCode.objects.get(pk=post.get("code"))
|
||||||
|
except IndieAuthCode.DoesNotExist:
|
||||||
|
return utils.forbid("invalid auth code")
|
||||||
|
code.delete()
|
||||||
|
if code.expired:
|
||||||
|
return utils.forbid("invalid auth code")
|
||||||
|
|
||||||
|
if code.response_type != "code":
|
||||||
|
return utils.bad_req("this endpoint only supports response_type=code")
|
||||||
|
if "client_id" in post and code.client_id != post["client_id"]:
|
||||||
|
return utils.forbid("client id did not match")
|
||||||
|
if code.redirect_uri != post.get("redirect_uri"):
|
||||||
|
return utils.forbid("redirect uri did not match")
|
||||||
|
|
||||||
|
if "me" in post and code.me != post["me"]:
|
||||||
|
return utils.forbid("me did not match")
|
||||||
|
|
||||||
|
return utils.choose_type(
|
||||||
|
req,
|
||||||
|
{
|
||||||
|
"access_token": tokens.gen_token(code),
|
||||||
|
"me": code.me,
|
||||||
|
"scope": code.scope,
|
||||||
|
},
|
||||||
|
)
|
2
lemonauth/views/tokens/__init__.py
Normal file
2
lemonauth/views/tokens/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from .list import TokensListView
|
||||||
|
from .revoke import TokensRevokeView
|
42
lemonauth/views/tokens/list.py
Normal file
42
lemonauth/views/tokens/list.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from typing import Dict, Optional, Set
|
||||||
|
from lemoncurry.requests import mf2
|
||||||
|
|
||||||
|
|
||||||
|
class ClientsDict(dict):
|
||||||
|
def __missing__(self, client_id):
|
||||||
|
self[client_id] = Client(client_id)
|
||||||
|
return self[client_id]
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
id: str
|
||||||
|
count: int
|
||||||
|
scopes: Set[str]
|
||||||
|
app: Optional[Dict[str, str]]
|
||||||
|
|
||||||
|
def __init__(self, client_id):
|
||||||
|
self.id = client_id
|
||||||
|
self.count = 0
|
||||||
|
self.scopes = set()
|
||||||
|
try:
|
||||||
|
apps = mf2(self.id).to_dict(filter_by_type="h-x-app")
|
||||||
|
self.app = apps[0]["properties"]
|
||||||
|
except (RequestException, IndexError):
|
||||||
|
self.app = None
|
||||||
|
|
||||||
|
|
||||||
|
class TokensListView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "lemonauth/tokens.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
clients = ClientsDict()
|
||||||
|
for token in self.request.user.token_set.all():
|
||||||
|
client = clients[token.client_id]
|
||||||
|
client.count += 1
|
||||||
|
client.scopes |= set(token.scope.split(" "))
|
||||||
|
context.update({"clients": clients, "title": "tokens"})
|
||||||
|
return context
|
9
lemonauth/views/tokens/revoke.py
Normal file
9
lemonauth/views/tokens/revoke.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
|
||||||
|
class TokensRevokeView(LoginRequiredMixin, View):
|
||||||
|
def delete(self, request, client_id: str):
|
||||||
|
request.user.token_set.filter(client_id=client_id).delete()
|
||||||
|
return HttpResponse(status=204)
|
BIN
lemoncurry.paw
Normal file
BIN
lemoncurry.paw
Normal file
Binary file not shown.
5
lemoncurry.svg
Normal file
5
lemoncurry.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" version="1.1">
|
||||||
|
<circle id="backdrop" fill="#353535" cx="400" cy="400" r="400" />
|
||||||
|
<path id="lemon" fill="#FFCB6B" d="m 633.03789,166.963 c -23.094,-23.093 -54.39,-28.893 -75.091,-16.834 -58.906,34.312 -181.25,-53.076996 -321.073,86.746 -139.823004,139.823 -52.433,262.166 -86.745,321.07 -12.059,20.702 -6.26,51.999 16.833,75.093 23.095,23.095 54.392,28.891 75.095,16.832 58.901,-34.31 181.246,53.079 321.068,-86.743 139.822,-139.822 52.435,-262.167 86.746,-321.071 12.059,-20.702 6.261,-51.999 -16.833,-75.093 z m -245.157,72.559 c -58.189,14.547 -133.808,90.155 -148.358,148.358 -1.817,7.27 -8.342,12.124 -15.511,12.124 -1.284,0 -2.59,-0.156 -3.893,-0.481 -8.572,-2.144 -13.784,-10.83 -11.642,-19.403 17.424,-69.693 101.839,-154.19 171.642,-171.642 8.575,-2.143 17.261,3.069 19.403,11.642 2.142,8.573 -3.069,17.259 -11.641,19.402 z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 948 B |
5
lemoncurry/admin.py
Normal file
5
lemoncurry/admin.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from otp_agents.decorators import otp_required
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.login = otp_required(admin.site.login, accept_trusted_agent=True)
|
|
@ -1,15 +1,50 @@
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
breadcrumbs = {}
|
breadcrumbs = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Crumb:
|
||||||
|
def __init__(self, route, label=None, parent=None):
|
||||||
|
self.route = route
|
||||||
|
self._label = label
|
||||||
|
self.parent = parent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
return self._label
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if hasattr(other, "route"):
|
||||||
|
return self.route == other.route
|
||||||
|
return self.route == other
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.route)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "Crumb('{0}')".format(self.route)
|
||||||
|
|
||||||
|
def use_match(self, match):
|
||||||
|
self.match = match
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return reverse(self.route)
|
||||||
|
|
||||||
|
|
||||||
def add(route, label=None, parent=None):
|
def add(route, label=None, parent=None):
|
||||||
breadcrumbs[route] = {'label': label, 'route': route, 'parent': parent}
|
if not isinstance(route, Crumb):
|
||||||
|
route = Crumb(route, label, parent)
|
||||||
|
breadcrumbs[route.route] = route
|
||||||
|
|
||||||
|
|
||||||
def find(route):
|
def find(match):
|
||||||
crumbs = []
|
crumbs = []
|
||||||
|
route = match.view_name
|
||||||
while route:
|
while route:
|
||||||
crumb = breadcrumbs[route]
|
crumb = breadcrumbs[route]
|
||||||
|
crumb.use_match(match)
|
||||||
crumbs.append(crumb)
|
crumbs.append(crumb)
|
||||||
route = crumb['parent']
|
route = crumb.parent
|
||||||
crumbs.reverse()
|
crumbs.reverse()
|
||||||
return crumbs
|
return crumbs
|
||||||
|
|
7
lemoncurry/debug.py
Normal file
7
lemoncurry/debug.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from debug_toolbar.middleware import show_toolbar as core_show_toolbar
|
||||||
|
|
||||||
|
|
||||||
|
def show_toolbar(request):
|
||||||
|
if request.path.endswith("/amp"):
|
||||||
|
return False
|
||||||
|
return core_show_toolbar(request)
|
43
lemoncurry/jinja2/__init__.py
Normal file
43
lemoncurry/jinja2/__init__.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from jinja2 import Environment
|
||||||
|
|
||||||
|
from compressor.contrib.jinja2ext import CompressorExtension
|
||||||
|
from django_activeurl.ext.django_jinja import ActiveUrl
|
||||||
|
|
||||||
|
from entries.kinds import all as entry_kinds
|
||||||
|
from wellknowns.favicons import icons as favicons
|
||||||
|
|
||||||
|
from .ago import ago
|
||||||
|
from .markdown import markdown
|
||||||
|
from ..theme import color as theme_color
|
||||||
|
from ..utils import friendly_url, load_package_json
|
||||||
|
|
||||||
|
|
||||||
|
def environment(**options):
|
||||||
|
env = Environment(
|
||||||
|
extensions=[ActiveUrl, CompressorExtension],
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
**options
|
||||||
|
)
|
||||||
|
env.filters.update(
|
||||||
|
{
|
||||||
|
"ago": ago,
|
||||||
|
"friendly_url": friendly_url,
|
||||||
|
"markdown": markdown,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
env.globals.update(
|
||||||
|
{
|
||||||
|
"entry_kinds": entry_kinds,
|
||||||
|
"favicons": favicons,
|
||||||
|
"package": load_package_json(),
|
||||||
|
"settings": settings,
|
||||||
|
"static": staticfiles_storage.url,
|
||||||
|
"theme_color": theme_color,
|
||||||
|
"url": reverse,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return env
|
9
lemoncurry/jinja2/ago.py
Normal file
9
lemoncurry/jinja2/ago.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from ago import human
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def ago(dt: datetime) -> str:
|
||||||
|
# We have to convert the datetime we get to local time first, because ago
|
||||||
|
# just strips the timezone from a timezone-aware datetime.
|
||||||
|
dt = dt.astimezone()
|
||||||
|
return human(dt, precision=1, past_tense="{}", abbreviate=True)
|
23
lemoncurry/jinja2/bleach.py
Normal file
23
lemoncurry/jinja2/bleach.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from bleach.sanitizer import Cleaner, ALLOWED_TAGS
|
||||||
|
from bleach.linkifier import LinkifyFilter
|
||||||
|
from jinja2 import pass_eval_context
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
TAGS = ["cite", "code", "details", "p", "pre", "img", "span", "summary"]
|
||||||
|
TAGS.extend(ALLOWED_TAGS)
|
||||||
|
ATTRIBUTES = {
|
||||||
|
"a": ["href", "title", "class"],
|
||||||
|
"details": ["open"],
|
||||||
|
"img": ["alt", "src", "title"],
|
||||||
|
"span": ["class"],
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaner = Cleaner(tags=TAGS, attributes=ATTRIBUTES, filters=(LinkifyFilter,))
|
||||||
|
|
||||||
|
|
||||||
|
@pass_eval_context
|
||||||
|
def bleach(ctx, html):
|
||||||
|
res = cleaner.clean(html)
|
||||||
|
if ctx.autoescape:
|
||||||
|
res = Markup(res)
|
||||||
|
return res
|
165
lemoncurry/jinja2/lemoncurry/layout.html
Normal file
165
lemoncurry/jinja2/lemoncurry/layout.html
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html{% block html_attr %} dir="ltr" lang="en" data-bs-theme="dark"{% endblock %}>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
|
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ request.site.name }}</title>
|
||||||
|
{% if atom is defined %}
|
||||||
|
<link rel="alternate" type="application/atom+xml" href="{{ atom }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% if rss is defined %}
|
||||||
|
<link rel="alternate" type="application/rss+xml" href="{{ rss }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
|
||||||
|
<base href="{{ request.build_absolute_uri(url('home:index')) }}" />
|
||||||
|
<link rel="authorization_endpoint" href="{{ url('lemonauth:indie') }}" />
|
||||||
|
<link rel="canonical" href="{{ request.build_absolute_uri() }}" />
|
||||||
|
<link rel="hub" href="{{ settings.PUSH_HUB }}" />
|
||||||
|
<link rel="manifest" href="{{ url('wellknowns:manifest') }}" />
|
||||||
|
<link rel="micropub" href="{{ url('micropub:micropub') }}" />
|
||||||
|
<link rel="token_endpoint" href="{{ url('lemonauth:token') }}" />
|
||||||
|
|
||||||
|
<meta name="generator" content="{{ package.name }} {{ package.version }}" />
|
||||||
|
<meta name="theme-color" content="{{ theme_color(3) }}" />
|
||||||
|
{% for i in favicons %}
|
||||||
|
<link rel="{{ i.rel }}" type="{{ i.mime }}" sizes="{{ i.sizes }}" href="{{ i.url }}" />
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||||
|
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/monokai.min.css"
|
||||||
|
integrity="sha384-88Jvj9Q2LiBDwL7w3yciRTcH5q2zzvMFYIm4xX9/evqxJsxA33Xk9XYKcvUlPITo" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/openwebicons@1.6.0/css/openwebicons.min.css"
|
||||||
|
integrity="sha384-NkRWM9o4Kfak7GwS+un+sProBBpj02vc/e1EoXvdCUSdRk0muOfkKJ5NtpueAuka" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/tippy.js@3.4.1/dist/tippy.css"
|
||||||
|
integrity="sha384-hm3Wtrva6FibonAOqHHXSpMxvGbz2g7l5FK5avbuNviir5MK6Ap4o3EOohztzHHm" crossorigin="anonymous">
|
||||||
|
{% compress css %}
|
||||||
|
<link rel="stylesheet" type="text/stylus" href="{{ static('lemoncurry/css/layout.styl') }}">
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
|
{% endcompress %}
|
||||||
|
|
||||||
|
<script type="text/javascript" defer src="https://kit.fontawesome.com/a3aade9b41.js" crossorigin="anonymous"></script>
|
||||||
|
</head>
|
||||||
|
<body{% block body_attr %}{% endblock %}>
|
||||||
|
<header>
|
||||||
|
<nav class="navbar navbar-expand-md"><div class="container-fluid">
|
||||||
|
<a class="navbar-brand" rel="home" href="{{ url('home:index') }}">{{ request.site.name }}</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
|
||||||
|
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% activeurl %}
|
||||||
|
<div class="collapse navbar-collapse" id="navbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% for kind in entry_kinds %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ kind.index }}">
|
||||||
|
<i class="{{ kind.icon }} fa-fw" aria-hidden="true"></i>
|
||||||
|
{{ kind.plural }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url('lemonauth:tokens') }}">
|
||||||
|
<i class="fas fa-cookie-bite fa-fw" aria-hidden="true"></i>
|
||||||
|
tokens
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url('admin:index') }}">
|
||||||
|
<i class="fas fa-cog fa-fw" aria-hidden="true"></i>
|
||||||
|
admin
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url('lemonauth:logout') }}">
|
||||||
|
<i class="fas fa-sign-out-alt fa-fw" aria-hidden="true"></i>
|
||||||
|
log out
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url('lemonauth:login') }}">
|
||||||
|
<i class="fas fa-sign-in-alt fa-fw" aria-hidden="true"></i>
|
||||||
|
log in
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endactiveurl %}
|
||||||
|
|
||||||
|
</div></nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block main %}
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>all content licensed under <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">cc by-sa 4.0</a></p>
|
||||||
|
{% if entries is defined and entries.has_other_pages() %}
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination">
|
||||||
|
{% if entries.has_previous() %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" rel="prev" href="{{ entries.reverse(entries.previous_page_number()) }}">
|
||||||
|
<i class="fas fa-step-backward" aria-hidden="true"></i> <span class="sr-only">previous page</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% for i in entries.paginator.page_range %}
|
||||||
|
{% if i == entries.number %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ i }} <span class="sr-only">(current page)</span></span>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ entries.reverse(i) }}">{{ i }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if entries.has_next() %}
|
||||||
|
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" rel="next" href="{{ entries.reverse(entries.next_page_number()) }}">
|
||||||
|
<i class="fas fa-step-forward" aria-hidden="true"></i> <span class="sr-only">next page</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
<p>powered by <a rel="code-repository" href="{{ package.repository }}/src/tag/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"
|
||||||
|
integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT"></script>
|
||||||
|
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js" crossorigin="anonymous"
|
||||||
|
integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp"></script>
|
||||||
|
<script src="https://unpkg.com/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"
|
||||||
|
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"
|
||||||
|
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"></script>
|
||||||
|
<script src="https://unpkg.com/tippy.js@6.3.7/dist/tippy-bundle.umd.js" crossorigin="anonymous"
|
||||||
|
integrity="sha384-dtMr4wkcxQWUqsJFgElu4AttgIhOsjr2vYIzP2mv0MZbD/uJ6OHxFdbgE3MOKabN"></script>
|
||||||
|
{% compress js %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
hljs.initHighlightingOnLoad();
|
||||||
|
</script>
|
||||||
|
{% block foot %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcompress %}
|
||||||
|
</body>
|
||||||
|
</html>
|
18
lemoncurry/jinja2/markdown.py
Normal file
18
lemoncurry/jinja2/markdown.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from jinja2 import pass_eval_context
|
||||||
|
from markdown import Markdown
|
||||||
|
|
||||||
|
from .bleach import bleach
|
||||||
|
|
||||||
|
md = Markdown(
|
||||||
|
extensions=(
|
||||||
|
"extra",
|
||||||
|
"sane_lists",
|
||||||
|
"smarty",
|
||||||
|
"toc",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pass_eval_context
|
||||||
|
def markdown(ctx, source):
|
||||||
|
return bleach(ctx, md.reset().convert(source))
|
16
lemoncurry/middleware.py
Normal file
16
lemoncurry/middleware.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseException(Exception):
|
||||||
|
def __init__(self, response: HttpResponse) -> None:
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseExceptionMiddleware(MiddlewareMixin):
|
||||||
|
def process_exception(
|
||||||
|
self, request: HttpRequest, exception: Exception
|
||||||
|
) -> HttpResponse:
|
||||||
|
if isinstance(exception, ResponseException):
|
||||||
|
return exception.response
|
||||||
|
raise exception
|
24
lemoncurry/msgpack.py
Normal file
24
lemoncurry/msgpack.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import msgpack
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
from django_redis.serializers.base import BaseSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def default(obj):
|
||||||
|
# Pickle anything that MessagePack can't handle itself.
|
||||||
|
return msgpack.ExtType(69, pickle.dumps(obj, protocol=4))
|
||||||
|
|
||||||
|
|
||||||
|
def ext_hook(code, data):
|
||||||
|
# Unpickle if we pickled - otherwise do nothing.
|
||||||
|
if code == 69:
|
||||||
|
return pickle.loads(data)
|
||||||
|
return msgpack.ExtType(code, data)
|
||||||
|
|
||||||
|
|
||||||
|
class MSGPackModernSerializer(BaseSerializer):
|
||||||
|
def dumps(self, value):
|
||||||
|
return msgpack.packb(value, default=default, use_bin_type=True)
|
||||||
|
|
||||||
|
def loads(self, value):
|
||||||
|
return msgpack.unpackb(value, ext_hook=ext_hook, raw=False)
|
48
lemoncurry/requests.py
Normal file
48
lemoncurry/requests.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import requests
|
||||||
|
from cachecontrol.wrapper import CacheControl
|
||||||
|
from cachecontrol.cache import BaseCache
|
||||||
|
from cachecontrol.heuristics import LastModified
|
||||||
|
from datetime import datetime
|
||||||
|
from django.core.cache import cache as django_cache
|
||||||
|
from hashlib import sha256
|
||||||
|
from mf2py import Parser
|
||||||
|
|
||||||
|
|
||||||
|
class DjangoCache(BaseCache):
|
||||||
|
@classmethod
|
||||||
|
def key(cls, url):
|
||||||
|
return "req:" + sha256(url.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def get(self, url):
|
||||||
|
key = self.key(url)
|
||||||
|
return django_cache.get(key)
|
||||||
|
|
||||||
|
def set(self, url, value, expires=None):
|
||||||
|
key = self.key(url)
|
||||||
|
if expires:
|
||||||
|
lifetime = (expires - datetime.utcnow()).total_seconds()
|
||||||
|
django_cache.set(key, value, lifetime)
|
||||||
|
else:
|
||||||
|
django_cache.set(key, value)
|
||||||
|
|
||||||
|
def delete(self, url):
|
||||||
|
key = self.key(url)
|
||||||
|
django_cache.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
req = CacheControl(
|
||||||
|
requests.Session(),
|
||||||
|
cache=DjangoCache(),
|
||||||
|
heuristic=LastModified(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get(url):
|
||||||
|
r = req.get(url)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def mf2(url):
|
||||||
|
r = get(url)
|
||||||
|
return Parser(doc=r.text, url=url, html_parser="html5lib")
|
|
@ -10,23 +10,29 @@ For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/1.11/ref/settings/
|
https://docs.djangoproject.com/en/1.11/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
from os import environ, path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
APPEND_SLASH = False
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
ADMINS = [
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
("dani", "dani@00dani.me"),
|
||||||
|
]
|
||||||
|
|
||||||
|
BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = '6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww'
|
SECRET_KEY = "6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww"
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS: List[str] = []
|
||||||
|
INTERNAL_IPS = ["127.0.0.1", "::1"]
|
||||||
|
|
||||||
# Settings to tighten up security - these can safely be on in dev mode too,
|
# Settings to tighten up security - these can safely be on in dev mode too,
|
||||||
# since I dev using a local HTTPS server.
|
# since I dev using a local HTTPS server.
|
||||||
|
@ -44,7 +50,7 @@ CSRF_COOKIE_SECURE = True
|
||||||
# Miscellanous headers to protect against attacks.
|
# Miscellanous headers to protect against attacks.
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
SECURE_BROWSER_XSS_FILTER = True
|
||||||
X_FRAME_OPTIONS = 'DENY'
|
X_FRAME_OPTIONS = "DENY"
|
||||||
|
|
||||||
# This technically isn't needed, since nginx doesn't let the app be accessed
|
# This technically isn't needed, since nginx doesn't let the app be accessed
|
||||||
# over insecure HTTP anyway. Just for completeness!
|
# over insecure HTTP anyway. Just for completeness!
|
||||||
|
@ -52,78 +58,104 @@ SECURE_SSL_REDIRECT = True
|
||||||
|
|
||||||
# We run behind nginx, so we need nginx to tell us whether we're using HTTPS or
|
# We run behind nginx, so we need nginx to tell us whether we're using HTTPS or
|
||||||
# not.
|
# not.
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"lemoncurry",
|
||||||
'django.contrib.auth',
|
"pyup_django",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.admin",
|
||||||
'django.contrib.humanize',
|
"django.contrib.admindocs",
|
||||||
'django.contrib.sessions',
|
"django.contrib.auth",
|
||||||
'django.contrib.sitemaps',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.messages',
|
"django.contrib.humanize",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.sites",
|
||||||
'compressor',
|
"django.contrib.sitemaps",
|
||||||
'django_activeurl',
|
"django.contrib.messages",
|
||||||
'django_agent_trust',
|
"django.contrib.staticfiles",
|
||||||
'django_otp',
|
"annoying",
|
||||||
'django_otp.plugins.otp_totp',
|
"compressor",
|
||||||
'favicon',
|
"computed_property",
|
||||||
'markdown_deux',
|
"corsheaders",
|
||||||
'meta',
|
"debug_toolbar",
|
||||||
|
"django_activeurl",
|
||||||
'lemoncurry',
|
"django_agent_trust",
|
||||||
'entries',
|
"django_extensions",
|
||||||
'home',
|
"django_otp",
|
||||||
'lemonauth',
|
"django_otp.plugins.otp_static",
|
||||||
'users',
|
"django_otp.plugins.otp_totp",
|
||||||
'wellknowns',
|
"django_rq",
|
||||||
|
"meta",
|
||||||
|
"entries",
|
||||||
|
"home",
|
||||||
|
"lemonauth",
|
||||||
|
"lemonshort",
|
||||||
|
"micropub",
|
||||||
|
"users",
|
||||||
|
"webmention",
|
||||||
|
"wellknowns",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.middleware.http.ConditionalGetMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.contrib.admindocs.middleware.XViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django_otp.middleware.OTPMiddleware',
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
'django_agent_trust.middleware.AgentMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django_otp.middleware.OTPMiddleware",
|
||||||
|
"django_agent_trust.middleware.AgentMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.contrib.sites.middleware.CurrentSiteMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"lemoncurry.middleware.ResponseExceptionMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'lemoncurry.urls'
|
ROOT_URLCONF = "lemoncurry.urls"
|
||||||
|
|
||||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
|
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.jinja2.Jinja2",
|
||||||
'DIRS': [],
|
"APP_DIRS": True,
|
||||||
'APP_DIRS': True,
|
"OPTIONS": {
|
||||||
'OPTIONS': {
|
"environment": "lemoncurry.jinja2.environment",
|
||||||
'context_processors': [
|
},
|
||||||
'django.template.context_processors.debug',
|
},
|
||||||
'django.template.context_processors.request',
|
{
|
||||||
'django.contrib.auth.context_processors.auth',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'lemoncurry.wsgi.application'
|
WSGI_APPLICATION = "lemoncurry.wsgi.application"
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
# https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-CACHES
|
# https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-CACHES
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
'default': {
|
"default": {
|
||||||
'BACKEND': 'redis_cache.RedisCache',
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
'LOCATION': '127.0.0.1:6380',
|
"LOCATION": "redis://127.0.0.1:6380/0",
|
||||||
|
"KEY_PREFIX": "lemoncurry",
|
||||||
|
"OPTIONS": {
|
||||||
|
"SERIALIZER": "lemoncurry.msgpack.MSGPackModernSerializer",
|
||||||
|
},
|
||||||
|
"VERSION": 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,44 +163,51 @@ CACHES = {
|
||||||
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
|
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
"NAME": environ.get("POSTGRES_DB", "lemoncurry"),
|
||||||
|
"USER": environ.get("POSTGRES_USER"),
|
||||||
|
"PASSWORD": environ.get("POSTGRES_PASSWORD"),
|
||||||
|
"HOST": environ.get("POSTGRES_HOST", "localhost"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'users.User'
|
AUTH_USER_MODEL = "users.User"
|
||||||
|
|
||||||
|
# Password hashers
|
||||||
|
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
|
||||||
|
PASSWORD_HASHERS = [
|
||||||
|
"django.contrib.auth.hashers.Argon2PasswordHasher",
|
||||||
|
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||||
|
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
|
||||||
|
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
|
||||||
|
"django.contrib.auth.hashers.BCryptPasswordHasher",
|
||||||
|
]
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
PW_VALIDATOR_MODULE = "django.contrib.auth.password_validation"
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{"NAME": PW_VALIDATOR_MODULE + ".UserAttributeSimilarityValidator"},
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
{"NAME": PW_VALIDATOR_MODULE + ".MinimumLengthValidator"},
|
||||||
},
|
{"NAME": PW_VALIDATOR_MODULE + ".CommonPasswordValidator"},
|
||||||
{
|
{"NAME": PW_VALIDATOR_MODULE + ".NumericPasswordValidator"},
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
LOGIN_URL = 'lemonauth:login'
|
LOGIN_URL = "lemonauth:login"
|
||||||
LOGIN_REDIRECT_URL = 'home:index'
|
LOGIN_REDIRECT_URL = "home:index"
|
||||||
LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL
|
LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/1.11/topics/i18n/
|
# https://docs.djangoproject.com/en/1.11/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-au'
|
LANGUAGE_CODE = "en-au"
|
||||||
|
|
||||||
TIME_ZONE = 'Australia/Sydney'
|
TIME_ZONE = "Australia/Sydney"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
|
@ -180,57 +219,51 @@ USE_TZ = True
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/1.11/howto/static-files/
|
# https://docs.djangoproject.com/en/1.11/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
|
STATIC_ROOT = path.join(BASE_DIR, "static")
|
||||||
STATICFILES_FINDERS = (
|
STATICFILES_FINDERS = (
|
||||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
'compressor.finders.CompressorFinder',
|
"compressor.finders.CompressorFinder",
|
||||||
)
|
)
|
||||||
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
|
||||||
|
|
||||||
COMPRESS_PRECOMPILERS = (
|
COMPRESS_PRECOMPILERS = (
|
||||||
('text/stylus', os.path.join(BASE_DIR, 'node_modules', '.bin', 'stylus') + ' {infile} -o {outfile}'),
|
("text/stylus", "npx stylus -u ./lemoncurry/static/lemoncurry/css/theme"),
|
||||||
)
|
)
|
||||||
|
|
||||||
MEDIA_URL = STATIC_URL + 'media/'
|
MEDIA_URL = STATIC_URL + "media/"
|
||||||
MEDIA_ROOT = os.path.join(STATIC_ROOT, 'media')
|
MEDIA_ROOT = path.join(STATIC_ROOT, "media")
|
||||||
|
|
||||||
|
# django-contrib-sites
|
||||||
# Settings specific to lemoncurry
|
# https://docs.djangoproject.com/en/dev/ref/contrib/sites/
|
||||||
LEMONCURRY_SITE_NAME = '00dani.me'
|
SITE_ID = 1
|
||||||
|
|
||||||
# django-agent-trust
|
# django-agent-trust
|
||||||
# https://pythonhosted.org/django-agent-trust/
|
# https://pythonhosted.org/django-agent-trust/
|
||||||
AGENT_COOKIE_SECURE = True
|
AGENT_COOKIE_SECURE = True
|
||||||
|
|
||||||
# django-otp
|
# django-cors-headers
|
||||||
# https://django-otp-official.readthedocs.io/en/latest/overview.html
|
CORS_ORIGIN_ALLOW_ALL = True
|
||||||
OTP_TOTP_ISSUER = LEMONCURRY_SITE_NAME
|
CORS_URLS_REGEX = r"^/(?!admin|auth/(?:login|logout|indie)).*$"
|
||||||
|
|
||||||
|
# lemonshort
|
||||||
# django-markdown-deux
|
SHORT_BASE_URL = "/s/"
|
||||||
# https://github.com/trentm/django-markdown-deux
|
SHORTEN_MODELS = {
|
||||||
def copy_update(source_dict, **kwargs):
|
"e": "entries.entry",
|
||||||
copy = source_dict.copy()
|
|
||||||
copy.update(**kwargs)
|
|
||||||
return copy
|
|
||||||
|
|
||||||
|
|
||||||
MARKDOWN_DEUX_DEFAULT_STYLE = {
|
|
||||||
"extras": {
|
|
||||||
"code-friendly": None,
|
|
||||||
},
|
|
||||||
"safe_mode": "escape",
|
|
||||||
}
|
|
||||||
|
|
||||||
MARKDOWN_DEUX_STYLES = {
|
|
||||||
'default': MARKDOWN_DEUX_DEFAULT_STYLE,
|
|
||||||
'trusted': copy_update(MARKDOWN_DEUX_DEFAULT_STYLE, safe_mode=False),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# django-meta
|
# django-meta
|
||||||
# https://django-meta.readthedocs.io/en/latest/settings.html
|
# https://django-meta.readthedocs.io/en/latest/settings.html
|
||||||
META_SITE_PROTOCOL = 'https'
|
META_SITE_PROTOCOL = "https"
|
||||||
META_SITE_NAME = LEMONCURRY_SITE_NAME
|
META_USE_SITES = True
|
||||||
META_USE_OG_PROPERTIES = True
|
META_USE_OG_PROPERTIES = True
|
||||||
META_USE_TWITTER_PROPERTIES = True
|
META_USE_TWITTER_PROPERTIES = True
|
||||||
|
|
||||||
|
# django-push
|
||||||
|
# https://django-push.readthedocs.io/en/latest/publisher.html
|
||||||
|
PUSH_HUB = "https://00dani.superfeedr.com/"
|
||||||
|
|
||||||
|
# django-rq
|
||||||
|
# https://github.com/ui/django-rq
|
||||||
|
RQ_QUEUES = {"default": {"USE_REDIS_CACHE": "default"}}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from .base import *
|
from .base import *
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['*']
|
ALLOWED_HOSTS = ["*"]
|
||||||
META_SITE_DOMAIN = '00dani.dev'
|
META_SITE_DOMAIN = "00dani.lo"
|
||||||
META_FB_APPID = '142105433189339'
|
META_FB_APPID = "142105433189339"
|
||||||
|
STATIC_URL = "https://static.00dani.lo/"
|
||||||
|
MEDIA_URL = "https://media.00dani.lo/"
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
from os import environ
|
from os import environ
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
from .base import *
|
from .base import *
|
||||||
from .base import DATABASES
|
from .base import BASE_DIR, DATABASES
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['00dani.me']
|
ALLOWED_HOSTS = ["00dani.me"]
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
SECRET_KEY = environ['DJANGO_SECRET_KEY']
|
SECRET_KEY = environ["DJANGO_SECRET_KEY"]
|
||||||
|
SERVER_EMAIL = "lemoncurry@00dani.me"
|
||||||
|
|
||||||
# Use Postgres instead of SQLite in production.
|
# Authenticate as an app-specific Postgres user in production.
|
||||||
DATABASES['default'] = {
|
DATABASES["default"]["USER"] = "lemoncurry"
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
|
||||||
'NAME': 'lemoncurry',
|
|
||||||
'USER': 'lemoncurry',
|
|
||||||
}
|
|
||||||
|
|
||||||
STATIC_URL = 'https://cdn.00dani.me/'
|
SHORT_BASE_URL = "https://nya.as/"
|
||||||
MEDIA_URL = STATIC_URL + 'media/'
|
|
||||||
META_SITE_DOMAIN = '00dani.me'
|
STATIC_ROOT = join(BASE_DIR, "..", "static")
|
||||||
META_FB_APPID = '145311792869199'
|
MEDIA_ROOT = join(BASE_DIR, "..", "media")
|
||||||
|
STATIC_URL = "https://cdn.00dani.me/"
|
||||||
|
MEDIA_URL = STATIC_URL + "m/"
|
||||||
|
META_SITE_DOMAIN = "00dani.me"
|
||||||
|
META_FB_APPID = "145311792869199"
|
||||||
|
|
8
lemoncurry/settings/test.py
Normal file
8
lemoncurry/settings/test.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
SECURE_SSL_REDIRECT = False
|
||||||
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
|
|
||||||
|
MEDIA_URL = "/media/"
|
||||||
|
STATIC_ROOT = path.join(BASE_DIR, "media")
|
1
lemoncurry/static/base16-materialtheme-scheme
Submodule
1
lemoncurry/static/base16-materialtheme-scheme
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit cbbc47444208fb8f28dbc48ea986c9dc81843e9a
|
|
@ -1,17 +0,0 @@
|
||||||
// Source: https://github.com/ntpeters/base16-materialtheme-scheme/blob/cbbc474/material-darker.yaml
|
|
||||||
$base00 = #212121
|
|
||||||
$base01 = #303030
|
|
||||||
$base02 = #353535
|
|
||||||
$base03 = #4A4A4A
|
|
||||||
$base04 = #B2CCD6
|
|
||||||
$base05 = #EEFFFF
|
|
||||||
$base06 = #EEFFFF
|
|
||||||
$base07 = #FFFFFF
|
|
||||||
$base08 = #F07178
|
|
||||||
$base09 = #F78C6C
|
|
||||||
$base0A = #FFCB6B
|
|
||||||
$base0B = #C3E88D
|
|
||||||
$base0C = #89DDFF
|
|
||||||
$base0D = #82AAFF
|
|
||||||
$base0E = #C792EA
|
|
||||||
$base0F = #FF5370
|
|
|
@ -1,29 +1,50 @@
|
||||||
@import 'base16-material-darker'
|
$monokai_bg = #272822
|
||||||
|
|
||||||
html
|
html
|
||||||
background-color $base01
|
background-color $base00
|
||||||
|
|
||||||
a
|
a
|
||||||
color $base0D
|
color $base0D
|
||||||
|
text-decoration none
|
||||||
&:hover
|
&:hover
|
||||||
color $base0C
|
color $base0C
|
||||||
|
|
||||||
|
code, kbd, pre, samp, .code, .kbd, .pre, .samp
|
||||||
|
font-family SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace
|
||||||
|
|
||||||
|
code, pre, .code, .pre
|
||||||
|
padding .2rem .4rem
|
||||||
|
font-size 90%
|
||||||
|
color $base0A
|
||||||
|
background-color $monokai_bg
|
||||||
|
border-radius .25rem
|
||||||
|
|
||||||
.form-control, .form-control:focus
|
.form-control, .form-control:focus
|
||||||
background-color $base01
|
background-color $base01
|
||||||
border-color $base00
|
border-color $base00
|
||||||
color $base07
|
color $base07
|
||||||
|
|
||||||
|
.list-group-item
|
||||||
|
background-color $base03
|
||||||
|
|
||||||
[class^="openwebicons-"], [class*=" openwebicons-"]
|
[class^="openwebicons-"], [class*=" openwebicons-"]
|
||||||
&::before
|
&::before
|
||||||
text-decoration none
|
text-decoration none
|
||||||
line-height 1
|
line-height 1
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='dark']
|
||||||
|
background-color $base03
|
||||||
|
color $base04
|
||||||
|
text-align center
|
||||||
|
for placement in top bottom left right
|
||||||
|
&[data-placement^={placement}] > .tippy-arrow::before
|
||||||
|
border-{placement}-color $base03
|
||||||
|
|
||||||
body
|
body
|
||||||
display flex
|
display flex
|
||||||
flex-direction column
|
flex-direction column
|
||||||
min-height 100vh
|
min-height 100vh
|
||||||
background-color $base00
|
background-color transparent
|
||||||
color $base07
|
color $base07
|
||||||
> header
|
> header
|
||||||
> .navbar
|
> .navbar
|
||||||
|
@ -35,21 +56,68 @@ body
|
||||||
> .breadcrumb
|
> .breadcrumb
|
||||||
background-color transparent
|
background-color transparent
|
||||||
border-radius 0
|
border-radius 0
|
||||||
|
flex-wrap nowrap
|
||||||
|
.breadcrumb-item
|
||||||
|
white-space nowrap
|
||||||
|
&.active
|
||||||
|
overflow-x hidden
|
||||||
|
text-overflow ellipsis
|
||||||
|
|
||||||
|
|
||||||
> main
|
> main
|
||||||
margin 2rem
|
padding 2rem 1rem
|
||||||
|
width 100%
|
||||||
flex 1
|
flex 1
|
||||||
display flex
|
display flex
|
||||||
|
|
||||||
> footer
|
> footer
|
||||||
display flex
|
display flex
|
||||||
justify-content space-evenly
|
justify-content space-evenly
|
||||||
margin auto 1rem
|
align-items center
|
||||||
|
margin 1rem
|
||||||
|
margin-top 0
|
||||||
text-align center
|
text-align center
|
||||||
|
> p, nav
|
||||||
|
margin 0 .5rem
|
||||||
|
&:last-child
|
||||||
|
margin 0
|
||||||
|
|
||||||
|
flex-wrap wrap
|
||||||
|
> nav
|
||||||
|
order -1
|
||||||
|
margin-bottom 1rem
|
||||||
|
width 100%
|
||||||
|
@media (min-width $md)
|
||||||
|
flex-wrap nowrap
|
||||||
|
> nav
|
||||||
|
order 0
|
||||||
|
margin-bottom 0
|
||||||
|
width unset
|
||||||
|
|
||||||
|
ul.pagination
|
||||||
|
margin 0
|
||||||
|
justify-content center
|
||||||
|
li.page-item
|
||||||
|
a.page-link
|
||||||
|
@extends a
|
||||||
|
.page-link
|
||||||
|
background-color $base02
|
||||||
|
border 1px solid rgba(0,0,0,.125)
|
||||||
|
|
||||||
|
.media
|
||||||
|
display flex
|
||||||
|
> .media-body
|
||||||
|
flex-grow 1
|
||||||
|
margin-left 3px
|
||||||
|
|
||||||
.card
|
.card
|
||||||
background-color $base02
|
background-color $base02
|
||||||
|
|
||||||
|
.card-footer
|
||||||
|
background-color $base01
|
||||||
|
&:nth-of-type(odd)
|
||||||
|
background-color $base02
|
||||||
|
|
||||||
&.h-card
|
&.h-card
|
||||||
max-width 25rem
|
max-width 25rem
|
||||||
position sticky
|
position sticky
|
||||||
|
|
36
lemoncurry/static/lemoncurry/css/theme.js
Normal file
36
lemoncurry/static/lemoncurry/css/theme.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
const {join} = require('path');
|
||||||
|
const {readFileSync} = require('fs');
|
||||||
|
|
||||||
|
const stylus = require('stylus');
|
||||||
|
const {safeLoad} = require('js-yaml');
|
||||||
|
|
||||||
|
const themePath = join(__dirname, '..', '..', 'base16-materialtheme-scheme', 'material-darker.yaml');
|
||||||
|
|
||||||
|
const breakpoints = {
|
||||||
|
sm: 576,
|
||||||
|
md: 768,
|
||||||
|
lg: 992,
|
||||||
|
xl: 1200,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = function() {
|
||||||
|
const theme = safeLoad(readFileSync(themePath, 'utf8'));
|
||||||
|
return function(style) {
|
||||||
|
for (let key in breakpoints) {
|
||||||
|
style.define('$' + key, new stylus.nodes.Unit(breakpoints[key], 'px'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const key = 'base0' + i.toString(16).toUpperCase();
|
||||||
|
const hex = theme[key];
|
||||||
|
const colour = new stylus.nodes.RGBA(
|
||||||
|
parseInt(hex.substr(0, 2), 16),
|
||||||
|
parseInt(hex.substr(2, 2), 16),
|
||||||
|
parseInt(hex.substr(4, 2), 16),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
style.define('$' + key, colour);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
|
@ -1 +0,0 @@
|
||||||
../../node_modules/openwebicons
|
|
|
@ -1,59 +0,0 @@
|
||||||
{% load compress favtags lemoncurry_tags meta static %}<!doctype html>
|
|
||||||
<html lang="en" class="{% block html_class %}{% endblock %}">
|
|
||||||
<head{% meta_namespaces %}>{% site_name as site_name %}{% request_uri request as uri %}
|
|
||||||
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ site_name }}</title>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
|
||||||
<link rel="canonical" href="{{ uri }}" />
|
|
||||||
|
|
||||||
<meta property="og:url" content="{{ uri }}" />
|
|
||||||
<meta property="og:title" content="{% firstof title site_name %}" />
|
|
||||||
{% include 'meta/meta.html' %}
|
|
||||||
{% placeFavicon %}
|
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css"
|
|
||||||
integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb" crossorigin="anonymous" />
|
|
||||||
{% compress css %}
|
|
||||||
<link rel="stylesheet" type="text/css" href={% static 'openwebicons/css/openwebicons.css' %} />
|
|
||||||
<link rel="stylesheet" type="text/stylus" href="{% static 'lemoncurry/css/layout.styl' %}" />
|
|
||||||
{% block styles %}{% endblock %}
|
|
||||||
{% endcompress %}
|
|
||||||
<script type="text/javascript" src="https://use.fontawesome.com/4fbab4ae27.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark">
|
|
||||||
<a class="navbar-brand" rel="home" href="{% url 'home:index' %}">{% site_name %}</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
|
|
||||||
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbar">
|
|
||||||
{% nav_left request %}
|
|
||||||
{% nav_right request %}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% if request.resolver_match.view_name %}
|
|
||||||
{% nav_crumbs request.resolver_match.view_name title %}
|
|
||||||
{% endif %}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{% block main %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p>all content licensed under <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">cc by-sa 4.0</a></p>
|
|
||||||
{% get_package_json as package %}
|
|
||||||
<p>powered by <a rel="code-repository" href="{{ package.repository }}/tree/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" crossorigin="anonymous"
|
|
||||||
integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" crossorigin="anonymous"
|
|
||||||
integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh"></script>
|
|
||||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js" crossorigin="anonymous"
|
|
||||||
integrity="sha384-alpBpkh1PFOepccYVYDB4do5UnbKysX5WZXm3XxPqe5iKTfUKjNkCk9SaVuEZflJ"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,10 +0,0 @@
|
||||||
{% if crumbs %}
|
|
||||||
<nav class="breadcrumbs" aria-label="breadcrumb" role="navigation">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
{% for crumb in crumbs %}
|
|
||||||
<li class="breadcrumb-item"><a href="{% url crumb.route %}">{{ crumb.label }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">{% firstof current.label title %}</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
|
@ -1,8 +0,0 @@
|
||||||
{% load activeurl %}{% activeurl %}<ul class="navbar-nav">
|
|
||||||
{% for item in items %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ item.url }}">
|
|
||||||
<i class="{{ item.icon }}"></i>
|
|
||||||
{{ item.label }}
|
|
||||||
</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>{% endactiveurl %}
|
|
12
lemoncurry/templatetags/absolute_url.py
Normal file
12
lemoncurry/templatetags/absolute_url.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from django import template
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
@register.filter(is_safe=True)
|
||||||
|
def absolute_url(url):
|
||||||
|
base = "https://" + Site.objects.get_current().domain
|
||||||
|
return urljoin(base, url)
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue