Compare commits

..

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

179 changed files with 2124 additions and 5196 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

4
Forwardfile Normal file
View file

@ -0,0 +1,4 @@
# vim: set ft=yaml :
host: 00dani.dev
port: 443
cname: dev.00dani.me

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017 - 2024 Danielle McLean
Copyright (c) 2017 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

49
Pipfile Normal file
View file

@ -0,0 +1,49 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[requires]
python_version = '3.6'
[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 = "*"
django-annoying = "*"
django-shorturls = "*"
accept-types = "*"
django-analytical = "*"
django-model-utils = "*"
python-jose = "*"
django-rq = "*"
ronkyuu = "*"
cachecontrol = "*"
hiredis = "*"
"mf2util" = "*"
[dev-packages]

565
Pipfile.lock generated Normal file
View file

@ -0,0 +1,565 @@
{
"_meta": {
"hash": {
"sha256": "89f67ee2a974377da512befef558bc9f3a6508ef6a7e8c69d28cfb3c4e7f64b8"
},
"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.3.0",
"platform_system": "Darwin",
"platform_version": "Darwin Kernel Version 17.3.0: Sun Oct 29 19:57:25 PDT 2017; root:xnu-4570.30.85~19/RELEASE_X86_64",
"python_full_version": "3.6.3",
"python_version": "3.6",
"sys_platform": "darwin"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.python.org/simple",
"verify_ssl": true
}
]
},
"default": {
"accept-types": {
"hashes": [
"sha256:9ae86512bf3a3eaad6a2793617a34eb15b384593e6c28697bef9b15ac237017a"
],
"version": "==0.3.0"
},
"beautifulsoup4": {
"hashes": [
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11",
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76",
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89"
],
"version": "==4.6.0"
},
"bleach": {
"hashes": [
"sha256:7a316eac1eef1e98b9813636ebe05878aab1a658d2708047fb00fe2bcbc49f84",
"sha256:760a9368002180fb8a0f4ea48dc6275378e6f311c39d0236d7b904fca1f5ea0d"
],
"version": "==2.1.1"
},
"cachecontrol": {
"hashes": [
"sha256:a9fc50e216c7c101f4ec4312f012dea501c2859cb256c7a68186a172ab71f632"
],
"version": "==0.12.3"
},
"certifi": {
"hashes": [
"sha256:244be0d93b71e93fc0a0a479862051414d0e00e16435707e5bf5000f92e04694",
"sha256:5ec74291ca1136b40f0379e1128ff80e866597e4e2c1e755739a913bbc3613c0"
],
"version": "==2017.11.5"
},
"chardet": {
"hashes": [
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691",
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
],
"version": "==6.7"
},
"django": {
"hashes": [
"sha256:75ce405d60f092f6adf904058d023eeea0e6d380f8d9c36134bac73da736023d",
"sha256:8918e392530d8fc6965a56af6504229e7924c27265893f3949aa0529cd1d4b99"
],
"version": "==1.11.7"
},
"django-activeurl": {
"hashes": [
"sha256:7ebc4a34f91e18f29eb02bfac503057d69b4e1b6f9e8dd1297798387876e54da"
],
"version": "==0.1.11"
},
"django-agent-trust": {
"hashes": [
"sha256:962653c4eeab63715a6efd27649a00302082c9fb1d931e3df959e57605eb8c25",
"sha256:b262db89410b9901c32f27f7dd6697bf61bfcfdc01651fe40699d0b81ebc4fcc"
],
"version": "==0.3.0"
},
"django-analytical": {
"hashes": [
"sha256:9060a34a5d1882021a399a52c6af993f790deb7f9abf7dfbb84fe3033e0cb134",
"sha256:e7c8e026d0a10d41cffa06163fcee24870597cedba19d5fa2609d35dec6463fc"
],
"version": "==2.3.0"
},
"django-annoying": {
"hashes": [
"sha256:07267defd06e37ad287053de4ea8c83ab4aae8114628830b7c91b70b63494572",
"sha256:1884f1452e0b9542c7db2ed7d8cc728b9386bc20af7c8e03607fad31a28b7ead",
"sha256:5321e6e3481fc455818b935824d9cd78669a9bb6a964baf816d191745c8617a6"
],
"version": "==0.10.3"
},
"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:4af2a4e1e932dadbda197b18585962d4fc20172b4e5a479490bc659fe998864d",
"sha256:d9ea75659f76d8f1e3eb8f390b47fc5bad0908d949c34a8a3c4c87978eb40a0f"
],
"version": "==1.9.1"
},
"django-favicon-plus": {
"hashes": [
"sha256:824da4ecd3501a157d9538ed1b0672227b2a8a5a3d940bd075ba5b5c636fb400"
],
"version": "==0.0.7"
},
"django-meta": {
"hashes": [
"sha256:2a5b8d95099f69fb9736630c4fbf4fcc2972a1fcd9c708a5bb72dde22e84d8dd",
"sha256:4a7dc51c40fd6a097825040af29ee0e049f1fce29b006e39f266f80ba988bac6"
],
"version": "==1.4"
},
"django-model-utils": {
"hashes": [
"sha256:60ead1ba50e1353f38bde12ab8b4a80b6a0f825a8e53c348fe259548cbd1a312"
],
"version": "==3.0.0"
},
"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"
},
"django-rq": {
"hashes": [
"sha256:7cd517c3e243603c8e5b24bfc4797d57d751a4ba1b471124157e9822a83335a2",
"sha256:368051f0a3ef08670dd186ea0202cbc7644e199fd48fd31eace2e637e915a7f7"
],
"version": "==0.9.6"
},
"django-shorturls": {
"hashes": [
"sha256:382ff617b36fea04981b30457377a46f2034d0940a40a20c6f637c55ea0bda93"
],
"version": "==2.0.0"
},
"ecdsa": {
"hashes": [
"sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c",
"sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa"
],
"version": "==0.13"
},
"future": {
"hashes": [
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
],
"version": "==0.16.0"
},
"gunicorn": {
"hashes": [
"sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6",
"sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622"
],
"version": "==19.7.1"
},
"hiredis": {
"hashes": [
"sha256:ca958e13128e49674aa4a96f02746f5de5973f39b57297b84d59fd44d314d5b5"
],
"version": "==0.2.0"
},
"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:41f59cbdab232f11680d5d4dec9f2e6782fd24d78e37ee833447702e34e675f4",
"sha256:e7e41d383f19bab9d57f5f3b18d158655bcd682e7e723f441b9e183e1e35a6b5",
"sha256:155521c337acecf8202091cff85bb9f709f238130ebadf04280fb1db11f5ad8b",
"sha256:d2c985d2460b81c6ca5feb8b86f1bc594ad59405d0bdf68626b85852b701553c",
"sha256:950e63387514aa1b881eba5ac6cb2ec51a118b3dafe99dd80ca19d8fb0142f30",
"sha256:470d7ce41e8047208ba1a376560bad17f1468df1f3097bc83902b26cfafdbb0c",
"sha256:e608839a5ee2180164424ccf279c8e2d9bbe8816d002c58fd97d6b621ba4aa94",
"sha256:87a66bcadac270fc010cb029022a93fc722bf1204a8b03e782d4c790f0edf7ca",
"sha256:2dedfeeecc2d5a939cf622602f5a1ce443ca82407f386880f739f1a9f08053ad",
"sha256:ba05732e4bcf59e948f61588851dcf620fd60d5bbd9d704203e5f59bbaa60219",
"sha256:2190266059fec3c5a55f9d6c30532c64c6d414d3228909c0af573fe4907e78d1",
"sha256:dd291debfaa535d9cb6cee8d7aca2328775e037d02d13f1634e57f49bc302cc4",
"sha256:29a36e354c39b2e24bc4ee103de53417ebb80f976a6ab9e8d093d559e2ac03e1",
"sha256:e37427d5a27eefbcfc48847e0b37f348113fac7280bc857421db39ffc6372570",
"sha256:b106d4d2383382399ad82108fd187e92f40b1c90f55c2d36bbcb1c44bcf940fc",
"sha256:0ee07da52d240f1dc3c83eef5cd5f1b7f018226c1121f2a54d446645779a6d17",
"sha256:3b33549fb8f91b38a7500078242b03cca513f3412a2cdae722e89bf83f95971d",
"sha256:4c12e90886d9c53ab434c8d0cebea122321cce19614c3c6b6d1a7700d7cc6212",
"sha256:79322000279cda10b53c374d53ca632ead3bc51c6aebf8e62c8fa93a4d08b750",
"sha256:6cba398eb37e0631e60e0e080c101cfe91769b2c8267105b64b4625e2581ea21",
"sha256:49a655956f8de69e1258bc0fcfc43eb3bd1e038655784d77d1869b4b81444e37",
"sha256:af8a5373241d09b8fc53e0490e1719ce5dc90a21b19db89b6596c1adcdd52270",
"sha256:e6b6698415c7e8d227a47a3b1038e1b37c2b438a1b48c2db7ad9e74ddbcd1149",
"sha256:155c916cf2645b4a8f2bd5d09065e92d1b67b8d464bdc001e0b524af84bedf6f",
"sha256:fa7320679ced5e25b20203d157280680fc84eb783b6cc650cb0c98e1858b7dd3",
"sha256:4187c4b0cefc3353181db048c51f42c489d9ac51e40b86c4851dc0671372971d",
"sha256:d5d29663e979e83b3fc361e97200f959cddb3a14797391d15273d84a5a8ae44b",
"sha256:940caef1ec7c78e0c34b0f6b94fe42d0f2022915ffc78643d28538a5cfd0f40e"
],
"version": "==4.1.1"
},
"markdown": {
"hashes": [
"sha256:73af797238b95768b3a9b6fe6270e250e5c09d988b8e5b223fd5efa4e06faf81"
],
"version": "==2.6.9"
},
"mf2py": {
"hashes": [
"sha256:021b675c0732bdbc3b8c153e1ee8e1f476c3d0ffc56a7908f9e9f90147c5fccd"
],
"version": "==1.0.5"
},
"mf2util": {
"hashes": [
"sha256:efb8ea1a275f16396993a3fbe32331b74a8f6985d3f7f47503641cf522f1f614"
],
"version": "==0.5.0"
},
"msgpack-python": {
"hashes": [
"sha256:637b012c9ea021de7a7a75d6ff5e82cfef6694babd7e14bb9a3adcb2a5bd52f0",
"sha256:658c1cd5dcf7786e0e7a6d523cd0c5b33f92e139e224bd73cb3a23ada618d2dc",
"sha256:920bbbaee07ad048a4d2b4160901b19775c61ef9439f856c74509e763a326249",
"sha256:e165006f7e3d2612f1bffe2f6f042ca317d8df724d8b72a39b14c2e46c67eaae",
"sha256:95d70edd50e3d2f6ea1189f77190e4a0172626e7405ddd1689f3f64814447cba",
"sha256:7e1b12ea0134460052fabcfaa0f488ec0fc21deb14832d66236fd2870757d8f1",
"sha256:8f36890251f20d96267618cf64735759d7ef7e91bc0b86b9480547d2d1397a68",
"sha256:1e68a277e4180baa7789be36f27f0891660205f6209f78a32282d3c422873d78",
"sha256:f52d9f96df952369fe4adcb0506e10c1c92d47f653f601a66da2a26a7e7141ea",
"sha256:58c9c1d7891a35bddc6ee5dbec10d347a7ae4983169c24fc5fc8a57ae792ca76",
"sha256:1a2b19df0f03519ec7f19f826afb935b202d8979b0856c6fb3dc28955799f886"
],
"version": "==0.4.8"
},
"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"
},
"pycrypto": {
"hashes": [
"sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c"
],
"version": "==2.6.1"
},
"python-jose": {
"hashes": [
"sha256:fed56224664af0ebc3947853f1bed23b5609f90c7b02e3dce5ef5757d0301664",
"sha256:18e19f200f37a8ee6247921572807cc63ee034abdbf6854df1ae7c1f505cabcc"
],
"version": "==1.4.0"
},
"python-memcached": {
"hashes": [
"sha256:2775829cb54b9e4c5b3bbd8028680f0c0ab695db154b9c46f0f074ff97540eb6"
],
"version": "==1.58"
},
"python-slugify": {
"hashes": [
"sha256:c3733135d3b184196fdb8844f6a74bbfb9cf6720d1dcce3254bdc434353f938f",
"sha256:57a385df7a1c6dbd15f7666eaff0ff29d3f60363b228b1197c5308ed3ba5f824"
],
"version": "==1.2.4"
},
"pytz": {
"hashes": [
"sha256:80af0f3008046b9975242012a985f04c5df1f01eed4ec1633d56cc47a75a6a48",
"sha256:feb2365914948b8620347784b6b6da356f31c9d03560259070b2f30cff3d469d",
"sha256:59707844a9825589878236ff2f4e0dc9958511b7ffaae94dc615da07d4a68d33",
"sha256:d0ef5ef55ed3d37854320d4926b04a4cb42a2e88f71da9ddfdacfde8e364f027",
"sha256:c41c62827ce9cafacd6f2f7018e4f83a6f1986e87bfd000b8cfbd4ab5da95f1a",
"sha256:8cc90340159b5d7ced6f2ba77694d946fc975b09f1a51d93f3ce3bb399396f94",
"sha256:dd2e4ca6ce3785c8dd342d1853dd9052b19290d5bf66060846e5dc6b8d6667f7",
"sha256:699d18a2a56f19ee5698ab1123bbcc1d269d061996aeb1eda6d89248d3542b82",
"sha256:fae4cffc040921b8a2d60c6cf0b5d662c1190fe54d718271db4eb17d44a185b7"
],
"version": "==2017.3"
},
"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"
},
"ronkyuu": {
"hashes": [
"sha256:5aa77b39d301bc174ab99ba8a53954627771cb501651a12103c58f51b32e84bf",
"sha256:85b25fef7f5fb0c93afd5377ea35b5ff72b2458f926bafdf10f0c9a1e19cab10"
],
"version": "==0.6"
},
"rq": {
"hashes": [
"sha256:aa9e73113713e3b2e8d633ebb5f8ceccf5c8c97c1aae4356dc3f46d446129f71",
"sha256:ae5b4507de1be3db4e737c3d59179c568b2906539f5eacaff218e5bb6b05937b"
],
"version": "==0.9.1"
},
"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

