Compare commits

..

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

171 changed files with 2747 additions and 4495 deletions

7
.gitignore vendored
View file

@ -1,3 +1,4 @@
# Created by https://www.gitignore.io/api/django
### Django ###
@ -14,10 +15,6 @@ 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

16
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,16 @@
image: python:3.6
variables:
GIT_SUBMODULE_STRATEGY: normal
PIP_CACHE_DIR: $CI_PROJECT_DIR/.pip-cache
PIPENV_VENV_IN_PROJECT: yeppers
cache:
paths:
- .pip-cache
- .venv
test:
script:
- pip install pipenv
- pipenv install
- pipenv run pytest

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

19
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,19 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v1.2.3
hooks:
- id: autopep8-wrapper
- id: check-byte-order-marker
- id: check-case-conflict
- id: check-json
- id: check-merge-conflict
- id: check-yaml
- id: end-of-file-fixer
- id: flake8
- id: mixed-line-ending
args:
- --fix=lf
- id: name-tests-test
args:
- --django
- id: trailing-whitespace

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 - 2018 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

48
Pipfile Normal file
View file

@ -0,0 +1,48 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[requires]
python_version = '3.6'
[packages]
django = "*"
django-compressor = "*"
gunicorn = {extras = ["gevent"]}
"psycopg2" = "*"
pillow = "*"
python-memcached = "*"
django-meta = "*"
django-activeurl = "*"
django-otp = "*"
qrcode = "*"
django-otp-agents = "*"
python-slugify = "*"
"mf2py" = "*"
markdown = "*"
bleach = "*"
django-debug-toolbar = "*"
xrd = "*"
django-push = "*"
pyyaml = "*"
django-annoying = "*"
accept-types = "*"
django-analytical = "*"
django-model-utils = "*"
python-jose = "*"
django-rq = "*"
ronkyuu = "*"
cachecontrol = "*"
hiredis = "*"
"mf2util" = "*"
django-cors-headers = "*"
pytest-django = "*"
"argon2-cffi" = "*"
python-baseconv = "*"
django-computed-property = "*"
docutils = "*"
django-super-favicon = "*"
django-redis = "*"
[dev-packages]

712
Pipfile.lock generated Normal file
View file

