Compare commits
233 commits
Author | SHA1 | Date | |
---|---|---|---|
Danielle McLean | 5348dc9f82 | ||
Danielle McLean | e36ad27d49 | ||
Danielle McLean | d21d4bda83 | ||
Danielle McLean | 8d8aa4749b | ||
Danielle McLean | 3baf75e59e | ||
Danielle McLean | 880b899e81 | ||
Danielle McLean | 6061d6f600 | ||
Danielle McLean | a680a6501c | ||
Danielle McLean | 625b5d963a | ||
Danielle McLean | 9d11cc7576 | ||
Danielle McLean | c49e17db90 | ||
Danielle McLean | 7696ff45db | ||
Danielle McLean | 731f177d18 | ||
Danielle McLean | 0061111ad8 | ||
Danielle McLean | 6b53c00d7c | ||
Danielle McLean | 1490a95735 | ||
Danielle McLean | c398b0d3f4 | ||
Danielle McLean | 95cca433bc | ||
Danielle McLean | 4f081c8d34 | ||
Danielle McLean | 8386f77d72 | ||
Danielle McLean | 03956637be | ||
Danielle McLean | 60bdaa27a0 | ||
Danielle McLean | a6fa7ebb3a | ||
Danielle McLean | d0bd6c1231 | ||
Danielle McLean | 960e64963f | ||
Danielle McLean | 0b1a548ee4 | ||
Danielle McLean | 04bd6dd35d | ||
Danielle McLean | 2e7d12b3e6 | ||
Danielle McLean | cd990e4e2f | ||
Danielle McLean | fe187da491 | ||
Danielle McLean | 636b470001 | ||
Danielle McLean | e5cf94d488 | ||
Danielle McLean | c5458c2d06 | ||
Danielle McLean | 7af8636687 | ||
Danielle McLean | 5ac46dad63 | ||
Danielle McLean | d4c814c79a | ||
Danielle McLean | db0d6e28a3 | ||
Danielle McLean | 2f8d62649e | ||
Danielle McLean | 683adc1b46 | ||
Danielle McLean | cfeb206154 | ||
Danielle McLean | c5c0f4258b | ||
Danielle McLean | 73addc2f75 | ||
Danielle McLean | 0ca50252dd | ||
Danielle McLean | 8d79be07da | ||
Danielle McLean | 37d5a7a20d | ||
Danielle McLean | 76496e7169 | ||
Danielle McLean | 7fcc3c8788 | ||
Danielle McLean | 4436db7d83 | ||
Danielle McLean | d017c642eb | ||
Danielle McLean | 7c5f311af9 | ||
Ben Lubar | 73155f399b | ||
Danielle McLean | e540f7b784 | ||
Danielle McLean | 0e8f816d0e | ||
Danielle McLean | 1bf0d8478a | ||
Danielle McLean | 594947852f | ||
Danielle McLean | b318ed5b06 | ||
Danielle McLean | 012aed42b1 | ||
Danielle McLean | 5c10bafb7d | ||
Danielle McLean | e660221265 | ||
Danielle McLean | e23ca7d215 | ||
Danielle McLean | 95b02269bb | ||
Danielle McLean | ce07ba8cdc | ||
Danielle McLean | 17e5c2c1b4 | ||
Danielle McLean | 4fd2ff826a | ||
Danielle McLean | 6efcc450a3 | ||
Danielle McLean | dc7442cfb6 | ||
Danielle McLean | 9c708b8c89 | ||
Danielle McLean | 40f0bd858b | ||
Danielle McLean | 639e1ec9c6 | ||
Danielle McLean | a35072bbc3 | ||
Danielle McLean | da5ca5edea | ||
Danielle McLean | 1e4df2d1b5 | ||
Danielle McLean | d68dda85ad | ||
Danielle McLean | 065619772e | ||
Danielle McLean | 7d17a92793 | ||
Danielle McLean | 1d4be082cf | ||
Danielle McLean | 2d643b48c6 | ||
Danielle McLean | bab7097fa3 | ||
Danielle McLean | fa8419976d | ||
Danielle McLean | 427dcde672 | ||
Danielle McLean | 580c61e924 | ||
Danielle McLean | 6c9b6eb061 | ||
Danielle McLean | 6d7b5db482 | ||
Danielle McLean | 8a0c24a9b5 | ||
Danielle McLean | c8e0b9c5fb | ||
Danielle McLean | 556329d5fa | ||
Danielle McLean | ac22c826cb | ||
Danielle McLean | 0adc7a0d5e | ||
Danielle McLean | f7d7936499 | ||
Danielle McLean | c8faa30724 | ||
Danielle McLean | 0d1d102f47 | ||
Danielle McLean | cf0264b5a6 | ||
Danielle McLean | 6054accc54 | ||
Danielle McLean | 778a9c870d | ||
Danielle McLean | dee64f130e | ||
Danielle McLean | bc8d7923b4 | ||
Danielle McLean | dec5ef153b | ||
Danielle McLean | 5cf566251a | ||
Danielle McLean | 7edc5d0165 | ||
Danielle McLean | 35ced9a451 | ||
Danielle McLean | 446029ce84 | ||
Danielle McLean | bb91d3c6b6 | ||
Danielle McLean | b32412f4fd | ||
Danielle McLean | ce0bf28725 | ||
Danielle McLean | 77816b6c5d | ||
Danielle McLean | b145f4ada9 | ||
Danielle McLean | e4aa5c6e6e | ||
Danielle McLean | fa66fbbf1e | ||
Danielle McLean | bc433f235f | ||
Danielle McLean | 2a38c8d21b | ||
Danielle McLean | 4bc7fde36b | ||
Danielle McLean | 5042f3bda7 | ||
Danielle McLean | fca5b3259d | ||
Danielle McLean | ee12c15d1c | ||
Danielle McLean | 741c2eb234 | ||
Danielle McLean | 9c843ee145 | ||
Danielle McLean | 8f416cb5d7 | ||
Danielle McLean | 5f9aca20c4 | ||
Danielle McLean | 59e40b551b | ||
Danielle McLean | 9586f59592 | ||
Danielle McLean | a585ab038b | ||
Danielle McLean | 81baf59085 | ||
Danielle McLean | d7abc468b9 | ||
Danielle McLean | 67f8ec6fae | ||
Danielle McLean | 24bc7816f1 | ||
Danielle McLean | f0678c3379 | ||
Danielle McLean | e94a856a08 | ||
Danielle McLean | 2c90114b9d | ||
Danielle McLean | 0860f37ac0 | ||
Danielle McLean | 8932317f08 | ||
Danielle McLean | f551a5214a | ||
Danielle McLean | d91676289b | ||
Danielle McLean | 121789febe | ||
Danielle McLean | 276ce34ae9 | ||
Danielle McLean | 5ce0af0731 | ||
Danielle McLean | 4c0be4ce8b | ||
Danielle McLean | f7fbf49e1e | ||
Danielle McLean | c9f66eb91c | ||
Danielle McLean | 142e3eff2b | ||
Danielle McLean | 8d4444cfb9 | ||
Danielle McLean | b59962a119 | ||
Danielle McLean | 335db51ffc | ||
Danielle McLean | a62522d36a | ||
Danielle McLean | 15e5219e3a | ||
Danielle McLean | a0bc5bacd8 | ||
Danielle McLean | 4aba1034e7 | ||
Danielle McLean | a35a1283da | ||
Danielle McLean | 382a79e6c7 | ||
Danielle McLean | 24843cc31b | ||
ffd367c6ca | |||
Danielle McLean | 73f0d4a7c3 | ||
Danielle McLean | 42f96b0ead | ||
42c7b9d854 | |||
7cd5e19c1e | |||
Danielle McLean | bbb860148d | ||
Danielle McLean | c405fc0dbb | ||
Danielle McLean | d65757f7df | ||
9e57d04ad5 | |||
Danielle McLean | e5c02b1fc6 | ||
Danielle McLean | 742296d5dd | ||
Danielle McLean | 4a208a91b6 | ||
Danielle McLean | 4dd7a6dcc4 | ||
Danielle McLean | aec98120ab | ||
Danielle McLean | 03b2668969 | ||
Danielle McLean | 46c2224a4f | ||
Danielle McLean | 9f3cbac3c4 | ||
Danielle McLean | 39d0a64c34 | ||
Danielle McLean | 9fb2e8552f | ||
Danielle McLean | eb20cc1d21 | ||
Danielle McLean | 86f4da306c | ||
Danielle McLean | 04b3b7f806 | ||
Danielle McLean | dd0951cc82 | ||
Danielle McLean | 4945b40810 | ||
Danielle McLean | c55f437885 | ||
Danielle McLean | b32cefe762 | ||
Danielle McLean | 30c4c8ec8f | ||
Danielle McLean | 4d974a5364 | ||
Danielle McLean | 0d5387823d | ||
Danielle McLean | 467ba19704 | ||
Danielle McLean | 9edb0571d8 | ||
Danielle McLean | e5bad72e36 | ||
Danielle McLean | 18ca8545e6 | ||
Danielle McLean | d4b8581793 | ||
Danielle McLean | 1b660d8af5 | ||
Danielle McLean | 2fc4a5e23e | ||
Danielle McLean | 30634f9ec2 | ||
Danielle McLean | 0239f7e031 | ||
Danielle McLean | 70e57e4155 | ||
Danielle McLean | e9c46f23db | ||
Danielle McLean | 0b43aad50e | ||
Danielle McLean | 70d4579448 | ||
Danielle McLean | c40372a020 | ||
Danielle McLean | ffd0d3384e | ||
Danielle McLean | 6f3f613cc8 | ||
Danielle McLean | 34ea2441bb | ||
Danielle McLean | 8c9977439e | ||
Danielle McLean | 8cbe51277e | ||
Danielle McLean | b649a79afc | ||
Danielle McLean | 8dd5fc0f50 | ||
Danielle McLean | 1d079abd19 | ||
Danielle McLean | 092cd5ca18 | ||
Danielle McLean | 6f84023f8c | ||
Danielle McLean | 78e6d76693 | ||
Danielle McLean | 0936bcb311 | ||
Danielle McLean | 01d5745fa7 | ||
Danielle McLean | 0f95cfa0bc | ||
Danielle McLean | 9d30534d1d | ||
Danielle McLean | 23ab0c4329 | ||
Danielle McLean | 132da5d69b | ||
Danielle McLean | 1654ceecf3 | ||
Danielle McLean | 025910029e | ||
Danielle McLean | 236a32ebc1 | ||
Danielle McLean | d3c79a4cc3 | ||
Danielle McLean | 3142c6073c | ||
Danielle McLean | 1cf0449371 | ||
Danielle McLean | 5252c59910 | ||
Danielle McLean | fceda5c698 | ||
Danielle McLean | 7cc173420d | ||
Danielle McLean | 2d6751cf0c | ||
Danielle McLean | baeae5cacd | ||
Danielle McLean | 93c45f8a90 | ||
Danielle McLean | 418f501afa | ||
Danielle McLean | 7e50300942 | ||
Danielle McLean | 43348a89da | ||
Danielle McLean | 6fb289727c | ||
Danielle McLean | 0fb62f5962 | ||
Danielle McLean | 365110544c | ||
Danielle McLean | 452dd3f47d | ||
Danielle McLean | 098284a617 | ||
Danielle McLean | 27e0cb9a34 | ||
Danielle McLean | f0cf3b3a68 | ||
Danielle McLean | 169f0687cb | ||
Danielle McLean | b3fb0a8600 |
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -1,4 +1,3 @@
|
|||
|
||||
# Created by https://www.gitignore.io/api/django
|
||||
|
||||
### Django ###
|
||||
|
@ -15,6 +14,10 @@ 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
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
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
2
.gitmodules
vendored
|
@ -1,3 +1,3 @@
|
|||
[submodule "lemoncurry/static/base16-materialtheme-scheme"]
|
||||
path = lemoncurry/static/base16-materialtheme-scheme
|
||||
url = git://github.com/ntpeters/base16-materialtheme-scheme.git
|
||||
url = https://github.com/ntpeters/base16-materialtheme-scheme
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
# vim: set ft=yaml :
|
||||
host: 00dani.dev
|
||||
port: 443
|
||||
cname: dev.00dani.me
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017 - 2018 Danielle McLean
|
||||
Copyright (c) 2017 - 2024 Danielle McLean
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
52
Pipfile
52
Pipfile
|
@ -1,52 +0,0 @@
|
|||
[[source]]
|
||||
|
||||
url = "https://pypi.python.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
|
||||
[requires]
|
||||
|
||||
python_version = '3.6'
|
||||
|
||||
|
||||
[packages]
|
||||
|
||||
django = ">=1.11,<2.0"
|
||||
django-compressor = "*"
|
||||
gunicorn = "*"
|
||||
"psycopg2" = "*"
|
||||
pillow = "*"
|
||||
python-memcached = "*"
|
||||
django-favicon-plus = "*"
|
||||
django-meta = "*"
|
||||
django-redis-cache = "*"
|
||||
django-activeurl = "*"
|
||||
django-otp = "*"
|
||||
qrcode = "*"
|
||||
django-otp-agents = "*"
|
||||
python-slugify = "*"
|
||||
"mf2py" = "*"
|
||||
markdown = "*"
|
||||
bleach = "*"
|
||||
django-debug-toolbar = "*"
|
||||
xrd = "*"
|
||||
django-push = "*"
|
||||
pyyaml = "*"
|
||||
django-annoying = "*"
|
||||
django-shorturls = "*"
|
||||
accept-types = "*"
|
||||
django-analytical = "*"
|
||||
django-model-utils = "*"
|
||||
python-jose = "*"
|
||||
django-rq = "*"
|
||||
ronkyuu = "*"
|
||||
cachecontrol = "*"
|
||||
hiredis = "*"
|
||||
"mf2util" = "*"
|
||||
django-cors-headers = "*"
|
||||
pytest-django = "*"
|
||||
"argon2-cffi" = "*"
|
||||
|
||||
|
||||
[dev-packages]
|
674
Pipfile.lock
generated
674
Pipfile.lock
generated
|
@ -1,674 +0,0 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "81ebb49766d8eff61ba665dc794078637f5fc9980e59f6cb7038fa5d993c7a95"
|
||||
},
|
||||
"host-environment-markers": {
|
||||
"implementation_name": "cpython",
|
||||
"implementation_version": "3.6.4",
|
||||
"os_name": "posix",
|
||||
"platform_machine": "x86_64",
|
||||
"platform_python_implementation": "CPython",
|
||||
"platform_release": "17.5.0",
|
||||
"platform_system": "Darwin",
|
||||
"platform_version": "Darwin Kernel Version 17.5.0: Fri Jan 12 23:22:48 PST 2018; root:xnu-4570.50.243~12/RELEASE_X86_64",
|
||||
"python_full_version": "3.6.4",
|
||||
"python_version": "3.6",
|
||||
"sys_platform": "darwin"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.6"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.python.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"accept-types": {
|
||||
"hashes": [
|
||||
"sha256:9ae86512bf3a3eaad6a2793617a34eb15b384593e6c28697bef9b15ac237017a"
|
||||
],
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"argon2-cffi": {
|
||||
"hashes": [
|
||||
"sha256:93f631fa567dbf948f26874476c9e9afb51e0a835372bf1a319df0c5aa071bfb",
|
||||
"sha256:131effd5eabbe08649bc672b5d602fd6e2772b03cfec2ddb2795f9d9babe3fba",
|
||||
"sha256:5f1099b0f5ee4a7148bbd323503983aa4387ab16769ff9b5c51d26f6b0f1719e",
|
||||
"sha256:f732ca584e81491cc11e3d12e18cbd8c63e137b3f461f378426a6fdaaef47fb0",
|
||||
"sha256:fcd5681388d1f18e4a7ee3ff7a9b68650bc04db044b5a0a832728cbce182806d",
|
||||
"sha256:4c510232a96e991079a743a9310d3c9a014856cdbca644fccc496db2a1ff0e17",
|
||||
"sha256:82db759b8a495aaed51aec4762b0f44e5e7ad80256e8baf512ae70cdb3b28c50",
|
||||
"sha256:c60764fe7f62cc52a74f326e366c60f7aa33a1586c8d02107394a01ae9db6e91",
|
||||
"sha256:07480018d77f4c7447924e6c44c5ba1789a918413fe3efaa391a097958bbd9f6",
|
||||
"sha256:77a3d50e6325df79499e1220b7c38adbd30588c2f6d7c2d764fddb2d3b02e650",
|
||||
"sha256:7f4b6d7c38258e76c1db293a6cf55b7e31701927fc773c5108e57578c7f8e09a",
|
||||
"sha256:a14e6d99787a2972d3802615911770fcba9c904401fb0dfb60bdeb250b4c5110",
|
||||
"sha256:cba2c8c539bed691513ae1bcd5a7da632d2aa2410d8b8ebdf56026eac7e2193f",
|
||||
"sha256:10e702dbd98a2148d22de9524a605021bdc55d05304beb90ea801ba58c4a4f1e",
|
||||
"sha256:d79c918cf8bf981cd23b43a1a547cd1eececb77f3607ba9fa7c0ec01bf1f05a5",
|
||||
"sha256:dc3028ec541146924e3c45973b458a7acf390b9e9ee0b64a13ac0853109a69bc",
|
||||
"sha256:3f3b48b4802e98bb9692d72108ecad2fecea969c254c17660b70ce5730bbe4a6",
|
||||
"sha256:67452b1f10e873ececcea657c25d063e4bb4007e115227a53157369de5848992",
|
||||
"sha256:9befaa6d9798d9771b8176174ba82160beaf1dcdbcc63cd2dc5212f723e5e2a3",
|
||||
"sha256:eb3fcb55224a47b8d50830561977c64761eaad9e349af0b2241eab089af44a14",
|
||||
"sha256:92b3f8f93b19081d520d911f1ce5902693edeeab2181c08aa0bb4130adba51aa",
|
||||
"sha256:05dd15949be3a7d9f65807fe58fad70526023a319747054bb89da209c4071a33",
|
||||
"sha256:7e4b75611b73f53012117ad21cdde7a17b32d1e99ff6799f22d827eb83a2a59b"
|
||||
],
|
||||
"version": "==18.1.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:a17a9573a6f475c99b551c0e0a812707ddda1ec9653bed04c13841404ed6f450",
|
||||
"sha256:1c7960ccfd6a005cd9f7ba884e6316b5e430a3f1a6c37c5f87d8b43f83b54ec9"
|
||||
],
|
||||
"version": "==17.4.0"
|
||||
},
|
||||
"beautifulsoup4": {
|
||||
"hashes": [
|
||||
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11",
|
||||
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76",
|
||||
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89"
|
||||
],
|
||||
"version": "==4.6.0"
|
||||
},
|
||||
"bleach": {
|
||||
"hashes": [
|
||||
"sha256:cf567e7ed30ea5e05b31231d88ae170af1c5544758b9d7bebbc20590b7c30b1e",
|
||||
"sha256:38fc8cbebea4e787d8db55d6f324820c7f74362b70db9142c1ac7920452d1a19"
|
||||
],
|
||||
"version": "==2.1.2"
|
||||
},
|
||||
"cachecontrol": {
|
||||
"hashes": [
|
||||
"sha256:a7d21ba4e3633d95ac9fed5be205ee6d1da36bdc4b8914eb7a57ff50b7e5628c"
|
||||
],
|
||||
"version": "==0.12.4"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:14131608ad2fd56836d33a71ee60fa1c82bc9d2c8d98b7bdbc631fe1b3cd1296",
|
||||
"sha256:edbc3f203427eef571f79a7692bb160a2b0f7ccaa31953e99bd17e307cf63f7d"
|
||||
],
|
||||
"version": "==2018.1.18"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:5d0d7023b72794ea847725680e2156d1d01bc698a9007fccce46d03c904fe093",
|
||||
"sha256:86903c0afab4a3390170aca61f753f5adad8ffff947030719ee44dedc5b68403",
|
||||
"sha256:7d35678a54da0d3f1bc30e3a58a232043753d57c691875b5a75e4e062793bc9a",
|
||||
"sha256:824cac33906be5c8e976f0d950924d88ec058989ef9cd2f77f5cd53cec417635",
|
||||
"sha256:6ca52651f6bd4b8647cb7dee15c82619de3e13490f8e0bc0620830a2245b51d1",
|
||||
"sha256:a183959a4b1e01d6172aeed356e2523ec8682596075aa6cf0003fe08da959a49",
|
||||
"sha256:9532c5bc0108bd0fe43c0eb3faa2ef98a2db60fc0d4019f106b88d46803dd663",
|
||||
"sha256:96652215ef328262b5f1d5647632bd342ac6b31dfbc495b21f1ab27cb06d621d",
|
||||
"sha256:6c99d19225e3135f6190a3bfce2a614cae8eaa5dcaf9e0705d4ccb79a3959a3f",
|
||||
"sha256:12cbf4c04c1ad07124bfc9e928c01e282feac9ec7dd72a18042d4fc56456289a",
|
||||
"sha256:69c37089ccf10692361c8d14dbf4138b00b46741ffe9628755054499f06ed548",
|
||||
"sha256:b8d1454ef627098dc76ccfd6211a08065e6f84efe3754d8d112049fec3768e71",
|
||||
"sha256:cd13f347235410c592f6e36395ee1c136a64b66534f10173bfa4df1dc88f47d0",
|
||||
"sha256:0640f12f04f257c4467075a804a4920a5d07ef91e11c525fc65d715c08231c81",
|
||||
"sha256:89a8d05b96bdeca8fdc89c5fa9469a357d30f6c066262e92c0c8d2e4d3c53cae",
|
||||
"sha256:a67c430a9bde73ae85b0c885fcf41b556760e42ea74c16dc70431a349989b448",
|
||||
"sha256:7a831170b621e98f45ed1d5758325be19619a593924127a0a47af9a72a117319",
|
||||
"sha256:796d0379102e6da5215acfcd20e8e69cca9d97309215b4ce088fe175b1c2f586",
|
||||
"sha256:0fe3b3d571543a4065059d1d3d6d39f4ca6da0f2207ad13547094522e32ead46",
|
||||
"sha256:678135090c311780382b1dd3f828f715583ea8a69687ed053c047d3cec6625d6",
|
||||
"sha256:f4992cd7b4c867f453d44c213ee29e8fd484cf81cfece4b6e836d0982b6fa1cf",
|
||||
"sha256:6d191fb20138fe1948727b20e7b96582b7b7e676135eabf72d910e10bf7bfa65",
|
||||
"sha256:ec208ca16e57904dd7f4c7568665f80b1f7eb7e3214be014560c28def219060d",
|
||||
"sha256:b3653644d6411bf4bd64c1f2ca3cb1b093f98c68439ade5cef328609bbfabf8c",
|
||||
"sha256:f4719d0bafc5f0a67b2ec432086d40f653840698d41fa6e9afa679403dea9d78",
|
||||
"sha256:87f837459c3c78d75cb4f5aadf08a7104db15e8c7618a5c732e60f252279c7a6",
|
||||
"sha256:df9083a992b17a28cd4251a3f5c879e0198bb26c9e808c4647e0a18739f1d11d"
|
||||
],
|
||||
"version": "==1.11.4"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691",
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
|
||||
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
|
||||
],
|
||||
"version": "==6.7"
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:ac4c797a328a5ac8777ad61bcd00da279773455cc78b4058de2a9842a0eb6ee8",
|
||||
"sha256:22383567385a9c406d8a5ce080a2694c82c6b733e157922197e8b393bb3aacd9"
|
||||
],
|
||||
"version": "==1.11.10"
|
||||
},
|
||||
"django-activeurl": {
|
||||
"hashes": [
|
||||
"sha256:7ebc4a34f91e18f29eb02bfac503057d69b4e1b6f9e8dd1297798387876e54da"
|
||||
],
|
||||
"version": "==0.1.11"
|
||||
},
|
||||
"django-agent-trust": {
|
||||
"hashes": [
|
||||
"sha256:f0ded1c2e1e8b06ea050f48c8db931ab7d2f85c566065dceb8e827d0690d87c5",
|
||||
"sha256:f7e24d3f50a0727c6a70d671778de3cca23a0c87bedc6e3dae1f61af7e759fc1"
|
||||
],
|
||||
"version": "==0.3.1"
|
||||
},
|
||||
"django-analytical": {
|
||||
"hashes": [
|
||||
"sha256:44dd65e30a3f11519852d5f5e50556c0f88cabb5720a2fd3637621952048abef",
|
||||
"sha256:cf7b4c0b368139a090da2b0b45741bdd28b54daa0cb2b83ef801021c8eb2c050"
|
||||
],
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"django-annoying": {
|
||||
"hashes": [
|
||||
"sha256:ee620f9bfe439061010c7d5ccc8c69514844d95c370c130c61938205fcfc4cc9",
|
||||
"sha256:93f54d244d453cba28d6cfb9deae7bba27e859762adee9ff7de4706017940931"
|
||||
],
|
||||
"version": "==0.10.4"
|
||||
},
|
||||
"django-appconf": {
|
||||
"hashes": [
|
||||
"sha256:ddab987d14b26731352c01ee69c090a4ebfc9141ed223bef039d79587f22acd9",
|
||||
"sha256:6a4d9aea683b4c224d97ab8ee11ad2d29a37072c0c6c509896dd9857466fb261"
|
||||
],
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"django-classy-tags": {
|
||||
"hashes": [
|
||||
"sha256:f6d12f5a4df3e387795a0d9ef2836af389cae9a1fbebda035dac043d4722b1f7",
|
||||
"sha256:792f9161d0e22d55b4fab6fc297bab8ab072ffaa3075b227613a6d8473624db8"
|
||||
],
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"django-compat": {
|
||||
"hashes": [
|
||||
"sha256:3ac9a3bedc56b9365d9eb241bc5157d0c193769bf995f9a78dc1bc24e7c2331b"
|
||||
],
|
||||
"version": "==1.0.15"
|
||||
},
|
||||
"django-compressor": {
|
||||
"hashes": [
|
||||
"sha256:7732676cfb9d58498dfb522b036f75f3f253f72ea1345ac036434fdc418c2e57",
|
||||
"sha256:9616570e5b08e92fa9eadc7a1b1b49639cce07ef392fc27c74230ab08075b30f"
|
||||
],
|
||||
"version": "==2.2"
|
||||
},
|
||||
"django-cors-headers": {
|
||||
"hashes": [
|
||||
"sha256:4e02be61ffaaab5917f1fd7cc3c305c4fb7ccd0156a649c96f49bc0a09c5f572",
|
||||
"sha256:451bc37a514792c2b46c52362368f7985985933ecdbf1a85f82652579a5cbe01"
|
||||
],
|
||||
"version": "==2.1.0"
|
||||
},
|
||||
"django-debug-toolbar": {
|
||||
"hashes": [
|
||||
"sha256:4af2a4e1e932dadbda197b18585962d4fc20172b4e5a479490bc659fe998864d",
|
||||
"sha256:d9ea75659f76d8f1e3eb8f390b47fc5bad0908d949c34a8a3c4c87978eb40a0f"
|
||||
],
|
||||
"version": "==1.9.1"
|
||||
},
|
||||
"django-favicon-plus": {
|
||||
"hashes": [
|
||||
"sha256:3394a951d8dc611eb1ea027ad1181d7f650ca234506585b27e93d7ed06b981bf"
|
||||
],
|
||||
"version": "==0.0.8"
|
||||
},
|
||||
"django-meta": {
|
||||
"hashes": [
|
||||
"sha256:21fc5d0d5fcacda5d038af0babd08afaa4d5bed1b746edb6522c4d3435da8db6",
|
||||
"sha256:dd4a440223cc6243a7815c183b6ada1f11b99b4d672471e67f8db4d8b48a5674"
|
||||
],
|
||||
"version": "==1.4.1"
|
||||
},
|
||||
"django-model-utils": {
|
||||
"hashes": [
|
||||
"sha256:4356fad5f6fc9910da865fbf9371f9c2e028606dff2bbd8e32e67569260e530d",
|
||||
"sha256:a9baa7de943b4e8afa61728ce8c42ce99e88cc87d40e74df2b060a78d22c0f5c"
|
||||
],
|
||||
"version": "==3.1.1"
|
||||
},
|
||||
"django-otp": {
|
||||
"hashes": [
|
||||
"sha256:06047e6f20e1527363ced31e4e8ea090f531cb33f4acd1bcaa6358a2efc05dbe",
|
||||
"sha256:b1b0166717e35363e8b8fba7d23e5a3e4f175b4893b2e1b68ac8bd3e1908c6a9"
|
||||
],
|
||||
"version": "==0.4.2"
|
||||
},
|
||||
"django-otp-agents": {
|
||||
"hashes": [
|
||||
"sha256:4ca8fae30418e0a813840cee5068d2fb96e3759787a5820d54921b90c7beaa7a",
|
||||
"sha256:8d9f26d5a186b059251bd03e1ab509b5861a678e463c49de9b0766080b2c16a5"
|
||||
],
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"django-push": {
|
||||
"hashes": [
|
||||
"sha256:88d9d57326c9b5f8485510527c780418da1a3c0c485dbb283281d6bf2ef6598d",
|
||||
"sha256:7101f2d66ff7fd932fe379c70f4d03f74955634fbecb187f03c3e82cd55b8274"
|
||||
],
|
||||
"version": "==1.0"
|
||||
},
|
||||
"django-redis-cache": {
|
||||
"hashes": [
|
||||
"sha256:2b4e3510bbcaf3d331975717afd6f15a36fbaf7622504599d2727dc99f90c64d"
|
||||
],
|
||||
"version": "==1.7.1"
|
||||
},
|
||||
"django-rq": {
|
||||
"hashes": [
|
||||
"sha256:88d590adc528256a90db2456164288d7fd1e3aa337b96766fb5e94d2a448a1c4",
|
||||
"sha256:628216d036004418d3adea589f7e971134d554995f7692ebb28501dabb7fb31e"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"django-shorturls": {
|
||||
"hashes": [
|
||||
"sha256:382ff617b36fea04981b30457377a46f2034d0940a40a20c6f637c55ea0bda93"
|
||||
],
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"ecdsa": {
|
||||
"hashes": [
|
||||
"sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c",
|
||||
"sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa"
|
||||
],
|
||||
"version": "==0.13"
|
||||
},
|
||||
"future": {
|
||||
"hashes": [
|
||||
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
|
||||
],
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
"sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6",
|
||||
"sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622"
|
||||
],
|
||||
"version": "==19.7.1"
|
||||
},
|
||||
"hiredis": {
|
||||
"hashes": [
|
||||
"sha256:ca958e13128e49674aa4a96f02746f5de5973f39b57297b84d59fd44d314d5b5"
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"html5lib": {
|
||||
"hashes": [
|
||||
"sha256:20b159aa3badc9d5ee8f5c647e5efd02ed2a66ab8d354930bd9ff139fc1dc0a3",
|
||||
"sha256:66cb0dcfdbbc4f9c3ba1a63fdb511ffdbd4f513b2b6d81b80cd26ce6b3fb3736"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4",
|
||||
"sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f"
|
||||
],
|
||||
"version": "==2.6"
|
||||
},
|
||||
"isodate": {
|
||||
"hashes": [
|
||||
"sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81",
|
||||
"sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8"
|
||||
],
|
||||
"version": "==0.6.0"
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
"sha256:41f59cbdab232f11680d5d4dec9f2e6782fd24d78e37ee833447702e34e675f4",
|
||||
"sha256:e7e41d383f19bab9d57f5f3b18d158655bcd682e7e723f441b9e183e1e35a6b5",
|
||||
"sha256:155521c337acecf8202091cff85bb9f709f238130ebadf04280fb1db11f5ad8b",
|
||||
"sha256:d2c985d2460b81c6ca5feb8b86f1bc594ad59405d0bdf68626b85852b701553c",
|
||||
"sha256:950e63387514aa1b881eba5ac6cb2ec51a118b3dafe99dd80ca19d8fb0142f30",
|
||||
"sha256:470d7ce41e8047208ba1a376560bad17f1468df1f3097bc83902b26cfafdbb0c",
|
||||
"sha256:e608839a5ee2180164424ccf279c8e2d9bbe8816d002c58fd97d6b621ba4aa94",
|
||||
"sha256:87a66bcadac270fc010cb029022a93fc722bf1204a8b03e782d4c790f0edf7ca",
|
||||
"sha256:2dedfeeecc2d5a939cf622602f5a1ce443ca82407f386880f739f1a9f08053ad",
|
||||
"sha256:ba05732e4bcf59e948f61588851dcf620fd60d5bbd9d704203e5f59bbaa60219",
|
||||
"sha256:2190266059fec3c5a55f9d6c30532c64c6d414d3228909c0af573fe4907e78d1",
|
||||
"sha256:dd291debfaa535d9cb6cee8d7aca2328775e037d02d13f1634e57f49bc302cc4",
|
||||
"sha256:29a36e354c39b2e24bc4ee103de53417ebb80f976a6ab9e8d093d559e2ac03e1",
|
||||
"sha256:e37427d5a27eefbcfc48847e0b37f348113fac7280bc857421db39ffc6372570",
|
||||
"sha256:b106d4d2383382399ad82108fd187e92f40b1c90f55c2d36bbcb1c44bcf940fc",
|
||||
"sha256:0ee07da52d240f1dc3c83eef5cd5f1b7f018226c1121f2a54d446645779a6d17",
|
||||
"sha256:3b33549fb8f91b38a7500078242b03cca513f3412a2cdae722e89bf83f95971d",
|
||||
"sha256:4c12e90886d9c53ab434c8d0cebea122321cce19614c3c6b6d1a7700d7cc6212",
|
||||
"sha256:79322000279cda10b53c374d53ca632ead3bc51c6aebf8e62c8fa93a4d08b750",
|
||||
"sha256:6cba398eb37e0631e60e0e080c101cfe91769b2c8267105b64b4625e2581ea21",
|
||||
"sha256:49a655956f8de69e1258bc0fcfc43eb3bd1e038655784d77d1869b4b81444e37",
|
||||
"sha256:af8a5373241d09b8fc53e0490e1719ce5dc90a21b19db89b6596c1adcdd52270",
|
||||
"sha256:e6b6698415c7e8d227a47a3b1038e1b37c2b438a1b48c2db7ad9e74ddbcd1149",
|
||||
"sha256:155c916cf2645b4a8f2bd5d09065e92d1b67b8d464bdc001e0b524af84bedf6f",
|
||||
"sha256:fa7320679ced5e25b20203d157280680fc84eb783b6cc650cb0c98e1858b7dd3",
|
||||
"sha256:4187c4b0cefc3353181db048c51f42c489d9ac51e40b86c4851dc0671372971d",
|
||||
"sha256:d5d29663e979e83b3fc361e97200f959cddb3a14797391d15273d84a5a8ae44b",
|
||||
"sha256:940caef1ec7c78e0c34b0f6b94fe42d0f2022915ffc78643d28538a5cfd0f40e"
|
||||
],
|
||||
"version": "==4.1.1"
|
||||
},
|
||||
"markdown": {
|
||||
"hashes": [
|
||||
"sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f",
|
||||
"sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81"
|
||||
],
|
||||
"version": "==2.6.11"
|
||||
},
|
||||
"mf2py": {
|
||||
"hashes": [
|
||||
"sha256:021b675c0732bdbc3b8c153e1ee8e1f476c3d0ffc56a7908f9e9f90147c5fccd"
|
||||
],
|
||||
"version": "==1.0.5"
|
||||
},
|
||||
"mf2util": {
|
||||
"hashes": [
|
||||
"sha256:efb8ea1a275f16396993a3fbe32331b74a8f6985d3f7f47503641cf522f1f614"
|
||||
],
|
||||
"version": "==0.5.0"
|
||||
},
|
||||
"msgpack-python": {
|
||||
"hashes": [
|
||||
"sha256:23f688905bb9fbf00faa7346e72a72e670e68f3f5d94aeea5c123dd0e07de49c"
|
||||
],
|
||||
"version": "==0.5.2"
|
||||
},
|
||||
"pillow": {
|
||||
"hashes": [
|
||||
"sha256:718ec7a122b28d64afc5fbc3a9b99bb0545ef511373cac06fe7624520e82cb20",
|
||||
"sha256:801cca8923508311bf5d6d0f7da5362552e8208ebd8ec0d7b9f2cd2ff5705734",
|
||||
"sha256:43334f9581cd067945b8898cef9eb5714ee4883f8de0304c011f1dbdb1d4e2aa",
|
||||
"sha256:153ec6f18f7b61641e0e6e502acfaf4a06c9aba2ea11c0b4b3578ea9f13a4a4a",
|
||||
"sha256:25193f934d37d836a6b1f4c062ce574a96cbca7c6d9dc8ddfbbac7f9c54deaa4",
|
||||
"sha256:b85f703c2ffe539313e39ce0676bed0f355cec45a16e58c9ab7417445843047c",
|
||||
"sha256:8580fc58074a16b749905b26cf8363f7b628dd167ba0130f5382cdc91c86b509",
|
||||
"sha256:2fcde9954c8882d1c7f93bb828caa34a4c5e3ee69dbc7895dc8652ad972b455a",
|
||||
"sha256:1a5b93084e01328a1cb1ecdad99d11d75e881e89a95f88d85b523646553b36c2",
|
||||
"sha256:b2240f298482f823576f397bb9f32ea913ad9456c526e141bc6f0a022b37a3e8",
|
||||
"sha256:b1d33c63a55d0d85df0ad02b2c16158fb4d8153afa7b908f1a67330fac694cd6",
|
||||
"sha256:6977cf073d83358b34f93abf5c1f1193b88675fe0e4441e0e28318bc3dcba7a0",
|
||||
"sha256:1912b7230459fd53682dae32b83cbd8e5d642ba36d4be18566f00a9c063aa13d",
|
||||
"sha256:4bd4a71501b6d51db4abc07e1f43f5a6fed0a1a9583cca0b401d6af50284b0db",
|
||||
"sha256:0013f590a8f260df60bcfd65db19d18efc04e7f046c3c82a40e2e2b3292a937c",
|
||||
"sha256:a224651a81e45ef4f1d0164e256c5f6b4abb49f2ae8f22ba2f3a9d0ff338e608",
|
||||
"sha256:c793dfaa130847ccff958492b76ae8b9304e60b8a79a92962cb19e368276a22b",
|
||||
"sha256:0b899ee80920bb533f26581af9b4660bc12aff4562555afe74e429101ebf3c94",
|
||||
"sha256:9525cd680a6f9e80c6c0af03cf973e6505c59f60b4745f682cd1a449e54b31bb",
|
||||
"sha256:35f7d998b8e82fb3fb51ff88b30485eb81cd7dd56ec7e1a8deba23eb88532d44",
|
||||
"sha256:5b0d657460d9f3615876fec6306e97ca15a471f6169b622d76a47e270998acf1",
|
||||
"sha256:ddd16ab250b4fc97db1c47407e78c25216a75c29d29d10ad37e51b7a2ec7b2c3",
|
||||
"sha256:b9f63451084a718eccdeb1e382768c94647915653af4d6019f64560d9e98642b",
|
||||
"sha256:a370d1c570f1d72e877099651e752332444b1c5009381f043c9da5fd47f3ebae",
|
||||
"sha256:dc4b018d5c9b636f7546583c5591b9ea00c328c3e5871992ef5b95bac353f097",
|
||||
"sha256:e126ff4fed71e78333840c07279e1617f63cfca76d63ad5b27d65a7277206a3d",
|
||||
"sha256:fcf64c91fd44485100a2965d23bb0e227d093e91f7e776c5ca3b32574766eb56",
|
||||
"sha256:2c042352b430d678db50c78c5214e19638eff8b688941271da2de21fd298dfe5",
|
||||
"sha256:17fe25efc785194d48c38fad85dce470013ba19d2fb66639e149f14bccf1327f",
|
||||
"sha256:2e818dbe445e86fc6c266973fe540c35125c42eb2cf13a6095e9adaa89c0deb5",
|
||||
"sha256:135e9aa65150c53f7db85bf2bebb8a0e1a48ea850e80cf66e16dd04fa09d309c",
|
||||
"sha256:7dfbefdb3fb911ca9faed307bf309861e9995e36cca6b761c7ba6d9b77a9744a",
|
||||
"sha256:12f29d6c23424f704c66b5b68c02fe0b571504459605cfe36ab8158359b0e1bb",
|
||||
"sha256:f8d49be8c282df8d2e1ab6ab53ab8abd859b1fa6fed384457ee85c9eff64ef97",
|
||||
"sha256:82b172e3264e62372c01b5b009b5b1a02fbb9276cbe5cc57ab00a6d6e5ed9a18",
|
||||
"sha256:57aa6198ba8acba1313c3b743e267d821a60cac77e6026caf0b55ca58d3d23be",
|
||||
"sha256:d60c1625b108432ace8b1fa1a584017e5efa73f107d0f493c7f39c79bebf1d41",
|
||||
"sha256:82d1ff571489765df2816785d532e243bde213752156c227fca595723ec5ff42",
|
||||
"sha256:37cc0339abfa9e295c75d9a7f227d35cb44716feb95057f9449c4a9e9a17daf7",
|
||||
"sha256:931030d1d6282b7900e6b0a7ff9ecdb503b5e1e6781800dab2b71a9f39405bff",
|
||||
"sha256:5cd36804f9f06a914a883fe682df5711d16d7b4f44d43189c5f013e7cd91e149"
|
||||
],
|
||||
"version": "==5.0.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff"
|
||||
],
|
||||
"version": "==0.6.0"
|
||||
},
|
||||
"psycopg2": {
|
||||
"hashes": [
|
||||
"sha256:594aa9a095de16614f703d759e10c018bdffeafce2921b8e80a0e8a0ebbc12e5",
|
||||
"sha256:1cf5d84290c771eeecb734abe2c6c3120e9837eb12f99474141a862b9061ac51",
|
||||
"sha256:0344b181e1aea37a58c218ccb0f0f771295de9aa25a625ed076e6996c6530f9e",
|
||||
"sha256:25250867a4cd1510fb755ef9cb38da3065def999d8e92c44e49a39b9b76bc893",
|
||||
"sha256:317612d5d0ca4a9f7e42afb2add69b10be360784d21ce4ecfbca19f1f5eadf43",
|
||||
"sha256:9d6266348b15b4a48623bf4d3e50445d8e581da413644f365805b321703d0fac",
|
||||
"sha256:ddca39cc55877653b5fcf59976d073e3d58c7c406ef54ae8e61ddf8782867182",
|
||||
"sha256:988d2ec7560d42ef0ac34b3b97aad14c4f068792f00e1524fa1d3749fe4e4b64",
|
||||
"sha256:7a9c6c62e6e05df5406e9b5235c31c376a22620ef26715a663cee57083b3c2ea",
|
||||
"sha256:7a75565181e75ba0b9fb174b58172bf6ea9b4331631cfe7bafff03f3641f5d73",
|
||||
"sha256:94e4128ba1ea56f02522fffac65520091a9de3f5c00da31539e085e13db4771b",
|
||||
"sha256:92179bd68c2efe72924a99b6745a9172471931fc296f9bfdf9645b75eebd6344",
|
||||
"sha256:b9358e203168fef7bfe9f430afaed3a2a624717a1d19c7afa7dfcbd76e3cd95c",
|
||||
"sha256:009e0bc09a57dbef4b601cb8b46a2abad51f5274c8be4bba276ff2884cd4cc53",
|
||||
"sha256:d3ac07240e2304181ffdb13c099840b5eb555efc7be9344503c0c03aa681de79",
|
||||
"sha256:40fa5630cd7d237cd93c4d4b64b9e5ed9273d1cfce55241c7f9066f5db70629d",
|
||||
"sha256:6c2f1a76a9ebd9ecf7825b9e20860139ca502c2bf1beabf6accf6c9e66a7e0c3",
|
||||
"sha256:37f54452c7787dbdc0a634ca9773362b91709917f0b365ed14b831f03cbd34ba",
|
||||
"sha256:8f5942a4daf1ffac42109dc4a72f786af4baa4fa702ede1d7c57b4b696c2e7d6",
|
||||
"sha256:bf708455cd1e9fa96c05126e89a0c59b200d086c7df7bbafc7d9be769e4149a3",
|
||||
"sha256:82c40ea3ac1555e0462803380609fbe8b26f52620f3d4f8eb480cfd8ceed8a14",
|
||||
"sha256:207ba4f9125a0a4200691e82d5eee7ea1485708eabe99a07fc7f08696fae62f4",
|
||||
"sha256:0cd4c848f0e9d805d531e44973c8f48962e20eb7fc0edac3db4f9dbf9ed5ab82",
|
||||
"sha256:57baf63aeb2965ca4b52613ce78e968b6d2bde700c97f6a7e8c6c236b51ab83e",
|
||||
"sha256:2954557393cfc9a5c11a5199c7a78cd9c0c793a047552d27b1636da50d013916",
|
||||
"sha256:7c31dade89634807196a6b20ced831fbd5bec8a21c4e458ea950c9102c3aa96f",
|
||||
"sha256:1286dd16d0e46d59fa54582725986704a7a3f3d9aca6c5902a7eceb10c60cb7e",
|
||||
"sha256:697ff63bc5451e0b0db48ad205151123d25683b3754198be7ab5fcb44334e519",
|
||||
"sha256:fc993c9331d91766d54757bbc70231e29d5ceb2d1ac08b1570feaa0c38ab9582",
|
||||
"sha256:9d64fed2681552ed642e9c0cc831a9e95ab91de72b47d0cb68b5bf506ba88647",
|
||||
"sha256:5c3213be557d0468f9df8fe2487eaf2990d9799202c5ff5cb8d394d09fad9b2a"
|
||||
],
|
||||
"version": "==2.7.3.2"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:8cca5c229d225f8c1e3085be4fcf306090b00850fefad892f9d96c7b6e2f310f",
|
||||
"sha256:ca18943e28235417756316bfada6cd96b23ce60dd532642690dcfdaba988a76d"
|
||||
],
|
||||
"version": "==1.5.2"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226"
|
||||
],
|
||||
"version": "==2.18"
|
||||
},
|
||||
"pycryptodome": {
|
||||
"hashes": [
|
||||
"sha256:444053c24b336daa7f84bf872df7a6b9950697559926aea5775f5aa757b67a3e",
|
||||
"sha256:29d3a581cfcc68ca66f7c5d4830944556ddca9e2747e214bde8028972bb1901f",
|
||||
"sha256:7bda0f395fd8ef6b1fa7cded00d5cca72005ff158fc30703e1337fe32fbf2102",
|
||||
"sha256:bdd8581dae617b9fbe6e8dbdd96590c02fc33eebc411b0273fd62b4d468d0bb7",
|
||||
"sha256:89a0a233ed3a216ae117323d8fb0da38f1ca344dc1021559e38416cce23592a0",
|
||||
"sha256:5d390f8c6562173b913f0359cd87d5bc2e3245cc88ec4edf59d8c52107f24d29",
|
||||
"sha256:44ad06faf5ee589c1127a18610695a65815ed5db724b58687294ee907ec546ba",
|
||||
"sha256:c8922f187fcac3b2afa6d200ef00cd4e69719799b54b4f2f2741b2e4c96ccd61",
|
||||
"sha256:2aeded7095564b8a068402531c7407517cd714a0fe9872f76c69bd4400b07613",
|
||||
"sha256:c88e9a04d3ed89689bc76ce0a90b018cdd4edb94ab99ce31264f2e15bad9d752",
|
||||
"sha256:64a0cccf590546e7de602378f21482cb06cd1a1995cdfb121b123394c48b05c3",
|
||||
"sha256:21fd74571b3579cbf36792916ad76a4ecf91581a112bb78ec48e20389dcdb912",
|
||||
"sha256:11ca73effcc15596b62d601a6b3c48ea607fb5219546d406312520d63c446bf5",
|
||||
"sha256:ce3110812d8823c3182fc7f841031387ee6fda27d8696da8949a99b026048e7e",
|
||||
"sha256:29e8d3770bc0a0366093eb693ca40c5be56ed5a7ca214af5156a0b2e23053549",
|
||||
"sha256:d9ae42a88c716a7ca9a53966562968921883211b6390eeab22e5b735dbc49f49",
|
||||
"sha256:d3136fe71a37882ca457bea5917f1db5431f18f1bd91b0f7c4cec57ac4d57016",
|
||||
"sha256:0ebbcdbd21b5d8569c5b44137e2071d28c14a7460afdd8b1f6398a1548c4773a",
|
||||
"sha256:5ce44a755be8aef369d1057a38bff01501db0b89ba38c3292578f42ed401f355",
|
||||
"sha256:1d3065b741ec8d269327e4487eacd187e0bf909e7a73d0a959da1a0918b16fa9",
|
||||
"sha256:cb81302f3295a14722f6c26c44ab4023d66f8394db4c316ccf5658dbada2ac91",
|
||||
"sha256:4fd2584719895ff041cf48766014ef6b5a170f5caf0e2dc735837b182e78d081",
|
||||
"sha256:c5dd29e9f1b733e74311bf95d0e544e91bd1d14bc0366e8f443562d8d9920b7d"
|
||||
],
|
||||
"version": "==3.4.11"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:95fa025cd6deb5d937e04e368a00552332b58cae23f63b76c8c540ff1733ab6d",
|
||||
"sha256:6074ea3b9c999bd6d0df5fa9d12dd95ccd23550df2a582f5f5b848331d2e82ca"
|
||||
],
|
||||
"version": "==3.4.0"
|
||||
},
|
||||
"pytest-django": {
|
||||
"hashes": [
|
||||
"sha256:00995c2999b884a38ae9cd30a8c00ed32b3d38c1041250ea84caf18085589662",
|
||||
"sha256:038ccc5a9daa1b1b0eb739ab7dce54e495811eca5ea3af4815a2a3ac45152309"
|
||||
],
|
||||
"version": "==3.1.2"
|
||||
},
|
||||
"python-jose": {
|
||||
"hashes": [
|
||||
"sha256:3b35cdb0e55a88581ff6d3f12de753aa459e940b50fe7ca5aa25149bc94cb37b",
|
||||
"sha256:391f860dbe274223d73dd87de25e4117bf09e8fe5f93a417663b1f2d7b591165"
|
||||
],
|
||||
"version": "==2.0.2"
|
||||
},
|
||||
"python-memcached": {
|
||||
"hashes": [
|
||||
"sha256:4dac64916871bd3550263323fc2ce18e1e439080a2d5670c594cf3118d99b594",
|
||||
"sha256:a2e28637be13ee0bf1a8b6843e7490f9456fd3f2a4cb60471733c7b5d5557e4f"
|
||||
],
|
||||
"version": "==1.59"
|
||||
},
|
||||
"python-slugify": {
|
||||
"hashes": [
|
||||
"sha256:c3733135d3b184196fdb8844f6a74bbfb9cf6720d1dcce3254bdc434353f938f",
|
||||
"sha256:57a385df7a1c6dbd15f7666eaff0ff29d3f60363b228b1197c5308ed3ba5f824"
|
||||
],
|
||||
"version": "==1.2.4"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:80af0f3008046b9975242012a985f04c5df1f01eed4ec1633d56cc47a75a6a48",
|
||||
"sha256:feb2365914948b8620347784b6b6da356f31c9d03560259070b2f30cff3d469d",
|
||||
"sha256:59707844a9825589878236ff2f4e0dc9958511b7ffaae94dc615da07d4a68d33",
|
||||
"sha256:d0ef5ef55ed3d37854320d4926b04a4cb42a2e88f71da9ddfdacfde8e364f027",
|
||||
"sha256:c41c62827ce9cafacd6f2f7018e4f83a6f1986e87bfd000b8cfbd4ab5da95f1a",
|
||||
"sha256:8cc90340159b5d7ced6f2ba77694d946fc975b09f1a51d93f3ce3bb399396f94",
|
||||
"sha256:dd2e4ca6ce3785c8dd342d1853dd9052b19290d5bf66060846e5dc6b8d6667f7",
|
||||
"sha256:699d18a2a56f19ee5698ab1123bbcc1d269d061996aeb1eda6d89248d3542b82",
|
||||
"sha256:fae4cffc040921b8a2d60c6cf0b5d662c1190fe54d718271db4eb17d44a185b7"
|
||||
],
|
||||
"version": "==2017.3"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
|
||||
"sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
|
||||
"sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269",
|
||||
"sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
|
||||
"sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
|
||||
"sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
|
||||
"sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
|
||||
"sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
|
||||
"sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
|
||||
"sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
|
||||
"sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
|
||||
"sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
|
||||
"sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
|
||||
"sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7"
|
||||
],
|
||||
"version": "==3.12"
|
||||
},
|
||||
"qrcode": {
|
||||
"hashes": [
|
||||
"sha256:60222a612b83231ed99e6cb36e55311227c395d0d0f62e41bb51ebbb84a9a22b",
|
||||
"sha256:4115ccee832620df16b659d4653568331015c718a754855caf5930805d76924e"
|
||||
],
|
||||
"version": "==5.3"
|
||||
},
|
||||
"rcssmin": {
|
||||
"hashes": [
|
||||
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
|
||||
],
|
||||
"version": "==1.0.6"
|
||||
},
|
||||
"redis": {
|
||||
"hashes": [
|
||||
"sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb",
|
||||
"sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"
|
||||
],
|
||||
"version": "==2.10.6"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
|
||||
"sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
|
||||
],
|
||||
"version": "==2.18.4"
|
||||
},
|
||||
"rjsmin": {
|
||||
"hashes": [
|
||||
"sha256:dd9591aa73500b08b7db24367f8d32c6470021f39d5ab4e50c7c02e4401386f1"
|
||||
],
|
||||
"version": "==1.0.12"
|
||||
},
|
||||
"ronkyuu": {
|
||||
"hashes": [
|
||||
"sha256:5aa77b39d301bc174ab99ba8a53954627771cb501651a12103c58f51b32e84bf",
|
||||
"sha256:85b25fef7f5fb0c93afd5377ea35b5ff72b2458f926bafdf10f0c9a1e19cab10"
|
||||
],
|
||||
"version": "==0.6"
|
||||
},
|
||||
"rq": {
|
||||
"hashes": [
|
||||
"sha256:c1711bc43f298061166805763e6fa2353f03142e057e83f338d6e197a1be3157",
|
||||
"sha256:31a5f04d1410111617ae78756b86fc6b0cf300fe7445843ea3758b86d9f67bc5"
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb",
|
||||
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9"
|
||||
],
|
||||
"version": "==1.11.0"
|
||||
},
|
||||
"sqlparse": {
|
||||
"hashes": [
|
||||
"sha256:d9cf190f51cbb26da0412247dfe4fb5f4098edb73db84e02f9fc21fdca31fed4",
|
||||
"sha256:ce028444cfab83be538752a2ffdb56bc417b7784ff35bb9a3062413717807dec"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"version": "==0.1"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
}
|
90
README.md
Normal file
90
README.md
Normal file
|
@ -0,0 +1,90 @@
|
|||
lemoncurry (always all-lowercase) is a Django-based personal site designed to
|
||||
operate as part of the [IndieWeb][]. It currently supports the following
|
||||
IndieWeb specifications natively.
|
||||
|
||||
- All content is exposed using standard [microformats2][] markup, making it
|
||||
easy for other sites and applications across the IndieWeb to consume.
|
||||
- Additionally, the site owner's profiles are exposed using [rel-me][],
|
||||
enabling independent verification of their identity across various services.
|
||||
This permits [IndieAuth.com][] to authenticate the site's owner using a
|
||||
social profile, such as a Twitter account. However, this functionality is not
|
||||
necessary because lemoncurry also fully implements…
|
||||
- [IndieAuth][], an protocol derived from OAuth 2.0 which enables the site's
|
||||
owner to authorise access to their domain directly from the lemoncurry site
|
||||
itself. Additionally, tokens for further access to the lemoncurry site may be
|
||||
requested and issued, including customisable token scope as in OAuth.
|
||||
- [Micropub][] is *partially* supported - using a token obtained through
|
||||
IndieAuth, clients may post new content to the lemoncurry site using either
|
||||
the form-encoded or JSON request formats. There is currently no support for
|
||||
updating or deleting existing content through Micropub, although this is of
|
||||
course planned.
|
||||
- [Webmention][], used to enable rich commenting and social interaction between
|
||||
separate IndieWeb sites, is partially supported. lemoncurry will correctly
|
||||
*send* webmentions to all URLs mentioned in a published entry. However, it
|
||||
currently does not expose an endpoint for *receiving* webmentions.
|
||||
- [WebSub][] is also partially supported. When content is posted through
|
||||
Micropub, WebSub is pinged as it should be - however, since only creating
|
||||
*new* content through Micropub is supported, updates do not currently cause a
|
||||
WebSub ping.
|
||||
|
||||
[IndieAuth]: https://www.w3.org/TR/indieauth/
|
||||
[IndieAuth.com]: https://indieauth.com/
|
||||
[IndieWeb]: https://indieweb.org/
|
||||
[microformats2]: http://microformats.org/wiki/microformats2
|
||||
[Micropub]: https://www.w3.org/TR/micropub/
|
||||
[rel-me]: http://microformats.org/wiki/rel-me
|
||||
[Webmention]: https://www.w3.org/TR/webmention/
|
||||
[WebSub]: https://www.w3.org/TR/websub/
|
||||
|
||||
# Requirements
|
||||
|
||||
lemoncurry uses the following tools:
|
||||
|
||||
* [Pipenv][] - developed with Pipenv 2018.5.18, but should work with most versions.
|
||||
* [Yarn][] - again, developed with Yarn 1.7.0, but should work with most versions.
|
||||
|
||||
As well as the following services:
|
||||
|
||||
* [PostgreSQL][] - create a database named `lemoncurry`. Socket auth is
|
||||
recommended, so ensure the UNIX user you'll be running lemoncurry with has
|
||||
access to that database. Alternatively, set the `POSTGRES_PASSWORD`
|
||||
environment variable to use password auth.
|
||||
* [Redis][] - lemoncurry expects to find Redis on port 6380, rather than the
|
||||
standard port of 6379. Sorry about that.
|
||||
|
||||
If you're running in production, I'd recommend [Gunicorn][], which is already part
|
||||
of lemoncurry's Pipfile. Ensure you run Gunicorn behind a secure reverse proxy,
|
||||
such as [Nginx][].
|
||||
|
||||
If you're running in development, the usual Django `runserver` command should
|
||||
be fine.
|
||||
|
||||
[Gunicorn]: https://gunicorn.org/
|
||||
[Nginx]: https://nginx.org/en/
|
||||
[Pipenv]: https://docs.pipenv.org/
|
||||
[PostgreSQL]: https://www.postgresql.org/
|
||||
[Redis]: https://redis.io/
|
||||
[Yarn]: https://yarnpkg.org/
|
||||
|
||||
# Installation
|
||||
|
||||
Clone the repo recursively - since it uses Git submodules - and then install
|
||||
both Python and Node dependencies.
|
||||
|
||||
```shellsession
|
||||
$ git clone --recursive https://git.00dani.me/00dani/lemoncurry
|
||||
$ cd lemoncurry
|
||||
$ pipenv install --dev
|
||||
$ yarn install
|
||||
```
|
||||
|
||||
Once those steps complete, you should be able to perform the usual Django steps
|
||||
to get a development server up and running. (If you'd prefer, you can use
|
||||
`pipenv shell` to activate lemoncurry's virtualenv, rather than prefacing each
|
||||
command with `pipenv run`. I like being explicit.)
|
||||
|
||||
```shellsession
|
||||
$ pipenv run ./manage.py migrate
|
||||
$ pipenv run ./manage.py collectstatic
|
||||
$ pipenv run ./manage.py runserver 3000
|
||||
```
|
|
@ -1 +0,0 @@
|
|||
default_app_config = 'entries.apps.EntriesConfig'
|
|
@ -8,12 +8,10 @@ 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)
|
||||
|
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class EntriesConfig(AppConfig):
|
||||
name = 'entries'
|
||||
name = "entries"
|
||||
|
|
34
entries/from_url.py
Normal file
34
entries/from_url.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from urllib.parse import urlparse
|
||||
|
||||
from django.contrib.sites.models import Site
|
||||
from django.http import HttpResponse
|
||||
from django.urls import resolve, Resolver404
|
||||
from micropub import error
|
||||
|
||||
from .models import Entry
|
||||
|
||||
|
||||
def from_url(url: str) -> Entry:
|
||||
domain = Site.objects.get_current().domain
|
||||
if not url:
|
||||
raise error.bad_req("url parameter required")
|
||||
if "//" not in url:
|
||||
url = "//" + url
|
||||
parts = urlparse(url, scheme="https")
|
||||
if parts.scheme not in ("http", "https") or parts.netloc != domain:
|
||||
raise error.bad_req("url does not point to this site")
|
||||
|
||||
try:
|
||||
match = resolve(parts.path)
|
||||
except Resolver404:
|
||||
raise error.bad_req("url does not point to a valid page on this site")
|
||||
|
||||
if match.view_name != "entries:entry":
|
||||
raise error.bad_req("url does not point to an entry on this site")
|
||||
|
||||
try:
|
||||
entry = Entry.objects.get(pk=match.kwargs["id"])
|
||||
except Entry.DoesNotExist:
|
||||
raise error.bad_req("url does not point to an existing entry")
|
||||
|
||||
return entry
|
15
entries/jinja2/entries/entry.html
Normal file
15
entries/jinja2/entries/entry.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
{% block head %}
|
||||
<link rel="shortlink" href="{{ entry.short_url }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% import 'entries/h-entry.html' as h %}
|
||||
{% block main %}
|
||||
<div class="entry">
|
||||
{{ h.hEntry(entry, indent_width=8) }}
|
||||
</div>
|
||||
{% endblock %}
|
55
entries/jinja2/entries/h-entry.html
Normal file
55
entries/jinja2/entries/h-entry.html
Normal file
|
@ -0,0 +1,55 @@
|
|||
{% macro hEntry(entry, indent_width) -%}
|
||||
{%- set i = ' ' * indent_width -%}
|
||||
<article class="h-entry media">
|
||||
{{i}}<aside class="info">
|
||||
{{i}}<a class="p-author h-card" href="{{ entry.author.url }}">
|
||||
{{i}}{% if entry.author.avatar %}<img class="u-photo img-fluid" src="{{ entry.author.avatar.url }}" alt="{{ entry.author.name }}" />{% endif %}
|
||||
{{i}}<span class="p-name sr-only">{{ entry.author.name }}</span>
|
||||
{{i}}</a>
|
||||
{{i}}<a class="u-uid u-url" href="{{ entry.url }}">
|
||||
{{i}}<time class="dt-published media" datetime="{{ entry.published.isoformat() }}" title="{{ entry.published.isoformat() }}">
|
||||
{{i}}<i class="fas fa-fw fa-calendar" aria-hidden="true"></i>
|
||||
{{i}}<div class="media-body">{{ entry.published | ago }}</div>
|
||||
{{i}}</time>
|
||||
{{i}}</a>
|
||||
{{i}}<time class="dt-updated media" datetime="{{ entry.updated.isoformat() }}" title="{{ entry.updated.isoformat() }}"{% if (entry.updated | ago) == (entry.published | ago) %} hidden{% endif %}>
|
||||
{{i}}<i class="fas fa-fw fa-pencil-alt" aria-hidden="true"></i>
|
||||
{{i}}<div class="media-body">{{ entry.updated | ago }}</div>
|
||||
{{i}}</time>
|
||||
{{i}}<a class="u-url media" href="{{ entry.short_url }}">
|
||||
{{i}}<i class="fas fa-fw fa-link" aria-hidden="true"></i>
|
||||
{{i}}<div class="media-body">{{ entry.short_url | friendly_url }}</div>
|
||||
{{i}}</a>
|
||||
{{i}}</aside>
|
||||
|
||||
{{i}}<div class="card media-body">
|
||||
{% if entry.photo %}
|
||||
{{i}}<img class="card-img-top u-photo" src="{{ entry.photo.url }}" />
|
||||
|
||||
{% endif %}
|
||||
{{i}}<div class="card-body">
|
||||
{% if entry.name %}
|
||||
{{i}}<h4 class="card-title p-name">{{ entry.name }}</h4>
|
||||
{% endif %}
|
||||
{{i}}<div class="e-content">
|
||||
{{i}}{{ entry.content | markdown }}
|
||||
{{i}}</div>
|
||||
|
||||
{% for c in entry.cats.all() %}
|
||||
{{i}}<a class="p-category card-link" href="{{ c.url }}">
|
||||
{{i}}<i class="fas fa-paw" aria-hidden="true"></i>
|
||||
{{i}}{{ c.name }}
|
||||
{{i}}</a>
|
||||
{% endfor %}
|
||||
{% for s in entry.syndications.all() %}
|
||||
{{i}}<a class="u-syndication card-link" href="{{ s.url }}">
|
||||
{{i}}<i class="{{ s.site.icon }}" aria-hidden="true"></i>
|
||||
{{i}}{{ s.site.domain }}
|
||||
{{i}}</a>
|
||||
{% endfor %}
|
||||
{{i}}</div>
|
||||
{{i}}</div>
|
||||
|
||||
{{i}}<script class="p-json-ld" type="application/ld+json">{{ entry.json_ld | tojson }}</script>
|
||||
{{i}}</article>
|
||||
{%- endmacro %}
|
20
entries/jinja2/entries/index.html
Normal file
20
entries/jinja2/entries/index.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
|
||||
{% block html_attr %}
|
||||
class="h-feed"{{ super() }}
|
||||
{%- endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% import 'entries/h-entry.html' as h %}
|
||||
{% block main %}
|
||||
<ol class="list-unstyled entries">
|
||||
{% for entry in entries %}
|
||||
<li>
|
||||
{{ h.hEntry(entry, indent_width=10) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endblock %}
|
|
@ -7,16 +7,20 @@ 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):
|
||||
result = webmention.findMentions(source)
|
||||
for target in result['refs']:
|
||||
def send_mentions(source, targets=None):
|
||||
if targets is None:
|
||||
targets = webmention.findMentions(source)["refs"]
|
||||
for target in targets:
|
||||
status, endpoint = webmention.discoverEndpoint(target)
|
||||
if endpoint is not None and status == 200:
|
||||
webmention.sendWebmention(source, target, endpoint)
|
||||
|
|
|
@ -14,69 +14,75 @@ class Entry:
|
|||
return self.index_page()
|
||||
|
||||
def index_page(self, page=0):
|
||||
kwargs = {'kind': self.plural}
|
||||
kwargs = {"kind": self}
|
||||
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'
|
||||
|
||||
@property
|
||||
def entry_amp(self):
|
||||
return self.entry + '_amp'
|
||||
return self.plural + "_entry"
|
||||
|
||||
@property
|
||||
def atom(self):
|
||||
return reverse('entries:atom_by_kind', kwargs={'kind': self.plural})
|
||||
return reverse("entries:atom_by_kind", kwargs={"kind": self})
|
||||
|
||||
@property
|
||||
def rss(self):
|
||||
return reverse('entries:rss_by_kind', kwargs={'kind': self.plural})
|
||||
return reverse("entries:rss_by_kind", kwargs={"kind": self})
|
||||
|
||||
|
||||
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
|
||||
|
|
|
@ -8,7 +8,6 @@ import django.db.models.deletion
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
@ -17,20 +16,41 @@ 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -7,23 +7,42 @@ 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,14 +6,13 @@ 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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -8,20 +8,28 @@ 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,20 +6,24 @@ 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -8,34 +8,41 @@ 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,20 +6,31 @@ 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,25 +6,24 @@ 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),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,21 +6,28 @@ 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",),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,15 +6,14 @@ 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"),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,19 +6,20 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('entries', '0010_entry_tags'),
|
||||
("entries", "0010_entry_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='Tag',
|
||||
new_name='Cat',
|
||||
old_name="Tag",
|
||||
new_name="Cat",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='entry',
|
||||
old_name='tags',
|
||||
new_name='cats',
|
||||
model_name="entry",
|
||||
old_name="tags",
|
||||
new_name="cats",
|
||||
),
|
||||
]
|
||||
|
|
29
entries/migrations/0012_auto_20180628_2044.py
Normal file
29
entries/migrations/0012_auto_20180628_2044.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 2.0.6 on 2018-06-28 10:44
|
||||
|
||||
import computed_property.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("entries", "0011_auto_20171120_1108"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="syndication",
|
||||
options={"ordering": ["domain"]},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="syndication",
|
||||
name="profile",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="syndication",
|
||||
name="domain",
|
||||
field=computed_property.fields.ComputedCharField(
|
||||
compute_from="calc_domain", default="", editable=False, max_length=255
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
22
entries/migrations/0013_alter_entry_kind.py
Normal file
22
entries/migrations/0013_alter_entry_kind.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 3.2.12 on 2022-03-12 04:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("entries", "0012_auto_20180628_2044"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="entry",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[("note", "note"), ("article", "article"), ("photo", "photo")],
|
||||
db_index=True,
|
||||
default="note",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,19 +1,23 @@
|
|||
from computed_property import ComputedCharField
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.sites.models import Site as DjangoSite
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from itertools import groupby
|
||||
from mf2util import interpret
|
||||
from slugify import slugify
|
||||
from textwrap import shorten
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from lemonshort.short_url import short_url
|
||||
from meta.models import ModelMeta
|
||||
from model_utils.models import TimeStampedModel
|
||||
from users.models import Profile
|
||||
from users.models import Site
|
||||
|
||||
from . import kinds
|
||||
from lemoncurry import requests, utils
|
||||
|
||||
ENTRY_KINDS = [(k.id, k.id) for k in kinds.all]
|
||||
|
||||
|
||||
|
@ -29,36 +33,33 @@ 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)
|
||||
|
@ -66,7 +67,7 @@ class Entry(ModelMeta, TimeStampedModel):
|
|||
|
||||
author = models.ForeignKey(
|
||||
get_user_model(),
|
||||
related_name='entries',
|
||||
related_name="entries",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
|
@ -74,10 +75,7 @@ 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):
|
||||
|
@ -88,31 +86,29 @@ 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):
|
||||
|
@ -127,26 +123,45 @@ 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.url
|
||||
return self.absolute_url
|
||||
|
||||
@property
|
||||
def absolute_url(self):
|
||||
base = "https://" + DjangoSite.objects.get_current().domain
|
||||
return urljoin(base, self.url)
|
||||
|
||||
@property
|
||||
def affected_urls(self):
|
||||
base = "https://" + DjangoSite.objects.get_current().domain
|
||||
kind = kinds.from_id[self.kind]
|
||||
urls = {
|
||||
self.url,
|
||||
reverse("entries:index", kwargs={"kind": kind}),
|
||||
reverse("entries:atom_by_kind", kwargs={"kind": kind}),
|
||||
reverse("entries:rss_by_kind", kwargs={"kind": kind}),
|
||||
} | {cat.url for cat in self.cats.all()}
|
||||
if kind.on_home:
|
||||
urls |= {
|
||||
reverse("home:index"),
|
||||
reverse("entries:atom"),
|
||||
reverse("entries:rss"),
|
||||
}
|
||||
return {urljoin(base, u) for u in urls}
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
kind = kinds.from_id[self.kind]
|
||||
args = [kind.plural, self.id]
|
||||
args = [kind, 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)
|
||||
def short_url(self):
|
||||
return short_url(self)
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
|
@ -154,49 +169,58 @@ class Entry(ModelMeta, TimeStampedModel):
|
|||
|
||||
@property
|
||||
def json_ld(self):
|
||||
base = 'https://' + Site.objects.get_current().domain
|
||||
base = "https://" + DjangoSite.objects.get_current().domain
|
||||
url = urljoin(base, self.url)
|
||||
|
||||
posting = {
|
||||
'@context': 'http://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
'@id': url,
|
||||
'url': url,
|
||||
'mainEntityOfPage': url,
|
||||
'author': {
|
||||
'@type': 'Person',
|
||||
'url': urljoin(base, self.author.url),
|
||||
'name': self.author.name,
|
||||
"@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']
|
||||
|
||||
|
||||
class SyndicationManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = super(SyndicationManager, self).get_queryset()
|
||||
return qs.select_related('profile__site')
|
||||
verbose_name_plural = "entries"
|
||||
ordering = ["-created"]
|
||||
|
||||
|
||||
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 = ['profile']
|
||||
ordering = ["domain"]
|
||||
|
|
|
@ -1,33 +1,32 @@
|
|||
from django.core.paginator import Paginator
|
||||
from typing import Callable
|
||||
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from lemoncurry.middleware import ResponseException
|
||||
|
||||
def paginate(queryset, reverse, page):
|
||||
class Page:
|
||||
def __init__(self, i):
|
||||
self.i = i
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return reverse(self.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 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)
|
||||
def reversible(p: Page) -> Page:
|
||||
p.reverse = reverse
|
||||
return p
|
||||
|
||||
paginator = Paginator(queryset, 10)
|
||||
entries = paginator.page(page or 1)
|
||||
|
||||
entries.pages = tuple(Page(i) for i in paginator.page_range)
|
||||
# If no page number was specified, return page one.
|
||||
if page is None:
|
||||
return reversible(paginator.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 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)
|
||||
|
||||
return entries
|
||||
# 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))
|
||||
|
|
|
@ -8,6 +8,6 @@ class EntriesSitemap(sitemaps.Sitemap):
|
|||
|
||||
def lastmod(self, entry):
|
||||
return entry.updated
|
||||
|
||||
|
||||
def location(self, entry):
|
||||
return entry.url
|
||||
|
|
|
@ -10,12 +10,40 @@ ol.entries, div.entry
|
|||
&:last-child
|
||||
margin-bottom 0
|
||||
|
||||
.card.h-entry
|
||||
.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
|
||||
.e-content
|
||||
ul
|
||||
list-style-type disc
|
||||
ul, ol
|
||||
margin-bottom 1rem
|
||||
padding-left 1.1rem
|
||||
ul
|
||||
list-style-type circle
|
||||
ul, ol
|
||||
|
@ -26,14 +54,8 @@ ol.entries, div.entry
|
|||
max-width 100%
|
||||
> :last-child
|
||||
margin-bottom 0
|
||||
|
||||
.card-footer
|
||||
text-align center
|
||||
> *
|
||||
display inline-block
|
||||
margin-right 1rem
|
||||
&:last-child
|
||||
margin-right 0
|
||||
.h-card > img
|
||||
height 1em
|
||||
vertical-align baseline
|
||||
.card-link
|
||||
display inline-block
|
||||
font-size 0.8rem
|
||||
margin-left 0
|
||||
margin-right 1.25rem
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
{% load shorturl static %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="amphtml" href="{{ entry.amp_url }}" />
|
||||
<link rel="shortlink" href="{% shorturl entry %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="entry">
|
||||
{% include 'entries/h-entry.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,157 +0,0 @@
|
|||
{% load absolute_url favtags friendly_url humanize jsonify lemoncurry_tags markdown shortlink 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 %}">
|
||||
{% shortlink entry as short %}<link rel="shortlink" href="{% absolute_url short %}" />
|
||||
<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 short %}">
|
||||
🔗
|
||||
{{ short | 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>
|
|
@ -1,71 +0,0 @@
|
|||
{% load bleach friendly_url humanize jsonify markdown shortlink %}<article class="card h-entry">
|
||||
{% if entry.photo %}<img class="card-img-top u-photo" src="{{ entry.photo.url }}" />{% endif %}
|
||||
|
||||
{% if entry.in_reply_to %}{% with reply=entry.reply_context %}
|
||||
<article class="card-header media u-in-reply-to h-cite">
|
||||
<a class="align-self-center p-author h-card" href="{{ reply.author.url }}">
|
||||
<img class="mr-3 rounded" width="100" src="{{ reply.author.photo }}"
|
||||
alt="{{ reply.author.name }}" title="{{ reply.author.name }}" />
|
||||
</a>
|
||||
<div class="media-body">
|
||||
{% if reply.name %}<h4 class="p-name">{{ reply.name }}</h4>{% endif %}
|
||||
<div class="e-content{% if not reply.name %} p-name{% endif %}">{{ reply.content | bleach }}</div>
|
||||
</div>
|
||||
</article>{% endwith %}{% endif %}
|
||||
|
||||
<div class="card-body">
|
||||
{% if entry.name %}<h4 class="card-title p-name">{{ entry.name }}</h4>{% endif %}
|
||||
<div class="e-content{% if not entry.name %} p-name{% endif %}">{{ entry.content | markdown }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<a class="p-author h-card" href="{{ entry.author.url }}">
|
||||
<img class="u-photo" src="{{ entry.author.avatar.url }}" />
|
||||
{{ entry.author.first_name }} {{ entry.author.last_name }}
|
||||
</a>
|
||||
<a class="u-uid u-url" href="{{ entry.url }}">
|
||||
<time class="dt-published" datetime="{{ entry.published.isoformat }}">
|
||||
<i class="fas fa-calendar"></i>
|
||||
{{ entry.published | naturaltime }}
|
||||
</time>
|
||||
</a>
|
||||
{% if entry.updated != entry.published %}
|
||||
<time class="dt-updated" datetime="{{ entry.updated.isoformat }}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
{{ entry.updated | naturaltime }}
|
||||
</time>
|
||||
{% endif %}
|
||||
<a class="u-url" href="{{ entry.amp_url }}">
|
||||
<i class="fas fa-bolt"></i>
|
||||
amp
|
||||
</a>
|
||||
{% shortlink entry as short %}<a class="u-url" href="{{ short }}">
|
||||
<i class="fas fa-link"></i>
|
||||
{{ short | 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>
|
|
@ -1,17 +0,0 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
{% load static %}
|
||||
{% block html_class %}h-feed{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<ol class="list-unstyled entries">
|
||||
{% for entry in entries %}
|
||||
<li>
|
||||
{% include 'entries/h-entry.html' %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endblock %}
|
0
entries/tests/views/__init__.py
Normal file
0
entries/tests/views/__init__.py
Normal file
29
entries/tests/views/feeds.py
Normal file
29
entries/tests/views/feeds.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_atom(client):
|
||||
res = client.get("/atom")
|
||||
assert res.status_code == 200
|
||||
assert res["Content-Type"] == "application/atom+xml; charset=utf-8"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rss(client):
|
||||
res = client.get("/rss")
|
||||
assert res.status_code == 200
|
||||
assert res["Content-Type"] == "application/rss+xml; charset=utf-8"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_atom_by_kind(client):
|
||||
res = client.get("/notes/atom")
|
||||
assert res.status_code == 200
|
||||
assert res["Content-Type"] == "application/atom+xml; charset=utf-8"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rss_by_kind(client):
|
||||
res = client.get("/notes/rss")
|
||||
assert res.status_code == 200
|
||||
assert res["Content-Type"] == "application/rss+xml; charset=utf-8"
|
|
@ -1,51 +1,58 @@
|
|||
from django.conf.urls import url
|
||||
from django.urls import reverse
|
||||
from django.urls import path, register_converter, 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 = (
|
||||
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'),
|
||||
path("atom", feeds.AtomHomeEntries(), name="atom"),
|
||||
path("rss", feeds.RssHomeEntries(), name="rss"),
|
||||
path("cats/<slug:slug>", lists.by_cat, name="cat"),
|
||||
path("cats/<slug:slug>/page/<int:page>", lists.by_cat, name="cat"),
|
||||
path("<kind:kind>", lists.by_kind, name="index"),
|
||||
path("<kind:kind>/page/<int:page>", lists.by_kind, name="index"),
|
||||
path("<kind:kind>/atom", feeds.AtomByKind(), name="atom_by_kind"),
|
||||
path("<kind:kind>/rss", feeds.RssByKind(), name="rss_by_kind"),
|
||||
path("<kind:kind>/<int:id>", perma.entry, name="entry"),
|
||||
path("<kind:kind>/<int:id>/<slug:slug>", perma.entry, name="entry"),
|
||||
)
|
||||
|
||||
|
||||
class IndexCrumb(crumbs.Crumb):
|
||||
def __init__(self):
|
||||
super().__init__(prefix('index'), parent='home:index')
|
||||
super().__init__(prefix("index"), parent="home:index")
|
||||
|
||||
@property
|
||||
def kind(self):
|
||||
return self.match.kwargs["kind"]
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self.match.kwargs['kind']
|
||||
return self.kind.plural
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return reverse(prefix('index'), kwargs={'kind': self.label})
|
||||
return reverse(prefix("index"), kwargs={"kind": self.kind})
|
||||
|
||||
|
||||
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"))
|
||||
|
|
|
@ -1,14 +1,26 @@
|
|||
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 from_plural, on_home
|
||||
from ..kinds import on_home
|
||||
from ..models import Entry
|
||||
|
||||
|
||||
class Atom1FeedWithHub(Atom1Feed):
|
||||
def add_root_elements(self, handler):
|
||||
super().add_root_elements(handler)
|
||||
handler.startElement("link", {"rel": "hub", "href": settings.PUSH_HUB})
|
||||
handler.endElement("link")
|
||||
|
||||
|
||||
class EntriesFeed(Feed):
|
||||
item_guid_is_permalink = True
|
||||
|
||||
def item_link(self, entry):
|
||||
return entry.absolute_url
|
||||
|
||||
def item_title(self, entry):
|
||||
return entry.title
|
||||
|
||||
|
@ -22,13 +34,12 @@ class EntriesFeed(Feed):
|
|||
return entry.author.email
|
||||
|
||||
def item_author_link(self, entry):
|
||||
base = 'https://' + Site.objects.get_current().domain
|
||||
return urljoin(base, entry.author.url)
|
||||
return entry.author.absolute_url
|
||||
|
||||
def item_pubdate(self, entry):
|
||||
return entry.published
|
||||
|
||||
def item_updatedate(self, entry):
|
||||
def item_updateddate(self, entry):
|
||||
return entry.updated
|
||||
|
||||
def item_categories(self, entry):
|
||||
|
@ -37,7 +48,7 @@ class EntriesFeed(Feed):
|
|||
|
||||
class RssByKind(EntriesFeed):
|
||||
def get_object(self, request, kind):
|
||||
return from_plural[kind]
|
||||
return kind
|
||||
|
||||
def title(self, kind):
|
||||
return "{0} ~ {1}".format(
|
||||
|
@ -59,7 +70,7 @@ class RssByKind(EntriesFeed):
|
|||
|
||||
|
||||
class AtomByKind(RssByKind):
|
||||
feed_type = Atom1Feed
|
||||
feed_type = Atom1FeedWithHub
|
||||
subtitle = RssByKind.description
|
||||
|
||||
|
||||
|
@ -68,7 +79,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(
|
||||
|
@ -80,5 +91,5 @@ class RssHomeEntries(EntriesFeed):
|
|||
|
||||
|
||||
class AtomHomeEntries(RssHomeEntries):
|
||||
feed_type = Atom1Feed
|
||||
feed_type = Atom1FeedWithHub
|
||||
subtitle = RssHomeEntries.description
|
||||
|
|
|
@ -1,42 +1,36 @@
|
|||
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):
|
||||
kind = kinds.from_plural[kind]
|
||||
@render_to("entries/index.html")
|
||||
def by_kind(request, kind, page=None):
|
||||
entries = Entry.objects.filter(kind=kind.id)
|
||||
entries = paginate(queryset=entries, reverse=kind.index_page, page=page)
|
||||
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):
|
||||
@render_to("entries/index.html")
|
||||
def by_cat(request, slug, page=None):
|
||||
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,
|
||||
}
|
||||
|
|
|
@ -3,21 +3,12 @@ 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,
|
||||
'meta': entry.as_meta(request)
|
||||
"entry": entry,
|
||||
"title": entry.title,
|
||||
}
|
||||
|
||||
|
||||
@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}
|
||||
|
|
5
gunicorn.py
Normal file
5
gunicorn.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
import multiprocessing
|
||||
|
||||
proc_name = "lemoncurry"
|
||||
worker_class = "gevent"
|
||||
workers = multiprocessing.cpu_count() * 2 + 1
|
107
home/jinja2/home/index.html
Normal file
107
home/jinja2/home/index.html
Normal file
|
@ -0,0 +1,107 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
|
||||
{% block html_attr %}
|
||||
class="h-feed"{{ super() }}
|
||||
{%- endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{{ static('home/css/index.styl') }}" />
|
||||
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% for key in user.keys.all() %}
|
||||
<link rel="pgpkey" href="{{ key.file.url }}" />
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<aside class="author">
|
||||
<article class="h-card p-author card">
|
||||
<a class="u-uid u-url" href="{{ user.full_url }}">
|
||||
{% if user.avatar %}
|
||||
<img class="u-photo card-img-top" src="{{ user.avatar.url }}" alt="{{ user.name }}" />
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
<div class="card-body">
|
||||
<h4 class="p-name card-title">
|
||||
<span class="p-given-name">{{ user.first_name }}</span> <span class="p-family-name">{{ user.last_name }}</span>
|
||||
</h4>
|
||||
{% if user.note %}
|
||||
<div class="p-note">
|
||||
{{ user.note | markdown }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<ul class="profiles">
|
||||
<li>
|
||||
<a class="u-email" rel="me" href="mailto:{{ user.email }}">
|
||||
<i class="fas fa-envelope" aria-hidden="true"></i>
|
||||
{{ user.email }}
|
||||
</a>
|
||||
</li>
|
||||
{% if user.xmpp %}
|
||||
|
||||
<li>
|
||||
<a class="u-impp" rel="me" href="xmpp:{{ user.xmpp }}">
|
||||
<i class="openwebicons-xmpp" aria-hidden="true"></i>
|
||||
{{ user.xmpp }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if user.keys.exists() %}
|
||||
|
||||
<div class="card-footer">
|
||||
<ul class="profiles">
|
||||
{% for key in user.keys.all() %}
|
||||
<a class="u-key" href="{{ key.file.url }}">
|
||||
<i class="fas fa-key" aria-hidden="true"></i>
|
||||
{{ key.pretty_print() }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user.profiles.exists() %}
|
||||
|
||||
<div class="card-footer">
|
||||
<ul class="profiles">
|
||||
{% for profile in user.profiles.all() %}
|
||||
<a class="u-url" rel="me" href="{{ profile.url }}" title="{{ profile }}">
|
||||
<i class="{{ profile.site.icon }}" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{ profile }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script class="p-json-ld" type="application/ld+json">{{ user.json_ld | tojson }}</script>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
{% import 'entries/h-entry.html' as h %}
|
||||
<ol class="list-unstyled entries">
|
||||
{% for entry in entries %}
|
||||
<li>
|
||||
{{ h.hEntry(entry, indent_width=10) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endblock %}
|
||||
|
||||
{% block foot %}
|
||||
<script type="text/javascript">
|
||||
tippy('.profiles [title]', {
|
||||
arrow: true,
|
||||
content: function(element) {
|
||||
return element.getAttribute('title');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -3,10 +3,10 @@ from django.urls import reverse
|
|||
|
||||
|
||||
class HomeSitemap(sitemaps.Sitemap):
|
||||
changefreq = 'daily'
|
||||
changefreq = "daily"
|
||||
|
||||
def items(self):
|
||||
return ('home:index',)
|
||||
return ("home:index",)
|
||||
|
||||
def location(self, item):
|
||||
return reverse(item)
|
||||
|
|
|
@ -1,8 +1,3 @@
|
|||
$sm = 576px
|
||||
$md = 768px
|
||||
$lg = 992px
|
||||
$xl = 1200px
|
||||
|
||||
main
|
||||
flex-direction column
|
||||
align-items center
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,9 +1,10 @@
|
|||
from django.conf.urls import url
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'home'
|
||||
app_name = "home"
|
||||
urlpatterns = [
|
||||
url(r'^(?:page/(?P<page>\d+))?$', views.index, name='index'),
|
||||
url(r'^robots.txt$', views.robots, name='robots.txt'),
|
||||
path("", views.index, name="index"),
|
||||
path("page/<int:page>", views.index, name="index"),
|
||||
path("robots.txt", views.robots, name="robots.txt"),
|
||||
]
|
||||
|
|
|
@ -8,39 +8,31 @@ 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):
|
||||
@render_to("home/index.html")
|
||||
def index(request, page=None):
|
||||
def url(page):
|
||||
kwargs = {'page': page} if page != 1 else {}
|
||||
return reverse('home:index', kwargs=kwargs)
|
||||
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'),
|
||||
'meta': user.as_meta(request),
|
||||
"user": user,
|
||||
"entries": entries,
|
||||
"atom": reverse("entries:atom"),
|
||||
"rss": reverse("entries:rss"),
|
||||
}
|
||||
|
||||
|
||||
def robots(request):
|
||||
base = utils.origin(request)
|
||||
lines = (
|
||||
'User-agent: *',
|
||||
'Sitemap: {0}'.format(urljoin(base, reverse('sitemap')))
|
||||
)
|
||||
return HttpResponse("\n".join(lines) + "\n", content_type='text/plain')
|
||||
lines = ("User-agent: *", "Sitemap: {0}".format(urljoin(base, reverse("sitemap"))))
|
||||
return HttpResponse("\n".join(lines) + "\n", content_type="text/plain")
|
||||
|
|
83
lemonauth/jinja2/lemonauth/indie.html
Normal file
83
lemonauth/jinja2/lemonauth/indie.html
Normal file
|
@ -0,0 +1,83 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{{ static('lemonauth/css/indie.styl') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<form class="card" method="post" action="{{ url('lemonauth:indie_approve') }}">
|
||||
<h4 class="card-header h-x-app">
|
||||
{% if app %}
|
||||
{% if app.logo is defined %}
|
||||
<img class="u-logo" src="{{ app.logo[0] }}" alt="{{ app.name[0] }}" />
|
||||
{% endif %}
|
||||
sign in to <span class="p-name">{{ app.name[0] }}</span> (<a class="u-url code" href="{{ params.client_id }}">{{ params.client_id }}</a>)?
|
||||
{% else %}
|
||||
sign in to <a class="u-url p-name code" href="{{ params.client_id }}">{{ params.client_id }}</a>?
|
||||
{% endif %}
|
||||
{% if verified %}
|
||||
<span data-tooltip data-tippy-theme="dark success" data-tippy-html="#verified-success">
|
||||
<i class="fas fa-check-circle verified-success"></i>
|
||||
</span>
|
||||
{% else %}
|
||||
<span data-tooltip data-tippy-theme="dark warning" data-tippy-html="#verified-warning">
|
||||
<i class="fas fa-question-circle verified-warning"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
|
||||
<div class="card-body">
|
||||
<p class="card-text">do you want to confirm your identity, <a class="code" href="{{ me }}">{{ me }}</a>, with this app?</p>
|
||||
{% if params.response_type == 'code' %}
|
||||
<p class="card-text">additionally, this app is requesting the following <i>scopes</i> - you can edit the scopes that will be granted to the app, if you wish</p>
|
||||
<div class="card-text form-group">
|
||||
{% for scope in scopes %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" id="scopes-{{ scope }}" name="scope" type="checkbox" checked value="{{ scope }}" />
|
||||
<label class="form-check-label" for="scopes-{{ scope }}">{{ scope }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="card-text"><small>you will be redirected to <a class="code" href="{{ redirect_uri }}">{{ redirect_uri }}</a> after authorising this app</small></p>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-success" type="submit">
|
||||
<i class="fas fa-check"></i>
|
||||
approve
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ csrf_input }}
|
||||
<input type="hidden" name="me" value="{{ me }}">
|
||||
<input type="hidden" name="client_id" value="{{ params.client_id }}">
|
||||
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||
{% if params.state %}
|
||||
<input type="hidden" name="state" value="{{ params.state }}">
|
||||
{% endif %}
|
||||
<input type="hidden" name="response_type" value="{{ params.response_type }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="verified-success" hidden>
|
||||
this client has been <strong>verified</strong> using <code>{{ '<link rel="redirect_uri">' | escape }}</code> <br/>- they are who they claim to be!
|
||||
</div>
|
||||
<div id="verified-warning" hidden>
|
||||
this client could <strong>not</strong> be verified using <code>{{ '<link rel="redirect_uri">' | escape }}</code> <br/>- check the redirect uri carefully yourself!
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block foot %}
|
||||
<script type="text/javascript">
|
||||
tippy('[data-tippy-html]', {
|
||||
arrow: true,
|
||||
allowHTML: true,
|
||||
maxWidth: 500,
|
||||
content: function(element) {
|
||||
return document.querySelector(element.getAttribute('data-tippy-html')).innerHTML;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
61
lemonauth/jinja2/lemonauth/login.html
Normal file
61
lemonauth/jinja2/lemonauth/login.html
Normal file
|
@ -0,0 +1,61 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{{ static('lemonauth/css/login.styl') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
{% if form.errors %}
|
||||
<p class="alert alert-danger">
|
||||
<strong>uh oh!</strong> your login details didn't match, please try again
|
||||
</p>
|
||||
|
||||
{% elif next %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<p class="alert alert-warning">
|
||||
<strong>hang on!</strong> your account doesn't have access to this page :( to proceed, please log in to an account that does have access!
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="alert alert-warning">
|
||||
<strong>oops!</strong> please log in to see this page
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
<form class="card" method="post" action="{{ url('lemonauth:login') }}">
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="{{ form.username.id_for_label }}">username</label>
|
||||
<input class="form-control" type="text" autocomplete="username" required id="{{ form.username.auto_id }}" name="{{ form.username.name }}" value="{{ form.username.value() or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="{{ form.password.id_for_label }}">password</label>
|
||||
<input class="form-control" type="password" autocomplete="current-password" required id="{{ form.password.auto_id }}" name="{{ form.password.name }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="{{ form.otp_token.id_for_label }}">otp token</label>
|
||||
<input class="form-control" type="text" required id="{{ form.otp_token.auto_id }}" name="{{ form.otp_token.name }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group form-check">
|
||||
<input class="form-check-input" type="checkbox" id="{{ form.otp_trust_agent.auto_id }}" name="{{ form.otp_trust_agent.name }}">
|
||||
<label for="{{ form.otp_trust_agent.id_for_label }}" class="form-check-label">
|
||||
remember this browser (don't tick this on a public computer!)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
|
||||
log in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ csrf_input }}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
68
lemonauth/jinja2/lemonauth/tokens.html
Normal file
68
lemonauth/jinja2/lemonauth/tokens.html
Normal file
|
@ -0,0 +1,68 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<div class="card-columns">
|
||||
{% for _, c in clients | dictsort %}
|
||||
<div class="h-x-app card">
|
||||
{% if c.app.logo %}
|
||||
<img class="u-photo card-img-top" src="{{ c.app.logo[0] }}" alt="{{ c.app.name[0] }}" />
|
||||
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="p-name">{{ c.app.name[0] if c.app else (c.id | friendly_url) }}</span>
|
||||
<span class="badge badge-light">
|
||||
<span class="p-count">{{ c.count }}</span>
|
||||
{{ 'tokens' if c.count > 1 else 'token' }}
|
||||
</span>
|
||||
</h5>
|
||||
|
||||
<h6 class="card-subtitle mb-2">
|
||||
<a class="u-url" href="{{ c.id }}">{{ c.id }}</a>
|
||||
</h6>
|
||||
|
||||
<p class="card-text">this client has access to the scopes:</p>
|
||||
</div>
|
||||
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for scope in c.scopes %}
|
||||
<li class="p-scope list-group-item">{{ scope }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<form class="card-footer text-right" action="{{ url('lemonauth:tokens_revoke', kwargs={'client_id': c.id}) }}">
|
||||
{{ csrf_input }}
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-ban" aria-hidden="true"></i>
|
||||
revoke access
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block foot %}
|
||||
<script type="text/javascript">
|
||||
$('form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var $f = $(this);
|
||||
if ($f.data('deleting')) return;
|
||||
$f.data('deleting', true);
|
||||
$f.find('button').prop({disabled: true})
|
||||
.find('[data-fa-i2svg]').removeClass('fa-ban').addClass('fa-circle-notch fa-spin');
|
||||
$.ajax({
|
||||
headers: {'X-CSRFToken': $f.find('[name="csrfmiddlewaretoken"]').val()},
|
||||
method: 'DELETE',
|
||||
url: $f.attr('action'),
|
||||
}).then(function() {
|
||||
var $app = $f.parent('.h-x-app');
|
||||
return $app.hide(function() {
|
||||
$app.remove();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,28 +1,42 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-10-29 05:05
|
||||
from __future__ import unicode_literals
|
||||
from typing import List, Tuple
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = [] # type: List[Tuple[str, str]]
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,13 +6,12 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lemonauth', '0001_initial'),
|
||||
("lemonauth", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='IndieAuthCode',
|
||||
name="IndieAuthCode",
|
||||
),
|
||||
]
|
||||
|
|
120
lemonauth/migrations/0003_indieauthcode_token.py
Normal file
120
lemonauth/migrations/0003_indieauthcode_token.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
# Generated by Django 2.0.6 on 2018-06-12 04:51
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import randomslugfield.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("lemonauth", "0002_delete_indieauthcode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="IndieAuthCode",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
randomslugfield.fields.RandomSlugField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
length=30,
|
||||
max_length=30,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("client_id", models.URLField()),
|
||||
("scope", models.TextField(blank=True)),
|
||||
("redirect_uri", models.URLField()),
|
||||
(
|
||||
"response_type",
|
||||
model_utils.fields.StatusField(
|
||||
choices=[("id", "id"), ("code", "code")],
|
||||
default="id",
|
||||
max_length=100,
|
||||
no_check_for_status=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Token",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
randomslugfield.fields.RandomSlugField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
length=30,
|
||||
max_length=30,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("client_id", models.URLField()),
|
||||
("scope", models.TextField(blank=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
62
lemonauth/models.py
Normal file
62
lemonauth/models.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
from datetime import timedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from randomslugfield import RandomSlugField
|
||||
from model_utils import Choices
|
||||
from model_utils.fields import StatusField
|
||||
from model_utils.models import TimeStampedModel
|
||||
|
||||
|
||||
class AuthSecret(TimeStampedModel):
|
||||
"""
|
||||
An AuthSecret is a model with an unguessable primary key, suitable for
|
||||
sharing with external sites for secure authentication.
|
||||
|
||||
AuthSecret is primarily used to factor out the many similarities between
|
||||
authorisation codes and tokens in IndieAuth - the two contain many
|
||||
identical fields, but just a few differences.
|
||||
"""
|
||||
|
||||
id = RandomSlugField(primary_key=True, length=30)
|
||||
client_id = models.URLField()
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
scope = models.TextField(blank=True)
|
||||
|
||||
@property
|
||||
def me(self):
|
||||
return self.user.full_url
|
||||
|
||||
def __contains__(self, scope):
|
||||
return scope in self.scope.split(" ")
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class IndieAuthCode(AuthSecret):
|
||||
"""
|
||||
An IndieAuthCode is an authorisation code that a client must provide to us
|
||||
to complete the IndieAuth process.
|
||||
|
||||
Codes are single-use, and if unused will be expired automatically after
|
||||
thirty seconds.
|
||||
"""
|
||||
|
||||
redirect_uri = models.URLField()
|
||||
|
||||
RESPONSE_TYPE = Choices("id", "code")
|
||||
response_type = StatusField(choices_name="RESPONSE_TYPE")
|
||||
|
||||
@property
|
||||
def expired(self):
|
||||
return self.created + timedelta(seconds=30) < now()
|
||||
|
||||
|
||||
class Token(AuthSecret):
|
||||
"""
|
||||
A Token grants a client long-term authorisation - it will not expire unless
|
||||
explicitly revoked by the user.
|
||||
"""
|
||||
|
||||
pass
|
|
@ -2,13 +2,11 @@
|
|||
img
|
||||
height 2em
|
||||
margin-right .5em
|
||||
.tippy-tooltip
|
||||
&.success-theme
|
||||
.tippy-box
|
||||
&[data-theme~='success']
|
||||
color $base0B
|
||||
background-color $base03
|
||||
&.warning-theme
|
||||
&[data-theme~='warning']
|
||||
color $base0A
|
||||
background-color $base03
|
||||
.verified-success
|
||||
color $base0B
|
||||
.verified-warning
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
{% 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="custom-controls-stacked card-text">
|
||||
{% for scope in scopes %}
|
||||
<label class="custom-control custom-checkbox">
|
||||
<input name="scope" type="checkbox" class="custom-control-input" checked value="{{ scope }}" />
|
||||
<span class="custom-control-indicator"></span>
|
||||
<span class="custom-control-description">{{ scope }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="card-text"><small>you will be redirected to <a class="code" href="{{ redirect_uri }}">{{ redirect_uri }}</a> after authorising this app</small></p>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-success" type="submit">
|
||||
<i class="fas fa-check"></i>
|
||||
approve
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% csrf_token %}
|
||||
<input name="me" type="hidden" value="{{ me }}" />
|
||||
<input name="client_id" type="hidden" value="{{ params.client_id }}" />
|
||||
<input name="redirect_uri" type="hidden" value="{{ redirect_uri }}" />
|
||||
{% if params.state %}<input name="state" type="hidden" value="{{ params.state }}" />{% endif %}
|
||||
<input name="response_type" type="hidden" value="{{ params.response_type }}" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="verified-success" hidden>
|
||||
this client has been <strong>verified</strong> using <code>{{ '<link rel="redirect_uri">' | force_escape }}</code> - they are who they claim to be!
|
||||
</div>
|
||||
<div id="verified-warning" hidden>
|
||||
this client could <strong>not</strong> be verified using <code>{{ '<link rel="redirect_uri">' | force_escape }}</code> - check the redirect uri carefully yourself!
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block foot %}
|
||||
<script type="text/javascript">
|
||||
tippy('[data-tooltip]', {arrow: true});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,51 +0,0 @@
|
|||
{% extends 'lemoncurry/layout.html' %}
|
||||
{% load lemonauth_tags static %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{% static 'lemonauth/css/login.styl' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
{% if form.errors %}
|
||||
<p class="alert alert-danger">
|
||||
<strong>Uh oh!</strong> Your username and password didn't match. Please try again.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if next %}
|
||||
{% if user.is_authenticated %}
|
||||
<p class="alert alert-warning">
|
||||
<strong>Hang on!</strong> Your account doesn't have access to this page. To proceed, please log in to an account that does have access.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="alert alert-warning">
|
||||
<strong>Oops!</strong> Please log in to see this page.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form class="card" method="post" action="{% url 'lemonauth:login' %}">
|
||||
<div class="card-body">
|
||||
{% form_field form.username %}
|
||||
{% form_field form.password %}
|
||||
{% form_field form.otp_token %}
|
||||
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input id="id_{{ form.otp_trust_agent.name }}" name="{{ form.otp_trust_agent.name }}" class="custom-control-input" type="checkbox" />
|
||||
<label for="id_{{ form.otp_trust_agent.name }}" class="custom-control-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 %}
|
|
@ -1,6 +0,0 @@
|
|||
<div class="form-group">
|
||||
<label for="{{ field.id_for_label }}">{{ field.label | lower }}</label>
|
||||
<input id="{{ field.id_for_label }}" class="form-control{% if field.errors %}is-invalid{% endif %}" type="{{ field.field.widget.input_type }}" required
|
||||
name="{{ field.html_name }}"{% if field.value %} value="{{ field.value }}"{% endif %}{% if field.help_text %} aria-describedby="{{ field.id_for_label }}-help"{% endif %} />
|
||||
{% if field.help_text %}<p id="{{ field.id_for_label }}-help" class="form-text">{{ field.help_text }}</p>{% endif %}
|
||||
</div>
|
|
@ -1,8 +0,0 @@
|
|||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('lemonauth/tags/form_field.html')
|
||||
def form_field(field):
|
||||
return {'field': field}
|
|
@ -1,78 +1,46 @@
|
|||
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
|
||||
from micropub import error
|
||||
from .models import IndieAuthCode, Token
|
||||
|
||||
|
||||
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]))
|
||||
def auth(request) -> Token:
|
||||
if "HTTP_AUTHORIZATION" in request.META:
|
||||
auth = request.META.get("HTTP_AUTHORIZATION").split(" ")
|
||||
if auth[0] != "Bearer":
|
||||
raise error.bad_req("auth type {0} not supported".format(auth[0]))
|
||||
if len(auth) != 2:
|
||||
return error.bad_req(
|
||||
'invalid Bearer auth format, must be Bearer <token>'
|
||||
)
|
||||
raise error.bad_req("invalid Bearer auth format, must be Bearer <token>")
|
||||
token = auth[1]
|
||||
elif 'access_token' in request.POST:
|
||||
token = request.POST.get('access_token')
|
||||
elif 'access_token' in request.GET:
|
||||
token = request.GET.get('access_token')
|
||||
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:
|
||||
return error.unauthorized()
|
||||
raise error.unauthorized()
|
||||
|
||||
try:
|
||||
token = decode(token)
|
||||
except Exception as e:
|
||||
return error.forbidden()
|
||||
token = Token.objects.get(pk=token)
|
||||
except Token.DoesNotExist:
|
||||
raise error.forbidden()
|
||||
|
||||
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',))
|
||||
return token
|
||||
|
||||
|
||||
def gen_auth_code(req):
|
||||
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)
|
||||
code = IndieAuthCode()
|
||||
code.user = req.user
|
||||
code.client_id = req.POST["client_id"]
|
||||
code.redirect_uri = req.POST["redirect_uri"]
|
||||
code.response_type = req.POST.get("response_type", "id")
|
||||
if "scope" in req.POST:
|
||||
code.scope = " ".join(req.POST.getlist("scope"))
|
||||
code.save()
|
||||
return code.id
|
||||
|
||||
|
||||
def gen_token(code):
|
||||
tok = {
|
||||
'uid': code['uid'],
|
||||
'cid': code['cid'],
|
||||
'sco': code['sco'],
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
return encode(tok)
|
||||
tok = Token()
|
||||
tok.user = code.user
|
||||
tok.client_id = code.client_id
|
||||
tok.scope = code.scope
|
||||
tok.save()
|
||||
return tok.id
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
from django.conf.urls import url
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'lemonauth'
|
||||
app_name = "lemonauth"
|
||||
urlpatterns = [
|
||||
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'),
|
||||
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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -2,3 +2,4 @@ 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
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
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
|
||||
|
@ -11,119 +10,116 @@ 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):
|
||||
(scheme, loc, path, params, q, fragment) = urlparse(url)
|
||||
if "//" not in url:
|
||||
url = "//" + url
|
||||
(scheme, netloc, path, params, query, fragment) = urlparse(url)
|
||||
if not scheme or scheme == "http":
|
||||
scheme = "https"
|
||||
if not path:
|
||||
path = '/'
|
||||
if not loc:
|
||||
loc, path = path, ''
|
||||
if not scheme:
|
||||
scheme = 'https'
|
||||
return urlunparse((scheme, loc, path, params, q, fragment))
|
||||
path = "/"
|
||||
return urlunparse((scheme, netloc, path, params, query, fragment))
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class IndieView(TemplateView):
|
||||
template_name = 'lemonauth/indie.html'
|
||||
required_params = ('me', 'client_id', 'redirect_uri')
|
||||
template_name = "lemonauth/indie.html"
|
||||
required_params = ("client_id", "redirect_uri")
|
||||
|
||||
@method_decorator(login_required)
|
||||
@method_decorator(render_to(template_name))
|
||||
def get(self, request):
|
||||
params = request.GET.dict()
|
||||
params.setdefault('response_type', 'id')
|
||||
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)
|
||||
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)
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
redirect_uri = urljoin(params["client_id"], params["redirect_uri"])
|
||||
|
||||
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)
|
||||
)
|
||||
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 = tokens.decode(post.get('code'))
|
||||
except Exception:
|
||||
code = IndieAuthCode.objects.get(pk=post.get("code"))
|
||||
except IndieAuthCode.DoesNotExist:
|
||||
# if anything at all goes wrong when decoding the auth code, bail
|
||||
# out immediately.
|
||||
return utils.forbid('invalid auth code')
|
||||
return utils.forbid("invalid auth code")
|
||||
code.delete()
|
||||
if code.expired:
|
||||
return utils.forbid("invalid auth code")
|
||||
|
||||
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')
|
||||
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")
|
||||
|
||||
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': me}, {
|
||||
'application/x-www-form-urlencoded': utils.form_encoded_response,
|
||||
'application/json': JsonResponse,
|
||||
})
|
||||
return utils.choose_type(
|
||||
request,
|
||||
{"me": code.me},
|
||||
{
|
||||
"application/x-www-form-urlencoded": utils.form_encoded_response,
|
||||
"application/json": JsonResponse,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def approve(request):
|
||||
params = {
|
||||
'me': urljoin(utils.origin(request), request.user.url),
|
||||
'code': tokens.gen_auth_code(request),
|
||||
"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))
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -1,49 +1,48 @@
|
|||
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,
|
||||
'scope': token.scope,
|
||||
"me": token.me,
|
||||
"client_id": token.client_id,
|
||||
"scope": token.scope,
|
||||
}
|
||||
return utils.choose_type(req, res)
|
||||
|
||||
def post(self, req):
|
||||
post = req.POST
|
||||
try:
|
||||
code = tokens.decode(post.get('code'))
|
||||
except Exception:
|
||||
return utils.forbid('invalid auth code')
|
||||
code = IndieAuthCode.objects.get(pk=post.get("code"))
|
||||
except IndieAuthCode.DoesNotExist:
|
||||
return utils.forbid("invalid auth code")
|
||||
code.delete()
|
||||
if code.expired:
|
||||
return utils.forbid("invalid auth code")
|
||||
|
||||
if code['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 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")
|
||||
|
||||
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')
|
||||
if "me" in post and code.me != post["me"]:
|
||||
return utils.forbid("me did not match")
|
||||
|
||||
return utils.choose_type(req, {
|
||||
'access_token': tokens.gen_token(code),
|
||||
'me': me,
|
||||
'scope': code['sco'],
|
||||
})
|
||||
return utils.choose_type(
|
||||
req,
|
||||
{
|
||||
"access_token": tokens.gen_token(code),
|
||||
"me": code.me,
|
||||
"scope": code.scope,
|
||||
},
|
||||
)
|
||||
|
|
2
lemonauth/views/tokens/__init__.py
Normal file
2
lemonauth/views/tokens/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .list import TokensListView
|
||||
from .revoke import TokensRevokeView
|
42
lemonauth/views/tokens/list.py
Normal file
42
lemonauth/views/tokens/list.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from requests.exceptions import RequestException
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import TemplateView
|
||||
from typing import Dict, Optional, Set
|
||||
from lemoncurry.requests import mf2
|
||||
|
||||
|
||||
class ClientsDict(dict):
|
||||
def __missing__(self, client_id):
|
||||
self[client_id] = Client(client_id)
|
||||
return self[client_id]
|
||||
|
||||
|
||||
class Client:
|
||||
id: str
|
||||
count: int
|
||||
scopes: Set[str]
|
||||
app: Optional[Dict[str, str]]
|
||||
|
||||
def __init__(self, client_id):
|
||||
self.id = client_id
|
||||
self.count = 0
|
||||
self.scopes = set()
|
||||
try:
|
||||
apps = mf2(self.id).to_dict(filter_by_type="h-x-app")
|
||||
self.app = apps[0]["properties"]
|
||||
except (RequestException, IndexError):
|
||||
self.app = None
|
||||
|
||||
|
||||
class TokensListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "lemonauth/tokens.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
clients = ClientsDict()
|
||||
for token in self.request.user.token_set.all():
|
||||
client = clients[token.client_id]
|
||||
client.count += 1
|
||||
client.scopes |= set(token.scope.split(" "))
|
||||
context.update({"clients": clients, "title": "tokens"})
|
||||
return context
|
9
lemonauth/views/tokens/revoke.py
Normal file
9
lemonauth/views/tokens/revoke.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.http import HttpResponse
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views import View
|
||||
|
||||
|
||||
class TokensRevokeView(LoginRequiredMixin, View):
|
||||
def delete(self, request, client_id: str):
|
||||
request.user.token_set.filter(client_id=client_id).delete()
|
||||
return HttpResponse(status=204)
|
BIN
lemoncurry.paw
BIN
lemoncurry.paw
Binary file not shown.
5
lemoncurry.svg
Normal file
5
lemoncurry.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" version="1.1">
|
||||
<circle id="backdrop" fill="#353535" cx="400" cy="400" r="400" />
|
||||
<path id="lemon" fill="#FFCB6B" d="m 633.03789,166.963 c -23.094,-23.093 -54.39,-28.893 -75.091,-16.834 -58.906,34.312 -181.25,-53.076996 -321.073,86.746 -139.823004,139.823 -52.433,262.166 -86.745,321.07 -12.059,20.702 -6.26,51.999 16.833,75.093 23.095,23.095 54.392,28.891 75.095,16.832 58.901,-34.31 181.246,53.079 321.068,-86.743 139.822,-139.822 52.435,-262.167 86.746,-321.071 12.059,-20.702 6.261,-51.999 -16.833,-75.093 z m -245.157,72.559 c -58.189,14.547 -133.808,90.155 -148.358,148.358 -1.817,7.27 -8.342,12.124 -15.511,12.124 -1.284,0 -2.59,-0.156 -3.893,-0.481 -8.572,-2.144 -13.784,-10.83 -11.642,-19.403 17.424,-69.693 101.839,-154.19 171.642,-171.642 8.575,-2.143 17.261,3.069 19.403,11.642 2.142,8.573 -3.069,17.259 -11.641,19.402 z" />
|
||||
</svg>
|
After Width: | Height: | Size: 948 B |
5
lemoncurry/admin.py
Normal file
5
lemoncurry/admin.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.contrib import admin
|
||||
from otp_agents.decorators import otp_required
|
||||
|
||||
|
||||
admin.site.login = otp_required(admin.site.login, accept_trusted_agent=True)
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
43
lemoncurry/jinja2/__init__.py
Normal file
43
lemoncurry/jinja2/__init__.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from jinja2 import Environment
|
||||
|
||||
from compressor.contrib.jinja2ext import CompressorExtension
|
||||
from django_activeurl.ext.django_jinja import ActiveUrl
|
||||
|
||||
from entries.kinds import all as entry_kinds
|
||||
from wellknowns.favicons import icons as favicons
|
||||
|
||||
from .ago import ago
|
||||
from .markdown import markdown
|
||||
from ..theme import color as theme_color
|
||||
from ..utils import friendly_url, load_package_json
|
||||
|
||||
|
||||
def environment(**options):
|
||||
env = Environment(
|
||||
extensions=[ActiveUrl, CompressorExtension],
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
**options
|
||||
)
|
||||
env.filters.update(
|
||||
{
|
||||
"ago": ago,
|
||||
"friendly_url": friendly_url,
|
||||
"markdown": markdown,
|
||||
}
|
||||
)
|
||||
env.globals.update(
|
||||
{
|
||||
"entry_kinds": entry_kinds,
|
||||
"favicons": favicons,
|
||||
"package": load_package_json(),
|
||||
"settings": settings,
|
||||
"static": staticfiles_storage.url,
|
||||
"theme_color": theme_color,
|
||||
"url": reverse,
|
||||
}
|
||||
)
|
||||
return env
|
9
lemoncurry/jinja2/ago.py
Normal file
9
lemoncurry/jinja2/ago.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from ago import human
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def ago(dt: datetime) -> str:
|
||||
# We have to convert the datetime we get to local time first, because ago
|
||||
# just strips the timezone from a timezone-aware datetime.
|
||||
dt = dt.astimezone()
|
||||
return human(dt, precision=1, past_tense="{}", abbreviate=True)
|
23
lemoncurry/jinja2/bleach.py
Normal file
23
lemoncurry/jinja2/bleach.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from bleach.sanitizer import Cleaner, ALLOWED_TAGS
|
||||
from bleach.linkifier import LinkifyFilter
|
||||
from jinja2 import pass_eval_context
|
||||
from markupsafe import Markup
|
||||
|
||||
TAGS = ["cite", "code", "details", "p", "pre", "img", "span", "summary"]
|
||||
TAGS.extend(ALLOWED_TAGS)
|
||||
ATTRIBUTES = {
|
||||
"a": ["href", "title", "class"],
|
||||
"details": ["open"],
|
||||
"img": ["alt", "src", "title"],
|
||||
"span": ["class"],
|
||||
}
|
||||
|
||||
cleaner = Cleaner(tags=TAGS, attributes=ATTRIBUTES, filters=(LinkifyFilter,))
|
||||
|
||||
|
||||
@pass_eval_context
|
||||
def bleach(ctx, html):
|
||||
res = cleaner.clean(html)
|
||||
if ctx.autoescape:
|
||||
res = Markup(res)
|
||||
return res
|
165
lemoncurry/jinja2/lemoncurry/layout.html
Normal file
165
lemoncurry/jinja2/lemoncurry/layout.html
Normal file
|
@ -0,0 +1,165 @@
|
|||
<!doctype html>
|
||||
<html{% block html_attr %} dir="ltr" lang="en" data-bs-theme="dark"{% endblock %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ request.site.name }}</title>
|
||||
{% if atom is defined %}
|
||||
<link rel="alternate" type="application/atom+xml" href="{{ atom }}" />
|
||||
{% endif %}
|
||||
{% if rss is defined %}
|
||||
<link rel="alternate" type="application/rss+xml" href="{{ rss }}" />
|
||||
{% endif %}
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
<base href="{{ request.build_absolute_uri(url('home:index')) }}" />
|
||||
<link rel="authorization_endpoint" href="{{ url('lemonauth:indie') }}" />
|
||||
<link rel="canonical" href="{{ request.build_absolute_uri() }}" />
|
||||
<link rel="hub" href="{{ settings.PUSH_HUB }}" />
|
||||
<link rel="manifest" href="{{ url('wellknowns:manifest') }}" />
|
||||
<link rel="micropub" href="{{ url('micropub:micropub') }}" />
|
||||
<link rel="token_endpoint" href="{{ url('lemonauth:token') }}" />
|
||||
|
||||
<meta name="generator" content="{{ package.name }} {{ package.version }}" />
|
||||
<meta name="theme-color" content="{{ theme_color(3) }}" />
|
||||
{% for i in favicons %}
|
||||
<link rel="{{ i.rel }}" type="{{ i.mime }}" sizes="{{ i.sizes }}" href="{{ i.url }}" />
|
||||
{% endfor %}
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/monokai.min.css"
|
||||
integrity="sha384-88Jvj9Q2LiBDwL7w3yciRTcH5q2zzvMFYIm4xX9/evqxJsxA33Xk9XYKcvUlPITo" crossorigin="anonymous">
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/openwebicons@1.6.0/css/openwebicons.min.css"
|
||||
integrity="sha384-NkRWM9o4Kfak7GwS+un+sProBBpj02vc/e1EoXvdCUSdRk0muOfkKJ5NtpueAuka" crossorigin="anonymous">
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/tippy.js@3.4.1/dist/tippy.css"
|
||||
integrity="sha384-hm3Wtrva6FibonAOqHHXSpMxvGbz2g7l5FK5avbuNviir5MK6Ap4o3EOohztzHHm" crossorigin="anonymous">
|
||||
{% compress css %}
|
||||
<link rel="stylesheet" type="text/stylus" href="{{ static('lemoncurry/css/layout.styl') }}">
|
||||
{% block styles %}{% endblock %}
|
||||
{% endcompress %}
|
||||
|
||||
<script type="text/javascript" defer src="https://kit.fontawesome.com/a3aade9b41.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body{% block body_attr %}{% endblock %}>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-md"><div class="container-fluid">
|
||||
<a class="navbar-brand" rel="home" href="{{ url('home:index') }}">{{ request.site.name }}</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
|
||||
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
{% activeurl %}
|
||||
<div class="collapse navbar-collapse" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% for kind in entry_kinds %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ kind.index }}">
|
||||
<i class="{{ kind.icon }} fa-fw" aria-hidden="true"></i>
|
||||
{{ kind.plural }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url('lemonauth:tokens') }}">
|
||||
<i class="fas fa-cookie-bite fa-fw" aria-hidden="true"></i>
|
||||
tokens
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url('admin:index') }}">
|
||||
<i class="fas fa-cog fa-fw" aria-hidden="true"></i>
|
||||
admin
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url('lemonauth:logout') }}">
|
||||
<i class="fas fa-sign-out-alt fa-fw" aria-hidden="true"></i>
|
||||
log out
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url('lemonauth:login') }}">
|
||||
<i class="fas fa-sign-in-alt fa-fw" aria-hidden="true"></i>
|
||||
log in
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endactiveurl %}
|
||||
|
||||
</div></nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>all content licensed under <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">cc by-sa 4.0</a></p>
|
||||
{% if entries is defined and entries.has_other_pages() %}
|
||||
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{% if entries.has_previous() %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" rel="prev" href="{{ entries.reverse(entries.previous_page_number()) }}">
|
||||
<i class="fas fa-step-backward" aria-hidden="true"></i> <span class="sr-only">previous page</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
{% for i in entries.paginator.page_range %}
|
||||
{% if i == entries.number %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ i }} <span class="sr-only">(current page)</span></span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ entries.reverse(i) }}">{{ i }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if entries.has_next() %}
|
||||
|
||||
<li class="page-item">
|
||||
<a class="page-link" rel="next" href="{{ entries.reverse(entries.next_page_number()) }}">
|
||||
<i class="fas fa-step-forward" aria-hidden="true"></i> <span class="sr-only">next page</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{% endif %}
|
||||
<p>powered by <a rel="code-repository" href="{{ package.repository }}/src/tag/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"
|
||||
integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT"></script>
|
||||
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js" crossorigin="anonymous"
|
||||
integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp"></script>
|
||||
<script src="https://unpkg.com/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"
|
||||
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"
|
||||
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"></script>
|
||||
<script src="https://unpkg.com/tippy.js@6.3.7/dist/tippy-bundle.umd.js" crossorigin="anonymous"
|
||||
integrity="sha384-dtMr4wkcxQWUqsJFgElu4AttgIhOsjr2vYIzP2mv0MZbD/uJ6OHxFdbgE3MOKabN"></script>
|
||||
{% compress js %}
|
||||
<script type="text/javascript">
|
||||
hljs.initHighlightingOnLoad();
|
||||
</script>
|
||||
{% block foot %}
|
||||
{% endblock %}
|
||||
{% endcompress %}
|
||||
</body>
|
||||
</html>
|
18
lemoncurry/jinja2/markdown.py
Normal file
18
lemoncurry/jinja2/markdown.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from jinja2 import pass_eval_context
|
||||
from markdown import Markdown
|
||||
|
||||
from .bleach import bleach
|
||||
|
||||
md = Markdown(
|
||||
extensions=(
|
||||
"extra",
|
||||
"sane_lists",
|
||||
"smarty",
|
||||
"toc",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pass_eval_context
|
||||
def markdown(ctx, source):
|
||||
return bleach(ctx, md.reset().convert(source))
|
16
lemoncurry/middleware.py
Normal file
16
lemoncurry/middleware.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
|
||||
class ResponseException(Exception):
|
||||
def __init__(self, response: HttpResponse) -> None:
|
||||
self.response = response
|
||||
|
||||
|
||||
class ResponseExceptionMiddleware(MiddlewareMixin):
|
||||
def process_exception(
|
||||
self, request: HttpRequest, exception: Exception
|
||||
) -> HttpResponse:
|
||||
if isinstance(exception, ResponseException):
|
||||
return exception.response
|
||||
raise exception
|
24
lemoncurry/msgpack.py
Normal file
24
lemoncurry/msgpack.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
import msgpack
|
||||
import pickle
|
||||
|
||||
from django_redis.serializers.base import BaseSerializer
|
||||
|
||||
|
||||
def default(obj):
|
||||
# Pickle anything that MessagePack can't handle itself.
|
||||
return msgpack.ExtType(69, pickle.dumps(obj, protocol=4))
|
||||
|
||||
|
||||
def ext_hook(code, data):
|
||||
# Unpickle if we pickled - otherwise do nothing.
|
||||
if code == 69:
|
||||
return pickle.loads(data)
|
||||
return msgpack.ExtType(code, data)
|
||||
|
||||
|
||||
class MSGPackModernSerializer(BaseSerializer):
|
||||
def dumps(self, value):
|
||||
return msgpack.packb(value, default=default, use_bin_type=True)
|
||||
|
||||
def loads(self, value):
|
||||
return msgpack.unpackb(value, ext_hook=ext_hook, raw=False)
|
|
@ -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")
|
||||
|
|
|
@ -10,25 +10,29 @@ For the full list of settings and their values, see
|
|||
https://docs.djangoproject.com/en/1.11/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from os import environ, path
|
||||
from typing import List
|
||||
|
||||
APPEND_SLASH = False
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
ADMINS = [
|
||||
("dani", "dani@00dani.me"),
|
||||
]
|
||||
|
||||
BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = '6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww'
|
||||
SECRET_KEY = "6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
INTERNAL_IPS = ['127.0.0.1', '::1']
|
||||
ALLOWED_HOSTS: List[str] = []
|
||||
INTERNAL_IPS = ["127.0.0.1", "::1"]
|
||||
|
||||
# Settings to tighten up security - these can safely be on in dev mode too,
|
||||
# since I dev using a local HTTPS server.
|
||||
|
@ -46,7 +50,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!
|
||||
|
@ -54,93 +58,104 @@ 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 = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.humanize',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.sitemaps',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
'analytical',
|
||||
'annoying',
|
||||
'compressor',
|
||||
'corsheaders',
|
||||
'debug_toolbar',
|
||||
'django_activeurl',
|
||||
'django_agent_trust',
|
||||
'django_otp',
|
||||
'django_otp.plugins.otp_totp',
|
||||
'django_rq',
|
||||
'favicon',
|
||||
'meta',
|
||||
'shorturls',
|
||||
|
||||
'lemoncurry',
|
||||
'entries',
|
||||
'home',
|
||||
'lemonauth',
|
||||
'micropub',
|
||||
'users',
|
||||
'wellknowns',
|
||||
"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",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'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',
|
||||
"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",
|
||||
]
|
||||
|
||||
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.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',
|
||||
"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",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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': 'redis_cache.RedisCache',
|
||||
'LOCATION': '127.0.0.1:6380',
|
||||
'KEY_PREFIX': 'lemoncurry',
|
||||
'OPTIONS': {
|
||||
'DB': 0,
|
||||
'PARSER_CLASS': 'redis.connection.HiredisParser',
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "redis://127.0.0.1:6380/0",
|
||||
"KEY_PREFIX": "lemoncurry",
|
||||
"OPTIONS": {
|
||||
"SERIALIZER": "lemoncurry.msgpack.MSGPackModernSerializer",
|
||||
},
|
||||
"VERSION": 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -148,54 +163,51 @@ CACHES = {
|
|||
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
||||
"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_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"
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
{"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
|
||||
|
||||
|
@ -207,20 +219,21 @@ USE_TZ = True
|
|||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.11/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = os.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', './node_modules/.bin/stylus {infile} -u ./lemoncurry/static/lemoncurry/css/theme -o {outfile}'),
|
||||
("text/stylus", "npx stylus -u ./lemoncurry/static/lemoncurry/css/theme"),
|
||||
)
|
||||
|
||||
MEDIA_URL = STATIC_URL + 'media/'
|
||||
MEDIA_ROOT = os.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/
|
||||
|
@ -232,30 +245,25 @@ 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',
|
||||
}
|
||||
|
||||
# django-shorturls
|
||||
# https://pypi.python.org/pypi/django-shorturls
|
||||
# lemonshort
|
||||
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"}}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from .base import *
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
META_SITE_DOMAIN = '00dani.dev'
|
||||
META_FB_APPID = '142105433189339'
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
META_SITE_DOMAIN = "00dani.lo"
|
||||
META_FB_APPID = "142105433189339"
|
||||
STATIC_URL = "https://static.00dani.lo/"
|
||||
MEDIA_URL = "https://media.00dani.lo/"
|
||||
|
|
|
@ -4,22 +4,19 @@ 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']
|
||||
SECRET_KEY = environ["DJANGO_SECRET_KEY"]
|
||||
SERVER_EMAIL = "lemoncurry@00dani.me"
|
||||
|
||||
# Use Postgres instead of SQLite in production.
|
||||
DATABASES['default'] = {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'lemoncurry',
|
||||
'USER': 'lemoncurry',
|
||||
}
|
||||
# Authenticate as an app-specific Postgres user in production.
|
||||
DATABASES["default"]["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 + 'media/'
|
||||
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 + "m/"
|
||||
META_SITE_DOMAIN = "00dani.me"
|
||||
META_FB_APPID = "145311792869199"
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
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")
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
$sm = 576px
|
||||
$md = 768px
|
||||
$lg = 992px
|
||||
$xl = 1200px
|
||||
$monokai_bg = #272822
|
||||
|
||||
html
|
||||
background-color $base00
|
||||
|
||||
a
|
||||
color $base0D
|
||||
text-decoration none
|
||||
&:hover
|
||||
color $base0C
|
||||
|
||||
|
@ -18,7 +16,7 @@ code, pre, .code, .pre
|
|||
padding .2rem .4rem
|
||||
font-size 90%
|
||||
color $base0A
|
||||
background-color $base00
|
||||
background-color $monokai_bg
|
||||
border-radius .25rem
|
||||
|
||||
.form-control, .form-control:focus
|
||||
|
@ -26,12 +24,22 @@ 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
|
||||
|
@ -57,7 +65,7 @@ body
|
|||
|
||||
|
||||
> main
|
||||
padding 2rem
|
||||
padding 2rem 1rem
|
||||
width 100%
|
||||
flex 1
|
||||
display flex
|
||||
|
@ -96,6 +104,12 @@ 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
|
||||
|
||||
|
|
|
@ -6,9 +6,20 @@ 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];
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
../../node_modules/openwebicons
|
|
@ -1 +0,0 @@
|
|||
../../node_modules/tippy.js/dist
|
|
@ -1,121 +0,0 @@
|
|||
{% load analytical compress favtags lemoncurry_tags meta static theme_colour %}<!doctype html>
|
||||
<html dir="ltr" lang="en" class="{% block html_class %}{% endblock %}">
|
||||
<head{% meta_namespaces %}>{% site_name as site_name %}{% request_uri request as uri %}{% request_origin request as origin %}
|
||||
{% analytical_head_top %}
|
||||
<base href="{{ origin }}" />
|
||||
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ site_name }}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<link rel="canonical" href="{{ uri }}" />
|
||||
{% if atom %}<link rel="alternate" type="application/atom+xml" href="{{ 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 %}" />
|
||||
|
||||
<meta property="og:url" content="{{ uri }}" />
|
||||
<meta property="og:title" content="{% firstof title site_name %}" />
|
||||
{% include 'meta/meta.html' %}
|
||||
{% placeFavicon %}
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
|
||||
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" 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.8/js/all.js"></script>
|
||||
{% analytical_head_bottom %}
|
||||
</head>
|
||||
<body>
|
||||
{% analytical_body_top %}
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-md navbar-dark">
|
||||
<a class="navbar-brand" rel="home" href="{% url 'home:index' %}">{% site_name %}</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
|
||||
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbar">
|
||||
{% nav_left request %}
|
||||
{% nav_right request %}
|
||||
</div>
|
||||
</nav>
|
||||
{% if request.resolver_match.view_name %}
|
||||
{% nav_crumbs request.resolver_match %}
|
||||
{% 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 %}
|
||||
|
||||
{% get_package_json as package %}
|
||||
<p>powered by <a rel="code-repository" href="{{ package.repository }}/tree/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" crossorigin="anonymous"
|
||||
integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" crossorigin="anonymous"
|
||||
integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" crossorigin="anonymous"
|
||||
integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl"></script>
|
||||
|
||||
{% compress js %}
|
||||
<script src="{% static 'tippy.js/tippy.standalone.js' %}"></script>
|
||||
{% block foot %}{% endblock %}
|
||||
{% endcompress %}
|
||||
{% analytical_body_bottom %}
|
||||
</body>
|
||||
</html>
|
|
@ -1,11 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,8 +0,0 @@
|
|||
{% load activeurl %}{% activeurl %}<ul class="navbar-nav">
|
||||
{% for item in items %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ item.url }}">
|
||||
<i class="{{ item.icon }} fa-fw"></i>
|
||||
{{ item.label }}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>{% endactiveurl %}
|
|
@ -1,10 +1,12 @@
|
|||
from django import template
|
||||
from django.contrib.sites.models import Site
|
||||
from urllib.parse import urljoin
|
||||
from ..utils import origin
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def absolute_url(context, url):
|
||||
return urljoin(origin(context.request), url)
|
||||
@register.simple_tag
|
||||
@register.filter(is_safe=True)
|
||||
def absolute_url(url):
|
||||
base = "https://" + Site.objects.get_current().domain
|
||||
return urljoin(base, url)
|
||||
|
|
|
@ -5,12 +5,13 @@ from django.utils.safestring import mark_safe
|
|||
from bleach.sanitizer import Cleaner, ALLOWED_TAGS
|
||||
from bleach.linkifier import LinkifyFilter
|
||||
|
||||
tags = ['cite', 'code', 'p', 'pre', 'img', 'span']
|
||||
tags = ["cite", "code", "details", "p", "pre", "img", "span", "summary"]
|
||||
tags.extend(ALLOWED_TAGS)
|
||||
attributes = {
|
||||
'a': ('href', 'title', 'class'),
|
||||
'img': ('alt', 'src', 'title'),
|
||||
'span': ('class',),
|
||||
"a": ["href", "title", "class"],
|
||||
"details": ["open"],
|
||||
"img": ["alt", "src", "title"],
|
||||
"span": ["class"],
|
||||
}
|
||||
|
||||
register = template.Library()
|
||||
|
|
|
@ -11,5 +11,5 @@ register = template.Library()
|
|||
@register.filter
|
||||
def jsonify(value):
|
||||
if isinstance(value, QuerySet):
|
||||
return mark_safe(serialize('json', value))
|
||||
return mark_safe(serialize("json", value))
|
||||
return mark_safe(json.dumps(value, cls=DjangoJSONEncoder))
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue