Compare commits

..

51 commits
master ... main

Author SHA1 Message Date
5348dc9f82
v1.12.5 2024-05-19 16:04:00 +10:00
e36ad27d49
Enable import sorting with Ruff 2024-05-19 16:00:14 +10:00
d21d4bda83
Paginate without errors if a page doesn't exist 2024-05-19 15:59:43 +10:00
8d8aa4749b
Update year range in LICENSE 2024-05-19 13:04:19 +10:00
3baf75e59e
Remove unused CI config files and the like 2024-05-19 13:03:57 +10:00
880b899e81
Update Highlight.js to 11.9.0 2024-03-13 19:14:35 +11:00
6061d6f600
Update Tippy.js to v6.3.7 2024-03-13 19:12:36 +11:00
a680a6501c
Remove defunct oEmbed converter service 2024-03-13 19:04:35 +11:00
625b5d963a
Remove unused django-analytical plugin 2024-03-13 19:03:58 +11:00
9d11cc7576
Swap from Poetry to PDM 2024-03-13 18:10:51 +11:00
c49e17db90
Upgrade Bootstrap to v5
This is just an in-place upgrade to produce a roughly unchanged page
design. Ideally I'm going to need to install Sass and use that, because
Bootstrap 5 relies a bit more heavily on using its Sass sources if you
want to customise things (which I do), but for now loading standard
Bootstrap from the CDN is fine.

I still prefer Stylus over both Sass and LESS, but the industry seem to
have decided on using Sass, which probably means I'll be better off
porting my customisations to Sass in the long run. Oh well.
2024-03-13 17:10:38 +11:00
7696ff45db
Upgrade to Font Awesome v6 2024-03-13 16:57:00 +11:00
731f177d18
Bump package versions to get stuff working again 2024-03-13 15:58:54 +11:00
0061111ad8
Ensure User.avatar is optional 2024-03-13 15:58:24 +11:00
6b53c00d7c
Remove deprecated reference to HiredisParser 2024-03-13 15:54:42 +11:00
1490a95735
Fix submodule not to use deprecated git:// protocol 2024-03-13 15:28:37 +11:00
c398b0d3f4
v1.12.4 2023-08-16 11:52:59 +10:00
95cca433bc
Catch all errors from requests, not just HTTPError 2023-08-16 11:52:43 +10:00
4f081c8d34
v1.12.3 2023-08-16 11:48:40 +10:00
8386f77d72
Gracefully handle failure to fetch h-x-app 2023-08-16 11:47:00 +10:00
03956637be
v1.12.2 2023-08-10 19:33:08 +10:00
60bdaa27a0
Update Nostr name aliases to match prod username 2023-08-10 19:32:41 +10:00
a6fa7ebb3a
v1.12.1 2023-08-10 19:29:19 +10:00
d0bd6c1231
Expand Nostr key field to 64 chars (32 hex bytes) 2023-08-10 19:28:04 +10:00
960e64963f
Explicitly install greenlet for prod usage 2023-08-10 19:26:47 +10:00
0b1a548ee4
1.12.0 2023-08-10 18:11:16 +10:00
04bd6dd35d
Add NIP-05 verification compatibility 2023-08-10 18:05:46 +10:00
2e7d12b3e6
Run Black over the whole codebase 2023-08-10 16:52:37 +10:00
cd990e4e2f
Rename Key to PgpKey, so other keys can fit too 2023-08-10 16:50:35 +10:00
fe187da491
Update pre-commit hooks 2023-08-10 16:48:42 +10:00
636b470001
Remove unused Pipenv package files 2023-08-10 16:32:44 +10:00
e5cf94d488
Remove favicons package that doesn't currently work 2023-08-10 16:32:06 +10:00
c5458c2d06
Migrate to Poetry rather than Pipenv 2023-08-10 16:30:06 +10:00
7af8636687
Drop super-favicon, incompatible with newer Django 2023-08-10 16:29:20 +10:00
5ac46dad63
Whoops, fix Pipfile.lock hash 2022-04-29 14:57:55 +10:00
d4c814c79a
Upgrade to Python 3.9
I also needed to patch my Jinja2 filters to not use a deprecated method
of declaring themselves, since the upgrade pulled in a newer minor
version of Jinja2.

The upgrade also tried to pull in Django 4, which many of the plugins
I'm using can't cope with yet, so it needed to be convinced not to do
that.
2022-04-29 14:54:49 +10:00
db0d6e28a3
makemigrations for minor tweaks to User and Entry 2022-03-12 15:27:59 +11:00
2f8d62649e
1.11.0 2022-03-12 15:16:43 +11:00
683adc1b46 Use proper path converter for lemonshort 2022-03-12 15:04:05 +11:00
cfeb206154 Fix dev settings to use .lo instead of .dev 2022-03-12 15:03:26 +11:00
c5c0f4258b Set DEFAULT_AUTO_FIELD to AutoField 2022-03-12 15:03:01 +11:00
73addc2f75 Remove unncessary default_app_config settings 2022-03-12 15:02:26 +11:00
0ca50252dd
Add mypy types for libraries that have them now 2022-02-22 12:35:38 +11:00
8d79be07da
Do a pipenv update to get patched Django again lol 2022-02-22 12:33:31 +11:00
37d5a7a20d
Do a pipenv update to get patched Django 2021-08-22 23:24:58 +10:00
76496e7169
Harmlessly ignore bad params to Libravatar
I was throwing 400s when bad parameters are provided, but the spec
actually says you're supposed to just ignore them entirely.
2021-08-22 23:08:45 +10:00
7fcc3c8788
1.10.3 2019-05-06 08:55:11 +10:00
4436db7d83
Bump up Font Awesome to 5.8.1 2019-05-06 08:46:06 +10:00
d017c642eb
Bump up Bootstrap to 4.3.1 2019-05-06 08:44:21 +10:00
7c5f311af9 Merge branch 'details' of BenLubar/lemoncurry into master 2019-05-05 18:36:52 -04:00
73155f399b
allow details tags 2019-05-05 00:12:49 -05:00
140 changed files with 3017 additions and 2402 deletions

5
.gitignore vendored
View file

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

View file

@ -1,21 +0,0 @@
image: python:3.6
services:
- postgres:latest
variables:
GIT_SUBMODULE_STRATEGY: normal
PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip
PIPENV_CACHE_DIR: $CI_PROJECT_DIR/.cache/pipenv
POSTGRES_HOST: postgres
POSTGRES_DB: nice_marmot
POSTGRES_USER: runner
POSTGRES_PASSWORD: ''
cache:
paths:
- .cache
test:
script:
- pip install pipenv
- pipenv sync --dev
- pipenv run pytest

2
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "lemoncurry/static/base16-materialtheme-scheme"] [submodule "lemoncurry/static/base16-materialtheme-scheme"]
path = 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

View file

@ -1,30 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.1.0
hooks:
- id: check-byte-order-marker
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-json
- id: check-merge-conflict
- id: check-yaml
- id: end-of-file-fixer
- id: flake8
- id: mixed-line-ending
args:
- --fix=lf
- id: trailing-whitespace
- repo: local
hooks:
- id: pytest
name: Check pytest unit tests pass
entry: pipenv run pytest
pass_filenames: false
language: system
types: [python]
- id: mypy
name: Check mypy static types match
entry: pipenv run mypy . --ignore-missing-imports
pass_filenames: false
language: system
types: [python]

View file

@ -1,3 +0,0 @@
requirements:
- Pipfile
- Pipfile.lock

View file

@ -1,16 +0,0 @@
language: python
cache:
directories:
- $PIP_CACHE_DIR
- $PIPENV_CACHE_DIR
env:
global:
- PIP_CACHE_DIR=$HOME/.cache/pip
- PIPENV_CACHE_DIR=$HOME/.cache/pipenv
python:
- '3.6'
install:
- pip install pipenv
- pipenv install --dev
script:
- pipenv run pytest

View file

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

View file

@ -1,6 +1,6 @@
MIT License 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

58
Pipfile
View file

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

989
Pipfile.lock generated
View file