@ -8,10 +8,12 @@ class SyndicationInline(admin.TabularInline):
class EntryAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("title", "id", "kind", "created")
list_filter = ("kind",)
inlines = (SyndicationInline,)
date_hierarchy = 'created'
list_display = ('title', 'id', 'kind', 'created')
list_filter = ('kind',)
inlines = (
SyndicationInline,
)
admin.site.register(Cat)

View file

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

View file

@ -1,26 +1,14 @@
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 urllib.parse import urljoin
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")
from .kinds import on_home
from .models import Entry
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
@ -34,12 +22,13 @@ class EntriesFeed(Feed):
return entry.author.email
def item_author_link(self, entry):
return entry.author.absolute_url
base = 'https://' + Site.objects.get_current().domain
return urljoin(base, entry.author.url)
def item_pubdate(self, entry):
return entry.published
def item_updateddate(self, entry):
def item_updatedate(self, entry):
return entry.updated
def item_categories(self, entry):
@ -47,30 +36,30 @@ class EntriesFeed(Feed):
class RssByKind(EntriesFeed):
def get_object(self, request, kind):
return kind
def __init__(self, kind):
self.kind = kind
def title(self, kind):
def title(self):
return "{0} ~ {1}".format(
kind.plural,
self.kind.plural,
Site.objects.get_current().name,
)
def link(self, kind):
return kind.index
def link(self):
return reverse('entries:' + self.kind.index)
def description(self, kind):
def description(self):
return "all {0} at {1}".format(
kind.plural,
self.kind.plural,
Site.objects.get_current().name,
)
def items(self, kind):
return Entry.objects.filter(kind=kind.id)
def items(self):
return Entry.objects.filter(kind=self.kind.id)
class AtomByKind(RssByKind):
feed_type = Atom1FeedWithHub
feed_type = Atom1Feed
subtitle = RssByKind.description
@ -79,7 +68,7 @@ class RssHomeEntries(EntriesFeed):
return Site.objects.get_current().name
def link(self):
return reverse("home:index")
return reverse('home:index')
def description(self):
return "content from {0}".format(
@ -91,5 +80,5 @@ class RssHomeEntries(EntriesFeed):
class AtomHomeEntries(RssHomeEntries):
feed_type = Atom1FeedWithHub
feed_type = Atom1Feed
subtitle = RssHomeEntries.description

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

@ -6,21 +6,14 @@ 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,
},
)
data = [('hub.mode', 'publish')] + [('hub.url[]', url) for url in urls]
requests.post(settings.PUSH_HUB, data=data)
@job
def send_mentions(source, targets=None):
if targets is None:
targets = webmention.findMentions(source)["refs"]
for target in targets:
def send_mentions(source):
result = webmention.findMentions(source)
for target in result['refs']:
status, endpoint = webmention.discoverEndpoint(target)
if endpoint is not None and status == 200:
webmention.sendWebmention(source, target, endpoint)