@ -0,0 +1,712 @@
{
"_meta": {
"hash": {
"sha256": "20d851bba1969d8787dfc9967508f3c9cb6dde62a1dd18baf57a7c85efbfd2f4"
},
"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"
],
"index": "pypi",
"version": "==0.3.0"
},
"argon2-cffi": {
"hashes": [
"sha256:05dd15949be3a7d9f65807fe58fad70526023a319747054bb89da209c4071a33",
"sha256:07480018d77f4c7447924e6c44c5ba1789a918413fe3efaa391a097958bbd9f6",
"sha256:10e702dbd98a2148d22de9524a605021bdc55d05304beb90ea801ba58c4a4f1e",
"sha256:131effd5eabbe08649bc672b5d602fd6e2772b03cfec2ddb2795f9d9babe3fba",
"sha256:3f3b48b4802e98bb9692d72108ecad2fecea969c254c17660b70ce5730bbe4a6",
"sha256:4c510232a96e991079a743a9310d3c9a014856cdbca644fccc496db2a1ff0e17",
"sha256:5f1099b0f5ee4a7148bbd323503983aa4387ab16769ff9b5c51d26f6b0f1719e",
"sha256:67452b1f10e873ececcea657c25d063e4bb4007e115227a53157369de5848992",
"sha256:77a3d50e6325df79499e1220b7c38adbd30588c2f6d7c2d764fddb2d3b02e650",
"sha256:7e4b75611b73f53012117ad21cdde7a17b32d1e99ff6799f22d827eb83a2a59b",
"sha256:7f4b6d7c38258e76c1db293a6cf55b7e31701927fc773c5108e57578c7f8e09a",
"sha256:82db759b8a495aaed51aec4762b0f44e5e7ad80256e8baf512ae70cdb3b28c50",
"sha256:92b3f8f93b19081d520d911f1ce5902693edeeab2181c08aa0bb4130adba51aa",
"sha256:93f631fa567dbf948f26874476c9e9afb51e0a835372bf1a319df0c5aa071bfb",
"sha256:9befaa6d9798d9771b8176174ba82160beaf1dcdbcc63cd2dc5212f723e5e2a3",
"sha256:a14e6d99787a2972d3802615911770fcba9c904401fb0dfb60bdeb250b4c5110",
"sha256:c60764fe7f62cc52a74f326e366c60f7aa33a1586c8d02107394a01ae9db6e91",
"sha256:cba2c8c539bed691513ae1bcd5a7da632d2aa2410d8b8ebdf56026eac7e2193f",
"sha256:d79c918cf8bf981cd23b43a1a547cd1eececb77f3607ba9fa7c0ec01bf1f05a5",
"sha256:dc3028ec541146924e3c45973b458a7acf390b9e9ee0b64a13ac0853109a69bc",
"sha256:eb3fcb55224a47b8d50830561977c64761eaad9e349af0b2241eab089af44a14",
"sha256:f732ca584e81491cc11e3d12e18cbd8c63e137b3f461f378426a6fdaaef47fb0",
"sha256:fcd5681388d1f18e4a7ee3ff7a9b68650bc04db044b5a0a832728cbce182806d"
],
"index": "pypi",
"version": "==18.1.0"
},
"attrs": {
"hashes": [
"sha256:1c7960ccfd6a005cd9f7ba884e6316b5e430a3f1a6c37c5f87d8b43f83b54ec9",
"sha256:a17a9573a6f475c99b551c0e0a812707ddda1ec9653bed04c13841404ed6f450"
],
"version": "==17.4.0"
},
"beautifulsoup4": {
"hashes": [
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76",
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11",
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89"
],
"version": "==4.6.0"
},
"bleach": {
"hashes": [
"sha256:b8fa79e91f96c2c2cd9fd1f9eda906efb1b88b483048978ba62fef680e962b34",
"sha256:eb7386f632349d10d9ce9d4a838b134d4731571851149f9cc2c05a9a837a9a44"
],
"index": "pypi",
"version": "==2.1.3"
},
"cachecontrol": {
"hashes": [
"sha256:a7d21ba4e3633d95ac9fed5be205ee6d1da36bdc4b8914eb7a57ff50b7e5628c"
],
"index": "pypi",
"version": "==0.12.4"
},
"certifi": {
"hashes": [
"sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
"sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
],
"version": "==2018.4.16"
},
"cffi": {
"hashes": [
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
],
"version": "==1.11.5"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
],
"version": "==6.7"
},
"django": {
"hashes": [
"sha256:2d8b9eed8815f172a8e898678ae4289a5e9176bc08295676eff4228dd638ea61",
"sha256:d81a1652963c81488e709729a80b510394050e312f386037f26b54912a3a10d0"
],
"index": "pypi",
"version": "==2.0.4"
},
"django-activeurl": {
"hashes": [
"sha256:ad5498bf589afaa117fe1c80d1a4fdbef29185cee47517254cd8f273b8a0140d",
"sha256:ebb3f2746fdc76fee2095b75cad713e746378393c6c2b8e36455919a780acd50"
],
"index": "pypi",
"version": "==0.1.12"
},
"django-agent-trust": {
"hashes": [
"sha256:f0ded1c2e1e8b06ea050f48c8db931ab7d2f85c566065dceb8e827d0690d87c5",
"sha256:f7e24d3f50a0727c6a70d671778de3cca23a0c87bedc6e3dae1f61af7e759fc1"
],
"version": "==0.3.1"
},
"django-analytical": {
"hashes": [
"sha256:44dd65e30a3f11519852d5f5e50556c0f88cabb5720a2fd3637621952048abef",
"sha256:cf7b4c0b368139a090da2b0b45741bdd28b54daa0cb2b83ef801021c8eb2c050"
],
"index": "pypi",
"version": "==2.4.0"
},
"django-annoying": {
"hashes": [
"sha256:93f54d244d453cba28d6cfb9deae7bba27e859762adee9ff7de4706017940931",
"sha256:ee620f9bfe439061010c7d5ccc8c69514844d95c370c130c61938205fcfc4cc9"
],
"index": "pypi",
"version": "==0.10.4"
},
"django-appconf": {
"hashes": [
"sha256:6a4d9aea683b4c224d97ab8ee11ad2d29a37072c0c6c509896dd9857466fb261",
"sha256:ddab987d14b26731352c01ee69c090a4ebfc9141ed223bef039d79587f22acd9"
],
"version": "==1.0.2"
},
"django-classy-tags": {
"hashes": [
"sha256:792f9161d0e22d55b4fab6fc297bab8ab072ffaa3075b227613a6d8473624db8",
"sha256:f6d12f5a4df3e387795a0d9ef2836af389cae9a1fbebda035dac043d4722b1f7"
],
"version": "==0.8.0"
},
"django-compressor": {
"hashes": [
"sha256:7732676cfb9d58498dfb522b036f75f3f253f72ea1345ac036434fdc418c2e57",
"sha256:9616570e5b08e92fa9eadc7a1b1b49639cce07ef392fc27c74230ab08075b30f"
],
"index": "pypi",
"version": "==2.2"
},
"django-computed-property": {
"hashes": [
"sha256:31aa6453a5c504ce196ba9ae3bacbe0557cadf7ae89e25431b90bf206febd3b3"
],
"index": "pypi",
"version": "==0.2.1"
},
"django-cors-headers": {
"hashes": [
"sha256:0e9532628b3aa8806442d4d0b15e56112e6cfbef3735e13401935c98b842a2b4",
"sha256:c7ec4816ec49416517b84f317499d1519db62125471922ab78d670474ed9b987"
],
"index": "pypi",
"version": "==2.2.0"
},
"django-debug-toolbar": {
"hashes": [
"sha256:4af2a4e1e932dadbda197b18585962d4fc20172b4e5a479490bc659fe998864d",
"sha256:d9ea75659f76d8f1e3eb8f390b47fc5bad0908d949c34a8a3c4c87978eb40a0f"
],
"index": "pypi",
"version": "==1.9.1"
},
"django-meta": {
"hashes": [
"sha256:21fc5d0d5fcacda5d038af0babd08afaa4d5bed1b746edb6522c4d3435da8db6",
"sha256:dd4a440223cc6243a7815c183b6ada1f11b99b4d672471e67f8db4d8b48a5674"
],
"index": "pypi",
"version": "==1.4.1"
},
"django-model-utils": {
"hashes": [
"sha256:4356fad5f6fc9910da865fbf9371f9c2e028606dff2bbd8e32e67569260e530d",
"sha256:a9baa7de943b4e8afa61728ce8c42ce99e88cc87d40e74df2b060a78d22c0f5c"
],
"index": "pypi",
"version": "==3.1.1"
},
"django-otp": {
"hashes": [
"sha256:0016baa0f11544aa3a709d1b4fca13d607397ae41025bdc4fdb8a7e80a39973c",
"sha256:fd9e787779c053ba77e47a6907539c01e8db41b09f99722793317ed9a4183b32"
],
"index": "pypi",
"version": "==0.4.3"
},
"django-otp-agents": {
"hashes": [
"sha256:4ca8fae30418e0a813840cee5068d2fb96e3759787a5820d54921b90c7beaa7a",
"sha256:8d9f26d5a186b059251bd03e1ab509b5861a678e463c49de9b0766080b2c16a5"
],
"index": "pypi",
"version": "==0.3.0"
},
"django-push": {
"hashes": [
"sha256:7101f2d66ff7fd932fe379c70f4d03f74955634fbecb187f03c3e82cd55b8274",
"sha256:88d9d57326c9b5f8485510527c780418da1a3c0c485dbb283281d6bf2ef6598d"
],
"index": "pypi",
"version": "==1.0"
},
"django-redis": {
"hashes": [
"sha256:15b47faef6aefaa3f47135a2aeb67372da300e4a4cf06809c66ab392686a2155",
"sha256:a90343c33a816073b735f0bed878eaeec4f83b75fcc0dce2432189b8ea130424"
],
"index": "pypi",
"version": "==4.9.0"
},
"django-rq": {
"hashes": [
"sha256:413df6e5789775b287b4b187ea09ff27f5f1d8205e999650ba9e52c34d58d252",
"sha256:71a604d4bfc18029c2f64da86bfadb803143f5784b3a340e3767202ced93245a"
],
"index": "pypi",
"version": "==1.1.0"
},
"django-super-favicon": {
"hashes": [
"sha256:56cb5268ea73ef3cbde5cb01fef02fea2ec00739cdae0566d3102009f052f683"
],
"index": "pypi",
"version": "==0.6.1"
},
"docutils": {
"hashes": [
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
],
"index": "pypi",
"version": "==0.14"
},
"ecdsa": {
"hashes": [
"sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c",
"sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa"
],
"version": "==0.13"
},
"future": {
"hashes": [
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
],
"version": "==0.16.0"
},
"gunicorn": {
"hashes": [
"sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6",
"sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622"
],
"index": "pypi",
"version": "==19.7.1"
},
"hiredis": {
"hashes": [
"sha256:ca958e13128e49674aa4a96f02746f5de5973f39b57297b84d59fd44d314d5b5"
],
"index": "pypi",
"version": "==0.2.0"
},
"html5lib": {
"hashes": [
"sha256:20b159aa3badc9d5ee8f5c647e5efd02ed2a66ab8d354930bd9ff139fc1dc0a3",
"sha256:66cb0dcfdbbc4f9c3ba1a63fdb511ffdbd4f513b2b6d81b80cd26ce6b3fb3736"
],
"version": "==1.0.1"
},
"idna": {
"hashes": [
"sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f",
"sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4"
],
"version": "==2.6"
},
"isodate": {
"hashes": [
"sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8",
"sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"
],
"version": "==0.6.0"
},
"lxml": {
"hashes": [
"sha256:01c45df6d90497c20aa2a07789a41941f9a1029faa30bf725fc7f6d515b1afe9",
"sha256:0c9fef4f8d444e337df96c54544aeb85b7215b2ed7483bb6c35de97ac99f1bcd",
"sha256:0e3cd94c95d30ba9ca3cff40e9b2a14e1a10a4fd8131105b86c6b61648f57e4b",
"sha256:0e7996e9b46b4d8b4ac1c329a00e2d10edcd8380b95d2a676fccabf4c1dd0512",
"sha256:1858b1933d483ec5727549d3fe166eeb54229fbd6a9d3d7ea26d2c8a28048058",
"sha256:1b164bba1320b14905dcff77da10d5ce9c411ac4acc4fb4ed9a2a4d10fae38c9",
"sha256:1b46f37927fa6cd1f3fe34b54f1a23bd5bea1d905657289e08e1297069a1a597",
"sha256:231047b05907315ae9a9b6925751f9fd2c479cf7b100fff62485a25e382ca0d4",
"sha256:28f0c6652c1b130f1e576b60532f84b19379485eb8da6185c29bd8c9c9bc97bf",
"sha256:34d49d0f72dd82b9530322c48b70ac78cca0911275da741c3b1d2f3603c5f295",
"sha256:3682a17fbf72d56d7e46db2e80ca23850b79c28cfe75dcd9b82f58808f730909",
"sha256:3cf2830b9a6ad7f6e965fa53a768d4d2372a7856f20ffa6ce43d2fe9c0d34b19",
"sha256:5b653c9379ce29ce271fbe1010c5396670f018e78b643e21beefbb3dc6d291de",
"sha256:65a272821d5d8194358d6b46f3ca727fa56a6b63981606eac737c86d27309cdd",
"sha256:691f2cd97cf026c611df1ea5055755eec7f878f2d4f4330dc8686583de6fc5fd",
"sha256:6b6379495d3baacf7ed755ac68547c8dff6ce5d37bf370f0b7678888dc1283f9",
"sha256:75322a531504d4f383264391d89993a42e286da8821ddc5ac315e57305cb84f0",
"sha256:7f457cbda964257f443bac861d3a36732dcba8183149e7818ee2fb7c86901b94",
"sha256:7ff1fc76d8804e0f870c343a72007ff587090c218b0f92d8ee784ac2b6eaf5b9",
"sha256:8523fbde9c2216f3f2b950cb01ebe52e785eaa8a07ffeb456dd3576ca1b4fb9b",
"sha256:8f37627f16e026523fca326f1b5c9a43534862fede6c3e99c2ba6a776d75c1ab",
"sha256:a7182ea298cc3555ea56ffbb0748fe0d5e0d81451e2bc16d7f4645cd01b1ca70",
"sha256:abbd2fb4a5a04c11b5e04eb146659a0cf67bb237dd3d7ca3b9994d3a9f826e55",
"sha256:accc9f6b77bed0a6f267b4fae120f6008a951193d548cdbe9b61fc98a08b1cf8",
"sha256:bd88c8ce0d1504fdfd96a35911dd4f3edfb2e560d7cfdb5a3d09aa571ae5fbae",
"sha256:c557ad647facb3c0027a9d0af58853f905e85a0a2f04dcb73f8e665272fcdc3a",
"sha256:defabb7fbb99f9f7b3e0b24b286a46855caef4776495211b066e9e6592d12b04",
"sha256:e2629cdbcad82b83922a3488937632a4983ecc0fed3e5cfbf430d069382eeb9b"
],
"version": "==4.2.1"
},
"markdown": {
"hashes": [
"sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f",
"sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81"
],
"index": "pypi",
"version": "==2.6.11"
},
"mf2py": {
"hashes": [
"sha256:021b675c0732bdbc3b8c153e1ee8e1f476c3d0ffc56a7908f9e9f90147c5fccd"
],
"index": "pypi",
"version": "==1.0.5"
},
"mf2util": {
"hashes": [
"sha256:efb8ea1a275f16396993a3fbe32331b74a8f6985d3f7f47503641cf522f1f614"
],
"index": "pypi",
"version": "==0.5.0"
},
"more-itertools": {
"hashes": [
"sha256:0dd8f72eeab0d2c3bd489025bb2f6a1b8342f9b198f6fc37b52d15cfa4531fea",
"sha256:11a625025954c20145b37ff6309cd54e39ca94f72f6bb9576d1195db6fa2442e",
"sha256:c9ce7eccdcb901a2c75d326ea134e0886abfbea5f93e91cc95de9507c0816c44"
],
"version": "==4.1.0"
},
"msgpack-python": {
"hashes": [
"sha256:378cc8a6d3545b532dfd149da715abae4fda2a3adb6d74e525d0d5e51f46909b"
],
"version": "==0.5.6"
},
"pillow": {
"hashes": [
"sha256:00633bc2ec40313f4daf351855e506d296ec3c553f21b66720d0f1225ca84c6f",
"sha256:03514478db61b034fc5d38b9bf060f994e5916776e93f02e59732a8270069c61",
"sha256:040144ba422216aecf7577484865ade90e1a475f867301c48bf9fbd7579efd76",
"sha256:16246261ff22368e5e32ad74d5ef40403ab6895171a7fc6d34f6c17cfc0f1943",
"sha256:1cb38df69362af35c14d4a50123b63c7ff18ec9a6d4d5da629a6f19d05e16ba8",
"sha256:2400e122f7b21d9801798207e424cbe1f716cee7314cd0c8963fdb6fc564b5fb",
"sha256:2ee6364b270b56a49e8b8a51488e847ab130adc1220c171bed6818c0d4742455",
"sha256:3b4560c3891b05022c464b09121bd507c477505a4e19d703e1027a3a7c68d896",
"sha256:41374a6afb3f44794410dab54a0d7175e6209a5a02d407119c81083f1a4c1841",
"sha256:438a3faf5f702c8d0f80b9f9f9b8382cfa048ca6a0d64ef71b86b563b0ee0359",
"sha256:472a124c640bde4d5468f6991c9fa7e30b723d84ac4195a77c6ab6aea30f2b9c",
"sha256:4d32c8e3623a61d6e29ccd024066cd1ba556555abfb4cd714155020e00107e3f",
"sha256:4d8077fd649ac40a5c4165f2c22fa2a4ad18c668e271ecb2f9d849d1017a9313",
"sha256:62ec7ae98357fcd46002c110bb7cad15fce532776f0cbe7ca1d44c49b837d49d",
"sha256:6c7cab6a05351cf61e469937c49dbf3cdf5ffb3eeac71f8d22dc9be3507598d8",
"sha256:6eca36905444c4b91fe61f1b9933a47a30480738a1dd26501ff67d94fc2bc112",
"sha256:74e2ebfd19c16c28ad43b8a28ff73b904ed382ea4875188838541751986e8c9a",
"sha256:7673e7473a13107059377c96c563aa36f73184c29d2926882e0a0210b779a1e7",
"sha256:81762cf5fca9a82b53b7b2d0e6b420e0f3b06167b97678c81d00470daa622d58",
"sha256:8554bbeb4218d9cfb1917c69e6f2d2ad0be9b18a775d2162547edf992e1f5f1f",
"sha256:9b66e968da9c4393f5795285528bc862c7b97b91251f31a08004a3c626d18114",
"sha256:a00edb2dec0035e98ac3ec768086f0b06dfabb4ad308592ede364ef573692f55",
"sha256:b48401752496757e95304a46213c3155bc911ac884bed2e9b275ce1c1df3e293",
"sha256:b6cf18f9e653a8077522bb3aa753a776b117e3e0cc872c25811cfdf1459491c2",
"sha256:bb8adab1877e9213385cbb1adc297ed8337e01872c42a30cfaa66ff8c422779c",
"sha256:c8a4b39ba380b57a31a4b5449a9d257b1302d8bc4799767e645dcee25725efe1",
"sha256:cee9bc75bff455d317b6947081df0824a8f118de2786dc3d74a3503fd631f4ef",
"sha256:d0dc1313dff48af64517cbbd85e046d6b477fbe5e9d69712801f024dcb08c62b",
"sha256:d5bf527ed83617edd1855a5c923eeeaf68bcb9ac0ceb28e3f19b575b3a424984",
"sha256:df5863a21f91de5ecdf7d32a32f406dd9867ebb35d41033b8bd9607a21887599",
"sha256:e39142332541ed2884c257495504858b22c078a5d781059b07aba4c3a80d7551",
"sha256:e52e8f675ba0b2b417fa98579e7286a41a8e23871f17f4793772f5aa884fea79",
"sha256:e6dd55d5d94b9e36929325dd0c9ab85bfde84a5fc35947c334c32af1af668944",
"sha256:e87cc1acbebf263f308a8494272c2d42016aa33c32bf14d209c81e1f65e11868",
"sha256:ea0091cd4100519cedfeea2c659f52291f535ac6725e2368bcf59e874f270efa",
"sha256:eeb247f4f4d962942b3b555530b0c63b77473c7bfe475e51c6b75b7344b49ce3",
"sha256:f0d4433adce6075efd24fc0285135248b0b50f5a58129c7e552030e04fe45c7f",
"sha256:f1f3bd92f8e12dc22884935a73c9f94c4d9bd0d34410c456540713d6b7832b8c",
"sha256:f42a87cbf50e905f49f053c0b1fb86c911c730624022bf44c8857244fc4cdaca",
"sha256:f5f302db65e2e0ae96e26670818157640d3ca83a3054c290eff3631598dcf819",
"sha256:f7634d534662bbb08976db801ba27a112aee23e597eeaf09267b4575341e45bf",
"sha256:fdd374c02e8bb2d6468a85be50ea66e1c4ef9e809974c30d8576728473a6ed03",
"sha256:fe6931db24716a0845bd8c8915bd096b77c2a7043e6fc59ae9ca364fe816f08b"
],
"index": "pypi",
"version": "==5.1.0"
},
"pluggy": {
"hashes": [
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
"sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
"sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
],
"version": "==0.6.0"
},
"psycopg2": {
"hashes": [
"sha256:027ae518d0e3b8fff41990e598bc7774c3d08a3a20e9ecc0b59fb2aaaf152f7f",
"sha256:092a80da1b052a181b6e6c765849c9b32d46c5dac3b81bf8c9b83e697f3cdbe8",
"sha256:0b9851e798bae024ed1a2a6377a8dab4b8a128a56ed406f572f9f06194e4b275",
"sha256:179c52eb870110a8c1b460c86d4f696d58510ea025602cd3f81453746fccb94f",
"sha256:19983b77ec1fc2a210092aa0333ee48811fd9fb5f194c6cd5b927ed409aea5f8",
"sha256:1d90379d01d0dc50ae9b40c863933d87ff82d51dd7d52cea5d1cb7019afd72cd",
"sha256:27467fd5af1dcc0a82d72927113b8f92da8f44b2efbdb8906bd76face95b596d",
"sha256:32702e3bd8bfe12b36226ba9846ed9e22336fc4bd710039d594b36bd432ae255",
"sha256:33f9e1032095e1436fa9ec424abcbd4c170da934fb70e391c5d78275d0307c75",
"sha256:36030ca7f4b4519ee4f52a74edc4ec73c75abfb6ea1d80ac7480953d1c0aa3c3",
"sha256:363fbbf4189722fc46779be1fad2597e2c40b3f577dc618f353a46391cf5d235",
"sha256:6f302c486132f8dd11f143e919e236ea4467d53bf18c451cac577e6988ecbd05",
"sha256:733166464598c239323142c071fa4c9b91c14359176e5ae7e202db6bcc1d2eb5",
"sha256:7cbc3b21ce2f681ca9ad2d8c0901090b23a30c955e980ebf1006d41f37068a95",
"sha256:888bba7841116e529f407f15c6d28fe3ef0760df8c45257442ec2f14f161c871",
"sha256:8966829cb0d21a08a3c5ac971a2eb67c3927ae27c247300a8476554cc0ce2ae8",
"sha256:8bf51191d60f6987482ef0cfe8511bbf4877a5aa7f313d7b488b53189cf26209",
"sha256:8eb94c0625c529215b53c08fb4e461546e2f3fc96a49c13d5474b5ad7aeab6cf",
"sha256:8ebba5314c609a05c6955e5773c7e0e57b8dd817e4f751f30de729be58fa5e78",
"sha256:932a4c101af007cb3132b1f8a9ffef23386acc53dad46536dc5ba43a3235ae02",
"sha256:ad75fe10bea19ad2188c5cb5fc4cdf53ee808d9b44578c94a3cd1e9fc2beb656",
"sha256:aeaba399254ca79c299d9fe6aa811d3c3eac61458dee10270de7f4e71c624998",
"sha256:b178e0923c93393e16646155794521e063ec17b7cc9f943f15b7d4b39776ea2c",
"sha256:b68e89bb086a9476fa85298caab43f92d0a6af135a5f433d1f6b6d82cafa7b55",
"sha256:d74cf9234ba76426add5e123449be08993a9b13ff434c6efa3a07caa305a619f",
"sha256:f3d3a88128f0c219bdc5b2d9ccd496517199660cea021c560a3252116df91cbd",
"sha256:fe6a7f87356116f5ea840c65b032af17deef0e1a5c34013a2962dd6f99b860dd"
],
"index": "pypi",
"version": "==2.7.4"
},
"py": {
"hashes": [
"sha256:29c9fab495d7528e80ba1e343b958684f4ace687327e6f789a94bf3d1915f881",
"sha256:983f77f3331356039fdd792e9220b7b8ee1aa6bd2b25f567a963ff1de5a64f6a"
],
"version": "==1.5.3"
},
"pycparser": {
"hashes": [
"sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226"
],
"version": "==2.18"
},
"pycryptodome": {
"hashes": [
"sha256:043c82cd3dd3120286a1b325ace93000cf52abb13a067c3ecb6220f874fe4c30",
"sha256:0cdd73492859d853f60b8185715312dbca465879661e28d354d1cf5ea11860e7",
"sha256:15013007e393d0cc0e69f4329a47c4c8597b7f3d02c12c03f805405542f70c71",
"sha256:19d81b92bff837cdade735b9023808556bb4868e1ce194dad4d5ec4e2b2851f3",
"sha256:1ceb3e87605c4f0080115a8a00abf45f5df27b0166a37fd669fbff4523273cfc",
"sha256:2354a77051ed4a2959ce2aac508071eb3e42fc348ea39228b2eac335990bf508",
"sha256:27bd2878200690b050dca34f505b5c623532324b3de40267c1484784063134df",
"sha256:322f239e51fda80233762400a8975ab728639b571fa58545b95b9c44042af010",
"sha256:49a71eb990af30ff6276cfe201eb83ed3640ae989c1b5973f7b55a46c94232d1",
"sha256:4b5a2680008da3ac0cef2d3661597e0cbf8a3eb19eed35b859fd67e2de63eb85",
"sha256:6d34fe5134eb5d62368e21e6f203ac1770bc7273e9536c4a280121312c2de53a",
"sha256:733d5eb7e5ceed8b9d0b3c24c81f52c04cb5de6786461388204fceefe4456aa5",
"sha256:7c73d3798fe2946953768b788ce554c0d4b390780f5e73d63bd833241af27bfe",
"sha256:97af76f5200f15e97cac58d77f319dec40b4bada98de697c91a9517e63b41d1a",
"sha256:97cc46ff02b99dafdc2e0385b325cec0f8a15bf8b285d6ed1d7e4a3bc2067ce1",
"sha256:a561b59e0c3548eb649af381b7c38c6fd8392bbd4d0a8214794b2b761f405af4",
"sha256:ab2c633bfc23cf41be9281228517cb6f87879f4f1aeb154ed72bd53ab7cc83e9",
"sha256:adb54316998337f315520bbd8ef4d8bbd940b4ddfaef8ba1db3c137c5e499399",
"sha256:b4a3b710287eb1fc3e2cc1af018063f003530dff00c9ea4c55ae19bc1f3923cc",
"sha256:bcfdb66d6604882c3f96eea922552c2487cc0aec4b883cd217b9d341d2f8fad0",
"sha256:c08c053eb8716bbbd5e13e38f453b9e46a063e68df8659f3c421dcb7519fd381",
"sha256:e51da4ef9d9e2695a04044152f380c2db17adc9fc6fad8e24d863ead9cd548ed",
"sha256:e850e07f54dc3de9a1efdd59d227fcd1cb30cdd307dafdc647c79e8f30cf5032",
"sha256:ebc579c41fe26748dc1bad4f9105f08740ee28826293a28103b3875968695a5e",
"sha256:ed94cb1b4bf24be734f2bf2db3e8ea75f3914d2f8e684291bee54bbe4a5a9151",
"sha256:f5e19802295e63bdf83bb92849285c01f7167840efb1c1e08507a50b10ba7efa",
"sha256:fc569682f012b1f62f8d28d8f9bc71f1de67648cd1bc124ef8ccf8db4edfc28a"
],
"version": "==3.6.1"
},
"pytest": {
"hashes": [
"sha256:6266f87ab64692112e5477eba395cfedda53b1933ccd29478e671e73b420c19c",
"sha256:fae491d1874f199537fd5872b5e1f0e74a009b979df9d53d1553fd03da1703e1"
],
"version": "==3.5.0"
},
"pytest-django": {
"hashes": [
"sha256:534505e0261cc566279032d9d887f844235342806fd63a6925689670fa1b29d7",
"sha256:7501942093db2250a32a4e36826edfc542347bb9b26c78ed0649cdcfd49e5789"
],
"index": "pypi",
"version": "==3.2.1"
},
"python-baseconv": {
"hashes": [
"sha256:1b98b11d0d1c00bf1165d62b0d183c8d2d496ae5baaa0991c0d4ffef079772d6"
],
"index": "pypi",
"version": "==1.2.1"
},
"python-jose": {
"hashes": [
"sha256:391f860dbe274223d73dd87de25e4117bf09e8fe5f93a417663b1f2d7b591165",
"sha256:3b35cdb0e55a88581ff6d3f12de753aa459e940b50fe7ca5aa25149bc94cb37b"
],
"index": "pypi",
"version": "==2.0.2"
},
"python-memcached": {
"hashes": [
"sha256:4dac64916871bd3550263323fc2ce18e1e439080a2d5670c594cf3118d99b594",
"sha256:a2e28637be13ee0bf1a8b6843e7490f9456fd3f2a4cb60471733c7b5d5557e4f"
],
"index": "pypi",
"version": "==1.59"
},
"python-slugify": {
"hashes": [
"sha256:5dbb360b882b2dabe0471a1a92f604504d83c2a73c71f2098d004ab62e695534"
],
"index": "pypi",
"version": "==1.2.5"
},
"pytz": {
"hashes": [
"sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555",
"sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749"
],
"version": "==2018.4"
},
"pyyaml": {
"hashes": [
"sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
"sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
"sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
"sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
"sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
"sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
"sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
"sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
"sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
"sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
"sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
"sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
"sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
"sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
],
"index": "pypi",
"version": "==3.12"
},
"qrcode": {
"hashes": [
"sha256:037b0db4c93f44586e37f84c3da3f763874fcac85b2974a69a98e399ac78e1bf",
"sha256:de4ffc15065e6ff20a551ad32b6b41264f3c75275675406ddfa8e3530d154be3"
],
"index": "pypi",
"version": "==6.0"
},
"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"
],
"index": "pypi",
"version": "==0.6"
},
"rq": {
"hashes": [
"sha256:31a5f04d1410111617ae78756b86fc6b0cf300fe7445843ea3758b86d9f67bc5",
"sha256:c1711bc43f298061166805763e6fa2353f03142e057e83f338d6e197a1be3157"
],
"version": "==0.10.0"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"sqlparse": {
"hashes": [
"sha256:ce028444cfab83be538752a2ffdb56bc417b7784ff35bb9a3062413717807dec",
"sha256:d9cf190f51cbb26da0412247dfe4fb5f4098edb73db84e02f9fc21fdca31fed4"
],
"version": "==0.2.4"
},
"unidecode": {
"hashes": [
"sha256:72f49d3729f3d8f5799f710b97c1451c5163102e76d64d20e170aedbbd923582",
"sha256:8c33dd588e0c9bc22a76eaa0c715a5434851f726131bd44a6c26471746efabf5"
],
"version": "==1.0.22"
},
"urllib3": {
"hashes": [
"sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
"sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
],
"version": "==1.22"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
],
"version": "==0.5.1"
},
"xrd": {
"hashes": [
"sha256:51d01f732b5b5b7983c5179ffaed864408d95a667b3a6630fe27aa7528274089"
],
"index": "pypi",
"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,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

@ -7,20 +7,16 @@ 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,
},
)
requests.post(settings.PUSH_HUB, data={
'hub.mode': 'publish',
'hub.url': url,
})
@job
def send_mentions(source, targets=None):
if targets is None:
targets = webmention.findMentions(source)["refs"]
for target in targets:
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