@ -1,989 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "1c5a457377ca29205680f06286e9ef47d6ff08d1de75b2e7371ca3993bf75a1c"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.7"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.python.org/simple",
"verify_ssl": true
}
]
},
"default": {
"accept-types": {
"hashes": [
"sha256:9ae86512bf3a3eaad6a2793617a34eb15b384593e6c28697bef9b15ac237017a"
],
"index": "pypi",
"version": "==0.3.0"
},
"ago": {
"hashes": [
"sha256:9d1956edd8103c266d968ae2a7eaf2f23470b6384e655aaaf54d1158408178ad"
],
"index": "pypi",
"version": "==0.0.93"
},
"argon2-cffi": {
"hashes": [
"sha256:003f588de43a817af6ecc1c06103fa0801de63849db3cb0f37576bb2da29043d",
"sha256:04528ebbcc5d77eb49e7c2560fcf9d489cdc3b14f89fdd975c72c0a12934025a",
"sha256:04ead34244af38d79742cc46a212fec94daf99b49add66878f5d4b22da72d4aa",
"sha256:0529aeb71b50e068d300c992f850387c2456f2d3d4083d17d18e75710d057682",
"sha256:0d18a3dcb4ca7f3717155994a4f131a43072e47b708e57c4be16f60253337dfd",
"sha256:0ecbd2346da3e5af84427fd8df3ece484c903a9dafd9470571def47df54f2780",
"sha256:193de795483b00d752d16ec5df11d119a3a2c43f5464edfaf919a2ca9cc5b991",
"sha256:22a99f90da7176ee86fbdfb0a95411bc807b9d795b89495ee88c2e0468a496e8",
"sha256:2829d648dfa4d42ce33ec0f36e863d1068fd729b38ef6f830262b43e04f9ba1c",
"sha256:3b61a4ef1eb785d41f190520db716aa598d15f147419cbbdc9061dc232126f09",
"sha256:3ddcdde047cd4dba2bcce7d890dcefd6723548b849fa82ba87e04a468079b9b1",
"sha256:457c5db9bb99f2ffb7ce9ebf923b523898e75464dd019fbebdd1c6096ddcf044",
"sha256:51d78eedbba1f9e45a1c3fb1470ad6d1faafc6ec42eabb969df29c2aa848b645",
"sha256:af0d3dbc8f32d95be480eedd5d77fe8714f5441a28b9abcfa687ecf5301a1abd",
"sha256:ca65f736d2129687008178e3d9956264fd2be2f69429edf0d755c2f97cd003f1",
"sha256:d371fcd42e01c78c76397120d07c67f6e16f5fef97d327ad372c8debe38f9f56",
"sha256:ec12248d4c1e045a736beebf55daf1430c45a29ab8d773d8540c224555784275"
],
"index": "pypi",
"version": "==18.3.0"
},
"beautifulsoup4": {
"hashes": [
"sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858",
"sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348",
"sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718"
],
"version": "==4.7.1"
},
"bleach": {
"hashes": [
"sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16",
"sha256:3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa"
],
"index": "pypi",
"version": "==3.1.0"
},
"cachecontrol": {
"hashes": [
"sha256:cef77effdf51b43178f6a2d3b787e3734f98ade253fa3187f3bb7315aaa42ff7"
],
"index": "pypi",
"version": "==0.12.5"
},
"certifi": {
"hashes": [
"sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7",
"sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"
],
"version": "==2018.11.29"
},
"cffi": {
"hashes": [
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
"sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30",
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
"sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b",
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
"sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e",
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
"sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5",
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
"sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2",
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
],
"version": "==1.11.5"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"version": "==7.0"
},
"django": {
"hashes": [
"sha256:a32c22af23634e1d11425574dce756098e015a165be02e4690179889b207c7a8",
"sha256:d6393918da830530a9516bbbcbf7f1214c3d733738779f06b0f649f49cc698c3"
],
"index": "pypi",
"version": "==2.1.5"
},
"django-activeurl": {
"hashes": [
"sha256:ad5498bf589afaa117fe1c80d1a4fdbef29185cee47517254cd8f273b8a0140d",
"sha256:ebb3f2746fdc76fee2095b75cad713e746378393c6c2b8e36455919a780acd50"
],
"index": "pypi",
"version": "==0.1.12"
},
"django-agent-trust": {
"hashes": [
"sha256:f0ded1c2e1e8b06ea050f48c8db931ab7d2f85c566065dceb8e827d0690d87c5",
"sha256:f7e24d3f50a0727c6a70d671778de3cca23a0c87bedc6e3dae1f61af7e759fc1"
],
"version": "==0.3.1"
},
"django-analytical": {
"hashes": [
"sha256:0871db04aa8c864b0b44c98b462d2b8682ba9c31ab89f50d54a5ddfdc3ff6223",
"sha256:bbe09b15cf22d7d4ab4cd0a5cc454aa912f08691d86886a0652081b9fd7a1ff0",
"sha256:d9e475567fc021ed9e3bd1fa77db11a14b12c85fbc5b56121897b0fa571e6ed6"
],
"index": "pypi",
"version": "==2.5.0"
},
"django-annoying": {
"hashes": [
"sha256:93f54d244d453cba28d6cfb9deae7bba27e859762adee9ff7de4706017940931",
"sha256:ee620f9bfe439061010c7d5ccc8c69514844d95c370c130c61938205fcfc4cc9"
],
"index": "pypi",
"version": "==0.10.4"
},
"django-appconf": {
"hashes": [
"sha256:6a4d9aea683b4c224d97ab8ee11ad2d29a37072c0c6c509896dd9857466fb261",
"sha256:ddab987d14b26731352c01ee69c090a4ebfc9141ed223bef039d79587f22acd9"
],
"version": "==1.0.2"
},
"django-classy-tags": {
"hashes": [
"sha256:792f9161d0e22d55b4fab6fc297bab8ab072ffaa3075b227613a6d8473624db8",
"sha256:f6d12f5a4df3e387795a0d9ef2836af389cae9a1fbebda035dac043d4722b1f7"
],
"version": "==0.8.0"
},
"django-compressor": {
"hashes": [
"sha256:7732676cfb9d58498dfb522b036f75f3f253f72ea1345ac036434fdc418c2e57",
"sha256:9616570e5b08e92fa9eadc7a1b1b49639cce07ef392fc27c74230ab08075b30f"
],
"index": "pypi",
"version": "==2.2"
},
"django-computed-property": {
"hashes": [
"sha256:2f848873b03e87d4aa5ac9f945e72be3a0daf0451468e186fad3c2ee0a21ff99",
"sha256:a464f4e87b50eda415b9a4b4e1a67574cf702b8958ab7cb0aa90e4862912eb29"
],
"index": "pypi",
"version": "==0.2.3"
},
"django-cors-headers": {
"hashes": [
"sha256:5545009c9b233ea7e70da7dbab7cb1c12afa01279895086f98ec243d7eab46fa",
"sha256:c4c2ee97139d18541a1be7d96fe337d1694623816d83f53cb7c00da9b94acae1"
],
"index": "pypi",
"version": "==2.4.0"
},
"django-debug-toolbar": {
"hashes": [
"sha256:89d75b60c65db363fb24688d977e5fbf0e73386c67acf562d278402a10fc3736",
"sha256:c2b0134119a624f4ac9398b44f8e28a01c7686ac350a12a74793f3dd57a9eea0"
],
"index": "pypi",
"version": "==1.11"
},
"django-extensions": {
"hashes": [
"sha256:8317a3fe479b1ba3e3a04ecf33fb8d6ccf09bb18f30eab64e34c40a593741d26",
"sha256:a76a61566f1c8d96acc7bcf765080b8e91367a25a2c6f8c5bddd574493839180"
],
"index": "pypi",
"version": "==2.1.4"
},
"django-meta": {
"hashes": [
"sha256:21fc5d0d5fcacda5d038af0babd08afaa4d5bed1b746edb6522c4d3435da8db6",
"sha256:dd4a440223cc6243a7815c183b6ada1f11b99b4d672471e67f8db4d8b48a5674"
],
"index": "pypi",
"version": "==1.4.1"
},
"django-model-utils": {
"hashes": [
"sha256:2c057f3bf0859aba27f04389f0cedd2d48f8c9b3848acb86fd9970794e58f477",
"sha256:8cd377744aa45f9f131d652ec460c57d1aaa88d3e9b586c8e27eb709341b9084"
],
"index": "pypi",
"version": "==3.1.2"
},
"django-otp": {
"hashes": [
"sha256:529f936e9b6acbb011ac575d2e7e1a6c823661627ebcb314c11cd721237da433",
"sha256:9b67805244b8bc48bb5b14600dc1d05a8dca54ad2ff8d6ded758c39856b860e1"
],
"index": "pypi",
"version": "==0.5.1"
},
"django-otp-agents": {
"hashes": [
"sha256:318254a022e79a565d2fa15ff1e4ef20b92a1284293496ffec044685972ad02e",
"sha256:7811706e8b2bfc0185eccac23a9d74b202db443004099e570fabf4a1423f0971"
],
"index": "pypi",
"version": "==0.5.0"
},
"django-push": {
"hashes": [
"sha256:9d73a27f147ea46f5e92d6ab36c19640b11214b43b378693c8961aaf8bea5b60",
"sha256:d5442fcb6d8254a7e837383ce766a72e8fb921f3bcfc2355440c2da8fbcf07b4"
],
"index": "pypi",
"version": "==1.1"
},
"django-randomslugfield": {
"hashes": [
"sha256:8f5866d9383f020fb7f270a218ddc65b6a33d3833633a0c995c68f28cac59efb"
],
"index": "pypi",
"version": "==0.3.0"
},
"django-redis": {
"hashes": [
"sha256:af0b393864e91228dd30d8c85b5c44d670b5524cb161b7f9e41acc98b6e5ace7",
"sha256:f46115577063d00a890867c6964ba096057f07cb756e78e0503b89cd18e4e083"
],
"index": "pypi",
"version": "==4.10.0"
},
"django-rq": {
"hashes": [
"sha256:3ab01d75694c4124baa497ebd4d6c7ebbf9d93cb4b390bfe253b62220562a299",
"sha256:48a41c464194096af34be7dfba2b1a71a1cec1f466734e51813b9d138fc20676"
],
"index": "pypi",
"version": "==1.3.0"
},
"django-super-favicon": {
"hashes": [
"sha256:56cb5268ea73ef3cbde5cb01fef02fea2ec00739cdae0566d3102009f052f683"
],
"index": "pypi",
"version": "==0.6.1"
},
"docutils": {
"hashes": [
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
],
"index": "pypi",
"version": "==0.14"
},
"gevent": {
"hashes": [
"sha256:0774babec518a24d9a7231d4e689931f31b332c4517a771e532002614e270a64",
"sha256:0e1e5b73a445fe82d40907322e1e0eec6a6745ca3cea19291c6f9f50117bb7ea",
"sha256:0ff2b70e8e338cf13bedf146b8c29d475e2a544b5d1fe14045aee827c073842c",
"sha256:107f4232db2172f7e8429ed7779c10f2ed16616d75ffbe77e0e0c3fcdeb51a51",
"sha256:14b4d06d19d39a440e72253f77067d27209c67e7611e352f79fe69e0f618f76e",
"sha256:1b7d3a285978b27b469c0ff5fb5a72bcd69f4306dbbf22d7997d83209a8ba917",
"sha256:1eb7fa3b9bd9174dfe9c3b59b7a09b768ecd496debfc4976a9530a3e15c990d1",
"sha256:2711e69788ddb34c059a30186e05c55a6b611cb9e34ac343e69cf3264d42fe1c",
"sha256:28a0c5417b464562ab9842dd1fb0cc1524e60494641d973206ec24d6ec5f6909",
"sha256:3249011d13d0c63bea72d91cec23a9cf18c25f91d1f115121e5c9113d753fa12",
"sha256:44089ed06a962a3a70e96353c981d628b2d4a2f2a75ea5d90f916a62d22af2e8",
"sha256:4bfa291e3c931ff3c99a349d8857605dca029de61d74c6bb82bd46373959c942",
"sha256:50024a1ee2cf04645535c5ebaeaa0a60c5ef32e262da981f4be0546b26791950",
"sha256:53b72385857e04e7faca13c613c07cab411480822ac658d97fd8a4ddbaf715c8",
"sha256:74b7528f901f39c39cdbb50cdf08f1a2351725d9aebaef212a29abfbb06895ee",
"sha256:7d0809e2991c9784eceeadef01c27ee6a33ca09ebba6154317a257353e3af922",
"sha256:896b2b80931d6b13b5d9feba3d4eebc67d5e6ec54f0cf3339d08487d55d93b0e",
"sha256:8d9ec51cc06580f8c21b41fd3f2b3465197ba5b23c00eb7d422b7ae0380510b0",
"sha256:9f7a1e96fec45f70ad364e46de32ccacab4d80de238bd3c2edd036867ccd48ad",
"sha256:ab4dc33ef0e26dc627559786a4fba0c2227f125db85d970abbf85b77506b3f51",
"sha256:d1e6d1f156e999edab069d79d890859806b555ce4e4da5b6418616322f0a3df1",
"sha256:d752bcf1b98174780e2317ada12013d612f05116456133a6acf3e17d43b71f05",
"sha256:e5bcc4270671936349249d26140c267397b7b4b1381f5ec8b13c53c5b53ab6e1"
],
"index": "pypi",
"version": "==1.4.0"
},
"greenlet": {
"hashes": [
"sha256:000546ad01e6389e98626c1367be58efa613fa82a1be98b0c6fc24b563acc6d0",
"sha256:0d48200bc50cbf498716712129eef819b1729339e34c3ae71656964dac907c28",
"sha256:23d12eacffa9d0f290c0fe0c4e81ba6d5f3a5b7ac3c30a5eaf0126bf4deda5c8",
"sha256:37c9ba82bd82eb6a23c2e5acc03055c0e45697253b2393c9a50cef76a3985304",
"sha256:51503524dd6f152ab4ad1fbd168fc6c30b5795e8c70be4410a64940b3abb55c0",
"sha256:8041e2de00e745c0e05a502d6e6db310db7faa7c979b3a5877123548a4c0b214",
"sha256:81fcd96a275209ef117e9ec91f75c731fa18dcfd9ffaa1c0adbdaa3616a86043",
"sha256:853da4f9563d982e4121fed8c92eea1a4594a2299037b3034c3c898cb8e933d6",
"sha256:8b4572c334593d449113f9dc8d19b93b7b271bdbe90ba7509eb178923327b625",
"sha256:9416443e219356e3c31f1f918a91badf2e37acf297e2fa13d24d1cc2380f8fbc",
"sha256:9854f612e1b59ec66804931df5add3b2d5ef0067748ea29dc60f0efdcda9a638",
"sha256:99a26afdb82ea83a265137a398f570402aa1f2b5dfb4ac3300c026931817b163",
"sha256:a19bf883b3384957e4a4a13e6bd1ae3d85ae87f4beb5957e35b0be287f12f4e4",
"sha256:a9f145660588187ff835c55a7d2ddf6abfc570c2651c276d3d4be8a2766db490",
"sha256:ac57fcdcfb0b73bb3203b58a14501abb7e5ff9ea5e2edfa06bb03035f0cff248",
"sha256:bcb530089ff24f6458a81ac3fa699e8c00194208a724b644ecc68422e1111939",
"sha256:beeabe25c3b704f7d56b573f7d2ff88fc99f0138e43480cecdfcaa3b87fe4f87",
"sha256:d634a7ea1fc3380ff96f9e44d8d22f38418c1c381d5fac680b272d7d90883720",
"sha256:d97b0661e1aead761f0ded3b769044bb00ed5d33e1ec865e891a8b128bf7c656"
],
"markers": "platform_python_implementation == 'CPython'",
"version": "==0.4.15"
},
"gunicorn": {
"extras": [
"gevent"
],
"hashes": [
"sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471",
"sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3"
],
"index": "pypi",
"version": "==19.9.0"
},
"hiredis": {
"hashes": [
"sha256:098d0c767115fb2ffee4f142d76ad86785829ef22b07a0ce2d289eef78186705",
"sha256:09f2d7bc980b2d1f402b39d4cc7841988493cf06580fd4b3120cbcf8a73402e5",
"sha256:1875ca8a843efd3220e1a39c0e754847030b7fd8ff4102c23d7ab7c791ccf0c2",
"sha256:2014924fb557541ee239e5035ddf8c4c03b166a0d6f45b265b1cad867091a617",
"sha256:22d814b567774c04459d7ff36979bacc4ada490d316579b3c357cfd949677c30",
"sha256:28c9da46e2f51d9a3b59dbf42e41278c979413bc0067a119fd51cf28a2f36379",
"sha256:4c0f4f3c94f4c9071e3f58546d05757a9e2e49e64769395963238548807ecd22",
"sha256:5ae5dd2d78ec1ea593207fa1c9bdc46da8b1713f038695f83457bc8307243ce2",
"sha256:602f667281cedf0e21c8648aaf72e70d6a0ccf7ee274b4bc9ea6aa889f27b66c",
"sha256:66344a5bdf19b39ba3c00101663806ef92fd75b1451a2f3f33ca73352fabaa01",
"sha256:691e5c4efca2cf974ed24414af4d32c91bbf7650353504da918e0c29ad171d8b",
"sha256:69a9d6c742ffd586093def8ac3dd1907907c8396b36af8b0490e9e52f0015e99",
"sha256:726e546d6b2133787b6c690176ed54d969f5c90f5bda650c5cbaa44bf18e4507",
"sha256:73227ed9d34648eae694cf3b4683840ea0e7dace682dcf3f1f732b42f9f0631c",
"sha256:736a30992ede64b79989869e1a068fec93405dac71d87a316f22ee72e75cb6a5",
"sha256:79742f23dbb6a8dcf8b857ced49e81f872a792f587a98f0a4fe620a175eb8b70",
"sha256:7bf565198d612ad6b19a83b2d65a8e88d2b82608b0c4513c89ecfc25bd1dcce0",
"sha256:ae104a4a622320e0174fb82874d383b6b5fef0e3a38375695e01300107cc3e67",
"sha256:bdd78231aac38eadd00112c6eb8f6862ce9264a397602cd7eb6fe47e1c7273ba",
"sha256:c6a7ab327dd01d77096f60bfaff1d0d5386960190fbd8a846ec8daafd529e0e3",
"sha256:cac4abfbd42d3bb7a363c51752f909645559617e3cbd2c9fd175159cd8bbc102",
"sha256:cf216d6fbacef0a20d23755d2d453dc6672ba5fd2b0cf76c548f4f620fce2cfe",
"sha256:d8222d09da762891d4ef39374b1f2318da0ba02faf609d254438672892155eee",
"sha256:dac079ff66c2d1c56f62f0a9b0a7a01784ad7b9eb136b9a931f7ab57463e70de",
"sha256:e68638e460e95a68dbcef6f78be8210f4405eac020f10ee52cafee54084d3699",
"sha256:fb8b80d2fb45857fd19d04387e4207412124e470c3b5d4d3568a6d9b74a72e99",
"sha256:fc3eb53b51acf467ae7d60e92af23dee33b20871d84ce33e6e61b9e00077ff48",
"sha256:ffe6d9bc7f3637d962eb4706a2908ba4064ec997beb99540bb385a0f7d7fe39a"
],
"index": "pypi",
"version": "==0.3.1"
},
"html5lib": {
"hashes": [
"sha256:20b159aa3badc9d5ee8f5c647e5efd02ed2a66ab8d354930bd9ff139fc1dc0a3",
"sha256:66cb0dcfdbbc4f9c3ba1a63fdb511ffdbd4f513b2b6d81b80cd26ce6b3fb3736"
],
"version": "==1.0.1"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.8"
},
"isodate": {
"hashes": [
"sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8",
"sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"
],
"version": "==0.6.0"
},
"jinja2": {
"hashes": [
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
],
"index": "pypi",
"version": "==2.10"
},
"lxml": {
"hashes": [
"sha256:0dd6589fa75d369ba06d2b5f38dae107f76ea127f212f6a7bee134f6df2d1d21",
"sha256:1afbac344aa68c29e81ab56c1a9411c3663157b5aee5065b7fa030b398d4f7e0",
"sha256:1baad9d073692421ad5dbbd81430aba6c7f5fdc347f03537ae046ddf2c9b2297",
"sha256:1d8736421a2358becd3edf20260e41a06a0bf08a560480d3a5734a6bcbacf591",
"sha256:1e1d9bddc5afaddf0de76246d3f2152f961697ad7439c559f179002682c45801",
"sha256:1f179dc8b2643715f020f4d119d5529b02cd794c1c8f305868b73b8674d2a03f",
"sha256:241fb7bdf97cb1df1edfa8f0bcdfd80525d4023dac4523a241907c8b2f44e541",
"sha256:2f9765ee5acd3dbdcdc0d0c79309e01f7c16bc8d39b49250bf88de7b46daaf58",
"sha256:312e1e1b1c3ce0c67e0b8105317323e12807955e8186872affb667dbd67971f6",
"sha256:3273db1a8055ca70257fd3691c6d2c216544e1a70b673543e15cc077d8e9c730",
"sha256:34dfaa8c02891f9a246b17a732ca3e99c5e42802416628e740a5d1cb2f50ff49",
"sha256:3aa3f5288af349a0f3a96448ebf2e57e17332d99f4f30b02093b7948bd9f94cc",
"sha256:51102e160b9d83c1cc435162d90b8e3c8c93b28d18d87b60c56522d332d26879",
"sha256:56115fc2e2a4140e8994eb9585119a1ae9223b506826089a3ba753a62bd194a6",
"sha256:69d83de14dbe8fe51dccfd36f88bf0b40f5debeac763edf9f8325180190eba6e",
"sha256:99fdce94aeaa3ccbdfcb1e23b34273605c5853aa92ec23d84c84765178662c6c",
"sha256:a7c0cd5b8a20f3093ee4a67374ccb3b8a126743b15a4d759e2a1bf098faac2b2",
"sha256:abe12886554634ed95416a46701a917784cb2b4c77bfacac6916681d49bbf83d",
"sha256:b4f67b5183bd5f9bafaeb76ad119e977ba570d2b0e61202f534ac9b5c33b4485",
"sha256:bdd7c1658475cc1b867b36d5c4ed4bc316be8d3368abe03d348ba906a1f83b0e",
"sha256:c6f24149a19f611a415a51b9bc5f17b6c2f698e0d6b41ffb3fa9f24d35d05d73",
"sha256:d1e111b3ab98613115a208c1017f266478b0ab224a67bc8eac670fa0bad7d488",
"sha256:d6520aa965773bbab6cb7a791d5895b00d02cf9adc93ac2bf4edb9ac1a6addc5",
"sha256:dd185cde2ccad7b649593b0cda72021bc8a91667417001dbaf24cd746ecb7c11",
"sha256:de2e5b0828a9d285f909b5d2e9d43f1cf6cf21fe65bc7660bdaa1780c7b58298",
"sha256:f726444b8e909c4f41b4fde416e1071cf28fa84634bfb4befdf400933b6463af"
],
"version": "==4.3.0"
},
"markdown": {
"hashes": [
"sha256:c00429bd503a47ec88d5e30a751e147dcb4c6889663cd3e2ba0afe858e009baa",
"sha256:d02e0f9b04c500cde6637c11ad7c72671f359b87b9fe924b2383649d8841db7c"
],
"index": "pypi",
"version": "==3.0.1"
},
"markupsafe": {
"hashes": [
"sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432",
"sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b",
"sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9",
"sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af",
"sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834",
"sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd",
"sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d",
"sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7",
"sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b",
"sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3",
"sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c",
"sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2",
"sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7",
"sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36",
"sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1",
"sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e",
"sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1",
"sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c",
"sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856",
"sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550",
"sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492",
"sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672",
"sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401",
"sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6",
"sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6",
"sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c",
"sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd",
"sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1"
],
"version": "==1.1.0"
},
"mf2py": {
"hashes": [
"sha256:84f1f8f2ff3f1deb1c30be497e7ccd805452996a662fd4a77f09e0105bede2c9"
],
"index": "pypi",
"version": "==1.1.2"
},
"mf2util": {
"hashes": [
"sha256:70b5e08dd553e19b1cb46cd6060ff6a41e57859588364efece6e52d14267c859"
],
"index": "pypi",
"version": "==0.5.1"
},
"msgpack": {
"hashes": [
"sha256:102802a9433dcf36f939b632cce9dea87310b2f163bb37ffc8bc343677726e88",
"sha256:3055c44f39833b6edb27fd48028dc7822d1fd75bfeef8a2434caed8d62bb24ee",
"sha256:3b7fd45c8e9e537640f541d3699b1773cf5cb9345d4a75f93baa8f055084e59c",
"sha256:64abc6bf3a2ac301702f5760f4e6e227d0fd4d84d9014ef9a40faa9d43365259",
"sha256:6e962c4adc7970af5a3d6a4f9bb87c617b1bd041fd9ab42355a263d421017ed9",
"sha256:72259661a83f8b08ef6ee83927ce4937f841226735824af5b10a536d886eeb36",
"sha256:78e297c3996fd9f35090fbddd1c148c2a71e0d6024500bcf3af90a4b9698bc19",
"sha256:85f1342b9d7549dd3daf494100d47a3dc7daae703cdbfc2c9ee7bbdc8a492cba",
"sha256:8ce9f88b6cb75d74eda2a5522e5c2e5ec0f17fd78605d6502abb61f46b306865",
"sha256:8d0af8d64198e4b4f942a15ea9cb0dd9c4a0bd3e4e2ba57425e108bdbd4c3a0f",
"sha256:9936ce3a530ca78db60b6631003b5f4ba383cfb1d9830a27d1b5c61857226e2f",
"sha256:b688721df31c4bad6f508fb262719eb7e4a3532024c66d3c44ad6a4704519dda",
"sha256:c28478328e9cd868ce54e8465eae9fa3605790450c66cc7e8bc416526917ef6e",
"sha256:cb4e228f3d93779a1d77a1e9d72759b79dfa2975c1a5bd2a090eaa98239fa4b1",
"sha256:d03d0b6e4adf5bd1cbf7a81a20a56c883351947a57b7b85235181b057adf1120",
"sha256:d2b179faebd278e5f4e255a6bbc7ccb467f02ed5c4c00c8a68dc926002223a20",
"sha256:f1a8f7bd84be103979a73da57be3cb929d702a656162ee466597b816fa9eec97"
],
"index": "pypi",
"version": "==0.6.0"
},
"packaging": {
"hashes": [
"sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807",
"sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9"
],
"version": "==18.0"
},
"pillow": {
"hashes": [
"sha256:051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e",
"sha256:0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7",
"sha256:0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a",
"sha256:163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3",
"sha256:18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1",
"sha256:24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1",
"sha256:267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7",
"sha256:3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1",
"sha256:4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3",
"sha256:4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055",
"sha256:505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf",
"sha256:5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f",
"sha256:5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f",
"sha256:7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239",
"sha256:801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe",
"sha256:825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c",
"sha256:9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697",
"sha256:a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494",
"sha256:a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356",
"sha256:a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6",
"sha256:a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000",
"sha256:b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f",
"sha256:ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c",
"sha256:cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca",
"sha256:cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8",
"sha256:d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3",
"sha256:d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad",
"sha256:e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9",
"sha256:f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc",
"sha256:fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e"
],
"index": "pypi",
"version": "==5.4.1"
},
"psycopg2-binary": {
"hashes": [
"sha256:036bcb198a7cc4ce0fe43344f8c2c9a8155aefa411633f426c8c6ed58a6c0426",
"sha256:1d770fcc02cdf628aebac7404d56b28a7e9ebec8cfc0e63260bd54d6edfa16d4",
"sha256:1fdc6f369dcf229de6c873522d54336af598b9470ccd5300e2f58ee506f5ca13",
"sha256:21f9ddc0ff6e07f7d7b6b484eb9da2c03bc9931dd13e36796b111d631f7135a3",
"sha256:247873cda726f7956f745a3e03158b00de79c4abea8776dc2f611d5ba368d72d",
"sha256:3aa31c42f29f1da6f4fd41433ad15052d5ff045f2214002e027a321f79d64e2c",
"sha256:475f694f87dbc619010b26de7d0fc575a4accf503f2200885cc21f526bffe2ad",
"sha256:4b5e332a24bf6e2fda1f51ca2a57ae1083352293a08eeea1fa1112dc7dd542d1",
"sha256:570d521660574aca40be7b4d532dfb6f156aad7b16b5ed62d1534f64f1ef72d8",
"sha256:59072de7def0690dd13112d2bdb453e20570a97297070f876fbbb7cbc1c26b05",
"sha256:5f0b658989e918ef187f8a08db0420528126f2c7da182a7b9f8bf7f85144d4e4",
"sha256:649199c84a966917d86cdc2046e03d536763576c0b2a756059ae0b3a9656bc20",
"sha256:6645fc9b4705ae8fbf1ef7674f416f89ae1559deec810f6dd15197dfa52893da",
"sha256:6872dd54d4e398d781efe8fe2e2d7eafe4450d61b5c4898aced7610109a6df75",
"sha256:6ce34fbc251fc0d691c8d131250ba6f42fd2b28ef28558d528ba8c558cb28804",
"sha256:73920d167a0a4d1006f5f3b9a3efce6f0e5e883a99599d38206d43f27697df00",
"sha256:8a671732b87ae423e34b51139628123bc0306c2cb85c226e71b28d3d57d7e42a",
"sha256:8d517e8fda2efebca27c2018e14c90ed7dc3f04d7098b3da2912e62a1a5585fe",
"sha256:9475a008eb7279e20d400c76471843c321b46acacc7ee3de0b47233a1e3fa2cf",
"sha256:96947b8cd7b3148fb0e6549fcb31258a736595d6f2a599f8cd450e9a80a14781",
"sha256:abf229f24daa93f67ac53e2e17c8798a71a01711eb9fcdd029abba8637164338",
"sha256:b1ab012f276df584beb74f81acb63905762c25803ece647016613c3d6ad4e432",
"sha256:b22b33f6f0071fe57cb4e9158f353c88d41e739a3ec0d76f7b704539e7076427",
"sha256:b3b2d53274858e50ad2ffdd6d97ce1d014e1e530f82ec8b307edd5d4c921badf",
"sha256:bab26a729befc7b9fab9ded1bba9c51b785188b79f8a2796ba03e7e734269e2e",
"sha256:daa1a593629aa49f506eddc9d23dc7f89b35693b90e1fbcd4480182d1203ea90",
"sha256:dd111280ce40e89fd17b19c1269fd1b74a30fce9d44a550840e86edb33924eb8",
"sha256:e0b86084f1e2e78c451994410de756deba206884d6bed68d5a3d7f39ff5fea1d",
"sha256:eb86520753560a7e89639500e2a254bb6f683342af598088cb72c73edcad21e6",
"sha256:ff18c5c40a38d41811c23e2480615425c97ea81fd7e9118b8b899c512d97c737"
],
"index": "pypi",
"version": "==2.7.6.1"
},
"pycparser": {
"hashes": [
"sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
],
"version": "==2.19"
},
"pyparsing": {
"hashes": [
"sha256:66c9268862641abcac4a96ba74506e594c884e3f57690a696d21ad8210ed667a",
"sha256:f6c5ef0d7480ad048c054c37632c67fca55299990fff127850181659eea33fc3"
],
"version": "==2.3.1"
},
"python-baseconv": {
"hashes": [
"sha256:1b98b11d0d1c00bf1165d62b0d183c8d2d496ae5baaa0991c0d4ffef079772d6"
],
"index": "pypi",
"version": "==1.2.1"
},
"python-magic": {
"hashes": [
"sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375",
"sha256:f3765c0f582d2dfc72c15f3b5a82aecfae9498bd29ca840d72f37d7bd38bfcd5"
],
"index": "pypi",
"version": "==0.4.15"
},
"python-slugify": {
"hashes": [
"sha256:d3e034397236020498e677a35e5c05dcc6ba1624b608b9ef7e5fe3090ccbd5a8"
],
"index": "pypi",
"version": "==2.0.1"
},
"pytz": {
"hashes": [
"sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9",
"sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c"
],
"version": "==2018.9"
},
"pyup-django": {
"hashes": [
"sha256:f02242b4c7a8926bf9118054429dcaf84e5708a050548abcd3b2b9de4a7570b9",
"sha256:fe84cef39c41d5feb24e307d6c8a55454db50df4c6955fa6a890a42b6e58650e"
],
"index": "pypi",
"version": "==0.4.0"
},
"pyyaml": {
"hashes": [
"sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b",
"sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf",
"sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a",
"sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3",
"sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1",
"sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1",
"sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613",
"sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04",
"sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f",
"sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537",
"sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"
],
"index": "pypi",
"version": "==3.13"
},
"qrcode": {
"hashes": [
"sha256:3996ee560fc39532910603704c82980ff6d4d5d629f9c3f25f34174ce8606cf5",
"sha256:505253854f607f2abf4d16092c61d4e9d511a3b4392e60bff957a68592b04369"
],
"index": "pypi",
"version": "==6.1"
},
"rcssmin": {
"hashes": [
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
],
"version": "==1.0.6"
},
"redis": {
"hashes": [
"sha256:2100750629beff143b6a200a2ea8e719fcf26420adabb81402895e144c5083cf",
"sha256:8e0bdd2de02e829b6225b25646f9fb9daffea99a252610d040409a6738541f0a"
],
"version": "==3.0.1"
},
"requests": {
"hashes": [
"sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
"sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
],
"version": "==2.21.0"
},
"rjsmin": {
"hashes": [
"sha256:dd9591aa73500b08b7db24367f8d32c6470021f39d5ab4e50c7c02e4401386f1"
],
"version": "==1.0.12"
},
"ronkyuu": {
"hashes": [
"sha256:5aa77b39d301bc174ab99ba8a53954627771cb501651a12103c58f51b32e84bf",
"sha256:85b25fef7f5fb0c93afd5377ea35b5ff72b2458f926bafdf10f0c9a1e19cab10"
],
"index": "pypi",
"version": "==0.6"
},
"rq": {
"hashes": [
"sha256:2ef7de3fa26a4ce41dcd0561bdba12bb62fa5b9b6d21120122d8dbbe44bb6a77",
"sha256:c3c6bdf738652b8464e8b32c5e158a5e646ee553711d5a1e9c6c23ab17ef68b1"
],
"version": "==0.13.0"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"soupsieve": {
"hashes": [
"sha256:009d8865916766f7f452880d08ff94ed4c5445011a3deaac67543b82bdb0b9ee",
"sha256:97599c45a1ddfe9ab0a0cba889b7f214b3e310b703f176a0610c0b54e207cc04"
],
"version": "==1.7.1"
},
"sqlparse": {
"hashes": [
"sha256:ce028444cfab83be538752a2ffdb56bc417b7784ff35bb9a3062413717807dec",
"sha256:d9cf190f51cbb26da0412247dfe4fb5f4098edb73db84e02f9fc21fdca31fed4"
],
"version": "==0.2.4"
},
"unidecode": {
"hashes": [
"sha256:092cdf7ad9d1052c50313426a625b717dab52f7ac58f859e09ea020953b1ad8f",
"sha256:8b85354be8fd0c0e10adbf0675f6dc2310e56fda43fa8fe049123b6c475e52fb"
],
"version": "==1.0.23"
},
"urllib3": {
"hashes": [
"sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
"sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
],
"version": "==1.24.1"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
],
"version": "==0.5.1"
},
"xrd": {
"hashes": [
"sha256:51d01f732b5b5b7983c5179ffaed864408d95a667b3a6630fe27aa7528274089"
],
"index": "pypi",
"version": "==0.1"
}
},
"develop": {
"argh": {
"hashes": [
"sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3",
"sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65"
],
"version": "==0.26.2"
},
"atomicwrites": {
"hashes": [
"sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0",
"sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee"
],
"version": "==1.2.1"
},
"attrs": {
"hashes": [
"sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69",
"sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"
],
"version": "==18.2.0"
},
"docopt": {
"hashes": [
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
],
"version": "==0.6.2"
},
"jedi": {
"hashes": [
"sha256:571702b5bd167911fe9036e5039ba67f820d6502832285cde8c881ab2b2149fd",
"sha256:c8481b5e59d34a5c7c42e98f6625e633f6ef59353abea6437472c7ec2093f191"
],
"version": "==0.13.2"
},
"more-itertools": {
"hashes": [
"sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4",
"sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc",
"sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9"
],
"version": "==5.0.0"
},
"mypy": {
"hashes": [
"sha256:986a7f97808a865405c5fd98fae5ebfa963c31520a56c783df159e9a81e41b3e",
"sha256:cc5df73cc11d35655a8c364f45d07b13c8db82c000def4bd7721be13356533b4"
],
"index": "pypi",
"version": "==0.660"
},
"mypy-extensions": {
"hashes": [
"sha256:37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812",
"sha256:b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"
],
"version": "==0.4.1"
},
"parso": {
"hashes": [
"sha256:35704a43a3c113cce4de228ddb39aab374b8004f4f2407d070b6a2ca784ce8a2",
"sha256:895c63e93b94ac1e1690f5fdd40b65f07c8171e3e53cbd7793b5b96c0e0a7f24"
],
"version": "==0.3.1"
},
"pathtools": {
"hashes": [
"sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"
],
"version": "==0.1.2"
},
"pluggy": {
"hashes": [
"sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616",
"sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a"
],
"version": "==0.8.1"
},
"prompt-toolkit": {
"hashes": [
"sha256:c1d6aff5252ab2ef391c2fe498ed8c088066f66bc64a8d5c095bbf795d9fec34",
"sha256:d4c47f79b635a0e70b84fdb97ebd9a274203706b1ee5ed44c10da62755cf3ec9",
"sha256:fd17048d8335c1e6d5ee403c3569953ba3eb8555d710bfc548faf0712666ea39"
],
"version": "==2.0.7"
},
"ptpython": {
"hashes": [
"sha256:51a74abe931f692360a32d650c2ba1ca329c08f3ed9b1de8abcd1164e0b0a6a7",
"sha256:938ee050e37d61c138dbbeb21383dfef8b9ed4ffb453a5f34041f42025bf5042",
"sha256:ebe9d68ea7532ec8ab306d4bdc7ec393701cd9bbd6eff0aa3067c821f99264d4"
],
"index": "pypi",
"version": "==2.0.4"
},
"py": {
"hashes": [
"sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694",
"sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"
],
"version": "==1.7.0"
},
"pygments": {
"hashes": [
"sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a",
"sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d"
],
"version": "==2.3.1"
},
"pytest": {
"hashes": [
"sha256:41568ea7ecb4a68d7f63837cf65b92ce8d0105e43196ff2b26622995bb3dc4b2",
"sha256:c3c573a29d7c9547fb90217ece8a8843aa0c1328a797e200290dc3d0b4b823be"
],
"version": "==4.1.1"
},
"pytest-django": {
"hashes": [
"sha256:1a5d33be930e3172fa238643a380414dc369fe8fa4b3c3de25e59ed142950736",
"sha256:e88e471d3d0f9acfb6293bb03d0ee8a33ed978734e92ea6b5312163a6c9e87cc"
],
"index": "pypi",
"version": "==3.4.5"
},
"pyyaml": {
"hashes": [
"sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b",
"sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf",
"sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a",
"sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3",
"sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1",
"sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1",
"sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613",
"sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04",
"sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f",
"sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537",
"sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"
],
"index": "pypi",
"version": "==3.13"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"typed-ast": {
"hashes": [
"sha256:023625bfa9359e29bd6e24cac2a4503495b49761d48a5f1e38333fc4ac4d93fe",
"sha256:07591f7a5fdff50e2e566c4c1e9df545c75d21e27d98d18cb405727ed0ef329c",
"sha256:153e526b0f4ffbfada72d0bb5ffe8574ba02803d2f3a9c605c8cf99dfedd72a2",
"sha256:3ad2bdcd46a4a1518d7376e9f5016d17718a9ed3c6a3f09203d832f6c165de4a",
"sha256:3ea98c84df53ada97ee1c5159bb3bc784bd734231235a1ede14c8ae0775049f7",
"sha256:51a7141ccd076fa561af107cfb7a8b6d06a008d92451a1ac7e73149d18e9a827",
"sha256:52c93cd10e6c24e7ac97e8615da9f224fd75c61770515cb323316c30830ddb33",
"sha256:6344c84baeda3d7b33e157f0b292e4dd53d05ddb57a63f738178c01cac4635c9",
"sha256:64699ca1b3bd5070bdeb043e6d43bc1d0cebe08008548f4a6bee782b0ecce032",
"sha256:74903f2e56bbffe29282ef8a5487d207d10be0f8513b41aff787d954a4cf91c9",
"sha256:7891710dba83c29ee2bd51ecaa82f60f6bede40271af781110c08be134207bf2",
"sha256:91976c56224e26c256a0de0f76d2004ab885a29423737684b4f7ebdd2f46dde2",
"sha256:9bad678a576ecc71f25eba9f1e3fd8d01c28c12a2834850b458428b3e855f062",
"sha256:b4726339a4c180a8b6ad9d8b50d2b6dc247e1b79b38fe2290549c98e82e4fd15",
"sha256:ba36f6aa3f8933edf94ea35826daf92cbb3ec248b89eccdc053d4a815d285357",
"sha256:bbc96bde544fd19e9ef168e4dfa5c3dfe704bfa78128fa76f361d64d6b0f731a",
"sha256:c0c927f1e44469056f7f2dada266c79b577da378bbde3f6d2ada726d131e4824",
"sha256:c0f9a3708008aa59f560fa1bd22385e05b79b8e38e0721a15a8402b089243442",
"sha256:f0bf6f36ff9c5643004171f11d2fdc745aa3953c5aacf2536a0685db9ceb3fb1",
"sha256:f5be39a0146be663cbf210a4d95c3c58b2d7df7b043c9047c5448e358f0550a2",
"sha256:fcd198bf19d9213e5cbf2cde2b9ef20a9856e716f76f9476157f90ae6de06cc6"
],
"version": "==1.2.0"
},
"watchdog": {
"hashes": [
"sha256:965f658d0732de3188211932aeb0bb457587f04f63ab4c1e33eab878e9de961d"
],
"index": "pypi",
"version": "==0.9.0"
},
"wcwidth": {
"hashes": [
"sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
"sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
],
"version": "==0.1.7"
},
"werkzeug": {
"hashes": [
"sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c",
"sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b"
],
"index": "pypi",
"version": "==0.14.1"
}
}
}