View file

@ -1,6 +1,3 @@
from django.urls import reverse
class Entry:
def __init__(self, id, plural, icon, on_home=True, slug=False):
self.id = id
@ -11,78 +8,62 @@ class Entry:
@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"
return self.plural + '_entry'
@property
def atom(self):
return reverse("entries:atom_by_kind", kwargs={"kind": self})
return self.plural + '_atom'
@property
def rss(self):
return reverse("entries:rss_by_kind", kwargs={"kind": self})
return self.plural + '_rss'
Note = Entry(
id="note",
icon="fas fa-paper-plane",
plural="notes",
id='note',
icon='fas fa-paper-plane',
plural='notes',
)
Article = Entry(
id="article",
icon="fas fa-file-alt",
plural="articles",
id='article',
icon='fas fa-file-alt',
plural='articles',
slug=True,
)
Photo = Entry(
id="photo",
icon="fas fa-camera",
plural="photos",
id='photo',
icon='fas fa-camera',
plural='photos',
)
Reply = Entry(
id="reply",
icon="fas fa-comment",
plural="replies",
id='reply',
icon='fas fa-comment',
plural='replies',
on_home=False,
)
Like = Entry(
id="like",
icon="fas fa-heart",
plural="likes",
id='like',
icon='fas fa-heart',
plural='likes',
on_home=False,
)
Repost = Entry(
id="repost",
icon="fas fa-retweet",
plural="reposts",
id='repost',
icon='fas fa-retweet',
plural='reposts',
)
all = (Note, Article, Photo)
on_home = {k.id for k in all if k.on_home}
from_id = {k.id: k for k in all}
from_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