@ -14,75 +14,69 @@ class Entry:
return self.index_page()
def index_page(self, page=0):
kwargs = {"kind": self}
kwargs = {'kind': self.plural}
if page > 1:
kwargs["page"] = page
return reverse("entries:index", kwargs=kwargs)
kwargs['page'] = page
return reverse('entries:index', kwargs=kwargs)
@property
def entry(self):
return self.plural + "_entry"
return self.plural + '_entry'
@property
def entry_amp(self):
return self.entry + '_amp'
@property
def atom(self):
return reverse("entries:atom_by_kind", kwargs={"kind": self})
return reverse('entries:atom_by_kind', kwargs={'kind': self.plural})
@property
def rss(self):
return reverse("entries:rss_by_kind", kwargs={"kind": self})
return reverse('entries:rss_by_kind', kwargs={'kind': self.plural})
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

@ -9,17 +9,17 @@ 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,20 @@
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
ENTRY_KINDS = [(k.id, k.id) for k in kinds.all]
@ -33,33 +30,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('cats', '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 +67,7 @@ class Entry(ModelMeta, TimeStampedModel):
author = models.ForeignKey(
get_user_model(),
related_name="entries",
related_name='entries',
on_delete=models.CASCADE,
)
@ -75,7 +75,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 +89,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(utils.to_plain(self.paragraphs[0]), width=100, placeholder='')
@property
def excerpt(self):
try:
return utils.to_plain(self.paragraphs[0 if self.name else 1])
except IndexError:
return " "
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,41 +128,27 @@ 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
base = 'https://' + Site.objects.get_current().domain
return urljoin(base, self.url)
@property
def affected_urls(self):
base = "https://" + DjangoSite.objects.get_current().domain
kind = kinds.from_id[self.kind]
urls = {
self.url,
reverse("entries:index", kwargs={"kind": kind}),
reverse("entries:atom_by_kind", kwargs={"kind": kind}),
reverse("entries:rss_by_kind", kwargs={"kind": kind}),
} | {cat.url for cat in self.cats.all()}
if kind.on_home:
urls |= {
reverse("home:index"),
reverse("entries:atom"),
reverse("entries:rss"),
}
return {urljoin(base, u) for u in urls}
@property
def url(self):
kind = kinds.from_id[self.kind]
args = [kind, self.id]
args = [kind.plural, self.id]
if kind.slug:
args.append(self.slug)
return reverse("entries:entry", args=args)
return reverse('entries:entry', args=args)
@property
def amp_url(self):
kind = kinds.from_id[self.kind]
args = [kind.plural, self.id]
if kind.slug:
args.append(self.slug)
return reverse('entries:entry_amp', args=args)
@property
def short_url(self):
@ -169,58 +160,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 +1,33 @@
from typing import Callable
from django.core.paginator import Page, Paginator
from django.core.paginator import Paginator
from django.shortcuts import redirect
from lemoncurry.middleware import ResponseException
def paginate(queryset, reverse, page):
class Page:
def __init__(self, i):
self.i = i
def paginate(queryset, reverse: Callable[[int], str], page: int | None) -> Page:
def redirect_to_page(i: int):
raise ResponseException(redirect(reverse(i)))
@property
def url(self):
return reverse(self.i)
def reversible(p: Page) -> Page:
p.reverse = reverse
return p
@property
def current(self):
return self.i == entries.number
# If the first page was requested, redirect to the clean version of the URL
# with no page suffix.
if page == '1':
return redirect(Page(1).url)
paginator = Paginator(queryset, 10)
entries = paginator.page(page or 1)
# If no page number was specified, return page one.
if page is None:
return reversible(paginator.page(1))
entries.pages = tuple(Page(i) for i in paginator.page_range)
# 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 entries.has_previous():
entries.prev = Page(entries.previous_page_number())
if entries.has_next():
entries.next = Page(entries.next_page_number())
# 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))
return entries

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

@ -10,40 +10,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 +26,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,17 @@
{% extends 'lemoncurry/layout.html' %}
{% load static %}
{% block head %}
<link rel="amphtml" href="{{ entry.amp_url }}" />
<link rel="shortlink" href="{{ entry.short_url }}" />
{% 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,157 @@
{% load absolute_url favtags friendly_url humanize jsonify lemoncurry_tags markdown theme_colour %}<!doctype html>
<html lang="en" class="h-entry">
<head>
<meta charset="utf-8" />
<title>{{ entry.title }} ~ {% site_name %}</title>
<link rel="canonical" href="{% absolute_url entry.url %}">
<link rel="shortlink" href="{% absolute_url entry.short_url %}" />
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<script class="p-json-ld" type="application/ld+json">
{{ entry.json_ld | jsonify }}
</script>
{% placeFavicon %}
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<style amp-custom>
*, ::after, ::before {
box-sizing: border-box;
}
html {
background-color: {% theme_colour 0 %};
font-family: sans-serif;
line-height: 1.15;
}
body {
color: {% theme_colour 7 %};
display: flex;
flex-direction: column;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
margin: 0;
min-height: 100vh;
}
body > header {
background-color: {% theme_colour 1 %};
padding: .5rem 1rem;
}
body > header > a {
color: inherit;
display: inline-block;
font-size: 1.25rem;
font-weight: unset;
margin-right: 1rem;
margin: 0;
padding-bottom: .3125rem;
padding-top: .3125rem;
}
body > main {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
margin: 2rem 1rem;
}
body > main > article {
background-color: {% theme_colour 2 %};
border: 1px solid rgba(0,0,0,.125);
border-radius: .25rem;
padding: 1.25rem;
width: 90%;
}
body > main > article > h4 {
font-size: 1.5rem;
font-weight: 500;
margin: 0;
margin-bottom: .75rem;
}
body > main > article > aside {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
}
a {
text-decoration: none;
color: {% theme_colour 13 %};
}
a:hover {
color: {% theme_colour 12 %};
}
amp-img {
vertical-align: baseline;
}
</style>
<script async src="https://cdn.ampproject.org/v0.js"></script>
</head>
<body>
<header>
<a rel="home" href="{% url 'home:index' %}">{% site_name %}</a>
</header>
<main>
<article>
{% if entry.photo %}
<amp-img class="u-photo" src="{{ entry.photo.url }}" height="{{ entry.photo.height }}" width="{{ entry.photo.width }}" layout="responsive">
<span class="value" hidden>{% absolute_url entry.photo.url %}</span>
</amp-img>
{% endif %}
{% if entry.name %}
<h4 class="p-name">{{ entry.name }}</h4>
{% endif %}
<main class="e-content{% if not entry.name %} p-name{% endif %}">
{{ entry.content | markdown }}
</main>
<aside>
<a class="p-author h-card" href="{% absolute_url entry.author.url %}">
<amp-img class="u-photo" src="{{ entry.author.avatar.url }}" height="16" width="16" layout="fixed">
<span class="value" hidden>{% absolute_url entry.author.avatar.url %}</span>
</amp-img>
<span class="p-name">{{ entry.author.name }}</span>
</a>
<a class="u-uid u-url" href="{% absolute_url entry.url %}">
<time class="dt-published" datetime="{{ entry.published.isoformat }}">
📅
{{ entry.published | naturaltime }}
</time>
</a>
{% if entry.published != entry.updated %}
<time class="dt-updated" datetime="{{ entry.updated.isoformat }}">
✏️
{{ entry.updated | naturaltime }}
</time>
{% endif %}
<a class="u-url" href="{% absolute_url entry.amp_url %}">
⚡️
amp
</a>
<a class="u-url" href="{% absolute_url entry.short_url %}">
🔗
{{ entry.short_url | friendly_url }}
</a>
{% for c in entry.cats.all %}
<a class="p-category" href="{% absolute_url c.url %}">
🐾
<span class="value">{{ c.name }}</span>
</a>
{% endfor %}
{% for s in entry.syndications.all %}
<a class="u-syndication" href="{% absolute_url s.url %}">
🗞
{{ s.profile }}
</a>
{% endfor %}
</aside>
</article>
</main>
</body>
</html>

View file

@ -0,0 +1,71 @@
{% load bleach friendly_url humanize jsonify markdown %}<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>
{% endif %}
<a class="u-url" href="{{ entry.amp_url }}">
<i class="fas fa-bolt"></i>
amp
</a>
<a class="u-url" href="{{ entry.short_url }}">
<i class="fas fa-link"></i>
{{ entry.short_url | friendly_url }}
</a>
</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 }}" aria-hidden="true"></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,51 @@
from django.urls import path, register_converter, reverse
from django.conf.urls import url
from django.urls import reverse
from . import kinds
from .views import feeds, lists, perma
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>[^/]+)"
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>[^/]+)'
slug_opt = "(?:" + slug + ")?"
slug_opt = '(?:' + slug + ')?'
app_name = "entries"
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"),
url('^atom$', feeds.AtomHomeEntries(), name='atom'),
url('^rss$', feeds.RssHomeEntries(), name='rss'),
url(to_pat('cats', slug, page), lists.by_cat, name='cat'),
url(to_pat(kind, page), lists.by_kind, name='index'),
url(to_pat(kind, '/atom'), feeds.AtomByKind(), name='atom_by_kind'),
url(to_pat(kind, '/rss'), feeds.RssByKind(), name='rss_by_kind'),
url(to_pat(kind, id, slug_opt, '/amp'), perma.entry_amp, name='entry_amp'),
url(to_pat(kind, id, slug_opt), 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"]
super().__init__(prefix('index'), parent='home:index')
@property
def label(self):
return self.kind.plural
return self.match.kwargs['kind']
@property
def url(self):
return reverse(prefix("index"), kwargs={"kind": self.kind})
return reverse(prefix('index'), kwargs={'kind': self.label})
crumbs.add(prefix("cat"), parent="home:index")
crumbs.add(prefix('cat'), parent='home:index')
crumbs.add(IndexCrumb())
crumbs.add(prefix("entry"), parent=prefix("index"))
crumbs.add(prefix('entry'), parent=prefix('index'))

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 ..kinds import from_plural, on_home
from ..models import Entry
class Atom1FeedWithHub(Atom1Feed):
def add_root_elements(self, handler):
super().add_root_elements(handler)
handler.startElement("link", {"rel": "hub", "href": settings.PUSH_HUB})
handler.endElement("link")
class EntriesFeed(Feed):
item_guid_is_permalink = True
def item_link(self, entry):
return entry.absolute_url
def item_title(self, entry):
return entry.title
@ -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):
@ -48,7 +37,7 @@ class EntriesFeed(Feed):
class RssByKind(EntriesFeed):
def get_object(self, request, kind):
return kind
return from_plural[kind]
def title(self, kind):
return "{0} ~ {1}".format(
@ -70,7 +59,7 @@ class RssByKind(EntriesFeed):
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,36 +1,42 @@
from annoying.decorators import render_to
from django.shortcuts import get_object_or_404
from django.urls import reverse
from .. import kinds
from ..models import Entry, Cat
from ..pagination import paginate
@render_to("entries/index.html")
def by_kind(request, kind, page=None):
@render_to('entries/index.html')
def by_kind(request, kind, page):
kind = kinds.from_plural[kind]
entries = Entry.objects.filter(kind=kind.id)
entries = paginate(queryset=entries, reverse=kind.index_page, page=page)
if hasattr(entries, 'content'):
return entries
return {
"entries": entries,
"atom": kind.atom,
"rss": kind.rss,
"title": kind.plural,
'entries': entries,
'atom': kind.atom,
'rss': kind.rss,
'title': kind.plural,
}
@render_to("entries/index.html")
def by_cat(request, slug, page=None):
@render_to('entries/index.html')
def by_cat(request, slug, page):
def url(page):
kwargs = {"slug": slug}
kwargs = {'slug': slug}
if page > 1:
kwargs["page"] = page
return reverse("entries:cat", kwargs=kwargs)
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)
if hasattr(entries, 'content'):
return entries
return {
"entries": entries,
"title": "#" + cat.name,
'entries': entries,
'title': '#' + cat.name,
}