View file

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

View file

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

View file

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

View file

@ -11,24 +11,24 @@ from .models import Entry
def from_url(url: str) -> Entry: def from_url(url: str) -> Entry:
domain = Site.objects.get_current().domain domain = Site.objects.get_current().domain
if not url: if not url:
raise error.bad_req('url parameter required') raise error.bad_req("url parameter required")
if '//' not in url: if "//" not in url:
url = '//' + url url = "//" + url
parts = urlparse(url, scheme='https') parts = urlparse(url, scheme="https")
if parts.scheme not in ('http', 'https') or parts.netloc != domain: if parts.scheme not in ("http", "https") or parts.netloc != domain:
raise error.bad_req('url does not point to this site') raise error.bad_req("url does not point to this site")
try: try:
match = resolve(parts.path) match = resolve(parts.path)
except Resolver404: except Resolver404:
raise error.bad_req('url does not point to a valid page on this site') raise error.bad_req("url does not point to a valid page on this site")
if match.view_name != 'entries:entry': if match.view_name != "entries:entry":
raise error.bad_req('url does not point to an entry on this site') raise error.bad_req("url does not point to an entry on this site")
try: try:
entry = Entry.objects.get(pk=match.kwargs['id']) entry = Entry.objects.get(pk=match.kwargs["id"])
except Entry.DoesNotExist: except Entry.DoesNotExist:
raise error.bad_req('url does not point to an existing entry') raise error.bad_req("url does not point to an existing entry")
return entry return entry

View file

@ -1,7 +1,6 @@
{% extends 'lemoncurry/layout.html' %} {% extends 'lemoncurry/layout.html' %}
{% block head %} {% block head %}
<link rel="shortlink" href="{{ entry.short_url }}" /> <link rel="shortlink" href="{{ entry.short_url }}" />
<link rel="alternate" type="application/json+oembed" href="https://wirres.net/oembed/oembed/php?url={{ entry.absolute_url | urlencode }}" />
{% endblock %} {% endblock %}
{% block styles %} {% block styles %}

View file

@ -3,7 +3,7 @@
<article class="h-entry media"> <article class="h-entry media">
{{i}}<aside class="info"> {{i}}<aside class="info">
{{i}}<a class="p-author h-card" href="{{ entry.author.url }}"> {{i}}<a class="p-author h-card" href="{{ entry.author.url }}">
{{i}}<img class="u-photo img-fluid" src="{{ entry.author.avatar.url }}" alt="{{ entry.author.name }}" /> {{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}}<span class="p-name sr-only">{{ entry.author.name }}</span>
{{i}}</a> {{i}}</a>
{{i}}<a class="u-uid u-url" href="{{ entry.url }}"> {{i}}<a class="u-uid u-url" href="{{ entry.url }}">

View file

@ -7,16 +7,19 @@ from ronkyuu import webmention
@job @job
def ping_hub(*urls): def ping_hub(*urls):
for url in urls: for url in urls:
requests.post(settings.PUSH_HUB, data={ requests.post(
'hub.mode': 'publish', settings.PUSH_HUB,
'hub.url': url, data={
}) "hub.mode": "publish",
"hub.url": url,
},
)
@job @job
def send_mentions(source, targets=None): def send_mentions(source, targets=None):
if targets is None: if targets is None:
targets = webmention.findMentions(source)['refs'] targets = webmention.findMentions(source)["refs"]
for target in targets: for target in targets:
status, endpoint = webmention.discoverEndpoint(target) status, endpoint = webmention.discoverEndpoint(target)
if endpoint is not None and status == 200: if endpoint is not None and status == 200:

View file

@ -14,62 +14,62 @@ class Entry:
return self.index_page() return self.index_page()
def index_page(self, page=0): def index_page(self, page=0):
kwargs = {'kind': self} kwargs = {"kind": self}
if page > 1: if page > 1:
kwargs['page'] = page kwargs["page"] = page
return reverse('entries:index', kwargs=kwargs) return reverse("entries:index", kwargs=kwargs)
@property @property
def entry(self): def entry(self):
return self.plural + '_entry' return self.plural + "_entry"
@property @property
def atom(self): def atom(self):
return reverse('entries:atom_by_kind', kwargs={'kind': self}) return reverse("entries:atom_by_kind", kwargs={"kind": self})
@property @property
def rss(self): def rss(self):
return reverse('entries:rss_by_kind', kwargs={'kind': self}) return reverse("entries:rss_by_kind", kwargs={"kind": self})
Note = Entry( Note = Entry(
id='note', id="note",
icon='fas fa-paper-plane', icon="fas fa-paper-plane",
plural='notes', plural="notes",
) )
Article = Entry( Article = Entry(
id='article', id="article",
icon='fas fa-file-alt', icon="fas fa-file-alt",
plural='articles', plural="articles",
slug=True, slug=True,
) )
Photo = Entry( Photo = Entry(
id='photo', id="photo",
icon='fas fa-camera', icon="fas fa-camera",
plural='photos', plural="photos",
) )
Reply = Entry( Reply = Entry(
id='reply', id="reply",
icon='fas fa-comment', icon="fas fa-comment",
plural='replies', plural="replies",
on_home=False, on_home=False,
) )
Like = Entry( Like = Entry(
id='like', id="like",
icon='fas fa-heart', icon="fas fa-heart",
plural='likes', plural="likes",
on_home=False, on_home=False,
) )
Repost = Entry( Repost = Entry(
id='repost', id="repost",
icon='fas fa-retweet', icon="fas fa-retweet",
plural='reposts', plural="reposts",
) )
all = (Note, Article, Photo) all = (Note, Article, Photo)
@ -79,7 +79,7 @@ from_plural = {k.plural: k for k in all}
class EntryKindConverter: class EntryKindConverter:
regex = '|'.join(k.plural for k in all) regex = "|".join(k.plural for k in all)
def to_python(self, plural): def to_python(self, plural):
return from_plural[plural] return from_plural[plural]

View file

@ -8,7 +8,6 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
@ -17,20 +16,41 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Entry', name="Entry",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('kind', models.CharField(choices=[('note', 'Note'), ('article', 'Article')], default='note', max_length=30)), "id",
('name', models.CharField(blank=True, max_length=100)), models.AutoField(
('summary', models.TextField(blank=True)), auto_created=True,
('content', models.TextField()), primary_key=True,
('published', models.DateTimeField()), serialize=False,
('updated', models.DateTimeField()), verbose_name="ID",
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ),
),
(
"kind",
models.CharField(
choices=[("note", "Note"), ("article", "Article")],
default="note",
max_length=30,
),
),
("name", models.CharField(blank=True, max_length=100)),
("summary", models.TextField(blank=True)),
("content", models.TextField()),
("published", models.DateTimeField()),
("updated", models.DateTimeField()),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'verbose_name_plural': 'entries', "verbose_name_plural": "entries",
'ordering': ['-published'], "ordering": ["-published"],
}, },
), ),
] ]

View file

@ -7,23 +7,42 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('users', '0005_auto_20171023_0158'), ("users", "0005_auto_20171023_0158"),
('entries', '0001_initial'), ("entries", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Syndication', name="Syndication",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('url', models.CharField(max_length=255)), "id",
('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='syndications', to='entries.Entry')), models.AutoField(
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.Profile')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("url", models.CharField(max_length=255)),
(
"entry",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="syndications",
to="entries.Entry",
),
),
(
"profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="users.Profile"
),
),
], ],
options={ options={
'ordering': ['profile'], "ordering": ["profile"],
}, },
), ),
] ]

View file

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

View file

@ -8,20 +8,28 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('entries', '0003_remove_entry_summary'), ("entries", "0003_remove_entry_summary"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='entry', model_name="entry",
name='author', name="author",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="entries",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='entry', model_name="entry",
name='kind', name="kind",
field=models.CharField(choices=[('note', 'note'), ('article', 'article')], db_index=True, default='note', max_length=30), field=models.CharField(
choices=[("note", "note"), ("article", "article")],
db_index=True,
default="note",
max_length=30,
),
), ),
] ]

View file

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

View file

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

View file

@ -6,20 +6,31 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('entries', '0006_auto_20171102_1200'), ("entries", "0006_auto_20171102_1200"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='entry', model_name="entry",
name='cite', name="cite",
field=models.CharField(blank=True, max_length=255), field=models.CharField(blank=True, max_length=255),
), ),
migrations.AlterField( migrations.AlterField(
model_name='entry', model_name="entry",
name='kind', 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), field=models.CharField(
choices=[
("note", "note"),
("article", "article"),
("photo", "photo"),
("reply", "reply"),
("like", "like"),
("repost", "repost"),
],
db_index=True,
default="note",
max_length=30,
),
), ),
] ]

View file

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

View file

@ -6,21 +6,28 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('entries', '0008_auto_20171116_2116'), ("entries", "0008_auto_20171116_2116"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Tag', name="Tag",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255, unique=True)), "id",
('slug', models.CharField(max_length=255, unique=True)), 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={ options={
'ordering': ('name',), "ordering": ("name",),
}, },
), ),
] ]

View file

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

View file

@ -9,17 +9,17 @@ class Migration(migrations.Migration):
atomic = False atomic = False
dependencies = [ dependencies = [
('entries', '0010_entry_tags'), ("entries", "0010_entry_tags"),
] ]
operations = [ operations = [
migrations.RenameModel( migrations.RenameModel(
old_name='Tag', old_name="Tag",
new_name='Cat', new_name="Cat",
), ),
migrations.RenameField( migrations.RenameField(
model_name='entry', model_name="entry",
old_name='tags', old_name="tags",
new_name='cats', new_name="cats",
), ),
] ]

View file

@ -5,25 +5,25 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('entries', '0011_auto_20171120_1108'), ("entries", "0011_auto_20171120_1108"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='syndication', name="syndication",
options={'ordering': ['domain']}, options={"ordering": ["domain"]},
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='syndication', model_name="syndication",
name='profile', name="profile",
), ),
migrations.AddField( migrations.AddField(
model_name='syndication', model_name="syndication",
name='domain', name="domain",
field=computed_property.fields.ComputedCharField( field=computed_property.fields.ComputedCharField(
compute_from='calc_domain', default='', editable=False, max_length=255), compute_from="calc_domain", default="", editable=False, max_length=255
),
preserve_default=False, preserve_default=False,
), ),
] ]

View 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,
),
),
]

View file

@ -17,6 +17,7 @@ from users.models import Site
from . import kinds from . import kinds
from lemoncurry import requests, utils from lemoncurry import requests, utils
ENTRY_KINDS = [(k.id, k.id) for k in kinds.all] ENTRY_KINDS = [(k.id, k.id) for k in kinds.all]
@ -32,38 +33,33 @@ class Cat(models.Model):
slug = models.CharField(max_length=255, unique=True) slug = models.CharField(max_length=255, unique=True)
def __str__(self): def __str__(self):
return '#' + self.name return "#" + self.name
@property @property
def url(self): def url(self):
return reverse('entries:cat', args=(self.slug,)) return reverse("entries:cat", args=(self.slug,))
class Meta: class Meta:
ordering = ('name',) ordering = ("name",)
class EntryManager(models.Manager): class EntryManager(models.Manager):
def get_queryset(self): def get_queryset(self):
qs = super(EntryManager, self).get_queryset() qs = super(EntryManager, self).get_queryset()
return (qs return qs.select_related("author").prefetch_related("cats", "syndications")
.select_related('author')
.prefetch_related('cats', 'syndications'))
class Entry(ModelMeta, TimeStampedModel): class Entry(ModelMeta, TimeStampedModel):
objects = EntryManager() objects = EntryManager()
kind = models.CharField( kind = models.CharField(
max_length=30, max_length=30, choices=ENTRY_KINDS, db_index=True, default=ENTRY_KINDS[0][0]
choices=ENTRY_KINDS,
db_index=True,
default=ENTRY_KINDS[0][0]
) )
name = models.CharField(max_length=100, blank=True) name = models.CharField(max_length=100, blank=True)
photo = models.ImageField(blank=True) photo = models.ImageField(blank=True)
content = models.TextField() 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) in_reply_to = models.CharField(max_length=255, blank=True)
like_of = models.CharField(max_length=255, blank=True) like_of = models.CharField(max_length=255, blank=True)
@ -71,7 +67,7 @@ class Entry(ModelMeta, TimeStampedModel):
author = models.ForeignKey( author = models.ForeignKey(
get_user_model(), get_user_model(),
related_name='entries', related_name="entries",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
@ -79,10 +75,7 @@ class Entry(ModelMeta, TimeStampedModel):
def reply_context(self): def reply_context(self):
if not self.in_reply_to: if not self.in_reply_to:
return None return None
return interpret( return interpret(requests.mf2(self.in_reply_to).to_dict(), self.in_reply_to)
requests.mf2(self.in_reply_to).to_dict(),
self.in_reply_to
)
@property @property
def published(self): def published(self):
@ -93,35 +86,29 @@ class Entry(ModelMeta, TimeStampedModel):
return self.modified return self.modified
_metadata = { _metadata = {
'description': 'excerpt', "description": "excerpt",
'image': 'image_url', "image": "image_url",
'twitter_creator': 'twitter_creator', "twitter_creator": "twitter_creator",
'og_profile_id': 'og_profile_id', "og_profile_id": "og_profile_id",
} }
@property @property
def title(self): def title(self):
if self.name: if self.name:
return self.name return self.name
return shorten( return shorten(utils.to_plain(self.paragraphs[0]), width=100, placeholder="")
utils.to_plain(self.paragraphs[0]),
width=100,
placeholder=''
)
@property @property
def excerpt(self): def excerpt(self):
try: try:
return utils.to_plain(self.paragraphs[0 if self.name else 1]) return utils.to_plain(self.paragraphs[0 if self.name else 1])
except IndexError: except IndexError:
return ' ' return " "
@property @property
def paragraphs(self): def paragraphs(self):
lines = self.content.splitlines() lines = self.content.splitlines()
return [ return ["\n".join(para) for k, para in groupby(lines, key=bool) if k]
"\n".join(para) for k, para in groupby(lines, key=bool) if k
]
@property @property
def twitter_creator(self): def twitter_creator(self):
@ -136,31 +123,31 @@ class Entry(ModelMeta, TimeStampedModel):
return self.photo.url if self.photo else self.author.avatar_url return self.photo.url if self.photo else self.author.avatar_url
def __str__(self): 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): def get_absolute_url(self):
return self.absolute_url return self.absolute_url
@property @property
def absolute_url(self): def absolute_url(self):
base = 'https://' + DjangoSite.objects.get_current().domain base = "https://" + DjangoSite.objects.get_current().domain
return urljoin(base, self.url) return urljoin(base, self.url)
@property @property
def affected_urls(self): def affected_urls(self):
base = 'https://' + DjangoSite.objects.get_current().domain base = "https://" + DjangoSite.objects.get_current().domain
kind = kinds.from_id[self.kind] kind = kinds.from_id[self.kind]
urls = { urls = {
self.url, self.url,
reverse('entries:index', kwargs={'kind': kind}), reverse("entries:index", kwargs={"kind": kind}),
reverse('entries:atom_by_kind', kwargs={'kind': kind}), reverse("entries:atom_by_kind", kwargs={"kind": kind}),
reverse('entries:rss_by_kind', kwargs={'kind': kind}), reverse("entries:rss_by_kind", kwargs={"kind": kind}),
} | {cat.url for cat in self.cats.all()} } | {cat.url for cat in self.cats.all()}
if kind.on_home: if kind.on_home:
urls |= { urls |= {
reverse('home:index'), reverse("home:index"),
reverse('entries:atom'), reverse("entries:atom"),
reverse('entries:rss') reverse("entries:rss"),
} }
return {urljoin(base, u) for u in urls} return {urljoin(base, u) for u in urls}
@ -170,7 +157,7 @@ class Entry(ModelMeta, TimeStampedModel):
args = [kind, self.id] args = [kind, self.id]
if kind.slug: if kind.slug:
args.append(self.slug) args.append(self.slug)
return reverse('entries:entry', args=args) return reverse("entries:entry", args=args)
@property @property
def short_url(self): def short_url(self):
@ -182,49 +169,48 @@ class Entry(ModelMeta, TimeStampedModel):
@property @property
def json_ld(self): def json_ld(self):
base = 'https://' + DjangoSite.objects.get_current().domain base = "https://" + DjangoSite.objects.get_current().domain
url = urljoin(base, self.url) url = urljoin(base, self.url)
posting = { posting = {
'@context': 'http://schema.org', "@context": "http://schema.org",
'@type': 'BlogPosting', "@type": "BlogPosting",
'@id': url, "@id": url,
'url': url, "url": url,
'mainEntityOfPage': url, "mainEntityOfPage": url,
'author': { "author": {
'@type': 'Person', "@type": "Person",
'url': urljoin(base, self.author.url), "url": urljoin(base, self.author.url),
'name': self.author.name, "name": self.author.name,
}, },
'headline': self.title, "headline": self.title,
'description': self.excerpt, "description": self.excerpt,
'datePublished': self.created.isoformat(), "datePublished": self.created.isoformat(),
'dateModified': self.modified.isoformat(), "dateModified": self.modified.isoformat(),
} }
if self.photo: if self.photo:
posting['image'] = (urljoin(base, self.photo.url), ) posting["image"] = (urljoin(base, self.photo.url),)
return posting return posting
class Meta: class Meta:
verbose_name_plural = 'entries' verbose_name_plural = "entries"
ordering = ['-created'] ordering = ["-created"]
class Syndication(models.Model): class Syndication(models.Model):
entry = models.ForeignKey( entry = models.ForeignKey(
Entry, Entry, related_name="syndications", on_delete=models.CASCADE
related_name='syndications',
on_delete=models.CASCADE
) )
url = models.CharField(max_length=255) url = models.CharField(max_length=255)
domain = ComputedCharField( domain = ComputedCharField(
compute_from='calc_domain', max_length=255, compute_from="calc_domain",
max_length=255,
) )
def calc_domain(self): def calc_domain(self):
domain = urlparse(self.url).netloc domain = urlparse(self.url).netloc
if domain.startswith('www.'): if domain.startswith("www."):
domain = domain[4:] domain = domain[4:]
return domain return domain
@ -234,7 +220,7 @@ class Syndication(models.Model):
try: try:
return Site.objects.get(domain=d) return Site.objects.get(domain=d)
except Site.DoesNotExist: except Site.DoesNotExist:
return Site(name=d, domain=d, icon='fas fa-newspaper') return Site(name=d, domain=d, icon="fas fa-newspaper")
class Meta: class Meta:
ordering = ['domain'] ordering = ["domain"]

View file

@ -1,35 +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 django.shortcuts import redirect
from lemoncurry.middleware import ResponseException from lemoncurry.middleware import ResponseException
def paginate(queryset, reverse, page): def paginate(queryset, reverse: Callable[[int], str], page: int | None) -> Page:
class Page: def redirect_to_page(i: int):
def __init__(self, i): raise ResponseException(redirect(reverse(i)))
self.i = i
@property def reversible(p: Page) -> Page:
def url(self): p.reverse = reverse
return reverse(self.i) return p
@property
def current(self):
return self.i == entries.number
# If the first page was requested, redirect to the clean version of the URL
# with no page suffix.
if page == 1:
raise ResponseException(redirect(Page(1).url))
paginator = Paginator(queryset, 10) 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(): # If the first page was explicitly requested, or the page number was negative, redirect to page one with no URL suffix.
entries.prev = Page(entries.previous_page_number()) if page <= 1:
if entries.has_next(): redirect_to_page(1)
entries.next = Page(entries.next_page_number())
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))

View file

@ -3,27 +3,27 @@ import pytest
@pytest.mark.django_db @pytest.mark.django_db
def test_atom(client): def test_atom(client):
res = client.get('/atom') res = client.get("/atom")
assert res.status_code == 200 assert res.status_code == 200
assert res['Content-Type'] == 'application/atom+xml; charset=utf-8' assert res["Content-Type"] == "application/atom+xml; charset=utf-8"
@pytest.mark.django_db @pytest.mark.django_db
def test_rss(client): def test_rss(client):
res = client.get('/rss') res = client.get("/rss")
assert res.status_code == 200 assert res.status_code == 200
assert res['Content-Type'] == 'application/rss+xml; charset=utf-8' assert res["Content-Type"] == "application/rss+xml; charset=utf-8"
@pytest.mark.django_db @pytest.mark.django_db
def test_atom_by_kind(client): def test_atom_by_kind(client):
res = client.get('/notes/atom') res = client.get("/notes/atom")
assert res.status_code == 200 assert res.status_code == 200
assert res['Content-Type'] == 'application/atom+xml; charset=utf-8' assert res["Content-Type"] == "application/atom+xml; charset=utf-8"
@pytest.mark.django_db @pytest.mark.django_db
def test_rss_by_kind(client): def test_rss_by_kind(client):
res = client.get('/notes/rss') res = client.get("/notes/rss")
assert res.status_code == 200 assert res.status_code == 200
assert res['Content-Type'] == 'application/rss+xml; charset=utf-8' assert res["Content-Type"] == "application/rss+xml; charset=utf-8"

View file

@ -3,47 +3,46 @@ from . import kinds
from .views import feeds, lists, perma from .views import feeds, lists, perma
from lemoncurry import breadcrumbs as crumbs from lemoncurry import breadcrumbs as crumbs
register_converter(kinds.EntryKindConverter, 'kind') register_converter(kinds.EntryKindConverter, "kind")
def to_pat(*args): def to_pat(*args):
return '^{0}$'.format(''.join(args)) return "^{0}$".format("".join(args))
def prefix(route): def prefix(route):
return app_name + ':' + route return app_name + ":" + route
id = r'/(?P<id>\d+)' id = r"/(?P<id>\d+)"
kind = r'(?P<kind>{0})'.format('|'.join(k.plural for k in kinds.all)) kind = r"(?P<kind>{0})".format("|".join(k.plural for k in kinds.all))
page = r'(?:/page/(?P<page>\d+))?' page = r"(?:/page/(?P<page>\d+))?"
slug = r'/(?P<slug>[^/]+)' slug = r"/(?P<slug>[^/]+)"
slug_opt = '(?:' + slug + ')?' slug_opt = "(?:" + slug + ")?"
app_name = 'entries' app_name = "entries"
urlpatterns = ( urlpatterns = (
path('atom', feeds.AtomHomeEntries(), name='atom'), path("atom", feeds.AtomHomeEntries(), name="atom"),
path('rss', feeds.RssHomeEntries(), name='rss'), path("rss", feeds.RssHomeEntries(), name="rss"),
path('cats/<slug:slug>', lists.by_cat, name='cat'), path("cats/<slug:slug>", lists.by_cat, name="cat"),
path('cats/<slug:slug>/page/<int:page>', 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>", lists.by_kind, name="index"),
path('<kind:kind>/page/<int:page>', 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>/atom", feeds.AtomByKind(), name="atom_by_kind"),
path('<kind:kind>/rss', feeds.RssByKind(), name='rss_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>', perma.entry, name='entry'), path("<kind:kind>/<int:id>/<slug:slug>", perma.entry, name="entry"),
path('<kind:kind>/<int:id>/<slug:slug>', perma.entry, name='entry'),
) )
class IndexCrumb(crumbs.Crumb): class IndexCrumb(crumbs.Crumb):
def __init__(self): def __init__(self):
super().__init__(prefix('index'), parent='home:index') super().__init__(prefix("index"), parent="home:index")
@property @property
def kind(self): def kind(self):
return self.match.kwargs['kind'] return self.match.kwargs["kind"]
@property @property
def label(self): def label(self):
@ -51,9 +50,9 @@ class IndexCrumb(crumbs.Crumb):
@property @property
def url(self): def url(self):
return reverse(prefix('index'), kwargs={'kind': self.kind}) 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(IndexCrumb())
crumbs.add(prefix('entry'), parent=prefix('index')) crumbs.add(prefix("entry"), parent=prefix("index"))

View file

@ -11,8 +11,8 @@ from ..models import Entry
class Atom1FeedWithHub(Atom1Feed): class Atom1FeedWithHub(Atom1Feed):
def add_root_elements(self, handler): def add_root_elements(self, handler):
super().add_root_elements(handler) super().add_root_elements(handler)
handler.startElement('link', {'rel': 'hub', 'href': settings.PUSH_HUB}) handler.startElement("link", {"rel": "hub", "href": settings.PUSH_HUB})
handler.endElement('link') handler.endElement("link")
class EntriesFeed(Feed): class EntriesFeed(Feed):
@ -79,7 +79,7 @@ class RssHomeEntries(EntriesFeed):
return Site.objects.get_current().name return Site.objects.get_current().name
def link(self): def link(self):
return reverse('home:index') return reverse("home:index")
def description(self): def description(self):
return "content from {0}".format( return "content from {0}".format(

View file

@ -5,32 +5,32 @@ from ..models import Entry, Cat
from ..pagination import paginate from ..pagination import paginate
@render_to('entries/index.html') @render_to("entries/index.html")
def by_kind(request, kind, page=None): def by_kind(request, kind, page=None):
entries = Entry.objects.filter(kind=kind.id) entries = Entry.objects.filter(kind=kind.id)
entries = paginate(queryset=entries, reverse=kind.index_page, page=page) entries = paginate(queryset=entries, reverse=kind.index_page, page=page)
return { return {
'entries': entries, "entries": entries,
'atom': kind.atom, "atom": kind.atom,
'rss': kind.rss, "rss": kind.rss,
'title': kind.plural, "title": kind.plural,
} }
@render_to('entries/index.html') @render_to("entries/index.html")
def by_cat(request, slug, page=None): def by_cat(request, slug, page=None):
def url(page): def url(page):
kwargs = {'slug': slug} kwargs = {"slug": slug}
if page > 1: if page > 1:
kwargs['page'] = page kwargs["page"] = page
return reverse('entries:cat', kwargs=kwargs) return reverse("entries:cat", kwargs=kwargs)
cat = get_object_or_404(Cat, slug=slug) cat = get_object_or_404(Cat, slug=slug)
entries = cat.entries.all() entries = cat.entries.all()
entries = paginate(queryset=entries, reverse=url, page=page) entries = paginate(queryset=entries, reverse=url, page=page)
return { return {
'entries': entries, "entries": entries,
'title': '#' + cat.name, "title": "#" + cat.name,
} }

View file

@ -3,12 +3,12 @@ from django.shortcuts import redirect, get_object_or_404
from ..models import Entry from ..models import Entry
@render_to('entries/entry.html') @render_to("entries/entry.html")
def entry(request, kind, id, slug=None): def entry(request, kind, id, slug=None):
entry = get_object_or_404(Entry, pk=id) entry = get_object_or_404(Entry, pk=id)
if request.path != entry.url: if request.path != entry.url:
return redirect(entry.url, permanent=True) return redirect(entry.url, permanent=True)
return { return {
'entry': entry, "entry": entry,
'title': entry.title, "title": entry.title,
} }

View file

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

View file

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

View file

@ -2,9 +2,9 @@ from django.urls import path
from . import views from . import views
app_name = 'home' app_name = "home"
urlpatterns = [ urlpatterns = [
path('', views.index, name='index'), path("", views.index, name="index"),
path('page/<int:page>', views.index, name='index'), path("page/<int:page>", views.index, name="index"),
path('robots.txt', views.robots, name='robots.txt'), path("robots.txt", views.robots, name="robots.txt"),
] ]

View file

@ -8,34 +8,31 @@ from urllib.parse import urljoin
from entries import kinds, pagination from entries import kinds, pagination
from lemoncurry import breadcrumbs, utils from lemoncurry import breadcrumbs, utils
breadcrumbs.add('home:index', 'home') breadcrumbs.add("home:index", "home")
@render_to('home/index.html') @render_to("home/index.html")
def index(request, page=None): def index(request, page=None):
def url(page): def url(page):
kwargs = {'page': page} if page != 1 else {} kwargs = {"page": page} if page != 1 else {}
return reverse('home:index', kwargs=kwargs) return reverse("home:index", kwargs=kwargs)
user = request.user user = request.user
if not hasattr(user, 'entries'): if not hasattr(user, "entries"):
user = get_object_or_404(User, pk=1) user = get_object_or_404(User, pk=1)
entries = user.entries.filter(kind__in=kinds.on_home) entries = user.entries.filter(kind__in=kinds.on_home)
entries = pagination.paginate(queryset=entries, reverse=url, page=page) entries = pagination.paginate(queryset=entries, reverse=url, page=page)
return { return {
'user': user, "user": user,
'entries': entries, "entries": entries,
'atom': reverse('entries:atom'), "atom": reverse("entries:atom"),
'rss': reverse('entries:rss'), "rss": reverse("entries:rss"),
} }
def robots(request): def robots(request):
base = utils.origin(request) base = utils.origin(request)
lines = ( lines = ("User-agent: *", "Sitemap: {0}".format(urljoin(base, reverse("sitemap"))))
'User-agent: *', return HttpResponse("\n".join(lines) + "\n", content_type="text/plain")
'Sitemap: {0}'.format(urljoin(base, reverse('sitemap')))
)
return HttpResponse("\n".join(lines) + "\n", content_type='text/plain')

View file

@ -62,17 +62,19 @@
</div> </div>
<div id="verified-success" hidden> <div id="verified-success" hidden>
this client has been <strong>verified</strong> using <code>{{ '<link rel="redirect_uri">' | escape }}</code> - they are who they claim to be! 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>
<div id="verified-warning" hidden> <div id="verified-warning" hidden>
this client could <strong>not</strong> be verified using <code>{{ '<link rel="redirect_uri">' | escape }}</code> - check the redirect uri carefully yourself! this client could <strong>not</strong> be verified using <code>{{ '<link rel="redirect_uri">' | escape }}</code> <br/>- check the redirect uri carefully yourself!
</div> </div>
{% endblock %} {% endblock %}
{% block foot %} {% block foot %}
<script type="text/javascript"> <script type="text/javascript">
tippy('[data-tippy-theme]', { tippy('[data-tippy-html]', {
arrow: true, arrow: true,
allowHTML: true,
maxWidth: 500,
content: function(element) { content: function(element) {
return document.querySelector(element.getAttribute('data-tippy-html')).innerHTML; return document.querySelector(element.getAttribute('data-tippy-html')).innerHTML;
} }

View file

@ -7,25 +7,36 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [] # type: List[Tuple[str, str]]
] # type: List[Tuple[str, str]]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='IndieAuthCode', name="IndieAuthCode",
fields=[ fields=[
('id', models.AutoField(auto_created=True, (
primary_key=True, serialize=False, verbose_name='ID')), "id",
('code', models.CharField(max_length=64, unique=True)), models.AutoField(
('me', models.CharField(max_length=255)), auto_created=True,
('client_id', models.CharField(max_length=255)), primary_key=True,
('redirect_uri', models.CharField(max_length=255)), serialize=False,
('response_type', models.CharField(choices=[ verbose_name="ID",
('id', 'id'), ('code', 'code')], default='id', max_length=4)), ),
('scope', models.CharField(blank=True, max_length=200)), ),
("code", models.CharField(max_length=64, unique=True)),
("me", models.CharField(max_length=255)),
("client_id", models.CharField(max_length=255)),
("redirect_uri", models.CharField(max_length=255)),
(
"response_type",
models.CharField(
choices=[("id", "id"), ("code", "code")],
default="id",
max_length=4,
),
),
("scope", models.CharField(blank=True, max_length=200)),
], ],
), ),
] ]

View file

@ -6,13 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('lemonauth', '0001_initial'), ("lemonauth", "0001_initial"),
] ]
operations = [ operations = [
migrations.DeleteModel( migrations.DeleteModel(
name='IndieAuthCode', name="IndieAuthCode",
), ),
] ]

View file

@ -9,43 +9,112 @@ import randomslugfield.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('lemonauth', '0002_delete_indieauthcode'), ("lemonauth", "0002_delete_indieauthcode"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='IndieAuthCode', name="IndieAuthCode",
fields=[ 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')), "created",
('id', randomslugfield.fields.RandomSlugField(blank=True, editable=False, length=30, max_length=30, primary_key=True, serialize=False, unique=True)), model_utils.fields.AutoCreatedField(
('client_id', models.URLField()), default=django.utils.timezone.now,
('scope', models.TextField(blank=True)), editable=False,
('redirect_uri', models.URLField()), verbose_name="created",
('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)), ),
(
"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={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Token', name="Token",
fields=[ 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')), "created",
('id', randomslugfield.fields.RandomSlugField(blank=True, editable=False, length=30, max_length=30, primary_key=True, serialize=False, unique=True)), model_utils.fields.AutoCreatedField(
('client_id', models.URLField()), default=django.utils.timezone.now,
('scope', models.TextField(blank=True)), editable=False,
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 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={ options={
'abstract': False, "abstract": False,
}, },
), ),
] ]

View file

@ -17,6 +17,7 @@ class AuthSecret(TimeStampedModel):
authorisation codes and tokens in IndieAuth - the two contain many authorisation codes and tokens in IndieAuth - the two contain many
identical fields, but just a few differences. identical fields, but just a few differences.
""" """
id = RandomSlugField(primary_key=True, length=30) id = RandomSlugField(primary_key=True, length=30)
client_id = models.URLField() client_id = models.URLField()
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
@ -27,7 +28,7 @@ class AuthSecret(TimeStampedModel):
return self.user.full_url return self.user.full_url
def __contains__(self, scope): def __contains__(self, scope):
return scope in self.scope.split(' ') return scope in self.scope.split(" ")
class Meta: class Meta:
abstract = True abstract = True
@ -41,10 +42,11 @@ class IndieAuthCode(AuthSecret):
Codes are single-use, and if unused will be expired automatically after Codes are single-use, and if unused will be expired automatically after
thirty seconds. thirty seconds.
""" """
redirect_uri = models.URLField() redirect_uri = models.URLField()
RESPONSE_TYPE = Choices('id', 'code') RESPONSE_TYPE = Choices("id", "code")
response_type = StatusField(choices_name='RESPONSE_TYPE') response_type = StatusField(choices_name="RESPONSE_TYPE")
@property @property
def expired(self): def expired(self):
@ -56,4 +58,5 @@ class Token(AuthSecret):
A Token grants a client long-term authorisation - it will not expire unless A Token grants a client long-term authorisation - it will not expire unless
explicitly revoked by the user. explicitly revoked by the user.
""" """
pass pass

View file

@ -2,10 +2,10 @@
img img
height 2em height 2em
margin-right .5em margin-right .5em
.tippy-tooltip .tippy-box
&.success-theme &[data-theme~='success']
color $base0B color $base0B
&.warning-theme &[data-theme~='warning']
color $base0A color $base0A
.verified-success .verified-success
color $base0B color $base0B

View file

@ -3,17 +3,17 @@ from .models import IndieAuthCode, Token
def auth(request) -> Token: def auth(request) -> Token:
if 'HTTP_AUTHORIZATION' in request.META: if "HTTP_AUTHORIZATION" in request.META:
auth = request.META.get('HTTP_AUTHORIZATION').split(' ') auth = request.META.get("HTTP_AUTHORIZATION").split(" ")
if auth[0] != 'Bearer': if auth[0] != "Bearer":
raise error.bad_req('auth type {0} not supported'.format(auth[0])) raise error.bad_req("auth type {0} not supported".format(auth[0]))
if len(auth) != 2: if len(auth) != 2:
raise 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] token = auth[1]
elif 'access_token' in request.POST: elif "access_token" in request.POST:
token = request.POST.get('access_token') token = request.POST.get("access_token")
elif 'access_token' in request.GET: elif "access_token" in request.GET:
token = request.GET.get('access_token') token = request.GET.get("access_token")
else: else:
raise error.unauthorized() raise error.unauthorized()
@ -28,11 +28,11 @@ def auth(request) -> Token:
def gen_auth_code(req): def gen_auth_code(req):
code = IndieAuthCode() code = IndieAuthCode()
code.user = req.user code.user = req.user
code.client_id = req.POST['client_id'] code.client_id = req.POST["client_id"]
code.redirect_uri = req.POST['redirect_uri'] code.redirect_uri = req.POST["redirect_uri"]
code.response_type = req.POST.get('response_type', 'id') code.response_type = req.POST.get("response_type", "id")
if 'scope' in req.POST: if "scope" in req.POST:
code.scope = ' '.join(req.POST.getlist('scope')) code.scope = " ".join(req.POST.getlist("scope"))
code.save() code.save()
return code.id return code.id

View file

@ -1,13 +1,17 @@
from django.urls import path from django.urls import path
from . import views from . import views
app_name = 'lemonauth' app_name = "lemonauth"
urlpatterns = [ urlpatterns = [
path('login', views.login, name='login'), path("login", views.login, name="login"),
path('logout', views.logout, name='logout'), path("logout", views.logout, name="logout"),
path('indie', views.IndieView.as_view(), name='indie'), path("indie", views.IndieView.as_view(), name="indie"),
path('indie/approve', views.indie_approve, name='indie_approve'), path("indie/approve", views.indie_approve, name="indie_approve"),
path('token', views.TokenView.as_view(), name='token'), path("token", views.TokenView.as_view(), name="token"),
path('tokens', views.TokensListView.as_view(), name='tokens'), path("tokens", views.TokensListView.as_view(), name="tokens"),
path('tokens/<path:client_id>', views.TokensRevokeView.as_view(), name='tokens_revoke'), path(
"tokens/<path:client_id>",
views.TokensRevokeView.as_view(),
name="tokens_revoke",
),
] ]

View file

@ -12,120 +12,114 @@ from urllib.parse import urlencode, urljoin, urlunparse, urlparse
from .. import tokens from .. import tokens
from ..models import IndieAuthCode from ..models import IndieAuthCode
breadcrumbs.add('lemonauth:indie', parent='home:index') breadcrumbs.add("lemonauth:indie", parent="home:index")
def canonical(url): def canonical(url):
if '//' not in url: if "//" not in url:
url = '//' + url url = "//" + url
(scheme, netloc, path, params, query, fragment) = urlparse(url) (scheme, netloc, path, params, query, fragment) = urlparse(url)
if not scheme or scheme == 'http': if not scheme or scheme == "http":
scheme = 'https' scheme = "https"
if not path: if not path:
path = '/' path = "/"
return urlunparse((scheme, netloc, path, params, query, fragment)) return urlunparse((scheme, netloc, path, params, query, fragment))
@method_decorator(csrf_exempt, name='dispatch') @method_decorator(csrf_exempt, name="dispatch")
class IndieView(TemplateView): class IndieView(TemplateView):
template_name = 'lemonauth/indie.html' template_name = "lemonauth/indie.html"
required_params = ('client_id', 'redirect_uri') required_params = ("client_id", "redirect_uri")
@method_decorator(login_required) @method_decorator(login_required)
@method_decorator(render_to(template_name)) @method_decorator(render_to(template_name))
def get(self, request): def get(self, request):
params = request.GET.dict() params = request.GET.dict()
params.setdefault('response_type', 'id') params.setdefault("response_type", "id")
for param in self.required_params: for param in self.required_params:
if param not in params: if param not in params:
return utils.bad_req( return utils.bad_req("parameter {0} is required".format(param))
'parameter {0} is required'.format(param)
)
me = request.user.full_url me = request.user.full_url
if 'me' in params: if "me" in params:
param_me = canonical(params['me']) param_me = canonical(params["me"])
if me != param_me: if me != param_me:
return utils.forbid( return utils.forbid(
'you are logged in as {}, not as {}'.format(me, param_me) "you are logged in as {}, not as {}".format(me, param_me)
) )
redirect_uri = urljoin(params['client_id'], params['redirect_uri']) redirect_uri = urljoin(params["client_id"], params["redirect_uri"])
type = params['response_type'] type = params["response_type"]
if type not in ('id', 'code'): if type not in ("id", "code"):
return utils.bad_req( return utils.bad_req("unknown response_type: {0}".format(type))
'unknown response_type: {0}'.format(type)
)
scopes = () scopes = ()
if type == 'code': if type == "code":
if 'scope' not in params: if "scope" not in params:
return utils.bad_req( return utils.bad_req("scopes required for code type")
'scopes required for code type' scopes = params["scope"].split(" ")
)
scopes = params['scope'].split(' ')
client = requests.mf2(params['client_id']) client = requests.mf2(params["client_id"])
rels = (client.to_dict()['rel-urls'] rels = client.to_dict()["rel-urls"].get(redirect_uri, {}).get("rels", ())
.get(redirect_uri, {}) verified = "redirect_uri" in rels
.get('rels', ()))
verified = 'redirect_uri' in rels
try: 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: except IndexError:
app = None app = None
return { return {
'app': app, "app": app,
'me': me, "me": me,
'redirect_uri': redirect_uri, "redirect_uri": redirect_uri,
'verified': verified, "verified": verified,
'params': params, "params": params,
'scopes': scopes, "scopes": scopes,
'title': 'indieauth from {client_id}'.format(**params), "title": "indieauth from {client_id}".format(**params),
} }
def post(self, request): def post(self, request):
post = request.POST.dict() post = request.POST.dict()
try: try:
code = IndieAuthCode.objects.get(pk=post.get('code')) code = IndieAuthCode.objects.get(pk=post.get("code"))
except IndieAuthCode.DoesNotExist: except IndieAuthCode.DoesNotExist:
# if anything at all goes wrong when decoding the auth code, bail # if anything at all goes wrong when decoding the auth code, bail
# out immediately. # out immediately.
return utils.forbid('invalid auth code') return utils.forbid("invalid auth code")
code.delete() code.delete()
if code.expired: if code.expired:
return utils.forbid('invalid auth code') return utils.forbid("invalid auth code")
if code.response_type != 'id': if code.response_type != "id":
return utils.bad_req( return utils.bad_req("this endpoint only supports response_type=id")
'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.client_id != post.get('client_id'): if code.redirect_uri != post.get("redirect_uri"):
return utils.forbid('client id did not match') return utils.forbid("redirect uri did not match")
if code.redirect_uri != post.get('redirect_uri'):
return utils.forbid('redirect uri did not match')
# If we got here, it's valid! Yay! # If we got here, it's valid! Yay!
return utils.choose_type(request, {'me': code.me}, { return utils.choose_type(
'application/x-www-form-urlencoded': utils.form_encoded_response, request,
'application/json': JsonResponse, {"me": code.me},
}) {
"application/x-www-form-urlencoded": utils.form_encoded_response,
"application/json": JsonResponse,
},
)
@login_required @login_required
@require_POST @require_POST
def approve(request): def approve(request):
params = { params = {
'me': urljoin(utils.origin(request), request.user.url), "me": urljoin(utils.origin(request), request.user.url),
'code': tokens.gen_auth_code(request), "code": tokens.gen_auth_code(request),
} }
if 'state' in request.POST: if "state" in request.POST:
params['state'] = request.POST['state'] params["state"] = request.POST["state"]
uri = request.POST['redirect_uri'] uri = request.POST["redirect_uri"]
sep = '&' if '?' in uri else '?' sep = "&" if "?" in uri else "?"
return redirect(uri + sep + urlencode(params)) return redirect(uri + sep + urlencode(params))

View file

@ -2,11 +2,11 @@ import django.contrib.auth.views
from otp_agents.forms import OTPAuthenticationForm from otp_agents.forms import OTPAuthenticationForm
from lemoncurry import breadcrumbs 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( login = django.contrib.auth.views.LoginView.as_view(
authentication_form=OTPAuthenticationForm, authentication_form=OTPAuthenticationForm,
extra_context={'title': 'log in'}, extra_context={"title": "log in"},
template_name='lemonauth/login.html', template_name="lemonauth/login.html",
redirect_authenticated_user=True, redirect_authenticated_user=True,
) )

View file

@ -7,41 +7,42 @@ from ..models import IndieAuthCode
from lemoncurry import utils from lemoncurry import utils
@method_decorator(csrf_exempt, name='dispatch') @method_decorator(csrf_exempt, name="dispatch")
class TokenView(View): class TokenView(View):
def get(self, req): def get(self, req):
token = tokens.auth(req) token = tokens.auth(req)
res = { res = {
'me': token.me, "me": token.me,
'client_id': token.client_id, "client_id": token.client_id,
'scope': token.scope, "scope": token.scope,
} }
return utils.choose_type(req, res) return utils.choose_type(req, res)
def post(self, req): def post(self, req):
post = req.POST post = req.POST
try: try:
code = IndieAuthCode.objects.get(pk=post.get('code')) code = IndieAuthCode.objects.get(pk=post.get("code"))
except IndieAuthCode.DoesNotExist: except IndieAuthCode.DoesNotExist:
return utils.forbid('invalid auth code') return utils.forbid("invalid auth code")
code.delete() code.delete()
if code.expired: if code.expired:
return utils.forbid('invalid auth code') return utils.forbid("invalid auth code")
if code.response_type != 'code': if code.response_type != "code":
return utils.bad_req( return utils.bad_req("this endpoint only supports response_type=code")
'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 'client_id' in post and code.client_id != post['client_id']: if code.redirect_uri != post.get("redirect_uri"):
return utils.forbid('client id did not match') return utils.forbid("redirect uri did not match")
if code.redirect_uri != post.get('redirect_uri'):
return utils.forbid('redirect uri did not match')
if 'me' in post and code.me != post['me']: if "me" in post and code.me != post["me"]:
return utils.forbid('me did not match') return utils.forbid("me did not match")
return utils.choose_type(req, { return utils.choose_type(
'access_token': tokens.gen_token(code), req,
'me': code.me, {
'scope': code.scope, "access_token": tokens.gen_token(code),
}) "me": code.me,
"scope": code.scope,
},
)

View file

@ -1,3 +1,4 @@
from requests.exceptions import RequestException
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.views.generic import TemplateView
from typing import Dict, Optional, Set from typing import Dict, Optional, Set
@ -20,15 +21,15 @@ class Client:
self.id = client_id self.id = client_id
self.count = 0 self.count = 0
self.scopes = set() self.scopes = set()
apps = mf2(self.id).to_dict(filter_by_type='h-x-app')
try: try:
self.app = apps[0]['properties'] apps = mf2(self.id).to_dict(filter_by_type="h-x-app")
except IndexError: self.app = apps[0]["properties"]
except (RequestException, IndexError):
self.app = None self.app = None
class TokensListView(LoginRequiredMixin, TemplateView): class TokensListView(LoginRequiredMixin, TemplateView):
template_name = 'lemonauth/tokens.html' template_name = "lemonauth/tokens.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -36,6 +37,6 @@ class TokensListView(LoginRequiredMixin, TemplateView):
for token in self.request.user.token_set.all(): for token in self.request.user.token_set.all():
client = clients[token.client_id] client = clients[token.client_id]
client.count += 1 client.count += 1
client.scopes |= set(token.scope.split(' ')) client.scopes |= set(token.scope.split(" "))
context.update({'clients': clients, 'title': 'tokens'}) context.update({"clients": clients, "title": "tokens"})
return context return context

View file

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

View file

@ -2,6 +2,6 @@ from debug_toolbar.middleware import show_toolbar as core_show_toolbar
def show_toolbar(request): def show_toolbar(request):
if request.path.endswith('/amp'): if request.path.endswith("/amp"):
return False return False
return core_show_toolbar(request) return core_show_toolbar(request)

View file

@ -22,18 +22,22 @@ def environment(**options):
lstrip_blocks=True, lstrip_blocks=True,
**options **options
) )
env.filters.update({ env.filters.update(
'ago': ago, {
'friendly_url': friendly_url, "ago": ago,
'markdown': markdown, "friendly_url": friendly_url,
}) "markdown": markdown,
env.globals.update({ }
'entry_kinds': entry_kinds, )
'favicons': favicons, env.globals.update(
'package': load_package_json(), {
'settings': settings, "entry_kinds": entry_kinds,
'static': staticfiles_storage.url, "favicons": favicons,
'theme_color': theme_color, "package": load_package_json(),
'url': reverse, "settings": settings,
}) "static": staticfiles_storage.url,
"theme_color": theme_color,
"url": reverse,
}
)
return env return env

View file

@ -6,4 +6,4 @@ def ago(dt: datetime) -> str:
# We have to convert the datetime we get to local time first, because ago # We have to convert the datetime we get to local time first, because ago
# just strips the timezone from a timezone-aware datetime. # just strips the timezone from a timezone-aware datetime.
dt = dt.astimezone() dt = dt.astimezone()
return human(dt, precision=1, past_tense='{}', abbreviate=True) return human(dt, precision=1, past_tense="{}", abbreviate=True)

View file

@ -1,19 +1,21 @@
from bleach.sanitizer import Cleaner, ALLOWED_TAGS from bleach.sanitizer import Cleaner, ALLOWED_TAGS
from bleach.linkifier import LinkifyFilter from bleach.linkifier import LinkifyFilter
from jinja2 import evalcontextfilter, Markup from jinja2 import pass_eval_context
from markupsafe import Markup
TAGS = ['cite', 'code', 'p', 'pre', 'img', 'span'] TAGS = ["cite", "code", "details", "p", "pre", "img", "span", "summary"]
TAGS.extend(ALLOWED_TAGS) TAGS.extend(ALLOWED_TAGS)
ATTRIBUTES = { ATTRIBUTES = {
'a': ('href', 'title', 'class'), "a": ["href", "title", "class"],
'img': ('alt', 'src', 'title'), "details": ["open"],
'span': ('class',), "img": ["alt", "src", "title"],
"span": ["class"],
} }
cleaner = Cleaner(tags=TAGS, attributes=ATTRIBUTES, filters=(LinkifyFilter,)) cleaner = Cleaner(tags=TAGS, attributes=ATTRIBUTES, filters=(LinkifyFilter,))
@evalcontextfilter @pass_eval_context
def bleach(ctx, html): def bleach(ctx, html):
res = cleaner.clean(html) res = cleaner.clean(html)
if ctx.autoescape: if ctx.autoescape:

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html{% block html_attr %} dir="ltr" lang="en"{% endblock %}> <html{% block html_attr %} dir="ltr" lang="en" data-bs-theme="dark"{% endblock %}>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
@ -27,10 +27,10 @@
<link rel="{{ i.rel }}" type="{{ i.mime }}" sizes="{{ i.sizes }}" href="{{ i.url }}" /> <link rel="{{ i.rel }}" type="{{ i.mime }}" sizes="{{ i.sizes }}" href="{{ i.url }}" />
{% endfor %} {% endfor %}
<link rel="stylesheet" type="text/css" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous"> integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/monokai.min.css" <link rel="stylesheet" type="text/css" href="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/monokai.min.css"
integrity="sha384-bHqbpRh/XW+phptvH9nQvMKHwPH1ZbOxpIeAB2D2OIEL4Ni7aZzZgMFpsRra+v1g" crossorigin="anonymous"> integrity="sha384-88Jvj9Q2LiBDwL7w3yciRTcH5q2zzvMFYIm4xX9/evqxJsxA33Xk9XYKcvUlPITo" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/openwebicons@1.6.0/css/openwebicons.min.css" <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"> 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" <link rel="stylesheet" type="text/css" href="https://unpkg.com/tippy.js@3.4.1/dist/tippy.css"
@ -40,12 +40,11 @@
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
{% endcompress %} {% endcompress %}
<script type="text/javascript" defer src="https://use.fontawesome.com/releases/v5.6.3/js/all.js" <script type="text/javascript" defer src="https://kit.fontawesome.com/a3aade9b41.js" crossorigin="anonymous"></script>
integrity="sha384-EIHISlAOj4zgYieurP0SdoiBYfGJKkgWedPHH4jCzpCXLmzVsw1ouK59MuUtP4a1" crossorigin="anonymous"></script>
</head> </head>
<body{% block body_attr %}{% endblock %}> <body{% block body_attr %}{% endblock %}>
<header> <header>
<nav class="navbar navbar-expand-md navbar-dark"> <nav class="navbar navbar-expand-md"><div class="container-fluid">
<a class="navbar-brand" rel="home" href="{{ url('home:index') }}">{{ request.site.name }}</a> <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" <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
@ -97,7 +96,7 @@
</div> </div>
{% endactiveurl %} {% endactiveurl %}
</nav> </div></nav>
</header> </header>
<main> <main>
@ -111,29 +110,29 @@
<nav> <nav>
<ul class="pagination"> <ul class="pagination">
{% if entries.prev %} {% if entries.has_previous() %}
<li class="page-item"> <li class="page-item">
<a class="page-link" rel="prev" href="{{ entries.prev.url }}"> <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> <i class="fas fa-step-backward" aria-hidden="true"></i> <span class="sr-only">previous page</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% for page in entries.pages %} {% for i in entries.paginator.page_range %}
{% if page.current %} {% if i == entries.number %}
<li class="page-item active"> <li class="page-item active">
<span class="page-link">{{ page.i }} <span class="sr-only">(current page)</span></span> <span class="page-link">{{ i }} <span class="sr-only">(current page)</span></span>
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ page.url }}">{{ page.i }}</a> <a class="page-link" href="{{ entries.reverse(i) }}">{{ i }}</a>
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if entries.next %} {% if entries.has_next() %}
<li class="page-item"> <li class="page-item">
<a class="page-link" rel="next" href="{{ entries.next.url }}"> <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> <i class="fas fa-step-forward" aria-hidden="true"></i> <span class="sr-only">next page</span>
</a> </a>
</li> </li>
@ -147,14 +146,14 @@
<script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous" <script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"
integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT"></script> integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" crossorigin="anonymous" <script src="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js" crossorigin="anonymous"
integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut"></script> integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" crossorigin="anonymous" <script src="https://unpkg.com/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k"></script> integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/highlight.min.js" crossorigin="anonymous" <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-BlPof9RtjBqeJFskKv3sK3dh4Wk70iKlpIe92FeVN+6qxaGUOUu+mZNpALZ+K7ya"></script> integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"></script>
<script src="https://unpkg.com/tippy.js@3.4.1/dist/tippy.standalone.min.js" crossorigin="anonymous" <script src="https://unpkg.com/tippy.js@6.3.7/dist/tippy-bundle.umd.js" crossorigin="anonymous"
integrity="sha384-x7dGoSfOWUdyPccAel9dkWte6n8GxDWbByavEixRzW0O9xvPGzg3y0qzZBwGNUw9"></script> integrity="sha384-dtMr4wkcxQWUqsJFgElu4AttgIhOsjr2vYIzP2mv0MZbD/uJ6OHxFdbgE3MOKabN"></script>
{% compress js %} {% compress js %}
<script type="text/javascript"> <script type="text/javascript">
hljs.initHighlightingOnLoad(); hljs.initHighlightingOnLoad();

View file

@ -1,16 +1,18 @@
from jinja2 import evalcontextfilter from jinja2 import pass_eval_context
from markdown import Markdown from markdown import Markdown
from .bleach import bleach from .bleach import bleach
md = Markdown(extensions=( md = Markdown(
'extra', extensions=(
'sane_lists', "extra",
'smarty', "sane_lists",
'toc', "smarty",
)) "toc",
)
)
@evalcontextfilter @pass_eval_context
def markdown(ctx, source): def markdown(ctx, source):
return bleach(ctx, md.reset().convert(source)) return bleach(ctx, md.reset().convert(source))

View file

@ -8,7 +8,9 @@ class ResponseException(Exception):
class ResponseExceptionMiddleware(MiddlewareMixin): class ResponseExceptionMiddleware(MiddlewareMixin):
def process_exception(self, request: HttpRequest, exception: Exception) -> HttpResponse: def process_exception(
self, request: HttpRequest, exception: Exception
) -> HttpResponse:
if isinstance(exception, ResponseException): if isinstance(exception, ResponseException):
return exception.response return exception.response
raise exception raise exception

View file

@ -11,7 +11,7 @@ from mf2py import Parser
class DjangoCache(BaseCache): class DjangoCache(BaseCache):
@classmethod @classmethod
def key(cls, url): def key(cls, url):
return 'req:' + sha256(url.encode('utf-8')).hexdigest() return "req:" + sha256(url.encode("utf-8")).hexdigest()
def get(self, url): def get(self, url):
key = self.key(url) key = self.key(url)
@ -45,4 +45,4 @@ def get(url):
def mf2(url): def mf2(url):
r = get(url) r = get(url)
return Parser(doc=r.text, url=url, html_parser='html5lib') return Parser(doc=r.text, url=url, html_parser="html5lib")

View file

@ -16,7 +16,7 @@ from typing import List
APPEND_SLASH = False APPEND_SLASH = False
ADMINS = [ ADMINS = [
('dani', 'dani@00dani.me'), ("dani", "dani@00dani.me"),
] ]
BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
@ -26,13 +26,13 @@ BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww' SECRET_KEY = "6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww"
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] # type: List[str] ALLOWED_HOSTS: List[str] = []
INTERNAL_IPS = ['127.0.0.1', '::1'] INTERNAL_IPS = ["127.0.0.1", "::1"]
# Settings to tighten up security - these can safely be on in dev mode too, # Settings to tighten up security - these can safely be on in dev mode too,
# since I dev using a local HTTPS server. # since I dev using a local HTTPS server.
@ -50,7 +50,7 @@ CSRF_COOKIE_SECURE = True
# Miscellanous headers to protect against attacks. # Miscellanous headers to protect against attacks.
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True SECURE_BROWSER_XSS_FILTER = True
X_FRAME_OPTIONS = 'DENY' X_FRAME_OPTIONS = "DENY"
# This technically isn't needed, since nginx doesn't let the app be accessed # This technically isn't needed, since nginx doesn't let the app be accessed
# over insecure HTTP anyway. Just for completeness! # over insecure HTTP anyway. Just for completeness!
@ -58,110 +58,104 @@ SECURE_SSL_REDIRECT = True
# We run behind nginx, so we need nginx to tell us whether we're using HTTPS or # We run behind nginx, so we need nginx to tell us whether we're using HTTPS or
# not. # not.
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'lemoncurry', "lemoncurry",
'pyup_django', "pyup_django",
"django.contrib.admin",
'django.contrib.admin', "django.contrib.admindocs",
'django.contrib.admindocs', "django.contrib.auth",
'django.contrib.auth', "django.contrib.contenttypes",
'django.contrib.contenttypes', "django.contrib.humanize",
'django.contrib.humanize', "django.contrib.sessions",
'django.contrib.sessions', "django.contrib.sites",
'django.contrib.sites', "django.contrib.sitemaps",
'django.contrib.sitemaps', "django.contrib.messages",
'django.contrib.messages', "django.contrib.staticfiles",
'django.contrib.staticfiles', "annoying",
"compressor",
'analytical', "computed_property",
'annoying', "corsheaders",
'compressor', "debug_toolbar",
'computed_property', "django_activeurl",
'corsheaders', "django_agent_trust",
'debug_toolbar', "django_extensions",
'django_activeurl', "django_otp",
'django_agent_trust', "django_otp.plugins.otp_static",
'django_extensions', "django_otp.plugins.otp_totp",
'django_otp', "django_rq",
'django_otp.plugins.otp_static', "meta",
'django_otp.plugins.otp_totp', "entries",
'django_rq', "home",
'favicon', "lemonauth",
'meta', "lemonshort",
"micropub",
'entries', "users",
'home', "webmention",
'lemonauth', "wellknowns",
'lemonshort',
'micropub',
'users',
'webmention',
'wellknowns',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware', "debug_toolbar.middleware.DebugToolbarMiddleware",
'django.middleware.http.ConditionalGetMiddleware', "django.middleware.http.ConditionalGetMiddleware",
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.admindocs.middleware.XViewMiddleware', "django.contrib.admindocs.middleware.XViewMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'corsheaders.middleware.CorsMiddleware', "corsheaders.middleware.CorsMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django_otp.middleware.OTPMiddleware', "django_otp.middleware.OTPMiddleware",
'django_agent_trust.middleware.AgentMiddleware', "django_agent_trust.middleware.AgentMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.contrib.sites.middleware.CurrentSiteMiddleware', "django.contrib.sites.middleware.CurrentSiteMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
'lemoncurry.middleware.ResponseExceptionMiddleware', "lemoncurry.middleware.ResponseExceptionMiddleware",
] ]
ROOT_URLCONF = 'lemoncurry.urls' ROOT_URLCONF = "lemoncurry.urls"
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.jinja2.Jinja2', "BACKEND": "django.template.backends.jinja2.Jinja2",
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'environment': 'lemoncurry.jinja2.environment', "environment": "lemoncurry.jinja2.environment",
}, },
}, },
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'lemoncurry.wsgi.application' WSGI_APPLICATION = "lemoncurry.wsgi.application"
# Cache # Cache
# https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-CACHES # https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-CACHES
CACHES = { CACHES = {
'default': { "default": {
'BACKEND': 'django_redis.cache.RedisCache', "BACKEND": "django_redis.cache.RedisCache",
'LOCATION': 'redis://127.0.0.1:6380/0', "LOCATION": "redis://127.0.0.1:6380/0",
'KEY_PREFIX': 'lemoncurry', "KEY_PREFIX": "lemoncurry",
'OPTIONS': { "OPTIONS": {
'PARSER_CLASS': 'redis.connection.HiredisParser', "SERIALIZER": "lemoncurry.msgpack.MSGPackModernSerializer",
'SERIALIZER': 'lemoncurry.msgpack.MSGPackModernSerializer',
}, },
'VERSION': 2, "VERSION": 2,
} }
} }
@ -169,50 +163,51 @@ CACHES = {
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases # https://docs.djangoproject.com/en/1.11/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.postgresql', "ENGINE": "django.db.backends.postgresql",
'NAME': environ.get('POSTGRES_DB', 'lemoncurry'), "NAME": environ.get("POSTGRES_DB", "lemoncurry"),
'USER': environ.get('POSTGRES_USER'), "USER": environ.get("POSTGRES_USER"),
'PASSWORD': environ.get('POSTGRES_PASSWORD'), "PASSWORD": environ.get("POSTGRES_PASSWORD"),
'HOST': environ.get('POSTGRES_HOST', 'localhost'), "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 # Password hashers
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
PASSWORD_HASHERS = [ PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher', "django.contrib.auth.hashers.Argon2PasswordHasher",
'django.contrib.auth.hashers.PBKDF2PasswordHasher', "django.contrib.auth.hashers.PBKDF2PasswordHasher",
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
'django.contrib.auth.hashers.BCryptPasswordHasher', "django.contrib.auth.hashers.BCryptPasswordHasher",
] ]
# Password validation # Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
PW_VALIDATOR_MODULE = 'django.contrib.auth.password_validation' PW_VALIDATOR_MODULE = "django.contrib.auth.password_validation"
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{'NAME': PW_VALIDATOR_MODULE + '.UserAttributeSimilarityValidator'}, {"NAME": PW_VALIDATOR_MODULE + ".UserAttributeSimilarityValidator"},
{'NAME': PW_VALIDATOR_MODULE + '.MinimumLengthValidator'}, {"NAME": PW_VALIDATOR_MODULE + ".MinimumLengthValidator"},
{'NAME': PW_VALIDATOR_MODULE + '.CommonPasswordValidator'}, {"NAME": PW_VALIDATOR_MODULE + ".CommonPasswordValidator"},
{'NAME': PW_VALIDATOR_MODULE + '.NumericPasswordValidator'}, {"NAME": PW_VALIDATOR_MODULE + ".NumericPasswordValidator"},
] ]
LOGIN_URL = 'lemonauth:login' LOGIN_URL = "lemonauth:login"
LOGIN_REDIRECT_URL = 'home:index' LOGIN_REDIRECT_URL = "home:index"
LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.11/topics/i18n/ # https://docs.djangoproject.com/en/1.11/topics/i18n/
LANGUAGE_CODE = 'en-au' LANGUAGE_CODE = "en-au"
TIME_ZONE = 'Australia/Sydney' TIME_ZONE = "Australia/Sydney"
USE_I18N = True USE_I18N = True
@ -224,21 +219,21 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/ # https://docs.djangoproject.com/en/1.11/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = "/static/"
STATIC_ROOT = path.join(BASE_DIR, 'static') STATIC_ROOT = path.join(BASE_DIR, "static")
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', "django.contrib.staticfiles.finders.FileSystemFinder",
'django.contrib.staticfiles.finders.AppDirectoriesFinder', "django.contrib.staticfiles.finders.AppDirectoriesFinder",
'compressor.finders.CompressorFinder', "compressor.finders.CompressorFinder",
) )
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
COMPRESS_PRECOMPILERS = ( COMPRESS_PRECOMPILERS = (
('text/stylus', 'npx stylus -u ./lemoncurry/static/lemoncurry/css/theme'), ("text/stylus", "npx stylus -u ./lemoncurry/static/lemoncurry/css/theme"),
) )
MEDIA_URL = STATIC_URL + 'media/' MEDIA_URL = STATIC_URL + "media/"
MEDIA_ROOT = path.join(STATIC_ROOT, 'media') MEDIA_ROOT = path.join(STATIC_ROOT, "media")
# django-contrib-sites # django-contrib-sites
# https://docs.djangoproject.com/en/dev/ref/contrib/sites/ # https://docs.djangoproject.com/en/dev/ref/contrib/sites/
@ -250,28 +245,25 @@ AGENT_COOKIE_SECURE = True
# django-cors-headers # django-cors-headers
CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_ALLOW_ALL = True
CORS_URLS_REGEX = r'^/(?!admin|auth/(?:login|logout|indie)).*$' CORS_URLS_REGEX = r"^/(?!admin|auth/(?:login|logout|indie)).*$"
# lemonshort # lemonshort
SHORT_BASE_URL = '/s/' SHORT_BASE_URL = "/s/"
SHORTEN_MODELS = { SHORTEN_MODELS = {
'e': 'entries.entry', "e": "entries.entry",
} }
# django-meta # django-meta
# https://django-meta.readthedocs.io/en/latest/settings.html # https://django-meta.readthedocs.io/en/latest/settings.html
META_SITE_PROTOCOL = 'https' META_SITE_PROTOCOL = "https"
META_USE_SITES = True META_USE_SITES = True
META_USE_OG_PROPERTIES = True META_USE_OG_PROPERTIES = True
META_USE_TWITTER_PROPERTIES = True META_USE_TWITTER_PROPERTIES = True
# django-push # django-push
# https://django-push.readthedocs.io/en/latest/publisher.html # https://django-push.readthedocs.io/en/latest/publisher.html
PUSH_HUB = 'https://00dani.superfeedr.com/' PUSH_HUB = "https://00dani.superfeedr.com/"
# django-rq # django-rq
# https://github.com/ui/django-rq # https://github.com/ui/django-rq
RQ_QUEUES = {'default': {'USE_REDIS_CACHE': 'default'}} RQ_QUEUES = {"default": {"USE_REDIS_CACHE": "default"}}
# django-super-favicon
FAVICON_STORAGE = 'django.core.files.storage.DefaultStorage'

View file

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

View file

@ -4,19 +4,19 @@ from os.path import join
from .base import * from .base import *
from .base import BASE_DIR, DATABASES from .base import BASE_DIR, DATABASES
ALLOWED_HOSTS = ['00dani.me'] ALLOWED_HOSTS = ["00dani.me"]
DEBUG = False DEBUG = False
SECRET_KEY = environ['DJANGO_SECRET_KEY'] SECRET_KEY = environ["DJANGO_SECRET_KEY"]
SERVER_EMAIL = 'lemoncurry@00dani.me' SERVER_EMAIL = "lemoncurry@00dani.me"
# Authenticate as an app-specific Postgres user in production. # Authenticate as an app-specific Postgres user in production.
DATABASES['default']['USER'] = 'lemoncurry' DATABASES["default"]["USER"] = "lemoncurry"
SHORT_BASE_URL = 'https://nya.as/' SHORT_BASE_URL = "https://nya.as/"
STATIC_ROOT = join(BASE_DIR, '..', 'static') STATIC_ROOT = join(BASE_DIR, "..", "static")
MEDIA_ROOT = join(BASE_DIR, '..', 'media') MEDIA_ROOT = join(BASE_DIR, "..", "media")
STATIC_URL = 'https://cdn.00dani.me/' STATIC_URL = "https://cdn.00dani.me/"
MEDIA_URL = STATIC_URL + 'm/' MEDIA_URL = STATIC_URL + "m/"
META_SITE_DOMAIN = '00dani.me' META_SITE_DOMAIN = "00dani.me"
META_FB_APPID = '145311792869199' META_FB_APPID = "145311792869199"

View file

@ -1,5 +1,8 @@
from .base import * from .base import *
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ["*"]
SECURE_SSL_REDIRECT = False SECURE_SSL_REDIRECT = False
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
MEDIA_URL = "/media/"
STATIC_ROOT = path.join(BASE_DIR, "media")

View file

@ -5,6 +5,7 @@ html
a a
color $base0D color $base0D
text-decoration none
&:hover &:hover
color $base0C color $base0C
@ -31,13 +32,13 @@ code, pre, .code, .pre
text-decoration none text-decoration none
line-height 1 line-height 1
for placement in top bottom left right .tippy-box[data-theme~='dark']
.tippy-popper[x-placement^={placement}] .tippy-tooltip.dark-theme .tippy-arrow
border-{placement}-color $base03
.tippy-tooltip.dark-theme
background-color $base03 background-color $base03
color $base04 color $base04
text-align center
for placement in top bottom left right
&[data-placement^={placement}] > .tippy-arrow::before
border-{placement}-color $base03
body body
display flex display flex
@ -103,6 +104,12 @@ ul.pagination
background-color $base02 background-color $base02
border 1px solid rgba(0,0,0,.125) border 1px solid rgba(0,0,0,.125)
.media
display flex
> .media-body
flex-grow 1
margin-left 3px
.card .card
background-color $base02 background-color $base02

View file

@ -8,5 +8,5 @@ register = template.Library()
@register.simple_tag @register.simple_tag
@register.filter(is_safe=True) @register.filter(is_safe=True)
def absolute_url(url): def absolute_url(url):
base = 'https://' + Site.objects.get_current().domain base = "https://" + Site.objects.get_current().domain
return urljoin(base, url) return urljoin(base, url)

View file

@ -5,12 +5,13 @@ from django.utils.safestring import mark_safe
from bleach.sanitizer import Cleaner, ALLOWED_TAGS from bleach.sanitizer import Cleaner, ALLOWED_TAGS
from bleach.linkifier import LinkifyFilter 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) tags.extend(ALLOWED_TAGS)
attributes = { attributes = {
'a': ('href', 'title', 'class'), "a": ["href", "title", "class"],
'img': ('alt', 'src', 'title'), "details": ["open"],
'span': ('class',), "img": ["alt", "src", "title"],
"span": ["class"],
} }
register = template.Library() register = template.Library()

View file

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

View file

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

View file

@ -3,12 +3,14 @@ from django import template
from markdown import Markdown from markdown import Markdown
from .bleach import bleach from .bleach import bleach
md = Markdown(extensions=( md = Markdown(
'extra', extensions=(
'sane_lists', "extra",
'smarty', "sane_lists",
'toc', "smarty",
)) "toc",
)
)
register = template.Library() register = template.Library()

View file

@ -6,43 +6,43 @@ from .. import breadcrumbs as b
@pytest.fixture @pytest.fixture
def nested_crumbs(): def nested_crumbs():
x = b.Crumb('nc.x', label='x') x = b.Crumb("nc.x", label="x")
y = b.Crumb('nc.y', label='y', parent='nc.x') y = b.Crumb("nc.y", label="y", parent="nc.x")
z = b.Crumb('nc.z', label='z', parent='nc.y') z = b.Crumb("nc.z", label="z", parent="nc.y")
crumbs = (x, y, z) crumbs = (x, y, z)
for crumb in crumbs: for crumb in crumbs:
b.breadcrumbs[crumb.route] = crumb b.breadcrumbs[crumb.route] = crumb
yield namedtuple('NestedCrumbs', 'x y z')(*crumbs) yield namedtuple("NestedCrumbs", "x y z")(*crumbs)
for crumb in crumbs: for crumb in crumbs:
del b.breadcrumbs[crumb.route] del b.breadcrumbs[crumb.route]
@pytest.fixture @pytest.fixture
def crumb_match(nested_crumbs): def crumb_match(nested_crumbs):
return namedtuple('Match', 'view_name')(nested_crumbs.z.route) return namedtuple("Match", "view_name")(nested_crumbs.z.route)
class TestAdd: class TestAdd:
def test_inserts_a_breadcrumb_without_parent(self): def test_inserts_a_breadcrumb_without_parent(self):
route = 'tests.add.insert' route = "tests.add.insert"
assert route not in b.breadcrumbs assert route not in b.breadcrumbs
b.add(route, 'some label') b.add(route, "some label")
assert route in b.breadcrumbs assert route in b.breadcrumbs
assert b.breadcrumbs[route] == route assert b.breadcrumbs[route] == route
route = b.breadcrumbs[route] route = b.breadcrumbs[route]
assert route.label == 'some label' assert route.label == "some label"
assert route.parent is None assert route.parent is None
def test_inserts_a_breadcrumb_with_parent(self): def test_inserts_a_breadcrumb_with_parent(self):
route = 'tests.add.with_parent' route = "tests.add.with_parent"
parent = 'tests.add.insert' parent = "tests.add.insert"
assert route not in b.breadcrumbs assert route not in b.breadcrumbs
b.add(route, 'child label', parent) b.add(route, "child label", parent)
assert route in b.breadcrumbs assert route in b.breadcrumbs
assert b.breadcrumbs[route] == route assert b.breadcrumbs[route] == route
route = b.breadcrumbs[route] route = b.breadcrumbs[route]
assert route.label == 'child label' assert route.label == "child label"
assert route.parent == parent assert route.parent == parent

View file

@ -5,22 +5,22 @@ from .. import utils
class TestOrigin: class TestOrigin:
def test_simple_http(self): def test_simple_http(self):
"""should return the correct origin for a vanilla HTTP site""" """should return the correct origin for a vanilla HTTP site"""
req = Mock(scheme='http', site=Mock(domain='lemoncurry.test')) req = Mock(scheme="http", site=Mock(domain="lemoncurry.test"))
assert utils.origin(req) == 'http://lemoncurry.test' assert utils.origin(req) == "http://lemoncurry.test"
def test_simple_https(self): def test_simple_https(self):
"""should return the correct origin for a vanilla HTTPS site""" """should return the correct origin for a vanilla HTTPS site"""
req = Mock(scheme='https', site=Mock(domain='secure.lemoncurry.test')) req = Mock(scheme="https", site=Mock(domain="secure.lemoncurry.test"))
assert utils.origin(req) == 'https://secure.lemoncurry.test' assert utils.origin(req) == "https://secure.lemoncurry.test"
class TestUri: class TestUri:
def test_siteroot(self): def test_siteroot(self):
"""should return correct full URI for requests to the site root""" """should return correct full URI for requests to the site root"""
req = Mock(scheme='https', path='/', site=Mock(domain='l.test')) req = Mock(scheme="https", path="/", site=Mock(domain="l.test"))
assert utils.uri(req) == 'https://l.test/' assert utils.uri(req) == "https://l.test/"
def test_path(self): def test_path(self):
"""should return correct full URI for requests with a path""" """should return correct full URI for requests with a path"""
req = Mock(scheme='https', path='/notes/23', site=Mock(domain='l.tst')) req = Mock(scheme="https", path="/notes/23", site=Mock(domain="l.tst"))
assert utils.uri(req) == 'https://l.tst/notes/23' assert utils.uri(req) == "https://l.tst/notes/23"

View file

@ -4,12 +4,14 @@ from yaml import safe_load
path = join( path = join(
settings.BASE_DIR, settings.BASE_DIR,
'lemoncurry', 'static', "lemoncurry",
'base16-materialtheme-scheme', 'material-darker.yaml', "static",
"base16-materialtheme-scheme",
"material-darker.yaml",
) )
with open(path, 'r') as f: with open(path, "r") as f:
theme = safe_load(f) theme = safe_load(f)
def color(i): def color(i):
return '#' + theme['base0' + format(i, '1X')] return "#" + theme["base0" + format(i, "1X")]

View file