@ -8,41 +8,34 @@ import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
("entries", "0005_auto_20171027_1557"),
('entries', '0005_auto_20171027_1557'),
]
operations = [
migrations.AlterModelOptions(
name="entry",
options={"ordering": ["-created"], "verbose_name_plural": "entries"},
name='entry',
options={'ordering': ['-created'], 'verbose_name_plural': 'entries'},
),
migrations.RenameField(
model_name="entry",
old_name="published",
new_name="created",
model_name='entry',
old_name='published',
new_name='created',
),
migrations.RenameField(
model_name="entry",
old_name="updated",
new_name="modified",
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",
),
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",
),
model_name='entry',
name='modified',
field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'),
),
]

View file

@ -6,31 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("entries", "0006_auto_20171102_1200"),
('entries', '0006_auto_20171102_1200'),
]
operations = [
migrations.AddField(
model_name="entry",
name="cite",
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,
),
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

@ -6,24 +6,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("entries", "0007_auto_20171113_0841"),
('entries', '0007_auto_20171113_0841'),
]
operations = [
migrations.RenameField(
model_name="entry",
old_name="cite",
new_name="in_reply_to",
model_name='entry',
old_name='cite',
new_name='in_reply_to',
),
migrations.AddField(
model_name="entry",
name="like_of",
model_name='entry',
name='like_of',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name="entry",
name="repost_of",
model_name='entry',
name='repost_of',
field=models.CharField(blank=True, max_length=255),
),
]

View file

@ -6,28 +6,21 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("entries", "0008_auto_20171116_2116"),
('entries', '0008_auto_20171116_2116'),
]
operations = [
migrations.CreateModel(
name="Tag",
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)),
('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",),
'ordering': ('name',),
},
),
]

View file

@ -6,14 +6,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("entries", "0009_tag"),
('entries', '0009_tag'),
]
operations = [
migrations.AddField(
model_name="entry",
name="tags",
field=models.ManyToManyField(related_name="entries", to="entries.Tag"),
model_name='entry',
name='tags',
field=models.ManyToManyField(related_name='entries', to='entries.Tag'),
),
]

View file