View file

@ -3,12 +3,21 @@ from django.shortcuts import redirect, get_object_or_404
from ..models import Entry
@render_to("entries/entry.html")
@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,
'entry': entry,
'title': entry.title,
'meta': entry.as_meta(request)
}
@render_to('entries/entry_amp.html')
def entry_amp(request, kind, id, slug=None):
entry = get_object_or_404(Entry, pk=id)
if request.path != entry.amp_url:
return redirect(entry.amp_url, permanent=True)
return {'entry': entry}

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,65 @@
{% 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 }}">
{% if user.avatar %}<img class="card-img-top u-photo" src="{{ user.avatar.url }}" alt="{{ user.first_name }} {{ user.last_name }}" />{% endif %}
</a>
<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" aria-hidden="true"></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 }}" aria-hidden="true"></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'^(?:page/(?P<page>\d+))?$', views.index, name='index'),
url(r'^robots.txt$', views.robots, name='robots.txt'),
]

View file

@ -8,31 +8,39 @@ from urllib.parse import urljoin
from entries import kinds, pagination
from lemoncurry import breadcrumbs, utils
breadcrumbs.add("home:index", "home")
breadcrumbs.add('home:index', 'home')
@render_to("home/index.html")
def index(request, page=None):
@render_to('home/index.html')
def index(request, page):
def url(page):
kwargs = {"page": page} if page != 1 else {}
return reverse("home:index", kwargs=kwargs)
kwargs = {'page': page} if page != 1 else {}
return reverse('home:index', kwargs=kwargs)
user = request.user
if not hasattr(user, "entries"):
if not hasattr(user, 'entries'):
user = get_object_or_404(User, pk=1)
entries = user.entries.filter(kind__in=kinds.on_home)
entries = pagination.paginate(queryset=entries, reverse=url, page=page)
# If we got a valid HTTP response, just return it without rendering.
if hasattr(entries, 'content'):
return entries
return {
"user": user,
"entries": entries,
"atom": reverse("entries:atom"),
"rss": reverse("entries:rss"),
'user': user,
'entries': entries,
'atom': reverse('entries:atom'),
'rss': reverse('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,70 @@
{% 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 %}
<span data-tooltip data-theme="success" data-html="#verified-success">
<i class="fas fa-check-circle verified-success"></i>
</span>
{% else %}
<span data-tooltip data-theme="warning" data-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_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,51 @@
{% 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 form-check">
<input id="id_{{ form.otp_trust_agent.name }}" name="{{ form.otp_trust_agent.name }}" class="form-check-input" type="checkbox" />
<label for="id_{{ form.otp_trust_agent.name }}" 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"></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,78 @@
from micropub import error
from .models import IndieAuthCode, Token
from jose import jwt
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from django.conf import settings
from micropub.views import error
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]))
def auth(request):
if 'HTTP_AUTHORIZATION' in request.META:
auth = request.META.get('HTTP_AUTHORIZATION').split(' ')
if auth[0] != 'Bearer':
return 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>")
return 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")
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()
return error.unauthorized()
try:
token = Token.objects.get(pk=token)
except Token.DoesNotExist:
raise error.forbidden()
token = decode(token)
except Exception as e:
return error.forbidden()
return token
return MicropubToken(token)
class MicropubToken:
def __init__(self, tok):
self.user = get_user_model().objects.get(pk=tok['uid'])
self.client = tok['cid']
self.scope = tok['sco']
self.me = self.user.full_url
self.scopes = self.scope.split(' ')
def __contains__(self, scope):
return scope in self.scopes
def encode(payload):
return jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
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', 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 from {client_id}'.format(**params),
}
def post(self, request):
post = request.POST.dict()
try:
code = IndieAuthCode.objects.get(pk=post.get("code"))
except IndieAuthCode.DoesNotExist:
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,49 @@
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)
if hasattr(token, 'content'):
return token
res = {
"me": token.me,
"client_id": token.client_id,
"scope": token.scope,
'me': token.me,
'client_id': token.client,
'scope': token.scope,
}
return utils.choose_type(req, res)
def post(self, req):
post = req.POST
try:
code = IndieAuthCode.objects.get(pk=post.get("code"))
except IndieAuthCode.DoesNotExist:
return utils.forbid("invalid auth code")
code.delete()
if code.expired:
return utils.forbid("invalid auth code")
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