@ -27,33 +27,37 @@ from entries.sitemaps import EntriesSitemap
from home.sitemaps import HomeSitemap from home.sitemaps import HomeSitemap
sections = { sections = {
'entries': EntriesSitemap, "entries": EntriesSitemap,
'home': HomeSitemap, "home": HomeSitemap,
} }
maps = {'sitemaps': sections} maps = {"sitemaps": sections}
urlpatterns = ( urlpatterns = (
path('', include('home.urls')), path("", include("home.urls")),
path('', include('entries.urls')), path("", include("entries.urls")),
path('', include('users.urls')), path("", include("users.urls")),
path('.well-known/', include('wellknowns.urls')), path(".well-known/", include("wellknowns.urls")),
path('admin/doc/', include('django.contrib.admindocs.urls')), path("admin/doc/", include("django.contrib.admindocs.urls")),
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
path('auth/', include('lemonauth.urls')), path("auth/", include("lemonauth.urls")),
path('favicon.ico', RedirectView.as_view( path(
url=settings.MEDIA_URL + 'favicon/favicon.ico')), "favicon.ico",
path('micropub', include('micropub.urls')), RedirectView.as_view(url=settings.MEDIA_URL + "favicon/favicon.ico"),
path('s/', include('lemonshort.urls')), ),
path('webmention', include('webmention.urls')), path("micropub", include("micropub.urls")),
path("s/", include("lemonshort.urls")),
path('django-rq/', include('django_rq.urls')), path("webmention", include("webmention.urls")),
path('sitemap.xml', sitemap.index, maps, name='sitemap'), path("django-rq/", include("django_rq.urls")),
path('sitemaps/<section>.xml', sitemap.sitemap, maps, path("sitemap.xml", sitemap.index, maps, name="sitemap"),
name='django.contrib.sitemaps.views.sitemap'), path(
"sitemaps/<section>.xml",
sitemap.sitemap,
maps,
name="django.contrib.sitemaps.views.sitemap",
),
) # type: Tuple[URLPattern, ...] ) # type: Tuple[URLPattern, ...]
if settings.DEBUG: if settings.DEBUG:
import debug_toolbar import debug_toolbar
urlpatterns += (
path('__debug__/', include(debug_toolbar.urls)), urlpatterns += (path("__debug__/", include(debug_toolbar.urls)),)
)

View file

@ -20,7 +20,7 @@ class PackageJson:
def load(self) -> Dict[str, Any]: def load(self) -> Dict[str, Any]:
if self.data is None: if self.data is None:
with open(join(settings.BASE_DIR, 'package.json')) as f: with open(join(settings.BASE_DIR, "package.json")) as f:
self.data = json.load(f) self.data = json.load(f)
assert self.data is not None assert self.data is not None
return self.data return self.data
@ -30,10 +30,10 @@ PACKAGE = PackageJson()
def friendly_url(url): def friendly_url(url):
if '//' not in url: if "//" not in url:
url = '//' + url url = "//" + url
(scheme, netloc, path, params, q, fragment) = urlparse(url) (scheme, netloc, path, params, q, fragment) = urlparse(url)
if path == '/': if path == "/":
return netloc return netloc
return "{}\u200B{}".format(netloc, path) return "{}\u200B{}".format(netloc, path)
@ -43,7 +43,7 @@ def load_package_json() -> Dict[str, Any]:
def origin(request): def origin(request):
return '{0}://{1}'.format(request.scheme, request.site.domain) return "{0}://{1}".format(request.scheme, request.site.domain)
def absolute_url(request, url): def absolute_url(request, url):
@ -56,19 +56,18 @@ def uri(request):
def form_encoded_response(content): def form_encoded_response(content):
return HttpResponse( return HttpResponse(
urlencode(content), urlencode(content), content_type="application/x-www-form-urlencoded"
content_type='application/x-www-form-urlencoded'
) )
REPS = { REPS = {
'application/x-www-form-urlencoded': form_encoded_response, "application/x-www-form-urlencoded": form_encoded_response,
'application/json': JsonResponse, "application/json": JsonResponse,
} }
def choose_type(request, content, reps=REPS): def choose_type(request, content, reps=REPS):
accept = request.META.get('HTTP_ACCEPT', '*/*') accept = request.META.get("HTTP_ACCEPT", "*/*")
type = get_best_match(accept, reps.keys()) type = get_best_match(accept, reps.keys())
if type: if type:
return reps[type](content) return reps[type](content)
@ -76,11 +75,11 @@ def choose_type(request, content, reps=REPS):
def bad_req(message): def bad_req(message):
return HttpResponseBadRequest(message, content_type='text/plain') return HttpResponseBadRequest(message, content_type="text/plain")
def forbid(message): def forbid(message):
return HttpResponseForbidden(message, content_type='text/plain') return HttpResponseForbidden(message, content_type="text/plain")
def to_plain(md): def to_plain(md):

View file

@ -1 +0,0 @@
default_app_config = 'lemonshort.apps.LemonshortConfig'

View file

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

View file

@ -7,9 +7,11 @@ chars = ascii_uppercase + ascii_lowercase
conv = BaseConverter(chars) conv = BaseConverter(chars)
def abc_to_id(abc): class AbcIdConverter:
return int(conv.decode(abc)) regex = "[a-zA-Z]+"
def to_python(self, value: str) -> int:
return int(conv.decode(value))
def id_to_abc(id): def to_url(self, value: int) -> str:
return conv.encode(id) return conv.encode(value)

View file

@ -2,7 +2,7 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from typing import Any, Dict, Type from typing import Any, Dict, Type
from .convert import id_to_abc from .convert import AbcIdConverter
prefixes = {} # type: Dict[Type[Any], str] prefixes = {} # type: Dict[Type[Any], str]
@ -11,7 +11,7 @@ def short_url(entity):
if not prefixes: if not prefixes:
for k, m in settings.SHORTEN_MODELS.items(): for k, m in settings.SHORTEN_MODELS.items():
prefixes[apps.get_model(m)] = k prefixes[apps.get_model(m)] = k
base = '/' base = "/"
if hasattr(settings, 'SHORT_BASE_URL'): if hasattr(settings, "SHORT_BASE_URL"):
base = settings.SHORT_BASE_URL base = settings.SHORT_BASE_URL
return base + prefixes[type(entity)] + id_to_abc(entity.id) return base + prefixes[type(entity)] + AbcIdConverter().to_url(entity.id)

View file

@ -1,30 +1,32 @@
from .. import convert from .. import convert
def test_abc_to_id(): def test_to_python():
samples = { samples = {
'A': 0, "A": 0,
'B': 1, "B": 1,
'Y': 24, "Y": 24,
'a': 26, "a": 26,
'b': 27, "b": 27,
'y': 50, "y": 50,
'BA': 52, "BA": 52,
'BAB': 2705, "BAB": 2705,
} }
converter = convert.AbcIdConverter()
for abc, id in samples.items(): for abc, id in samples.items():
assert convert.abc_to_id(abc) == id assert converter.to_python(abc) == id
def test_id_to_abc(): def test_id_to_abc():
samples = { samples = {
1: 'B', 1: "B",
24: 'Y', 24: "Y",
26: 'a', 26: "a",
52: 'BA', 52: "BA",
78: 'Ba', 78: "Ba",
104: 'CA', 104: "CA",
130: 'Ca', 130: "Ca",
} }
converter = convert.AbcIdConverter()
for id, abc in samples.items(): for id, abc in samples.items():
assert convert.id_to_abc(id) == abc assert converter.to_url(id) == abc

View file

@ -1,10 +1,13 @@
from django.conf import settings from django.conf import settings
from django.urls import path from django.urls import path, register_converter
from .convert import AbcIdConverter
from .views import unshort from .views import unshort
app_name = 'lemonshort' register_converter(AbcIdConverter, "abc_id")
app_name = "lemonshort"
urlpatterns = tuple( urlpatterns = tuple(
path('{0!s}<tiny>'.format(k), unshort, name=m, kwargs={'model': m}) path("{0!s}<abc_id:tiny>".format(k), unshort, name=m, kwargs={"model": m})
for k, m in settings.SHORTEN_MODELS.items() for k, m in settings.SHORTEN_MODELS.items()
) )

View file

@ -1,9 +1,9 @@
from django.apps import apps from django.apps import apps
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from .convert import abc_to_id from .convert import AbcIdConverter
def unshort(request, model, tiny): def unshort(request, model, tiny):
entity = get_object_or_404(apps.get_model(model), pk=abc_to_id(tiny)) entity = get_object_or_404(apps.get_model(model), pk=tiny)
return redirect(entity, permanent=True) return redirect(entity, permanent=True)

View file

@ -1 +0,0 @@
default_app_config = 'micropub.apps.MicropubConfig'

View file

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

View file

@ -4,33 +4,35 @@ from typing import Optional
def forbidden() -> ResponseException: def forbidden() -> ResponseException:
return res('forbidden', 403) return res("forbidden", 403)
def unauthorized() -> ResponseException: def unauthorized() -> ResponseException:
return res('unauthorized', 401) return res("unauthorized", 401)
def bad_req(msg: str) -> ResponseException: def bad_req(msg: str) -> ResponseException:
return res('invalid_request', msg=msg) return res("invalid_request", msg=msg)
def bad_type(type: str) -> ResponseException: def bad_type(type: str) -> ResponseException:
msg = 'unsupported request type {0}'.format(type) msg = "unsupported request type {0}".format(type)
return res('invalid_request', 415, msg) return res("invalid_request", 415, msg)
def bad_scope(scope: str) -> ResponseException: def bad_scope(scope: str) -> ResponseException:
return res('insufficient_scope', 401, scope=scope) return res("insufficient_scope", 401, scope=scope)
def res(error: str, def res(
status: Optional[int]=400, error: str,
msg: Optional[str]=None, status: Optional[int] = 400,
scope: Optional[str]=None): msg: Optional[str] = None,
content = {'error': error} scope: Optional[str] = None,
):
content = {"error": error}
if msg is not None: if msg is not None:
content['error_description'] = msg content["error_description"] = msg
if scope: if scope:
content['scope'] = scope content["scope"] = scope
return ResponseException(JsonResponse(content, status=status)) return ResponseException(JsonResponse(content, status=status))

View file

@ -2,8 +2,8 @@ from django.urls import path
from .views import micropub from .views import micropub
from .views.media import media from .views.media import media
app_name = 'micropub' app_name = "micropub"
urlpatterns = ( urlpatterns = (
path('', micropub, name='micropub'), path("", micropub, name="micropub"),
path('/media', media, name='media'), path("/media", media, name="media"),
) )

View file

@ -10,22 +10,22 @@ from .delete import delete
from .query import query from .query import query
actions = { actions = {
'create': create, "create": create,
'delete': delete, "delete": delete,
} }
@csrf_exempt @csrf_exempt
@require_http_methods(['GET', 'HEAD', 'POST']) @require_http_methods(["GET", "HEAD", "POST"])
def micropub(request): def micropub(request):
request.token = tokens.auth(request) request.token = tokens.auth(request)
if request.method in ('GET', 'HEAD'): if request.method in ("GET", "HEAD"):
return query(request) return query(request)
action = request.POST.get('action', 'create') action = request.POST.get("action", "create")
if request.content_type == 'application/json': if request.content_type == "application/json":
request.json = json.load(request) request.json = json.load(request)
action = request.json.get('action', 'create') action = request.json.get("action", "create")
if action not in actions: if action not in actions:
raise error.bad_req('unknown action: {}'.format(action)) raise error.bad_req("unknown action: {}".format(action))
return actions[action](request) return actions[action](request)

View file

@ -14,63 +14,62 @@ def form_to_mf2(request):
properties = {} properties = {}
post = request.POST post = request.POST
for key in post.keys(): for key in post.keys():
if key.endswith('[]'): if key.endswith("[]"):
key = key[:-2] key = key[:-2]
if key == 'access_token': if key == "access_token":
continue continue
properties[key] = post.getlist(key) + post.getlist(key + '[]') properties[key] = post.getlist(key) + post.getlist(key + "[]")
type = [] type = []
if 'h' in properties: if "h" in properties:
type = ['h-' + p for p in properties['h']] type = ["h-" + p for p in properties["h"]]
del properties['h'] del properties["h"]
return {'type': type, 'properties': properties} return {"type": type, "properties": properties}
def create(request): def create(request):
normalise = { normalise = {
'application/json': lambda r: r.json, "application/json": lambda r: r.json,
'application/x-www-form-urlencoded': form_to_mf2, "application/x-www-form-urlencoded": form_to_mf2,
} }
if 'create' not in request.token: if "create" not in request.token:
raise error.bad_scope('create') raise error.bad_scope("create")
if request.content_type not in normalise: if request.content_type not in normalise:
raise error.unsupported_type(request.content_type) raise error.unsupported_type(request.content_type)
body = normalise[request.content_type](request) body = normalise[request.content_type](request)
if 'type' not in body: if "type" not in body:
raise error.bad_req('mf2 object type required') raise error.bad_req("mf2 object type required")
if body['type'] != ['h-entry']: if body["type"] != ["h-entry"]:
raise error.bad_req('only h-entry supported') raise error.bad_req("only h-entry supported")
entry = Entry(author=request.token.user) entry = Entry(author=request.token.user)
props = body.get('properties', {}) props = body.get("properties", {})
kind = Note kind = Note
if 'name' in props: if "name" in props:
entry.name = '\n'.join(props['name']) entry.name = "\n".join(props["name"])
kind = Article kind = Article
if 'content' in props: if "content" in props:
entry.content = '\n'.join( entry.content = "\n".join(
c if isinstance(c, str) else c['html'] c if isinstance(c, str) else c["html"] for c in props["content"]
for c in props['content']
) )
if 'in-reply-to' in props: if "in-reply-to" in props:
entry.in_reply_to = props['in-reply-to'] entry.in_reply_to = props["in-reply-to"]
kind = Reply kind = Reply
if 'like-of' in props: if "like-of" in props:
entry.like_of = props['like-of'] entry.like_of = props["like-of"]
kind = Like kind = Like
if 'repost-of' in props: if "repost-of" in props:
entry.repost_of = props['repost-of'] entry.repost_of = props["repost-of"]
kind = Repost kind = Repost
cats = [Cat.objects.from_name(c) for c in props.get('category', [])] cats = [Cat.objects.from_name(c) for c in props.get("category", [])]
entry.kind = kind.id entry.kind = kind.id
entry.save() entry.save()
entry.cats.set(cats) entry.cats.set(cats)
entry.save() entry.save()
for url in props.get('syndication', []): for url in props.get("syndication", []):
entry.syndications.create(url=url) entry.syndications.create(url=url)
base = utils.origin(request) base = utils.origin(request)
@ -80,6 +79,6 @@ def create(request):
send_mentions.delay(perma) send_mentions.delay(perma)
res = HttpResponse(status=201) res = HttpResponse(status=201)
res['Location'] = perma res["Location"] = perma
res['Link'] = '<{}>; rel="shortlink"'.format(short) res["Link"] = '<{}>; rel="shortlink"'.format(short)
return res return res

View file

@ -6,24 +6,25 @@ from entries.jobs import ping_hub, send_mentions
from .. import error from .. import error
def delete(request): def delete(request):
normalise = { normalise = {
'application/json': lambda r: r.json.get('url'), "application/json": lambda r: r.json.get("url"),
'application/x-www-form-urlencoded': lambda r: r.POST.get('url'), "application/x-www-form-urlencoded": lambda r: r.POST.get("url"),
} }
if 'delete' not in request.token: if "delete" not in request.token:
raise error.bad_scope('delete') raise error.bad_scope("delete")
if request.content_type not in normalise: if request.content_type not in normalise:
raise error.unsupported_type(request.content_type) raise error.unsupported_type(request.content_type)
url = normalise[request.content_type](request) url = normalise[request.content_type](request)
entry = from_url(url) entry = from_url(url)
if entry.author != request.token.user: if entry.author != request.token.user:
raise error.forbid('entry belongs to another user') raise error.forbid("entry belongs to another user")
perma = entry.absolute_url perma = entry.absolute_url
pings = entry.affected_urls pings = entry.affected_urls
mentions = webmention.findMentions(perma)['refs'] mentions = webmention.findMentions(perma)["refs"]
entry.delete() entry.delete()

View file

@ -11,9 +11,9 @@ from lemoncurry.utils import absolute_url
from .. import error from .. import error
ACCEPTED_MEDIA_TYPES = ( ACCEPTED_MEDIA_TYPES = (
'image/gif', "image/gif",
'image/jpeg', "image/jpeg",
'image/png', "image/png",
) )
@ -21,15 +21,13 @@ ACCEPTED_MEDIA_TYPES = (
@require_POST @require_POST
def media(request): def media(request):
token = tokens.auth(request) token = tokens.auth(request)
if 'file' not in request.FILES: if "file" not in request.FILES:
raise error.bad_req( raise error.bad_req(
"a file named 'file' must be provided to the media endpoint" "a file named 'file' must be provided to the media endpoint"
) )
file = request.FILES['file'] file = request.FILES["file"]
if file.content_type not in ACCEPTED_MEDIA_TYPES: if file.content_type not in ACCEPTED_MEDIA_TYPES:
raise error.bad_req( raise error.bad_req("unacceptable file type {0}".format(file.content_type))
'unacceptable file type {0}'.format(file.content_type)
)
mime = None mime = None
sha = hashlib.sha256() sha = hashlib.sha256()
@ -40,14 +38,15 @@ def media(request):
if mime != file.content_type: if mime != file.content_type:
raise error.bad_req( raise error.bad_req(
'detected file type {0} did not match specified file type {1}' "detected file type {0} did not match specified file type {1}".format(
.format(mime, file.content_type) mime, file.content_type
)
) )
path = 'mp/{0[0]}/{2}.{1}'.format(*mime.split('/'), sha.hexdigest()) path = "mp/{0[0]}/{2}.{1}".format(*mime.split("/"), sha.hexdigest())
path = store.save(path, file) path = store.save(path, file)
url = absolute_url(request, store.url(path)) url = absolute_url(request, store.url(path))
res = HttpResponse(status=201) res = HttpResponse(status=201)
res['Location'] = url res["Location"] = url
return res return res

View file

@ -7,48 +7,47 @@ from lemoncurry.utils import absolute_url
from .. import error from .. import error
def config(request): def config(request):
config = syndicate_to(request) config = syndicate_to(request)
config['media-endpoint'] = absolute_url(request, reverse('micropub:media')) config["media-endpoint"] = absolute_url(request, reverse("micropub:media"))
return config return config
def source(request): def source(request):
if 'url' not in request.GET: if "url" not in request.GET:
raise error.bad_req('must specify url parameter for source query') raise error.bad_req("must specify url parameter for source query")
entry = from_url(request.GET['url']) entry = from_url(request.GET["url"])
props = {} props = {}
keys = set(request.GET.getlist('properties') + request.GET.getlist('properties[]')) keys = set(request.GET.getlist("properties") + request.GET.getlist("properties[]"))
if not keys or 'content' in keys: if not keys or "content" in keys:
props['content'] = [entry.content] props["content"] = [entry.content]
if (not keys or 'category' in keys) and entry.cats.exists(): if (not keys or "category" in keys) and entry.cats.exists():
props['category'] = [cat.name for cat in entry.cats.all()] props["category"] = [cat.name for cat in entry.cats.all()]
if (not keys or 'name' in keys) and entry.name: if (not keys or "name" in keys) and entry.name:
props['name'] = [entry.name] props["name"] = [entry.name]
if (not keys or 'syndication' in keys) and entry.syndications.exists(): if (not keys or "syndication" in keys) and entry.syndications.exists():
props['syndication'] = [synd.url for synd in entry.syndications.all()] props["syndication"] = [synd.url for synd in entry.syndications.all()]
return {'type': ['h-entry'], 'properties': props} return {"type": ["h-entry"], "properties": props}
def syndicate_to(request): def syndicate_to(request):
return {'syndicate-to': []} return {"syndicate-to": []}
queries = { queries = {
'config': config, "config": config,
'source': source, "source": source,
'syndicate-to': syndicate_to, "syndicate-to": syndicate_to,
} }
def query(request): def query(request):
if 'q' not in request.GET: if "q" not in request.GET:
raise error.bad_req('must specify q parameter') raise error.bad_req("must specify q parameter")
q = request.GET['q'] q = request.GET["q"]
if q not in queries: if q not in queries:
raise error.bad_req('unsupported query {0}'.format(q)) raise error.bad_req("unsupported query {0}".format(q))
res = queries[q](request) res = queries[q](request)
return JsonResponse(res) return JsonResponse(res)

View file

@ -1,6 +1,6 @@
{ {
"name": "lemoncurry", "name": "lemoncurry",
"version": "1.10.2", "version": "1.12.4",
"repository": "https://git.00dani.me/00dani/lemoncurry", "repository": "https://git.00dani.me/00dani/lemoncurry",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {

1156
pdm.lock Normal file

File diff suppressed because it is too large Load diff

78
pyproject.toml Normal file
View file

@ -0,0 +1,78 @@
[project]
name = "lemoncurry"
version = "1.12.5"
description = "Indieweb-compatible personal website"
authors = [
{name = "Danielle McLean", email = "dani@00dani.me"},
]
license = {text = "MIT"}
requires-python = "<4.0,>=3.12"
dependencies = [
"accept-types",
"ago",
"argon2-cffi",
"bleach",
"cachecontrol",
"django<4,>=3",
"django-activeurl",
"django-annoying",
"django-compressor",
"django-computed-property",
"django-cors-headers",
"django-debug-toolbar",
"django-extensions",
"django-meta",
"django-model-utils",
"django-otp",
"django-otp-agents",
"django-push",
"django-randomslugfield",
"django-redis",
"django-rq",
"docutils",
"gevent",
"gunicorn[gevent]",
"hiredis",
"jinja2",
"markdown",
"mf2py",
"mf2util",
"msgpack",
"pillow",
"psycopg2-binary",
"python-baseconv",
"python-magic",
"python-slugify",
"pyup-django",
"pyyaml",
"qrcode",
"ronkyuu",
"xrd",
"greenlet",
]
[tool.pdm.dev-dependencies]
dev = [
"mypy",
"ptpython",
"pytest-django",
"types-bleach",
"types-markdown",
"types-python-slugify",
"types-pyyaml",
"types-requests",
"watchdog",
"werkzeug",
]
[tool.pdm.build]
includes = []
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

View file

@ -1 +0,0 @@
default_app_config = 'users.apps.UsersConfig'

View file

@ -1,14 +1,14 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import Key, Profile, Site, User from .models import PgpKey, Profile, Site, User
class SiteAdmin(admin.ModelAdmin): class SiteAdmin(admin.ModelAdmin):
list_display = ('name', 'icon', 'domain', 'url_template') list_display = ("name", "icon", "domain", "url_template")
class KeyInline(admin.TabularInline): class PgpKeyInline(admin.TabularInline):
model = Key model = PgpKey
extra = 1 extra = 1
@ -19,10 +19,11 @@ class ProfileInline(admin.TabularInline):
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
fieldsets = BaseUserAdmin.fieldsets + ( fieldsets = BaseUserAdmin.fieldsets + (
('Profile', {'fields': ('avatar', 'xmpp', 'note')}), ("Profile", {"fields": ("avatar", "xmpp", "note")}),
("Nostr", {"fields": ("nostr_key", "nostr_relays")}),
) )
inlines = ( inlines = (
KeyInline, PgpKeyInline,
ProfileInline, ProfileInline,
) )

View file

@ -2,5 +2,5 @@ from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
name = 'users' name = "users"
verbose_name = 'Users and Profiles' verbose_name = "Users and Profiles"

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