@ -6,20 +6,19 @@ from django.db import migrations
class Migration(migrations.Migration):
atomic = False
dependencies = [
("entries", "0010_entry_tags"),
('entries', '0010_entry_tags'),
]
operations = [
migrations.RenameModel(
old_name="Tag",
new_name="Cat",
old_name='Tag',
new_name='Cat',
),
migrations.RenameField(
model_name="entry",
old_name="tags",
new_name="cats",
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,23 +1,19 @@
from computed_property import ComputedCharField
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site as DjangoSite
from django.contrib.sites.models import Site
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 urllib.parse import urljoin
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
from lemoncurry import requests
ENTRY_KINDS = [(k.id, k.id) for k in kinds.all]
@ -33,33 +29,36 @@ class Cat(models.Model):
slug = models.CharField(max_length=255, unique=True)
def __str__(self):
return "#" + self.name
return '#' + self.name
@property
def url(self):
return reverse("entries:cat", args=(self.slug,))
return reverse('entries:cat', args=(self.slug,))
class Meta:
ordering = ("name",)
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):
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")
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)
@ -67,7 +66,7 @@ class Entry(ModelMeta, TimeStampedModel):
author = models.ForeignKey(
get_user_model(),
related_name="entries",
related_name='entries',
on_delete=models.CASCADE,
)
@ -75,7 +74,10 @@ class Entry(ModelMeta, TimeStampedModel):
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)
return interpret(
requests.mf2(self.in_reply_to).to_dict(),
self.in_reply_to
)
@property
def published(self):
@ -86,29 +88,31 @@ class Entry(ModelMeta, TimeStampedModel):
return self.modified
_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,45 +127,18 @@ 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):
@ -169,58 +146,49 @@ class Entry(ModelMeta, TimeStampedModel):
@property
def json_ld(self):
base = "https://" + DjangoSite.objects.get_current().domain
base = 'https://' + Site.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,
'@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(),
'headline': self.title,
'description': self.excerpt,
'datePublished': self.created.isoformat(),
'dateModified': self.modified.isoformat(),
}
if self.photo:
posting["image"] = (urljoin(base, self.photo.url),)
posting['image'] = (urljoin(base, self.photo.url), )
return posting
class Meta:
verbose_name_plural = "entries"
ordering = ["-created"]
verbose_name_plural = 'entries'
ordering = ['-created']
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%
display flex
margin-bottom 0
flex 1
@ -10,40 +9,12 @@ 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
@ -54,8 +25,14 @@ ol.entries, div.entry
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
text-align center
> *
display inline-block
margin-right 1rem
&:last-child
margin-right 0
.h-card > img
height 1em
vertical-align baseline

View file

@ -0,0 +1,14 @@
{% extends 'lemoncurry/layout.html' %}
{% load shorturl static %}
{% block head %}<link rel="shortlink" href="{% shorturl entry %}" />{% endblock %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
{% endblock %}
{% block main %}
<div class="entry">
{% include 'entries/h-entry.html' %}
</div>
{% endblock %}

View file

@ -0,0 +1,67 @@
{% load bleach friendly_url humanize jsonify markdown shortlink %}<article class="card h-entry">
{% if entry.photo %}<img class="card-img-top u-photo" src="{{ entry.photo.url }}" />{% endif %}
{% if entry.in_reply_to %}{% with reply=entry.reply_context %}
<article class="card-header media u-in-reply-to h-cite">
<a class="align-self-center p-author h-card" href="{{ reply.author.url }}">
<img class="mr-3 rounded" width="100" src="{{ reply.author.photo }}"
alt="{{ reply.author.name }}" title="{{ reply.author.name }}" />
</a>
<div class="media-body">
{% if reply.name %}<h4 class="p-name">{{ reply.name }}</h4>{% endif %}
<div class="e-content{% if not reply.name %} p-name{% endif %}">{{ reply.content | bleach }}</div>
</div>
</article>{% endwith %}{% 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-uid u-url" href="{{ entry.url }}">
<time class="dt-published" datetime="{{ entry.published.isoformat }}">
<i class="fas fa-calendar"></i>
{{ entry.published | naturaltime }}
</time>
</a>
{% if entry.updated != entry.published %}
<time class="dt-updated" datetime="{{ entry.updated.isoformat }}">
<i class="fas fa-pencil-alt"></i>
{{ entry.updated | naturaltime }}
</time>
{% shortlink entry as short %}<a class="u-url" href="{{ short }}">
<i class="fas fa-link"></i>
{{ short | friendly_url }}
</a>
{% endif %}
</div>
{% if entry.cats.exists %}
<div class="card-footer">
{% for c in entry.cats.all %}
<a class="p-category" href="{{ c.url }}">
<i class="fas fa-paw"></i>
{{ c.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% if entry.syndications.exists %}
<div class="card-footer">
{% for s in entry.syndications.all %}
<a class="u-syndication" href="{{ s.url }}">
<i class="{{ s.profile.site.icon }}"></i>
{{ s.profile }}
</a>
{% endfor %}
</div>
{% endif %}
<script class="p-json-ld" type="application/ld+json">{{ entry.json_ld | jsonify }}</script>
</article>

View file

@ -0,0 +1,17 @@
{% 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="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,34 @@
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 feeds, 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 = [
url('^atom$', feeds.AtomHomeEntries(), name='atom'),
url('^rss$', feeds.RssHomeEntries(), name='rss'),
url('^cats/(?P<slug>.+)$', views.cat, name='cat'),
]
crumbs.add(prefix('cat'), parent='home:index')
slug_opt = "(?:" + slug + ")?"
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, '/atom'), feeds.AtomByKind(k), name=k.atom),
url(to_pat(kind, '/rss'), feeds.RssByKind(k), name=k.rss),
url(to_pat(kind, id, slug), views.entry, name=k.entry),
)
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))

35
entries/views.py Normal file
View file