@ -14,7 +14,7 @@ class Crumb:
return self._label
def __eq__(self, other):
if hasattr(other, "route"):
if hasattr(other, 'route'):
return self.route == other.route
return self.route == other

View file

@ -2,6 +2,6 @@ from debug_toolbar.middleware import show_toolbar as core_show_toolbar
def show_toolbar(request):
if request.path.endswith("/amp"):
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,14 +10,8 @@ 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
from os import path
APPEND_SLASH = False
ADMINS = [
("dani", "dani@00dani.me"),
]
BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
@ -26,13 +20,13 @@ BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
# 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 +44,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 +52,98 @@ 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.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',
'analytical',
'annoying',
'compressor',
'computed_property',
'corsheaders',
'debug_toolbar',
'django_activeurl',
'django_agent_trust',
'django_otp',
'django_otp.plugins.otp_totp',
'django_rq',
'favicon',
'meta',
'lemoncurry',
'entries',
'home',
'lemonauth',
'lemonshort',
'micropub',
'users',
'webmention',
'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.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',
]
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': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6380/0',
'KEY_PREFIX': 'lemoncurry',
'OPTIONS': {
'PARSER_CLASS': 'redis.connection.HiredisParser',
'SERIALIZER': 'django_redis.serializers.msgpack.MSGPackSerializer',
},
"VERSION": 2,
'VERSION': 2,
}
}
@ -163,51 +151,47 @@ 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': path.join(BASE_DIR, 'db.sqlite3'),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
AUTH_USER_MODEL = "users.User"
AUTH_USER_MODEL = 'users.User'
# Password hashers
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
"django.contrib.auth.hashers.BCryptPasswordHasher",
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
'django.contrib.auth.hashers.BCryptPasswordHasher',
]
# Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
PW_VALIDATOR_MODULE = "django.contrib.auth.password_validation"
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': PW_VALIDATOR_MODULE + '.UserAttributeSimilarityValidator'},
{'NAME': PW_VALIDATOR_MODULE + '.MinimumLengthValidator'},
{'NAME': PW_VALIDATOR_MODULE + '.CommonPasswordValidator'},
{'NAME': PW_VALIDATOR_MODULE + '.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 +203,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 = 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', 'npx stylus -u ./static/lemoncurry/css/theme'),
)
MEDIA_URL = STATIC_URL + "media/"
MEDIA_ROOT = path.join(STATIC_ROOT, "media")
MEDIA_URL = STATIC_URL + 'media/'
MEDIA_ROOT = path.join(STATIC_ROOT, 'media')
# django-contrib-sites
# https://docs.djangoproject.com/en/dev/ref/contrib/sites/
@ -245,25 +228,33 @@ AGENT_COOKIE_SECURE = True
# django-cors-headers
CORS_ORIGIN_ALLOW_ALL = True
CORS_URLS_REGEX = r"^/(?!admin|auth/(?:login|logout|indie)).*$"
# django-debug-toolbar
# https://django-debug-toolbar.readthedocs.io/en/stable/configuration.html
DEBUG_TOOLBAR_CONFIG = {
'SHOW_TOOLBAR_CALLBACK': 'lemoncurry.debug.show_toolbar',
}
# lemonshort
SHORT_BASE_URL = "/s/"
SHORT_BASE_URL = '/s/'
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'}}
# django-super-favicon
FAVICON_STORAGE = 'django.core.files.storage.DefaultStorage'

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,22 @@ 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'

