Compare commits

..

No commits in common. "main" and "v1.4.3" have entirely different histories.
main ... v1.4.3

178 changed files with 1432 additions and 5888 deletions

8
.gitignore vendored
View file

@ -1,3 +1,4 @@
# Created by https://www.gitignore.io/api/django
### Django ###
@ -14,10 +15,5 @@ media
# <django-project-name>/staticfiles/
# End of https://www.gitignore.io/api/django
/.pdm-python
/.env
/.mypy_cache
/.pytest_cache
/static
/node_modules
node_modules

2
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "lemoncurry/static/base16-materialtheme-scheme"]
path = lemoncurry/static/base16-materialtheme-scheme
url = https://github.com/ntpeters/base16-materialtheme-scheme
url = git://github.com/ntpeters/base16-materialtheme-scheme.git

21
LICENSE
View file

@ -1,21 +0,0 @@
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.

33
Pipfile Normal file
View file

@ -0,0 +1,33 @@
[[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 = "*"
"mf2py" = "*"
markdown = "*"
bleach = "*"
django-debug-toolbar = "*"
xrd = "*"
django-push = "*"
pyyaml = "*"
[dev-packages]

442
Pipfile.lock generated Normal file
View file

@ -0,0 +1,442 @@
{
"_meta": {
"hash": {
"sha256": "dc4aa7d01f78e031ce0e169c70b3056b8a436bfc3195126b6a801943ee39e2fb"
},
"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": {
"beautifulsoup4": {
"hashes": [
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11",
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76",
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89"
],
"version": "==4.6.0"
},
"bleach": {
"hashes": [
"sha256:7a316eac1eef1e98b9813636ebe05878aab1a658d2708047fb00fe2bcbc49f84",
"sha256:760a9368002180fb8a0f4ea48dc6275378e6f311c39d0236d7b904fca1f5ea0d"
],
"version": "==2.1.1"
},
"certifi": {
"hashes": [
"sha256:54a07c09c586b0e4c619f02a5e94e36619da8e2b053e20f594348c0611803704",
"sha256:40523d2efb60523e113b44602298f0960e900388cf3bb6043f645cf57ea9e3f5"
],
"version": "==2017.7.27.1"
},
"chardet": {
"hashes": [
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691",
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"
],
"version": "==3.0.4"
},
"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-debug-toolbar": {
"hashes": [
"sha256:0b4d2b1ac49a8bc5604518e8e20f56c1c08c0c4873336107e7c773c42537876b",
"sha256:e9f08b94f9423ac76cfc287151182bbaddbe7521ae32bef9f9863e2ac58018d3"
],
"version": "==1.8"
},
"django-favicon-plus": {
"hashes": [
"sha256:824da4ecd3501a157d9538ed1b0672227b2a8a5a3d940bd075ba5b5c636fb400"
],
"version": "==0.0.7"
},
"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-push": {
"hashes": [
"sha256:88d9d57326c9b5f8485510527c780418da1a3c0c485dbb283281d6bf2ef6598d",
"sha256:7101f2d66ff7fd932fe379c70f4d03f74955634fbecb187f03c3e82cd55b8274"
],
"version": "==1.0"
},
"django-redis-cache": {
"hashes": [
"sha256:2b4e3510bbcaf3d331975717afd6f15a36fbaf7622504599d2727dc99f90c64d"
],
"version": "==1.7.1"
},
"gunicorn": {
"hashes": [
"sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6",
"sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622"
],
"version": "==19.7.1"
},
"html5lib": {
"hashes": [
"sha256:08a3efc117a4fc8c82c3c6d10d6f58ae266428d57ed50258a1466d2cd88de745",
"sha256:0d5fd54d5b2b79b876007a70c033a4023577768d18022c15681c00561432a0f9"
],
"version": "==1.0b10"
},
"idna": {
"hashes": [
"sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4",
"sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f"
],
"version": "==2.6"
},
"isodate": {
"hashes": [
"sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81",
"sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8"
],
"version": "==0.6.0"
},
"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"
},
"markdown": {
"hashes": [
"sha256:73af797238b95768b3a9b6fe6270e250e5c09d988b8e5b223fd5efa4e06faf81"
],
"version": "==2.6.9"
},
"mf2py": {
"hashes": [
"sha256:021b675c0732bdbc3b8c153e1ee8e1f476c3d0ffc56a7908f9e9f90147c5fccd"
],
"version": "==1.0.5"
},
"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"
},
"pyyaml": {
"hashes": [
"sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
"sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
"sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269",
"sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
"sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
"sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
"sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
"sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
"sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
"sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
"sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
"sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
"sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
"sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7"
],
"version": "==3.12"
},
"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"
},
"requests": {
"hashes": [
"sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
"sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
],
"version": "==2.18.4"
},
"rjsmin": {
"hashes": [
"sha256:dd9591aa73500b08b7db24367f8d32c6470021f39d5ab4e50c7c02e4401386f1"
],
"version": "==1.0.12"
},
"six": {
"hashes": [
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb",
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9"
],
"version": "==1.11.0"
},
"sqlparse": {
"hashes": [
"sha256:d9cf190f51cbb26da0412247dfe4fb5f4098edb73db84e02f9fc21fdca31fed4",
"sha256:ce028444cfab83be538752a2ffdb56bc417b7784ff35bb9a3062413717807dec"
],
"version": "==0.2.4"
},
"unidecode": {
"hashes": [
"sha256:61f807220eda0203a774a09f84b4304a3f93b5944110cc132af29ddb81366883",
"sha256:280a6ab88e1f2eb5af79edff450021a0d3f0448952847cd79677e55e58bad051"
],
"version": "==0.4.21"
},
"urllib3": {
"hashes": [
"sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
"sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
],
"version": "==1.22"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
],
"version": "==0.5.1"
},
"xrd": {
"hashes": [
"sha256:51d01f732b5b5b7983c5179ffaed864408d95a667b3a6630fe27aa7528274089"
],
"version": "==0.1"
}
},
"develop": {}
}