@ -0,0 +1,35 @@
from annoying.decorators import render_to
from django.shortcuts import get_object_or_404, redirect
from .models import Entry, Cat
@render_to('entries/index.html')
def index(request, kind):
entries = Entry.objects.filter(kind=kind.id)
return {
'entries': entries,
'atom': 'entries:' + kind.atom,
'rss': 'entries:' + kind.rss,
'title': kind.plural,
}
@render_to('entries/index.html')
def cat(request, slug):
cat = get_object_or_404(Cat, slug=slug)
return {
'entries': cat.entries.all(),
'title': '#' + cat.name,
}
@render_to('entries/entry.html')
def entry(request, id, slug=None):
entry = Entry.objects.get(pk=id)
if request.path != entry.url:
return redirect(entry.url, permanent=True)
return {
'entry': entry,
'title': entry.title,
'meta': entry.as_meta(request)
}

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

View file

@ -0,0 +1,64 @@
{% 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 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 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>
{% 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"></i> {{ user.email }}
</a></li>
{% if user.xmpp %}<li><a class="u-impp" rel="me" href="xmpp:{{ user.xmpp }}">
<i class="openwebicons-xmpp"></i> {{ user.xmpp }}
</a></li>{% endif %}
</ul>
</div>
<div class="card-footer">
<ul class="profiles">
{% for key in user.keys.all %}<li>
<a class="u-key" href="{{ key.file.url }}">
<i class="fas fa-key"></i> {{ key.pretty_print }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="card-footer">
<ul class="profiles">
{% for profile in user.profiles.all %}<li>
<a class="u-url" rel="me" href="{{ profile.url }}" title="{{ profile }}"><i class="{{ profile.site.icon }}"></i> <span class="sr-only">{{ profile }}</span></a>
</li>{% endfor %}
</ul>
</div>
<script class="p-json-ld" type="application/ld+json">{{ user.json_ld | jsonify }}</script>
</article>
</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,9 @@
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'),
url(r'^robots.txt$', views.robots, name='robots.txt'),
]

View file

@ -5,34 +5,31 @@ from django.urls import reverse
from users.models import User
from urllib.parse import urljoin
from entries import kinds, pagination
from entries import kinds
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)
@render_to('home/index.html')
def index(request):
query = User.objects.prefetch_related('entries', 'profiles', 'keys')
user = get_object_or_404(query, 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"),
'user': user,
'entries': entries,
'atom': 'entries:atom',
'rss': 'entries:rss',
'meta': user.as_meta(request),
}
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")
lines = (
'User-agent: *',
'Sitemap: {0}'.format(urljoin(base, reverse('sitemap')))
)
return HttpResponse("\n".join(lines) + "\n", content_type='text/plain')

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 +1,28 @@
# -*- 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]]
dependencies = [
]
operations = [
migrations.CreateModel(
name="IndieAuthCode",
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)),
('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

@ -6,12 +6,13 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("lemonauth", "0001_initial"),
('lemonauth', '0001_initial'),
]
operations = [
migrations.DeleteModel(
name="IndieAuthCode",
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,11 +2,13 @@
img
height 2em
margin-right .5em
.tippy-box
&[data-theme~='success']
.tippy-tooltip
&.success-theme
color $base0B
&[data-theme~='warning']
background-color $base03
&.warning-theme
color $base0A
background-color $base03
.verified-success
color $base0B
.verified-warning

View file

@ -0,0 +1,67 @@
{% extends 'lemoncurry/layout.html' %}
{% load markdown 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_approve' %}">
<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 %}?
{% if verified %}
<i class="fas fa-check-circle verified-success" data-tooltip data-theme="success" data-html="#verified-success"></i>
{% else %}
<i class="fas fa-question-circle verified-warning" data-tooltip data-theme="warning" data-html="#verified-warning"></i>
{% 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="custom-controls-stacked card-text">
{% for scope in scopes %}
<label class="custom-control custom-checkbox">
<input name="scope" type="checkbox" class="custom-control-input" checked value="{{ scope }}" />
<span class="custom-control-indicator"></span>
<span class="custom-control-description">{{ scope }}</span>
</label>
{% 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_token %}
<input name="me" type="hidden" value="{{ me }}" />
<input name="client_id" type="hidden" value="{{ params.client_id }}" />
<input name="redirect_uri" type="hidden" value="{{ redirect_uri }}" />
{% if params.state %}<input name="state" type="hidden" value="{{ params.state }}" />{% endif %}
<input name="response_type" type="hidden" value="{{ params.response_type }}" />
</form>
</div>
<div id="verified-success" hidden>
this client has been <strong>verified</strong> using <code>{{ '<link rel="redirect_uri">' | force_escape }}</code> - 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">' | force_escape }}</code> - check the redirect uri carefully yourself!
</div>
{% endblock %}
{% block foot %}
<script type="text/javascript">
tippy('[data-tooltip]', {arrow: true});
</script>
{% 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="fas fa-sign-in-alt"></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 +1,37 @@
from micropub import error
from .models import IndieAuthCode, Token
from jose import jwt
from datetime import datetime, timedelta
from django.conf import settings
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()
def encode(payload):
return jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
try:
token = Token.objects.get(pk=token)
except Token.DoesNotExist:
raise error.forbidden()
return token
def decode(token):
return jwt.decode(token, settings.SECRET_KEY, algorithms=('HS256',))
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
code = {
'uid': req.user.id,
'cid': req.POST['client_id'],
'uri': req.POST['redirect_uri'],
'typ': req.POST.get('response_type', 'id'),
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(seconds=30),
}
if 'scope' in req.POST:
code['sco'] = ' '.join(req.POST.getlist('scope'))
return encode(code)
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
tok = {
'uid': code['uid'],
'cid': code['cid'],
'sco': code['sco'],
'iat': datetime.utcnow(),
}
return encode(tok)

View file

@ -1,17 +1,11 @@
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'),
url('^indie/approve$', views.indie_approve, name='indie_approve'),
url('^token$', views.TokenView.as_view(), name='token'),
]

View file

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

View file

@ -1,4 +1,5 @@
from annoying.decorators import render_to
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import redirect
@ -10,116 +11,119 @@ from lemoncurry import breadcrumbs, requests, utils
from urllib.parse import urlencode, urljoin, urlunparse, urlparse
from .. import tokens
from ..models import IndieAuthCode
breadcrumbs.add("lemonauth:indie", parent="home:index")
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"
(scheme, loc, path, params, q, fragment) = urlparse(url)
if not path:
path = "/"
return urlunparse((scheme, netloc, path, params, query, fragment))
path = '/'
if not loc:
loc, path = path, ''
if not scheme:
scheme = 'https'
return urlunparse((scheme, loc, path, params, q, fragment))
@method_decorator(csrf_exempt, name="dispatch")
@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.setdefault('response_type', 'id')
for param in self.required_params:
if param not in params:
return utils.bad_req("parameter {0} is required".format(param))
me = request.user.full_url
if "me" in params:
param_me = canonical(params["me"])
if me != param_me:
return utils.forbid(
"you are logged in as {}, not as {}".format(me, param_me)
return utils.bad_req(
'parameter {0} is required'.format(param)
)
redirect_uri = urljoin(params["client_id"], params["redirect_uri"])
me = canonical(params['me'])
user = urljoin(utils.origin(request), request.user.url)
if user != me:
return utils.forbid(
'you are logged in but not as {0}'.format(me)
)
type = params["response_type"]
if type not in ("id", "code"):
return utils.bad_req("unknown response_type: {0}".format(type))
redirect_uri = urljoin(params['client_id'], params['redirect_uri'])
type = params['response_type']
if type not in ('id', 'code'):
return utils.bad_req(
'unknown response_type: {0}'.format(type)
)
scopes = ()
if type == "code":
if "scope" not in params:
return utils.bad_req("scopes required for code type")
scopes = params["scope"].split(" ")
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 = requests.mf2(params['client_id'])
rels = (client.to_dict()['rel-urls']
.get(redirect_uri, {})
.get('rels', ()))
verified = 'redirect_uri' in rels
try:
app = client.to_dict(filter_by_type="h-x-app")[0]["properties"]
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),
'app': app,
'me': me,
'redirect_uri': redirect_uri,
'verified': verified,
'params': params,
'scopes': scopes,
'title': 'indieauth',
}
def post(self, request):
post = request.POST.dict()
try:
code = IndieAuthCode.objects.get(pk=post.get("code"))
except IndieAuthCode.DoesNotExist:
code = tokens.decode(post.get('code'))
except Exception:
# 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")
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 code['typ'] != 'id':
return utils.bad_req(
'this endpoint only supports response_type=id'
)
if code['cid'] != post.get('client_id'):
return utils.forbid('client id did not match')
if code['uri'] != post.get('redirect_uri'):
return utils.forbid('redirect uri did not match')
user = get_user_model().objects.get(pk=code['uid'])
me = urljoin(utils.origin(request), user.url)
# 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,
},
)
return utils.choose_type(request, {'me': 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),
'me': urljoin(utils.origin(request), request.user.url),
'code': tokens.gen_auth_code(request),
}
if "state" in request.POST:
params["state"] = request.POST["state"]
if 'state' in request.POST:
params['state'] = request.POST['state']
uri = request.POST["redirect_uri"]
sep = "&" if "?" in uri else "?"
uri = request.POST['redirect_uri']
sep = '&' if '?' in uri else '?'
return redirect(uri + sep + urlencode(params))

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 +1,58 @@
from django.contrib.auth import get_user_model
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from urllib.parse import urljoin
from .. import tokens
from ..models import IndieAuthCode
from lemoncurry import utils
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(csrf_exempt, name='dispatch')
class TokenView(View):
def get(self, req):
token = tokens.auth(req)
token = req.META.get('HTTP_AUTHORIZATION', '').split(' ')
if not token:
return utils.bad_req('missing Authorization header')
if token[0] != 'Bearer':
return utils.bad_req('only Bearer auth is supported')
try:
token = tokens.decode(token[1])
except Exception:
return utils.forbid('invalid token')
user = get_user_model().objects.get(pk=token['uid'])
me = urljoin(utils.origin(req), user.url)
res = {
"me": token.me,
"client_id": token.client_id,
"scope": token.scope,
'me': me,
'client_id': token['cid'],
'scope': token['sco'],
}
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")
code = tokens.decode(post.get('code'))
except Exception:
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 code['typ'] != 'code':
return utils.bad_req(
'this endpoint only supports response_type=code'
)
if code['cid'] != post.get('client_id'):
return utils.forbid('client id did not match')
if code['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")
user = get_user_model().objects.get(pk=code['uid'])
me = urljoin(utils.origin(req), user.url)
if me != post.get('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,
},
)
return utils.choose_type(req, {
'access_token': tokens.gen_token(code),
'me': me,
'scope': code['sco'],
})

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

@ -11,7 +11,7 @@ from mf2py import Parser
class DjangoCache(BaseCache):
@classmethod
def key(cls, url):
return "req:" + sha256(url.encode("utf-8")).hexdigest()
return 'req:' + sha256(url.encode('utf-8')).hexdigest()
def get(self, url):
key = self.key(url)
@ -45,4 +45,4 @@ def get(url):
def mf2(url):
r = get(url)
return Parser(doc=r.text, url=url, html_parser="html5lib")
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,91 @@ 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.sites',
'django.contrib.sitemaps',
'django.contrib.messages',
'django.contrib.staticfiles',
'analytical',
'annoying',
'compressor',
'debug_toolbar',
'django_activeurl',
'django_agent_trust',
'django_otp',
'django_otp.plugins.otp_totp',
'django_rq',
'favicon',
'meta',
'shorturls',
'lemoncurry',
'entries',
'home',
'lemonauth',
'micropub',
'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.contrib.sites.middleware.CurrentSiteMiddleware',
'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",
'default': {
'BACKEND': 'redis_cache.RedisCache',
'LOCATION': '127.0.0.1:6380',
'KEY_PREFIX': 'lemoncurry',
'OPTIONS': {
'DB': 0,
'PARSER_CLASS': 'redis.connection.HiredisParser',
},
"VERSION": 2,
}
}
@ -163,51 +146,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,21 +195,20 @@ 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/
@ -243,27 +218,23 @@ SITE_ID = 1
# 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/"
# django-shorturls
# https://pypi.python.org/pypi/django-shorturls
SHORTEN_MODELS = {
"e": "entries.entry",
'e': 'entries.entry',
}
# django-meta
# https://django-meta.readthedocs.io/en/latest/settings.html
META_SITE_PROTOCOL = "https"
META_SITE_PROTOCOL = 'https'
META_USE_SITES = True
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/"
PUSH_HUB = 'https://00dani.superfeedr.com/'
# django-rq
# https://github.com/ui/django-rq
RQ_QUEUES = {"default": {"USE_REDIS_CACHE": "default"}}
RQ_QUEUES = {'default': {'USE_REDIS_CACHE': 'default'}}

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