View file

@ -1,8 +1,4 @@
from .base import *
ALLOWED_HOSTS = ["*"]
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,11 +1,14 @@
$monokai_bg = #272822
$sm = 576px
$md = 768px
$lg = 992px
$xl = 1200px
html
background-color $base00
a
color $base0D
text-decoration none
&:hover
color $base0C
@ -24,22 +27,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 +58,7 @@ body
> main
padding 2rem 1rem
padding 2rem
width 100%
flex 1
display flex
@ -104,12 +97,6 @@ ul.pagination
background-color $base02
border 1px solid rgba(0,0,0,.125)
.media
display flex
> .media-body
flex-grow 1
margin-left 3px
.card
background-color $base02

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,131 @@
{% load analytical compress favicon 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 %}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<base href="{{ origin }}" />
<link rel="canonical" href="{{ uri }}" />
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ site_name }}</title>
{% analytical_head_top %}
{% if atom %}<link rel="alternate" type="application/atom+xml" href="{{ atom }}" />{% endif %}
{% if rss %}<link rel="alternate" type="application/rss+xml" href="{{ 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 %}" />
{% get_package_json as package %}
<meta name="generator" content="{{ package.name }} {{ package.version }}" />
<meta property="og:url" content="{{ uri }}" />
<meta property="og:title" content="{% firstof title site_name %}" />
{% include 'meta/meta.html' %}
{% get_favicons 'favicon/' %}
<link rel="stylesheet" type="text/css" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css"
integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous" />
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/monokai.min.css"
integrity="sha384-bHqbpRh/XW+phptvH9nQvMKHwPH1ZbOxpIeAB2D2OIEL4Ni7aZzZgMFpsRra+v1g" 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.10/js/all.js"
integrity="sha384-slN8GvtUJGnv6ca26v8EzVaR9DC58QEwsIk9q1QXdCU8Yu8ck/tL/5szYlBbqmS+" crossorigin="anonymous"></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 %}
{% 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>
{% if entries.has_other_pages %}
<nav>
<ul class="pagination">
{% if entries.prev %}
<li class="page-item">
<a class="page-link" rel="prev" href="{{ entries.prev.url }}">
<i class="fas fa-step-backward"></i><span class="sr-only">previous page</span>
</a>
</li>
{% endif %}
{% for page in entries.pages %}
{% if page.current %}
<li class="page-item active">
<span class="page-link">{{ page.i }} <span class="sr-only">(current page)</span></span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ page.url }}">{{ page.i }}</a>
</li>
{% endif %}
{% endfor %}
{% if entries.next %}
<li class="page-item">
<a class="page-link" rel="next" href="{{ entries.next.url }}">
<i class="fas fa-step-forward"></i><span class="sr-only">next page</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<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.3.1.slim.min.js" crossorigin="anonymous"
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-uefMccjFJAIv6A+rW+L4AHf99KvxDjWSu1z9VI8SKNVmz4sk7buKt/6v9KI65qnm"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" crossorigin="anonymous"
integrity="sha384-ZeLYJ2PNSQjvogWP559CDAf02Qb8FE5OyQicqtz/+UhZutbrwyr87Be7NPH/RgyC"></script>
{% compress js %}
<script src="{% static 'tippy.js/tippy.standalone.js' %}"></script>
<script type="text/javascript">
hljs.initHighlightingOnLoad();
</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="{{ crumb.url }}">{{ 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

@ -8,5 +8,5 @@ register = template.Library()
@register.simple_tag
@register.filter(is_safe=True)
def absolute_url(url):
base = "https://" + Site.objects.get_current().domain
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 = ['cite', 'code', 'p', 'pre', '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))

View file

@ -1,3 +1,4 @@
from django import template
from django.conf import settings
from django.contrib.sites.models import Site
@ -39,71 +40,65 @@ def site_name():
return Site.objects.get_current().name
@register.inclusion_tag("lemoncurry/tags/nav.html")
@register.inclusion_tag('lemoncurry/tags/nav.html')
def nav_left(request):
items = (
MenuItem(label=k.plural, icon=k.icon, url=("entries:index", (k,)))
for k in kinds.all
)
return {"items": items, "request": request}
items = (MenuItem(
label=k.plural,
icon=k.icon,
url=('entries:index', (k.plural,))
) for k in kinds.all)
return {'items': items, 'request': request}
@register.inclusion_tag("lemoncurry/tags/nav.html")
@register.inclusion_tag('lemoncurry/tags/nav.html')
def nav_right(request):
if request.user.is_authenticated:
items = (
MenuItem(label="admin", icon="fas fa-cog", url="admin:index"),
MenuItem(
label="log out", icon="fas fa-sign-out-alt", url="lemonauth:logout"
),
MenuItem(label='admin', icon='fas fa-cog', url='admin:index'),
MenuItem(label='log out', icon='fas fa-sign-out-alt', url='lemonauth:logout'),
)
else:
items = (
MenuItem(label="log in", icon="fas fa-sign-in-alt", url="lemonauth:login"),
MenuItem(label='log in', icon='fas fa-sign-in-alt', url='lemonauth:login'),
)
return {"items": items, "request": request}
return {'items': items, 'request': request}
@register.inclusion_tag("lemoncurry/tags/breadcrumbs.html", takes_context=True)
@register.inclusion_tag('lemoncurry/tags/breadcrumbs.html', takes_context=True)
def nav_crumbs(context, route):
crumbs = breadcrumbs.find(route)
current = crumbs.pop()
item_list_element = [
{
"@type": "ListItem",
"position": i + 1,
"item": {
"@id": context["origin"] + crumb.url,
"@type": "WebPage",
"name": crumb.label,
},
item_list_element = [{
'@type': 'ListItem',
'position': i + 1,
'item': {
'@id': context['origin'] + crumb.url,
'@type': 'WebPage',
'name': crumb.label
}
for i, crumb in enumerate(crumbs)
]
item_list_element.append(
{
"@type": "ListItem",
"position": len(item_list_element) + 1,
"item": {
"id": context["uri"],
"@type": "WebPage",
"name": current.label or context.get("title"),
},
} for i, crumb in enumerate(crumbs)]
item_list_element.append({
'@type': 'ListItem',
'position': len(item_list_element) + 1,
'item': {
'id': context['uri'],
'@type': 'WebPage',
'name': current.label or context.get('title'),
}
)
})
breadcrumb_list = {
"@context": "http://schema.org",
"@type": "BreadcrumbList",
"itemListElement": item_list_element,
'@context': 'http://schema.org',
'@type': 'BreadcrumbList',
'itemListElement': item_list_element
}
return {
"breadcrumb_list": breadcrumb_list,
"crumbs": crumbs,
"current": current,
"title": context.get("title"),
'breadcrumb_list': breadcrumb_list,
'crumbs': crumbs,
'current': current,
'title': context.get('title'),
}

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