View file

@ -1,90 +0,0 @@
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
```

View file

@ -0,0 +1 @@
default_app_config = 'entries.apps.EntriesConfig'

View file

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Cat, Entry, Syndication
from .models import Entry, Syndication
class SyndicationInline(admin.TabularInline):
@ -8,11 +8,9 @@ class SyndicationInline(admin.TabularInline):
class EntryAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("title", "id", "kind", "created")
list_filter = ("kind",)
inlines = (SyndicationInline,)
inlines = (
SyndicationInline,
)
admin.site.register(Cat)
admin.site.register(Entry, EntryAdmin)

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class EntriesConfig(AppConfig):
name = "entries"
name = 'entries'

View file

@ -1,34 +0,0 @@
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

View file

@ -1,15 +0,0 @@
{% 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 %}

View file

@ -1,55 +0,0 @@
{% 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 %}

View file

@ -1,20 +0,0 @@
{% 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 %}

View file

@ -1,26 +0,0 @@
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)

View file

@ -1,88 +1,39 @@
from django.urls import reverse
class Entry:
def __init__(self, id, plural, icon, on_home=True, slug=False):
def __init__(self, id, plural, icon, slug=False):
self.id = id
self.plural = plural
self.icon = icon
self.on_home = on_home
self.slug = slug
@property
def index(self):
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)
return self.plural + '_index'
@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})
return self.plural + '_entry'
Note = Entry(
id="note",
icon="fas fa-paper-plane",
plural="notes",
id='note',
icon='fa fa-paper-plane',
plural='notes',
)
Article = Entry(
id="article",
icon="fas fa-file-alt",
plural="articles",
id='article',
icon='fa fa-file-text',
plural='articles',
slug=True,
)
Photo = Entry(
id="photo",
icon="fas fa-camera",
plural="photos",
)
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",
id='photo',
icon='fa fa-camera',
plural='photos',
)
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_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

View file

@ -8,6 +8,7 @@ import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
@ -16,41 +17,20 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name="Entry",
name='Entry',
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,
),
),
("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,
),
),
('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)),
('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={
"verbose_name_plural": "entries",
"ordering": ["-published"],
'verbose_name_plural': 'entries',
'ordering': ['-published'],
},
),
]

View file

@ -7,42 +7,23 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("users", "0005_auto_20171023_0158"),
("entries", "0001_initial"),
('users', '0005_auto_20171023_0158'),
('entries', '0001_initial'),
]
operations = [
migrations.CreateModel(
name="Syndication",
name='Syndication',
fields=[
(
"id",
models.AutoField(
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"
),
),
('id', models.AutoField(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={
"ordering": ["profile"],
'ordering': ['profile'],
},
),
]

View file

@ -6,13 +6,14 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("entries", "0002_syndication"),
('entries', '0002_syndication'),
]
operations = [
migrations.RemoveField(
model_name="entry",
name="summary",
model_name='entry',
name='summary',
),
]

View file

@ -8,28 +8,20 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("entries", "0003_remove_entry_summary"),
('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,
),
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,
),
model_name='entry',
name='kind',
field=models.CharField(choices=[('note', 'note'), ('article', 'article')], db_index=True, default='note', max_length=30),
),
]

View file

@ -6,24 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("entries", "0004_auto_20171027_0846"),
('entries', '0004_auto_20171027_0846'),
]
operations = [
migrations.AddField(
model_name="entry",
name="photo",
field=models.ImageField(blank=True, upload_to=""),
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,
),
model_name='entry',
name='kind',
field=models.CharField(choices=[('note', 'note'), ('article', 'article'), ('photo', 'photo')], db_index=True, default='note', max_length=30),
),
]

View file

@ -1,48 +0,0 @@
# -*- 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",
),
),
]

View file

@ -1,36 +0,0 @@
# -*- 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,
),
),
]

View file

@ -1,29 +0,0 @@
# -*- 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),
),
]

View file

@ -1,33 +0,0 @@
# -*- 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",),
},
),
]

View file

@ -1,19 +0,0 @@
# -*- 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"),
),
]

View file

@ -1,25 +0,0 @@
# -*- 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",
),
]

View file

@ -1,29 +0,0 @@
# 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,
),
]

View file

@ -1,22 +0,0 @@
# 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,
),
),
]

View file

@ -1,114 +1,71 @@
from computed_property import ComputedCharField
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site as DjangoSite
from django.db import models
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 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 lemoncurry import requests, utils
ENTRY_KINDS = [(k.id, k.id) for k in kinds.all]
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")
return qs.select_related('author').prefetch_related('syndications')
class Entry(ModelMeta, TimeStampedModel):
class Entry(ModelMeta, models.Model):
objects = EntryManager()
kind = models.CharField(
max_length=30, choices=ENTRY_KINDS, db_index=True, default=ENTRY_KINDS[0][0]
max_length=30,
choices=ENTRY_KINDS,
db_index=True,
default=ENTRY_KINDS[0][0]
)
name = models.CharField(max_length=100, blank=True)
photo = models.ImageField(blank=True)
content = models.TextField()
cats = models.ManyToManyField(Cat, related_name="entries")
in_reply_to = models.CharField(max_length=255, blank=True)
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",
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
published = models.DateTimeField()
updated = models.DateTimeField()
_metadata = {
"description": "excerpt",
"image": "image_url",
"twitter_creator": "twitter_creator",
"og_profile_id": "og_profile_id",
'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="")
return shorten(self.paragraphs[0], width=100, placeholder='')
@property
def excerpt(self):
try:
return utils.to_plain(self.paragraphs[0 if self.name else 1])
return self.paragraphs[0 if self.name else 1]
except IndexError:
return " "
return ' '
@property
def paragraphs(self):
lines = self.content.splitlines()
return ["\n".join(para) for k, para in groupby(lines, key=bool) if k]
return [
"\n".join(para) for k, para in groupby(lines, key=bool) if k
]
@property
def twitter_creator(self):
@ -123,104 +80,43 @@ class Entry(ModelMeta, TimeStampedModel):
return self.photo.url if self.photo else self.author.avatar_url
def __str__(self):
return "{0} {1}: {2}".format(self.kind, self.id, self.title)
return '{0} {1}: {2}'.format(self.kind, self.id, self.title)
def get_absolute_url(self):
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}
return self.url
@property
def url(self):
kind = kinds.from_id[self.kind]
args = [kind, self.id]
args = [self.id]
if kind.slug:
args.append(self.slug)
return reverse("entries:entry", args=args)
@property
def short_url(self):
return short_url(self)
return reverse('entries:' + kind.entry, args=args)
@property
def slug(self):
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:
verbose_name_plural = "entries"
ordering = ["-created"]
verbose_name_plural = 'entries'
ordering = ['-published']
class SyndicationManager(models.Manager):
def get_queryset(self):
qs = super(SyndicationManager, self).get_queryset()
return qs.select_related('profile__site')
class Syndication(models.Model):
objects = SyndicationManager()
entry = models.ForeignKey(
Entry, related_name="syndications", on_delete=models.CASCADE
Entry,
related_name='syndications',
on_delete=models.CASCADE
)
profile = models.ForeignKey(Profile, on_delete=models.CASCADE)
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:
ordering = ["domain"]
ordering = ['profile']

View file

@ -1,32 +0,0 @@
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))

View file

@ -8,6 +8,6 @@ class EntriesSitemap(sitemaps.Sitemap):
def lastmod(self, entry):
return entry.updated
def location(self, entry):
return entry.url

View file

@ -1,5 +1,4 @@
ol.entries, div.entry
max-width 100%
ol.entries
display flex
margin-bottom 0
flex 1
@ -10,52 +9,29 @@ ol.entries, div.entry
&:last-child
margin-bottom 0
.h-entry.media
> aside.info
display flex
flex-direction column
align-items flex-start
font-size 0.8rem
margin-right 0.4rem
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
.card.h-entry
.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
.card-footer
display flex
justify-content space-evenly
flex-wrap wrap
> *
margin-right 1rem
&:last-child
margin-right 0
.h-card > img
height 1em
vertical-align baseline

View file

@ -0,0 +1,12 @@
{% 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 %}

View file

@ -0,0 +1,31 @@
{% load humanize markdown %}<article class="card h-entry">
{% if entry.photo %}<img class="card-img-top u-photo" src="{{ entry.photo.url }}" />{% endif %}
<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>

View file

@ -0,0 +1,15 @@
{% 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 entries">
{% for entry in entries %}
<li>
{% include 'entries/h-entry.html' %}
</li>
{% endfor %}
</ol>
{% endblock %}

View file

@ -1,29 +0,0 @@
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"

View file

@ -1,58 +1,26 @@
from django.urls import path, register_converter, reverse
from . import kinds
from .views import feeds, lists, perma
from django.conf.urls import url
from . import kinds, views
from lemoncurry import breadcrumbs as crumbs
register_converter(kinds.EntryKindConverter, "kind")
def to_pat(*args):
return "^{0}$".format("".join(args))
return '^{0}$'.format(''.join(args))
def prefix(route):
return app_name + ":" + route
return app_name + ':' + route
id = r"/(?P<id>\d+)"
kind = r"(?P<kind>{0})".format("|".join(k.plural for k in kinds.all))
page = r"(?:/page/(?P<page>\d+))?"
slug = r"/(?P<slug>[^/]+)"
app_name = 'entries'
urlpatterns = []
for k in kinds.all:
kind = k.plural
id = r'/(?P<id>\d+)'
slug = r'(?:/(?P<slug>.+))?'
urlpatterns += (
url(to_pat(kind), views.index, name=k.index, kwargs={'kind': k}),
url(to_pat(kind, id, slug), views.entry, name=k.entry),
)
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"))
crumbs.add(prefix(k.index), label=k.plural, parent='home:index')
crumbs.add(prefix(k.entry), parent=prefix(k.index))

21
entries/views.py Normal file
View file

@ -0,0 +1,21 @@
from django.shortcuts import redirect, 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)
if request.path != entry.url:
return redirect(entry.url, permanent=True)
return render(request, 'entries/entry.html', {
'entry': entry,
'title': entry.title,
'meta': entry.as_meta(request)
})

View file

@ -1,95 +0,0 @@
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

View file

@ -1,36 +0,0 @@
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,
}

View file

@ -1,14 +0,0 @@
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,
}

View file

@ -1,5 +0,0 @@
import multiprocessing
proc_name = "lemoncurry"
worker_class = "gevent"
workers = multiprocessing.cpu_count() * 2 + 1

View file

@ -1,107 +0,0 @@
{% 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 %}

View file

@ -3,10 +3,10 @@ from django.urls import reverse
class HomeSitemap(sitemaps.Sitemap):
changefreq = "daily"
changefreq = 'daily'
def items(self):
return ("home:index",)
return ('home:index',)
def location(self, item):
return reverse(item)

View file

@ -1,3 +1,8 @@
$sm = 576px
$md = 768px
$lg = 992px
$xl = 1200px
main
flex-direction column
align-items center
@ -5,18 +10,11 @@ main
margin-bottom 1rem
.p-note > :last-child
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)
flex-direction row-reverse
align-items unset
aside.author
margin-bottom 0
ol.entries
margin-right 2rem
justify-content flex-start
margin-right 1rem

View file

@ -0,0 +1,48 @@
{% extends 'lemoncurry/layout.html' %}
{% load jsonify markdown 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 }}</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>
<script type="application/ld+json">{{ person | jsonify }}</script>
</aside>
<ol class="list-unstyled entries">
{% for entry in entries %}
<li>
{% include 'entries/h-entry.html' %}
</li>
{% endfor %}
</ol>
{% endblock %}

View file

@ -1,10 +1,8 @@
from django.urls import path
from django.conf.urls import url
from . import views
app_name = "home"
app_name = 'home'
urlpatterns = [
path("", views.index, name="index"),
path("page/<int:page>", views.index, name="index"),
path("robots.txt", views.robots, name="robots.txt"),
url(r'^$', views.index, name='index'),
]

View file

@ -1,38 +1,31 @@
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 django.shortcuts import get_object_or_404, render
from users.models import User
from urllib.parse import urljoin
from entries import kinds, pagination
from lemoncurry import breadcrumbs, utils
breadcrumbs.add("home:index", "home")
breadcrumbs.add('home:index', 'home')
@render_to("home/index.html")
def index(request, page=None):
def url(page):
kwargs = {"page": page} if page != 1 else {}
return reverse("home:index", kwargs=kwargs)
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 index(request):
query = User.objects.prefetch_related('entries', 'profiles', 'keys')
user = get_object_or_404(query, pk=1)
uri = utils.uri(request)
person = {
'@context': 'http://schema.org',
'@type': 'Person',
'@id': uri,
'url': uri,
'name': '{0} {1}'.format(user.first_name, user.last_name),
'email': user.email,
'image': user.avatar.url,
'givenName': user.first_name,
'familyName': user.last_name,
'sameAs': [profile.url for profile in user.profiles.all()]
}
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")
entries = user.entries.all()
return render(request, 'home/index.html', {
'user': user,
'person': person,
'entries': entries,
'meta': user.as_meta(request),
})

View file

@ -1,83 +0,0 @@
{% 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 %}

View file

@ -1,61 +0,0 @@
{% 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 %}

View file

@ -1,68 +0,0 @@
{% 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 %}

View file

@ -1,42 +0,0 @@
# -*- 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)),
],
),
]

View file

@ -1,17 +0,0 @@
# -*- 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",
),
]

View file

@ -1,120 +0,0 @@
# 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,
},
),
]

View file

@ -1,62 +0,0 @@
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

View file

@ -2,12 +2,3 @@
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

View file

@ -0,0 +1,36 @@
{% extends 'lemoncurry/layout.html' %}
{% load static %}
{% 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' %}">
<h4 class="card-header h-x-app">
{% if app %}<img class="u-logo p-name" src="{{ app.logo | first }}" alt="{{ app.name | first }}" />{% endif %}
sign in to
{% if app %}{{ app.name | first }}{% endif %}
{% if app %}({% endif %}<a class="u-url code{% if not app %} p-name{% endif %}" href="{{ params.client_id }}">{{ params.client_id }}</a>{% if app %}){% endif %}?
</h4>
<div class="card-body">
<p class="card-text">do you want to confirm your identity, <a class="code" href="{{ params.me }}">{{ params.me }}</a>, with this app?</p>
<p class="card-text"><small>you will be redirected to <a class="code" href="{{ params.redirect_uri }}">{{ params.redirect_uri }}</a> after authorising this app</small></p>
</div>
<div class="card-footer">
<button class="btn btn-success" type="submit">
<i class="fa fa-check"></i>
approve
</button>
</div>
{% csrf_token %}
{% if params.state %}
<input name="state" type="hidden" value="{{ params.state }}" />
{% endif %}
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% 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 %}

View file

@ -0,0 +1,6 @@
<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>

View file

@ -0,0 +1,8 @@
from django import template
register = template.Library()
@register.inclusion_tag('lemonauth/tags/form_field.html')
def form_field(field):
return {'field': field}

View file

@ -1,46 +0,0 @@
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

View file

@ -1,17 +1,9 @@
from django.urls import path
from django.conf.urls import url
from . import views
app_name = "lemonauth"
app_name = 'lemonauth'
urlpatterns = [
path("login", views.login, name="login"),
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",
),
url('^login$', views.login, name='login'),
url('^logout$', views.logout, name='logout'),
url('^indie$', views.IndieView.as_view(), name='indie'),
]

View file

@ -1,5 +1,3 @@
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
from .indie import IndieView

View file

@ -1,125 +1,59 @@
from annoying.decorators import render_to
import mf2py
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import redirect
from django.http import HttpResponseForbidden, HttpResponseBadRequest
from django.shortcuts import render
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 lemoncurry import breadcrumbs, utils
from urllib.parse import urljoin
from .. import tokens
from ..models import IndieAuthCode
breadcrumbs.add("lemonauth:indie", parent="home:index")
breadcrumbs.add('lemonauth:indie', label='indieauth', 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")
template_name = 'lemonauth/indie.html'
required_params = ('me', '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")
params = request.GET
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)
return HttpResponseBadRequest(
'parameter {0} is required'.format(param),
content_type='text/plain',
)
redirect_uri = urljoin(params["client_id"], params["redirect_uri"])
me = params['me']
if me[-1] == '/':
me = me[:-1]
type = params["response_type"]
if type not in ("id", "code"):
return utils.bad_req("unknown response_type: {0}".format(type))
origin = utils.origin(request)
user = urljoin(origin, request.user.url)
if user not in (me, me + '/'):
return HttpResponseForbidden(
'you are logged in but not as {0}'.format(me),
content_type='text/plain',
)
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
client = mf2py.Parser(url=params['client_id'], html_parser='html5lib')
rels = (client.to_dict()['rel-urls']
.get(params['redirect_uri'], {})
.get('rels', ()))
if 'redirect_uri' not in rels:
return HttpResponseBadRequest(
'your redirect_uri is not published on your client_id page',
content_type='text/plain'
)
try:
app = client.to_dict(filter_by_type="h-x-app")[0]["properties"]
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))
return render(request, self.template_name, {
'app': app,
'params': params,
'title': 'indieauth',
})

View file

@ -2,11 +2,11 @@ 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")
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",
extra_context={'title': 'log in'},
template_name='lemonauth/login.html',
redirect_authenticated_user=True,
)

View file

@ -1,48 +0,0 @@
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,
},
)

View file

@ -1,2 +0,0 @@
from .list import TokensListView
from .revoke import TokensRevokeView

View file

@ -1,42 +0,0 @@
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

View file

@ -1,9 +0,0 @@
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)

Binary file not shown.

View file

@ -1,5 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 948 B

View file

@ -1,5 +0,0 @@
from django.contrib import admin
from otp_agents.decorators import otp_required
admin.site.login = otp_required(admin.site.login, accept_trusted_agent=True)

View file

@ -1,50 +1,15 @@
from django.urls import reverse
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):
if not isinstance(route, Crumb):
route = Crumb(route, label, parent)
breadcrumbs[route.route] = route
breadcrumbs[route] = {'label': label, 'route': route, 'parent': parent}
def find(match):
def find(route):
crumbs = []
route = match.view_name
while route:
crumb = breadcrumbs[route]
crumb.use_match(match)
crumbs.append(crumb)
route = crumb.parent
route = crumb['parent']
crumbs.reverse()
return crumbs

View file

@ -1,7 +0,0 @@
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)

View file

@ -1,43 +0,0 @@
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

View file

@ -1,9 +0,0 @@
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)

View file

@ -1,23 +0,0 @@
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

View file

@ -1,165 +0,0 @@
<!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>

View file

@ -1,18 +0,0 @@
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))

View file

@ -1,16 +0,0 @@
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

View file

@ -1,24 +0,0 @@
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)

View file

@ -1,48 +0,0 @@
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")

View file

@ -10,29 +10,25 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.11/ref/settings/
"""
from os import environ, path
from typing import List
import os
import re
APPEND_SLASH = False
ADMINS = [
("dani", "dani@00dani.me"),
]
BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
# 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!
DEBUG = True
ALLOWED_HOSTS: List[str] = []
INTERNAL_IPS = ["127.0.0.1", "::1"]
ALLOWED_HOSTS = []
INTERNAL_IPS = ['127.0.0.1', '::1']
# Settings to tighten up security - these can safely be on in dev mode too,
# since I dev using a local HTTPS server.
@ -50,7 +46,7 @@ CSRF_COOKIE_SECURE = True
# Miscellanous headers to protect against attacks.
SECURE_CONTENT_TYPE_NOSNIFF = 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
# over insecure HTTP anyway. Just for completeness!
@ -58,104 +54,79 @@ SECURE_SSL_REDIRECT = True
# We run behind nginx, so we need nginx to tell us whether we're using HTTPS or
# not.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Application definition
INSTALLED_APPS = [
"lemoncurry",
"pyup_django",
"django.contrib.admin",
"django.contrib.admindocs",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.humanize",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.sitemaps",
"django.contrib.messages",
"django.contrib.staticfiles",
"annoying",
"compressor",
"computed_property",
"corsheaders",
"debug_toolbar",
"django_activeurl",
"django_agent_trust",
"django_extensions",
"django_otp",
"django_otp.plugins.otp_static",
"django_otp.plugins.otp_totp",
"django_rq",
"meta",
"entries",
"home",
"lemonauth",
"lemonshort",
"micropub",
"users",
"webmention",
"wellknowns",
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.humanize',
'django.contrib.sessions',
'django.contrib.sitemaps',
'django.contrib.messages',
'django.contrib.staticfiles',
'compressor',
'debug_toolbar',
'django_activeurl',
'django_agent_trust',
'django_otp',
'django_otp.plugins.otp_totp',
'favicon',
'meta',
'lemoncurry',
'entries',
'home',
'lemonauth',
'users',
'wellknowns',
]
MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.http.ConditionalGetMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.admindocs.middleware.XViewMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"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",
'debug_toolbar.middleware.DebugToolbarMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_otp.middleware.OTPMiddleware',
'django_agent_trust.middleware.AgentMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
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 = [
{
"BACKEND": "django.template.backends.jinja2.Jinja2",
"APP_DIRS": True,
"OPTIONS": {
"environment": "lemoncurry.jinja2.environment",
},
},
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"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",
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'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
# https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-CACHES
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6380/0",
"KEY_PREFIX": "lemoncurry",
"OPTIONS": {
"SERIALIZER": "lemoncurry.msgpack.MSGPackModernSerializer",
},
"VERSION": 2,
'default': {
'BACKEND': 'redis_cache.RedisCache',
'LOCATION': '127.0.0.1:6380',
}
}
@ -163,51 +134,44 @@ CACHES = {
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": environ.get("POSTGRES_DB", "lemoncurry"),
"USER": environ.get("POSTGRES_USER"),
"PASSWORD": environ.get("POSTGRES_PASSWORD"),
"HOST": environ.get("POSTGRES_HOST", "localhost"),
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
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",
]
AUTH_USER_MODEL = 'users.User'
# Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
PW_VALIDATOR_MODULE = "django.contrib.auth.password_validation"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": PW_VALIDATOR_MODULE + ".UserAttributeSimilarityValidator"},
{"NAME": PW_VALIDATOR_MODULE + ".MinimumLengthValidator"},
{"NAME": PW_VALIDATOR_MODULE + ".CommonPasswordValidator"},
{"NAME": PW_VALIDATOR_MODULE + ".NumericPasswordValidator"},
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'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_REDIRECT_URL = "home:index"
LOGIN_URL = 'lemonauth:login'
LOGIN_REDIRECT_URL = 'home:index'
LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL
# Internationalization
# 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
@ -219,51 +183,40 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = path.join(BASE_DIR, "static")
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
"compressor.finders.CompressorFinder",
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'compressor.finders.CompressorFinder',
)
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
COMPRESS_PRECOMPILERS = (
("text/stylus", "npx stylus -u ./lemoncurry/static/lemoncurry/css/theme"),
('text/stylus', './node_modules/.bin/stylus {infile} -u ./lemoncurry/static/lemoncurry/css/theme -o {outfile}'),
)
MEDIA_URL = STATIC_URL + "media/"
MEDIA_ROOT = path.join(STATIC_ROOT, "media")
MEDIA_URL = STATIC_URL + 'media/'
MEDIA_ROOT = os.path.join(STATIC_ROOT, 'media')
# django-contrib-sites
# https://docs.djangoproject.com/en/dev/ref/contrib/sites/
SITE_ID = 1
# Settings specific to lemoncurry
LEMONCURRY_SITE_NAME = '00dani.me'
# django-agent-trust
# https://pythonhosted.org/django-agent-trust/
AGENT_COOKIE_SECURE = True
# django-cors-headers
CORS_ORIGIN_ALLOW_ALL = True
CORS_URLS_REGEX = r"^/(?!admin|auth/(?:login|logout|indie)).*$"
# lemonshort
SHORT_BASE_URL = "/s/"
SHORTEN_MODELS = {
"e": "entries.entry",
}
# django-otp
# https://django-otp-official.readthedocs.io/en/latest/overview.html
OTP_TOTP_ISSUER = LEMONCURRY_SITE_NAME
# django-meta
# https://django-meta.readthedocs.io/en/latest/settings.html
META_SITE_PROTOCOL = "https"
META_USE_SITES = True
META_SITE_PROTOCOL = 'https'
META_SITE_NAME = LEMONCURRY_SITE_NAME
META_USE_OG_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"}}
PUSH_HUB = 'https://00dani.superfeedr.com/'

View file

@ -1,7 +1,5 @@
from .base import *
ALLOWED_HOSTS = ["*"]
META_SITE_DOMAIN = "00dani.lo"
META_FB_APPID = "142105433189339"
STATIC_URL = "https://static.00dani.lo/"
MEDIA_URL = "https://media.00dani.lo/"
ALLOWED_HOSTS = ['*']
META_SITE_DOMAIN = '00dani.dev'
META_FB_APPID = '142105433189339'

View file

@ -1,22 +1,20 @@
from os import environ
from os.path import join
from .base import *
from .base import BASE_DIR, DATABASES
from .base import DATABASES
ALLOWED_HOSTS = ["00dani.me"]
ALLOWED_HOSTS = ['00dani.me']
DEBUG = False
SECRET_KEY = environ["DJANGO_SECRET_KEY"]
SERVER_EMAIL = "lemoncurry@00dani.me"
SECRET_KEY = environ['DJANGO_SECRET_KEY']
# Authenticate as an app-specific Postgres user in production.
DATABASES["default"]["USER"] = "lemoncurry"
# Use Postgres instead of SQLite in production.
DATABASES['default'] = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'lemoncurry',
'USER': 'lemoncurry',
}
SHORT_BASE_URL = "https://nya.as/"
STATIC_ROOT = join(BASE_DIR, "..", "static")
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"
STATIC_URL = 'https://cdn.00dani.me/'
MEDIA_URL = STATIC_URL + 'media/'
META_SITE_DOMAIN = '00dani.me'
META_FB_APPID = '145311792869199'

View file

@ -1,8 +0,0 @@
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")

View file

@ -1,22 +1,19 @@
$monokai_bg = #272822
html
background-color $base00
background-color $base01
a
color $base0D
text-decoration none
&:hover
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
code, .code
padding .2rem .4rem
font-size 90%
color $base0A
background-color $monokai_bg
background-color $base00
border-radius .25rem
.form-control, .form-control:focus
@ -24,27 +21,17 @@ code, pre, .code, .pre
border-color $base00
color $base07
.list-group-item
background-color $base03
[class^="openwebicons-"], [class*=" openwebicons-"]
&::before
text-decoration none
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
display flex
flex-direction column
min-height 100vh
background-color transparent
background-color $base00
color $base07
> header
> .navbar
@ -65,59 +52,18 @@ body
> main
padding 2rem 1rem
width 100%
margin 2rem
flex 1
display flex
> footer
display flex
justify-content space-evenly
align-items center
margin 1rem
margin-top 0
margin auto 1rem
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
background-color $base02
.card-footer
background-color $base01
&:nth-of-type(odd)
background-color $base02
&.h-card
max-width 25rem
position sticky

View file

@ -6,31 +6,13 @@ 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);
style.define('$' + key, new stylus.nodes.Literal('#' + theme[key]));
}
};
};

View file

@ -0,0 +1 @@
../../node_modules/openwebicons

View file

@ -0,0 +1,64 @@
{% 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 %}{% request_origin request as origin %}
<base href="{{ origin }}" />
<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 }}" />
<link rel="hub" href="{% get_push_hub %}" />
<link rel="self" href="{{ uri }}" />
<link rel="manifest" href="{% url 'wellknowns:manifest' %}" />
<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 %}
{% 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>

View file

@ -0,0 +1,11 @@
{% load jsonify %}{% 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>
<script type="application/ld+json">{{ breadcrumb_list | jsonify }}</script>
</nav>
{% endif %}

View file

@ -0,0 +1,8 @@
{% 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 %}

View file

@ -1,12 +0,0 @@
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)

View file

@ -5,13 +5,10 @@ from django.utils.safestring import mark_safe
from bleach.sanitizer import Cleaner, ALLOWED_TAGS
from bleach.linkifier import LinkifyFilter
tags = ["cite", "code", "details", "p", "pre", "img", "span", "summary"]
tags = ['code', 'p']
tags.extend(ALLOWED_TAGS)
attributes = {
"a": ["href", "title", "class"],
"details": ["open"],
"img": ["alt", "src", "title"],
"span": ["class"],
'a': ('href', 'title', 'class')
}
register = template.Library()

View file

@ -1,10 +0,0 @@
from django import template
from urllib.parse import urlparse
register = template.Library()
@register.filter
def friendly_url(url):
(scheme, netloc, path, params, q, fragment) = urlparse(url)
return netloc + path

View file

@ -11,5 +11,5 @@ register = template.Library()
@register.filter
def jsonify(value):
if isinstance(value, QuerySet):
return mark_safe(serialize("json", value))
return mark_safe(serialize('json', value))
return mark_safe(json.dumps(value, cls=DjangoJSONEncoder))

Some files were not shown because too many files have changed in this diff Show more