@ -4,19 +4,24 @@ from os.path import join
from .base import *
from .base import BASE_DIR, 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/"
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_ROOT = join(BASE_DIR, '..', 'static')
MEDIA_ROOT = join(BASE_DIR, '..', 'media')
STATIC_URL = 'https://cdn.00dani.me/'
MEDIA_URL = STATIC_URL + 'media/'
META_SITE_DOMAIN = '00dani.me'
META_FB_APPID = '145311792869199'
GOOGLE_ANALYTICS_PROPERTY_ID = 'UA-109060843-1'

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
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,22 +21,12 @@ 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
@ -65,7 +52,7 @@ body
> main
padding 2rem 1rem
padding 2rem
width 100%
flex 1
display flex
@ -73,42 +60,13 @@ body
> footer
display flex
justify-content space-evenly
align-items center
margin 1rem
margin-top 0
text-align center
> p, nav
margin 0 .5rem
> p
margin-right 1rem
&: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
margin-right 0
.card
background-color $base02

View file

@ -6,20 +6,9 @@ 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];

View file

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

1
lemoncurry/static/tippy.js Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/tippy.js/dist

View file

@ -0,0 +1,85 @@
{% load analytical compress favtags lemoncurry_tags meta static theme_colour %}<!doctype html>
<html dir="ltr" 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 %}
{% analytical_head_top %}
<base href="{{ origin }}" />
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ site_name }}</title>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<link rel="canonical" href="{{ uri }}" />
{% if atom %}<link rel="alternate" type="application/atom+xml" href="{% url atom %}" />{% endif %}
{% if rss %}<link rel="alternate" type="application/rss+xml" href="{% url rss %}" /> {% endif %}
{% block head %}{% endblock %}
<link rel="authorization_endpoint" href="{{ origin }}{% url 'lemonauth:indie' %}" />
<link rel="token_endpoint" href="{{ origin }}{% url 'lemonauth:token' %}" />
<link rel="micropub" href="{{ origin }}{% url 'micropub:micropub' %}" />
<link rel="openid.delegate" href="{{ origin }}" />
<link rel="openid.server" href="https://openid.indieauth.com/openid" />
<link rel="hub" href="{% get_push_hub %}" />
<link rel="self" href="{{ uri }}" />
<link rel="manifest" href="{% url 'wellknowns:manifest' %}" />
<meta name="theme-color" content="{% theme_colour 2 %}" />
<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/css" href={% static 'tippy.js/tippy.css' %} />
<link rel="stylesheet" type="text/stylus" href="{% static 'lemoncurry/css/layout.styl' %}" />
{% block styles %}{% endblock %}
{% endcompress %}
<script type="text/javascript" defer src="https://use.fontawesome.com/releases/v5.0.1/js/all.js"></script>
{% analytical_head_bottom %}
</head>
<body>
{% analytical_body_top %}
<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>
{% compress js %}
<script src="{% static 'tippy.js/tippy.standalone.js' %}"></script>
{% block foot %}{% endblock %}
{% endcompress %}
{% analytical_body_bottom %}
</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 }} fa-fw"></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,12 @@ 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', 'img', 'span']
tags.extend(ALLOWED_TAGS)
attributes = {
"a": ["href", "title", "class"],
"details": ["open"],
"img": ["alt", "src", "title"],
"span": ["class"],
'a': ('href', 'title', 'class'),
'img': ('alt', 'src', 'title'),
'span': ('class',),
}
register = template.Library()

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