Compare commits

...

111 commits
v1.9.9 ... main

Author SHA1 Message Date
Danielle McLean 880b899e81
Update Highlight.js to 11.9.0 2024-03-13 19:14:35 +11:00
Danielle McLean 6061d6f600
Update Tippy.js to v6.3.7 2024-03-13 19:12:36 +11:00
Danielle McLean a680a6501c
Remove defunct oEmbed converter service 2024-03-13 19:04:35 +11:00
Danielle McLean 625b5d963a
Remove unused django-analytical plugin 2024-03-13 19:03:58 +11:00
Danielle McLean 9d11cc7576
Swap from Poetry to PDM 2024-03-13 18:10:51 +11:00
Danielle McLean 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
Danielle McLean 7696ff45db
Upgrade to Font Awesome v6 2024-03-13 16:57:00 +11:00
Danielle McLean 731f177d18
Bump package versions to get stuff working again 2024-03-13 15:58:54 +11:00
Danielle McLean 0061111ad8
Ensure User.avatar is optional 2024-03-13 15:58:24 +11:00
Danielle McLean 6b53c00d7c
Remove deprecated reference to HiredisParser 2024-03-13 15:54:42 +11:00
Danielle McLean 1490a95735
Fix submodule not to use deprecated git:// protocol 2024-03-13 15:28:37 +11:00
Danielle McLean c398b0d3f4
v1.12.4 2023-08-16 11:52:59 +10:00
Danielle McLean 95cca433bc
Catch all errors from requests, not just HTTPError 2023-08-16 11:52:43 +10:00
Danielle McLean 4f081c8d34
v1.12.3 2023-08-16 11:48:40 +10:00
Danielle McLean 8386f77d72
Gracefully handle failure to fetch h-x-app 2023-08-16 11:47:00 +10:00
Danielle McLean 03956637be
v1.12.2 2023-08-10 19:33:08 +10:00
Danielle McLean 60bdaa27a0
Update Nostr name aliases to match prod username 2023-08-10 19:32:41 +10:00
Danielle McLean a6fa7ebb3a
v1.12.1 2023-08-10 19:29:19 +10:00
Danielle McLean d0bd6c1231
Expand Nostr key field to 64 chars (32 hex bytes) 2023-08-10 19:28:04 +10:00
Danielle McLean 960e64963f
Explicitly install greenlet for prod usage 2023-08-10 19:26:47 +10:00
Danielle McLean 0b1a548ee4
1.12.0 2023-08-10 18:11:16 +10:00
Danielle McLean 04bd6dd35d
Add NIP-05 verification compatibility 2023-08-10 18:05:46 +10:00
Danielle McLean 2e7d12b3e6
Run Black over the whole codebase 2023-08-10 16:52:37 +10:00
Danielle McLean cd990e4e2f
Rename Key to PgpKey, so other keys can fit too 2023-08-10 16:50:35 +10:00
Danielle McLean fe187da491
Update pre-commit hooks 2023-08-10 16:48:42 +10:00
Danielle McLean 636b470001
Remove unused Pipenv package files 2023-08-10 16:32:44 +10:00
Danielle McLean e5cf94d488
Remove favicons package that doesn't currently work 2023-08-10 16:32:06 +10:00
Danielle McLean c5458c2d06
Migrate to Poetry rather than Pipenv 2023-08-10 16:30:06 +10:00
Danielle McLean 7af8636687
Drop super-favicon, incompatible with newer Django 2023-08-10 16:29:20 +10:00
Danielle McLean 5ac46dad63
Whoops, fix Pipfile.lock hash 2022-04-29 14:57:55 +10:00
Danielle McLean 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
Danielle McLean db0d6e28a3
makemigrations for minor tweaks to User and Entry 2022-03-12 15:27:59 +11:00
Danielle McLean 2f8d62649e
1.11.0 2022-03-12 15:16:43 +11:00
Danielle McLean 683adc1b46 Use proper path converter for lemonshort 2022-03-12 15:04:05 +11:00
Danielle McLean cfeb206154 Fix dev settings to use .lo instead of .dev 2022-03-12 15:03:26 +11:00
Danielle McLean c5c0f4258b Set DEFAULT_AUTO_FIELD to AutoField 2022-03-12 15:03:01 +11:00
Danielle McLean 73addc2f75 Remove unncessary default_app_config settings 2022-03-12 15:02:26 +11:00
Danielle McLean 0ca50252dd
Add mypy types for libraries that have them now 2022-02-22 12:35:38 +11:00
Danielle McLean 8d79be07da
Do a pipenv update to get patched Django again lol 2022-02-22 12:33:31 +11:00
Danielle McLean 37d5a7a20d
Do a pipenv update to get patched Django 2021-08-22 23:24:58 +10:00
Danielle McLean 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
Danielle McLean 7fcc3c8788
1.10.3 2019-05-06 08:55:11 +10:00
Danielle McLean 4436db7d83
Bump up Font Awesome to 5.8.1 2019-05-06 08:46:06 +10:00
Danielle McLean d017c642eb
Bump up Bootstrap to 4.3.1 2019-05-06 08:44:21 +10:00
Danielle McLean 7c5f311af9 Merge branch 'details' of BenLubar/lemoncurry into master 2019-05-05 18:36:52 -04:00
Ben Lubar 73155f399b
allow details tags 2019-05-05 00:12:49 -05:00
Danielle McLean e540f7b784
Do a yarn upgrade c: 2019-01-25 10:21:25 +11:00
Danielle McLean 0e8f816d0e
Remove deprecated pre-commit hook autopep8-wrapper 2019-01-24 12:29:38 +11:00
Danielle McLean 1bf0d8478a
Placate a deprecation warning from Django by importing 'static' from a different module 2019-01-24 12:28:55 +11:00
Danielle McLean 594947852f
1.10.2 2019-01-17 12:29:22 +11:00
Danielle McLean b318ed5b06
Fix broken Tippy tooltips caused by changes to Tippy's API in version 3 2019-01-17 12:29:16 +11:00
Danielle McLean 012aed42b1
1.10.1 2019-01-17 11:55:56 +11:00
Danielle McLean 5c10bafb7d
Bump up the versions of Highlight.js, Tippy.js, and OpenWebIcons as well 2019-01-17 11:55:47 +11:00
Danielle McLean e660221265
Upgrade Font Awesome to 5.6.3 2019-01-17 11:48:40 +11:00
Danielle McLean e23ca7d215
Upgrade Bootstrap to 4.2.1 2019-01-17 11:47:53 +11:00
Danielle McLean 95b02269bb
Perform pre-commit autoupdate 2019-01-17 11:44:15 +11:00
Danielle McLean ce07ba8cdc
Perform a pipenv update since everything is old 2019-01-17 11:43:30 +11:00
Danielle McLean 17e5c2c1b4
Remove calls to as_meta since the info is ignored anyway - gives a decent performance boost :o 2018-07-12 21:00:52 +10:00
Danielle McLean 4fd2ff826a
Support Libravatar matching by OpenID URL as well as by email address 2018-07-11 13:13:12 +10:00
Danielle McLean 6efcc450a3
Fix the tests by ensuring lemoncurry.settings.test is always loaded regardless of the environment 2018-07-11 13:06:40 +10:00
Danielle McLean dc7442cfb6
Add a migration which just sets help text on users.User fields 2018-07-11 13:02:25 +10:00
Danielle McLean 9c708b8c89
Don't preload_app when running with Gunicorn since apparently that breaks database access 2018-07-11 13:01:00 +10:00
Danielle McLean 40f0bd858b
Stop pooling Postgres connections, because Django's pool isn't thread-safe and breaks under gevent 2018-07-10 15:25:01 +10:00
Danielle McLean 639e1ec9c6
Add Gunicorn config file so we can use server hooks 2018-07-05 11:09:35 +10:00
Danielle McLean a35072bbc3
1.10.0 2018-07-05 09:06:27 +10:00
Danielle McLean da5ca5edea
Ignore the .env file, since I wanna use it 2018-07-03 16:07:15 +10:00
Danielle McLean 1e4df2d1b5
Implement the Micropub source query internally rather than by simply parsing the visible content 2018-07-03 10:18:24 +10:00
Danielle McLean d68dda85ad
Refactor the Micropub error responses into a non-view module, have them produce an immediately raise-able exception 2018-07-03 10:03:35 +10:00
Danielle McLean 065619772e
Use ResponseException for various places rather than needing to check the return value for responseness 2018-07-03 09:51:51 +10:00
Danielle McLean 7d17a92793
Introduce a middleware that allows for HttpResponses to be thrown from inner utility functions, to avoid boilerplate in views 2018-07-03 09:41:00 +10:00
Danielle McLean 1d4be082cf
Refactor the 'find an entry based on a URL' behaviour into a utility function 2018-07-03 09:19:50 +10:00
Danielle McLean 2d643b48c6
Allow GIFs to be sent to the Micropub media endpoint 2018-07-03 08:45:45 +10:00
Danielle McLean bab7097fa3
Properly send webmentions after deleting an entry :3 2018-07-02 15:30:32 +10:00
Danielle McLean fa8419976d
Enable support for deleting entries through Micropub :D 2018-07-02 15:08:13 +10:00
Danielle McLean 427dcde672
Make lots of improvements to the narrow-screen layout 2018-07-01 15:26:55 +10:00
Danielle McLean 580c61e924
Adjust sizing of p-author photo and spacing inside entries 2018-07-01 15:01:14 +10:00
Danielle McLean 6c9b6eb061
Shrink the precision of 'ago' datetimes so they stay compact 2018-07-01 14:56:24 +10:00
Danielle McLean 6d7b5db482
Restore favicon links in the page <head> 2018-07-01 14:53:21 +10:00
Danielle McLean 8a0c24a9b5
Run pipenv lock to downgrade PyYAML to 3.12, since 4.1 was removed from PyPI 2018-07-01 14:45:20 +10:00
Danielle McLean c8e0b9c5fb
Save any provided syndications for a new entry when creating it 2018-06-28 21:07:24 +10:00
Danielle McLean 556329d5fa
Have syndications infer the correct Site from their URL rather than require an explicit Profile reference 2018-06-28 20:51:43 +10:00
Danielle McLean ac22c826cb
Canonicalise the 'me' parameter better, so if I just enter the bare domain it'll work fine 2018-06-28 20:11:37 +10:00
Danielle McLean 0adc7a0d5e
Handle cats and syndications with better wrapping behaviour - &nbsp; doesn't work as well as I'd hoped 2018-06-28 16:37:19 +10:00
Danielle McLean f7d7936499
Whoops, make sure ago actually emits the /correct/ relative timestamps using the right timezone 2018-06-28 13:03:53 +10:00
Danielle McLean c8faa30724
Switch to another relative-date-formatting library which supports tiny abbreviated formats 2018-06-28 12:57:09 +10:00
Danielle McLean 0d1d102f47
Lots of spacing adjustments so that the new entry layout doesn't suck on mobile 2018-06-28 12:25:22 +10:00
Danielle McLean cf0264b5a6
Allow info fields to wrap if long, rather than squish the main content of the entry 2018-06-28 12:10:19 +10:00
Danielle McLean 6054accc54
Force individual cats and syndications not to wrap, so that they don't wrap between the icon and the text 2018-06-28 11:52:10 +10:00
Danielle McLean 778a9c870d
Move cats and syndications back into the card, shrinking them down to avoid taking up too much vertical space 2018-06-28 11:48:37 +10:00
Danielle McLean dee64f130e
Switch to a less bright theme-color since base0A didn't work so well 2018-06-28 11:23:56 +10:00
Danielle McLean bc8d7923b4
Restore the <base> and rel="canonical" URLs to the layout <head> 2018-06-28 11:19:32 +10:00
Danielle McLean dec5ef153b
Set a theme-color in the template again, so mobile Chrome uses it properly 2018-06-28 11:10:56 +10:00
Danielle McLean 5cf566251a
Redesign the display of entry 'metadata', like author and category, to be way more space-efficient - should make tiny statuses less 'heavy' 2018-06-28 11:03:31 +10:00
Danielle McLean 7edc5d0165
Move the Django media URL from /media to just /m, so we get shorter overall URLs to that kinda stuff 2018-06-28 09:37:18 +10:00
Danielle McLean 35ced9a451
Whoops, only revoke the current user's Micropub tokens for a client, not every single token for that client 2018-06-28 08:38:55 +10:00
Danielle McLean 446029ce84
Add a page that lists all authorised Micropub clients and allows a client's access to be revoked easily 2018-06-25 22:31:42 +10:00
Danielle McLean bb91d3c6b6
Resilently handle IndieAuth clients that don't have a logo in their h-x-app 2018-06-25 18:01:29 +10:00
Danielle McLean b32412f4fd
Add a bunch of <link> tags I forgot about, oops 2018-06-25 13:53:11 +10:00
Danielle McLean ce0bf28725
1.9.10 2018-06-25 10:45:45 +10:00
Danielle McLean 77816b6c5d
Complete migration to Jinja2 by porting the home page template 2018-06-25 10:43:45 +10:00
Danielle McLean b145f4ada9
Render the Markdown content for entries in Jinja2 - the resulting HTML isn't pretty yet, I'll probably need to write an html5lib filter that prettifies it 2018-06-25 10:11:52 +10:00
Danielle McLean e4aa5c6e6e
Loosen the checks on IndieAuth parameters so that generic OAuth 2.0 clients like Paw.app can be used 2018-06-23 13:43:15 +10:00
Danielle McLean fa66fbbf1e
Bump Tippy.js to 2.5.3 and Font Awesome to 5.1.0 2018-06-22 12:40:30 +10:00
Danielle McLean bc433f235f
Use a mypy-friendly approach to loading and caching the package.json file 2018-06-22 12:31:03 +10:00
Danielle McLean 2a38c8d21b
Bump versions with pipenv update 2018-06-22 12:14:28 +10:00
Danielle McLean 4bc7fde36b
Oops, I accidentally used a 'ref' attribute instead of 'rel' :3 2018-06-19 16:49:41 +10:00
Danielle McLean 5042f3bda7
Port the entries-by-kind feed over to Jinja2, wasn't too tricky c: 2018-06-19 16:46:54 +10:00
Danielle McLean fca5b3259d
Run using a separate domain for static assets in dev as well, to closer match production 2018-06-19 15:58:08 +10:00
Danielle McLean ee12c15d1c
Mostly port the individual entry template to Jinja2 - the actual entry content isn't being rendered, and there's no breadcrumbs yet, but otherwise it's spot-on 2018-06-19 15:47:10 +10:00
Danielle McLean 741c2eb234
Switch from stateless JOSE tokens to stateful tokens in the DB, since they can then be much smaller and we're using a DB anyway 2018-06-12 14:57:53 +10:00
Danielle McLean 9c843ee145
Fix the repo URL syntax on the Django template as well, since it's probably gonna be sticking around for a while 2018-06-12 12:32:55 +10:00
156 changed files with 3909 additions and 2671 deletions

6
.gitignore vendored
View file

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

2
.gitmodules vendored
View file

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

View file

@ -1,31 +1,41 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v1.3.0
rev: v4.4.0
hooks:
- id: autopep8-wrapper
- id: check-byte-order-marker
- id: check-ast
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-json
- id: check-merge-conflict
- id: check-symlinks
- id: check-toml
- id: check-vcs-permalinks
- id: check-yaml
- id: destroyed-symlinks
- id: end-of-file-fixer
- id: flake8
- id: fix-byte-order-marker
- id: mixed-line-ending
args:
- --fix=lf
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 23.7.0
hooks:
- id: black
language_version: python3.11
- repo: local
hooks:
- id: pytest
name: Check pytest unit tests pass
entry: pipenv run pytest
entry: poetry run pytest
pass_filenames: false
language: system
types: [python]
- id: mypy
name: Check mypy static types match
entry: pipenv run mypy . --ignore-missing-imports
entry: poetry run mypy . --ignore-missing-imports
pass_filenames: false
language: system
types: [python]

57
Pipfile
View file

@ -1,57 +0,0 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[requires]
python_version = '3.6'
[packages]
django = "*"
django-compressor = "*"
gunicorn = {extras = ["gevent"]}
"psycopg2-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 = "*"
python-jose = "*"
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 = "*"
[dev-packages]
ptpython = "*"
pytest-django = "*"
werkzeug = "*"
watchdog = "*"
mypy = "*"

964
Pipfile.lock generated
View file

@ -1,964 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "176c601737f4eb5da6b8689846e05d8df8b5b9ef25706fba98ffac6296e3d1d2"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.python.org/simple",
"verify_ssl": true
}
]
},
"default": {
"accept-types": {
"hashes": [
"sha256:9ae86512bf3a3eaad6a2793617a34eb15b384593e6c28697bef9b15ac237017a"
],
"index": "pypi",
"version": "==0.3.0"
},
"argon2-cffi": {
"hashes": [
"sha256:05dd15949be3a7d9f65807fe58fad70526023a319747054bb89da209c4071a33",
"sha256:07480018d77f4c7447924e6c44c5ba1789a918413fe3efaa391a097958bbd9f6",
"sha256:10e702dbd98a2148d22de9524a605021bdc55d05304beb90ea801ba58c4a4f1e",
"sha256:131effd5eabbe08649bc672b5d602fd6e2772b03cfec2ddb2795f9d9babe3fba",
"sha256:3f3b48b4802e98bb9692d72108ecad2fecea969c254c17660b70ce5730bbe4a6",
"sha256:4c510232a96e991079a743a9310d3c9a014856cdbca644fccc496db2a1ff0e17",
"sha256:5f1099b0f5ee4a7148bbd323503983aa4387ab16769ff9b5c51d26f6b0f1719e",
"sha256:67452b1f10e873ececcea657c25d063e4bb4007e115227a53157369de5848992",
"sha256:77a3d50e6325df79499e1220b7c38adbd30588c2f6d7c2d764fddb2d3b02e650",
"sha256:7e4b75611b73f53012117ad21cdde7a17b32d1e99ff6799f22d827eb83a2a59b",
"sha256:7f4b6d7c38258e76c1db293a6cf55b7e31701927fc773c5108e57578c7f8e09a",
"sha256:82db759b8a495aaed51aec4762b0f44e5e7ad80256e8baf512ae70cdb3b28c50",
"sha256:92b3f8f93b19081d520d911f1ce5902693edeeab2181c08aa0bb4130adba51aa",
"sha256:93f631fa567dbf948f26874476c9e9afb51e0a835372bf1a319df0c5aa071bfb",
"sha256:9befaa6d9798d9771b8176174ba82160beaf1dcdbcc63cd2dc5212f723e5e2a3",
"sha256:a14e6d99787a2972d3802615911770fcba9c904401fb0dfb60bdeb250b4c5110",
"sha256:c60764fe7f62cc52a74f326e366c60f7aa33a1586c8d02107394a01ae9db6e91",
"sha256:cba2c8c539bed691513ae1bcd5a7da632d2aa2410d8b8ebdf56026eac7e2193f",
"sha256:d79c918cf8bf981cd23b43a1a547cd1eececb77f3607ba9fa7c0ec01bf1f05a5",
"sha256:dc3028ec541146924e3c45973b458a7acf390b9e9ee0b64a13ac0853109a69bc",
"sha256:eb3fcb55224a47b8d50830561977c64761eaad9e349af0b2241eab089af44a14",
"sha256:f732ca584e81491cc11e3d12e18cbd8c63e137b3f461f378426a6fdaaef47fb0",
"sha256:fcd5681388d1f18e4a7ee3ff7a9b68650bc04db044b5a0a832728cbce182806d"
],
"index": "pypi",
"version": "==18.1.0"
},
"beautifulsoup4": {
"hashes": [
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76",
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11",
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89"
],
"version": "==4.6.0"
},
"bleach": {
"hashes": [
"sha256:b8fa79e91f96c2c2cd9fd1f9eda906efb1b88b483048978ba62fef680e962b34",
"sha256:eb7386f632349d10d9ce9d4a838b134d4731571851149f9cc2c05a9a837a9a44"
],
"index": "pypi",
"version": "==2.1.3"
},
"cachecontrol": {
"hashes": [
"sha256:cef77effdf51b43178f6a2d3b787e3734f98ade253fa3187f3bb7315aaa42ff7"
],
"index": "pypi",
"version": "==0.12.5"
},
"certifi": {
"hashes": [
"sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
"sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
],
"version": "==2018.4.16"
},
"cffi": {
"hashes": [
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
],
"version": "==1.11.5"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
],
"version": "==6.7"
},
"django": {
"hashes": [
"sha256:3eb25c99df1523446ec2dc1b00e25eb2ecbdf42c9d8b0b8b32a204a8db9011f8",
"sha256:69ff89fa3c3a8337015478a1a0744f52a9fef5d12c1efa01a01f99bcce9bf10c"
],
"index": "pypi",
"version": "==2.0.6"
},
"django-activeurl": {
"hashes": [
"sha256:ad5498bf589afaa117fe1c80d1a4fdbef29185cee47517254cd8f273b8a0140d",
"sha256:ebb3f2746fdc76fee2095b75cad713e746378393c6c2b8e36455919a780acd50"
],
"index": "pypi",
"version": "==0.1.12"
},
"django-agent-trust": {
"hashes": [
"sha256:f0ded1c2e1e8b06ea050f48c8db931ab7d2f85c566065dceb8e827d0690d87c5",
"sha256:f7e24d3f50a0727c6a70d671778de3cca23a0c87bedc6e3dae1f61af7e759fc1"
],
"version": "==0.3.1"
},
"django-analytical": {
"hashes": [
"sha256:44dd65e30a3f11519852d5f5e50556c0f88cabb5720a2fd3637621952048abef",
"sha256:cf7b4c0b368139a090da2b0b45741bdd28b54daa0cb2b83ef801021c8eb2c050"
],
"index": "pypi",
"version": "==2.4.0"
},
"django-annoying": {
"hashes": [
"sha256:93f54d244d453cba28d6cfb9deae7bba27e859762adee9ff7de4706017940931",
"sha256:ee620f9bfe439061010c7d5ccc8c69514844d95c370c130c61938205fcfc4cc9"
],
"index": "pypi",
"version": "==0.10.4"
},
"django-appconf": {
"hashes": [
"sha256:6a4d9aea683b4c224d97ab8ee11ad2d29a37072c0c6c509896dd9857466fb261",
"sha256:ddab987d14b26731352c01ee69c090a4ebfc9141ed223bef039d79587f22acd9"
],
"version": "==1.0.2"
},
"django-classy-tags": {
"hashes": [
"sha256:792f9161d0e22d55b4fab6fc297bab8ab072ffaa3075b227613a6d8473624db8",
"sha256:f6d12f5a4df3e387795a0d9ef2836af389cae9a1fbebda035dac043d4722b1f7"
],
"version": "==0.8.0"
},
"django-compressor": {
"hashes": [
"sha256:7732676cfb9d58498dfb522b036f75f3f253f72ea1345ac036434fdc418c2e57",
"sha256:9616570e5b08e92fa9eadc7a1b1b49639cce07ef392fc27c74230ab08075b30f"
],
"index": "pypi",
"version": "==2.2"
},
"django-computed-property": {
"hashes": [
"sha256:31aa6453a5c504ce196ba9ae3bacbe0557cadf7ae89e25431b90bf206febd3b3"
],
"index": "pypi",
"version": "==0.2.1"
},
"django-cors-headers": {
"hashes": [
"sha256:0e9532628b3aa8806442d4d0b15e56112e6cfbef3735e13401935c98b842a2b4",
"sha256:c7ec4816ec49416517b84f317499d1519db62125471922ab78d670474ed9b987"
],
"index": "pypi",
"version": "==2.2.0"
},
"django-debug-toolbar": {
"hashes": [
"sha256:4af2a4e1e932dadbda197b18585962d4fc20172b4e5a479490bc659fe998864d",
"sha256:d9ea75659f76d8f1e3eb8f390b47fc5bad0908d949c34a8a3c4c87978eb40a0f"
],
"index": "pypi",
"version": "==1.9.1"
},
"django-extensions": {
"hashes": [
"sha256:3be3debf53c77ca795bdf713726c923aa3c3f895e1a42e2e31a68c1a562346a4",
"sha256:94bfac99eb262c5ac27e53eda96925e2e53fe0b331af7dde37012d07639a649c"
],
"index": "pypi",
"version": "==2.0.7"
},
"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:0016baa0f11544aa3a709d1b4fca13d607397ae41025bdc4fdb8a7e80a39973c",
"sha256:fd9e787779c053ba77e47a6907539c01e8db41b09f99722793317ed9a4183b32"
],
"index": "pypi",
"version": "==0.4.3"
},
"django-otp-agents": {
"hashes": [
"sha256:4ca8fae30418e0a813840cee5068d2fb96e3759787a5820d54921b90c7beaa7a",
"sha256:8d9f26d5a186b059251bd03e1ab509b5861a678e463c49de9b0766080b2c16a5"
],
"index": "pypi",
"version": "==0.3.0"
},
"django-push": {
"hashes": [
"sha256:9d73a27f147ea46f5e92d6ab36c19640b11214b43b378693c8961aaf8bea5b60",
"sha256:d5442fcb6d8254a7e837383ce766a72e8fb921f3bcfc2355440c2da8fbcf07b4"
],
"index": "pypi",
"version": "==1.1"
},
"django-redis": {
"hashes": [
"sha256:15b47faef6aefaa3f47135a2aeb67372da300e4a4cf06809c66ab392686a2155",
"sha256:a90343c33a816073b735f0bed878eaeec4f83b75fcc0dce2432189b8ea130424"
],
"index": "pypi",
"version": "==4.9.0"
},
"django-rq": {
"hashes": [
"sha256:413df6e5789775b287b4b187ea09ff27f5f1d8205e999650ba9e52c34d58d252",
"sha256:71a604d4bfc18029c2f64da86bfadb803143f5784b3a340e3767202ced93245a"
],
"index": "pypi",
"version": "==1.1.0"
},
"django-super-favicon": {
"hashes": [
"sha256:56cb5268ea73ef3cbde5cb01fef02fea2ec00739cdae0566d3102009f052f683"
],
"index": "pypi",
"version": "==0.6.1"
},
"docutils": {
"hashes": [
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
],
"index": "pypi",
"version": "==0.14"
},
"ecdsa": {
"hashes": [
"sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c",
"sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa"
],
"version": "==0.13"
},
"future": {
"hashes": [
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
],
"version": "==0.16.0"
},
"gevent": {
"hashes": [
"sha256:01ee9787d0a2182c0d56026d3923f73e6879835b1a85d4f996d00d09f1ecab20",
"sha256:0bae07cfd8a5a027b8e8a94d74e875d10809f2cc05bde84f31fd1748e4d43657",
"sha256:42b667080f7ab662d9c13d2e4d03289ee807d7926af9917685c844e481141563",
"sha256:4b668ea8a3e9e348aec21cd43e6fab63c5b50f0c6d57fa8b4cfb6cf9c72953d1",
"sha256:5756aabe722e158eff7a34124163f4c4f3dff01d078d00365ca21b380381906d",
"sha256:59465c7bce7671834f58b44ef62cd8626f1557a0e7e3de44a3b596056f8adc73",
"sha256:6a28d9e375df29200a5e0503f899a45b902cc0e40f4e4de1471773d87c43607a",
"sha256:73e50dccb4a2787002867ea59f40f5e0a5080f841d003f7660794bde216187c9",
"sha256:795006da15d13227d811c09999d37acc92c43c27341a2c9ae96094135ad56908",
"sha256:8957d025e3c361b56b268f35e59777013827848e166d8c0219e47d3b80e2f1ac",
"sha256:8c461a5897e520dd5ec4de725dae030d8c0ac74d07c704aa1fb3b6453315b865",
"sha256:9bff994b9eb5fb2652af74dc8adb09914f3752db25381ccb2c75e2fa45e4f522",
"sha256:a9fa2de95f203982135aaa80979270df83a195a38a152103cd3723b185e407ff",
"sha256:ac0d572a48275495db9513d7bb5d41ccf4f820b7df4594e704fa5891de0d86c4",
"sha256:ba94d6b3998fbb2828fb9d585e409ec46d958bafd7e3f185a14146c3615231d7",
"sha256:bb3bd3aaec9cc51f6fcbb2e7ac2063a1a0160159fe0fee5e978ebceef4ed35d2",
"sha256:cb2f2810a4a1de40cec38d18d3255eb6f4b0778ed3b4dcce03b0b7d462f1f8d4",
"sha256:df7794dc0117215a236b7efe83850dbf6ff90c34c5d0b4da01843f89efabc3d4",
"sha256:e66bac19c88faad0884da2e7b95ef90053927e552e3e50046145014d54dd9d01",
"sha256:f04bbc9b64696775a2367b1d07c124d1ae1d1d70bdd4523db28f81de126d22e6",
"sha256:fd6fdfa71cf4a21d33c32df2b261b18aaf41128e6db29c1d27c5fa5e0a5459d5"
],
"index": "pypi",
"version": "==1.3.3"
},
"greenlet": {
"hashes": [
"sha256:09ef2636ea35782364c830f07127d6c7a70542b178268714a9a9ba16318e7e8b",
"sha256:0fef83d43bf87a5196c91e73cb9772f945a4caaff91242766c5916d1dd1381e4",
"sha256:1b7df09c6598f5cfb40f843ade14ed1eb40596e75cd79b6fa2efc750ba01bb01",
"sha256:1fff21a2da5f9e03ddc5bd99131a6b8edf3d7f9d6bc29ba21784323d17806ed7",
"sha256:42118bf608e0288e35304b449a2d87e2ba77d1e373e8aa221ccdea073de026fa",
"sha256:50643fd6d54fd919f9a0a577c5f7b71f5d21f0959ab48767bd4bb73ae0839500",
"sha256:58798b5d30054bb4f6cf0f712f08e6092df23a718b69000786634a265e8911a9",
"sha256:5b49b3049697aeae17ef7bf21267e69972d9e04917658b4e788986ea5cc518e8",
"sha256:75c413551a436b462d5929255b6dc9c0c3c2b25cbeaee5271a56c7fda8ca49c0",
"sha256:769b740aeebd584cd59232be84fdcaf6270b8adc356596cdea5b2152c82caaac",
"sha256:ad2383d39f13534f3ca5c48fe1fc0975676846dc39c2cece78c0f1f9891418e0",
"sha256:b417bb7ff680d43e7bd7a13e2e08956fa6acb11fd432f74c97b7664f8bdb6ec1",
"sha256:b6ef0cabaf5a6ecb5ac122e689d25ba12433a90c7b067b12e5f28bdb7fb78254",
"sha256:c2de19c88bdb0366c976cc125dca1002ec1b346989d59524178adfd395e62421",
"sha256:c7b04a6dc74087b1598de8d713198de4718fa30ec6cbb84959b26426c198e041",
"sha256:f8f2a0ae8de0b49c7b5b2daca4f150fdd9c1173e854df2cce3b04123244f9f45",
"sha256:fcfadaf4bf68a27e5dc2f42cbb2f4b4ceea9f05d1d0b8f7787e640bed2801634"
],
"markers": "platform_python_implementation == 'CPython'",
"version": "==0.4.13"
},
"gunicorn": {
"hashes": [
"sha256:7ef2b828b335ed58e3b64ffa84caceb0a7dd7c5ca12f217241350dec36a1d5dc",
"sha256:bc59005979efb6d2dd7d5ba72d99f8a8422862ad17ff3a16e900684630dd2a10"
],
"index": "pypi",
"version": "==19.8.1"
},
"hiredis": {
"hashes": [
"sha256:ca958e13128e49674aa4a96f02746f5de5973f39b57297b84d59fd44d314d5b5"
],
"index": "pypi",
"version": "==0.2.0"
},
"html5lib": {
"hashes": [
"sha256:20b159aa3badc9d5ee8f5c647e5efd02ed2a66ab8d354930bd9ff139fc1dc0a3",
"sha256:66cb0dcfdbbc4f9c3ba1a63fdb511ffdbd4f513b2b6d81b80cd26ce6b3fb3736"
],
"version": "==1.0.1"
},
"idna": {
"hashes": [
"sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f",
"sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4"
],
"version": "==2.6"
},
"isodate": {
"hashes": [
"sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8",
"sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"
],
"version": "==0.6.0"
},
"jinja2": {
"hashes": [
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
],
"index": "pypi",
"version": "==2.10"
},
"lxml": {
"hashes": [
"sha256:01c45df6d90497c20aa2a07789a41941f9a1029faa30bf725fc7f6d515b1afe9",
"sha256:0c9fef4f8d444e337df96c54544aeb85b7215b2ed7483bb6c35de97ac99f1bcd",
"sha256:0e3cd94c95d30ba9ca3cff40e9b2a14e1a10a4fd8131105b86c6b61648f57e4b",
"sha256:0e7996e9b46b4d8b4ac1c329a00e2d10edcd8380b95d2a676fccabf4c1dd0512",
"sha256:1858b1933d483ec5727549d3fe166eeb54229fbd6a9d3d7ea26d2c8a28048058",
"sha256:1b164bba1320b14905dcff77da10d5ce9c411ac4acc4fb4ed9a2a4d10fae38c9",
"sha256:1b46f37927fa6cd1f3fe34b54f1a23bd5bea1d905657289e08e1297069a1a597",
"sha256:231047b05907315ae9a9b6925751f9fd2c479cf7b100fff62485a25e382ca0d4",
"sha256:28f0c6652c1b130f1e576b60532f84b19379485eb8da6185c29bd8c9c9bc97bf",
"sha256:34d49d0f72dd82b9530322c48b70ac78cca0911275da741c3b1d2f3603c5f295",
"sha256:3682a17fbf72d56d7e46db2e80ca23850b79c28cfe75dcd9b82f58808f730909",
"sha256:3cf2830b9a6ad7f6e965fa53a768d4d2372a7856f20ffa6ce43d2fe9c0d34b19",
"sha256:5b653c9379ce29ce271fbe1010c5396670f018e78b643e21beefbb3dc6d291de",
"sha256:65a272821d5d8194358d6b46f3ca727fa56a6b63981606eac737c86d27309cdd",
"sha256:691f2cd97cf026c611df1ea5055755eec7f878f2d4f4330dc8686583de6fc5fd",
"sha256:6b6379495d3baacf7ed755ac68547c8dff6ce5d37bf370f0b7678888dc1283f9",
"sha256:75322a531504d4f383264391d89993a42e286da8821ddc5ac315e57305cb84f0",
"sha256:7f457cbda964257f443bac861d3a36732dcba8183149e7818ee2fb7c86901b94",
"sha256:7ff1fc76d8804e0f870c343a72007ff587090c218b0f92d8ee784ac2b6eaf5b9",
"sha256:8523fbde9c2216f3f2b950cb01ebe52e785eaa8a07ffeb456dd3576ca1b4fb9b",
"sha256:8f37627f16e026523fca326f1b5c9a43534862fede6c3e99c2ba6a776d75c1ab",
"sha256:a7182ea298cc3555ea56ffbb0748fe0d5e0d81451e2bc16d7f4645cd01b1ca70",
"sha256:abbd2fb4a5a04c11b5e04eb146659a0cf67bb237dd3d7ca3b9994d3a9f826e55",
"sha256:accc9f6b77bed0a6f267b4fae120f6008a951193d548cdbe9b61fc98a08b1cf8",
"sha256:bd88c8ce0d1504fdfd96a35911dd4f3edfb2e560d7cfdb5a3d09aa571ae5fbae",
"sha256:c557ad647facb3c0027a9d0af58853f905e85a0a2f04dcb73f8e665272fcdc3a",
"sha256:defabb7fbb99f9f7b3e0b24b286a46855caef4776495211b066e9e6592d12b04",
"sha256:e2629cdbcad82b83922a3488937632a4983ecc0fed3e5cfbf430d069382eeb9b"
],
"version": "==4.2.1"
},
"markdown": {
"hashes": [
"sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f",
"sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81"
],
"index": "pypi",
"version": "==2.6.11"
},
"markupsafe": {
"hashes": [
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
],
"version": "==1.0"
},
"mf2py": {
"hashes": [
"sha256:9231bc1317dd5d01973b78a60e52ecd76dd84089c132540e799e4a62aeff3436",
"sha256:bdc3f65ab8a1319b763012dfa8def6ce33214f19e1c53fc2844ed9e7afaf1413"
],
"index": "pypi",
"version": "==1.1.0"
},
"mf2util": {
"hashes": [
"sha256:efb8ea1a275f16396993a3fbe32331b74a8f6985d3f7f47503641cf522f1f614"
],
"index": "pypi",
"version": "==0.5.0"
},
"msgpack": {
"hashes": [
"sha256:0b3b1773d2693c70598585a34ca2715873ba899565f0a7c9a1545baef7e7fbdc",
"sha256:0bae5d1538c5c6a75642f75a1781f3ac2275d744a92af1a453c150da3446138b",
"sha256:0ee8c8c85aa651be3aa0cd005b5931769eaa658c948ce79428766f1bd46ae2c3",
"sha256:1369f9edba9500c7a6489b70fdfac773e925342f4531f1e3d4c20ac3173b1ae0",
"sha256:22d9c929d1d539f37da3d1b0e16270fa9d46107beab8c0d4d2bddffffe895cee",
"sha256:2ff43e3247a1e11d544017bb26f580a68306cec7a6257d8818893c1fda665f42",
"sha256:31a98047355d34d047fcdb55b09cb19f633cf214c705a765bd745456c142130c",
"sha256:8767eb0032732c3a0da92cbec5ac186ef89a3258c6edca09161472ca0206c45f",
"sha256:8acc8910218555044e23826980b950e96685dc48124a290c86f6f41a296ea172",
"sha256:ab189a6365be1860a5ecf8159c248f12d33f79ea799ae9695fa6a29896dcf1d4",
"sha256:cfd6535feb0f1cf1c7cdb25773e965cc9f92928244a8c3ef6f8f8a8e1f7ae5c4",
"sha256:e274cd4480d8c76ec467a85a9c6635bbf2258f0649040560382ab58cabb44bcf",
"sha256:f86642d60dca13e93260187d56c2bef2487aa4d574a669e8ceefcf9f4c26fd00",
"sha256:f8a57cbda46a94ed0db55b73e6ab0c15e78b4ede8690fa491a0e55128d552bb0",
"sha256:fcea97a352416afcbccd7af9625159d80704a25c519c251c734527329bb20d0e"
],
"index": "pypi",
"version": "==0.5.6"
},
"packaging": {
"hashes": [
"sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0",
"sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b"
],
"version": "==17.1"
},
"pillow": {
"hashes": [
"sha256:00633bc2ec40313f4daf351855e506d296ec3c553f21b66720d0f1225ca84c6f",
"sha256:03514478db61b034fc5d38b9bf060f994e5916776e93f02e59732a8270069c61",
"sha256:040144ba422216aecf7577484865ade90e1a475f867301c48bf9fbd7579efd76",
"sha256:16246261ff22368e5e32ad74d5ef40403ab6895171a7fc6d34f6c17cfc0f1943",
"sha256:1cb38df69362af35c14d4a50123b63c7ff18ec9a6d4d5da629a6f19d05e16ba8",
"sha256:2400e122f7b21d9801798207e424cbe1f716cee7314cd0c8963fdb6fc564b5fb",
"sha256:2ee6364b270b56a49e8b8a51488e847ab130adc1220c171bed6818c0d4742455",
"sha256:3b4560c3891b05022c464b09121bd507c477505a4e19d703e1027a3a7c68d896",
"sha256:41374a6afb3f44794410dab54a0d7175e6209a5a02d407119c81083f1a4c1841",
"sha256:438a3faf5f702c8d0f80b9f9f9b8382cfa048ca6a0d64ef71b86b563b0ee0359",
"sha256:472a124c640bde4d5468f6991c9fa7e30b723d84ac4195a77c6ab6aea30f2b9c",
"sha256:4d32c8e3623a61d6e29ccd024066cd1ba556555abfb4cd714155020e00107e3f",
"sha256:4d8077fd649ac40a5c4165f2c22fa2a4ad18c668e271ecb2f9d849d1017a9313",
"sha256:62ec7ae98357fcd46002c110bb7cad15fce532776f0cbe7ca1d44c49b837d49d",
"sha256:6c7cab6a05351cf61e469937c49dbf3cdf5ffb3eeac71f8d22dc9be3507598d8",
"sha256:6eca36905444c4b91fe61f1b9933a47a30480738a1dd26501ff67d94fc2bc112",
"sha256:74e2ebfd19c16c28ad43b8a28ff73b904ed382ea4875188838541751986e8c9a",
"sha256:7673e7473a13107059377c96c563aa36f73184c29d2926882e0a0210b779a1e7",
"sha256:81762cf5fca9a82b53b7b2d0e6b420e0f3b06167b97678c81d00470daa622d58",
"sha256:8554bbeb4218d9cfb1917c69e6f2d2ad0be9b18a775d2162547edf992e1f5f1f",
"sha256:9b66e968da9c4393f5795285528bc862c7b97b91251f31a08004a3c626d18114",
"sha256:a00edb2dec0035e98ac3ec768086f0b06dfabb4ad308592ede364ef573692f55",
"sha256:b48401752496757e95304a46213c3155bc911ac884bed2e9b275ce1c1df3e293",
"sha256:b6cf18f9e653a8077522bb3aa753a776b117e3e0cc872c25811cfdf1459491c2",
"sha256:bb8adab1877e9213385cbb1adc297ed8337e01872c42a30cfaa66ff8c422779c",
"sha256:c8a4b39ba380b57a31a4b5449a9d257b1302d8bc4799767e645dcee25725efe1",
"sha256:cee9bc75bff455d317b6947081df0824a8f118de2786dc3d74a3503fd631f4ef",
"sha256:d0dc1313dff48af64517cbbd85e046d6b477fbe5e9d69712801f024dcb08c62b",
"sha256:d5bf527ed83617edd1855a5c923eeeaf68bcb9ac0ceb28e3f19b575b3a424984",
"sha256:df5863a21f91de5ecdf7d32a32f406dd9867ebb35d41033b8bd9607a21887599",
"sha256:e39142332541ed2884c257495504858b22c078a5d781059b07aba4c3a80d7551",
"sha256:e52e8f675ba0b2b417fa98579e7286a41a8e23871f17f4793772f5aa884fea79",
"sha256:e6dd55d5d94b9e36929325dd0c9ab85bfde84a5fc35947c334c32af1af668944",
"sha256:e87cc1acbebf263f308a8494272c2d42016aa33c32bf14d209c81e1f65e11868",
"sha256:ea0091cd4100519cedfeea2c659f52291f535ac6725e2368bcf59e874f270efa",
"sha256:eeb247f4f4d962942b3b555530b0c63b77473c7bfe475e51c6b75b7344b49ce3",
"sha256:f0d4433adce6075efd24fc0285135248b0b50f5a58129c7e552030e04fe45c7f",
"sha256:f1f3bd92f8e12dc22884935a73c9f94c4d9bd0d34410c456540713d6b7832b8c",
"sha256:f42a87cbf50e905f49f053c0b1fb86c911c730624022bf44c8857244fc4cdaca",
"sha256:f5f302db65e2e0ae96e26670818157640d3ca83a3054c290eff3631598dcf819",
"sha256:f7634d534662bbb08976db801ba27a112aee23e597eeaf09267b4575341e45bf",
"sha256:fdd374c02e8bb2d6468a85be50ea66e1c4ef9e809974c30d8576728473a6ed03",
"sha256:fe6931db24716a0845bd8c8915bd096b77c2a7043e6fc59ae9ca364fe816f08b"
],
"index": "pypi",
"version": "==5.1.0"
},
"psycopg2-binary": {
"hashes": [
"sha256:02eb674e3d5810e19b4d5d00720b17130e182da1ba259dda608aaf33d787347d",
"sha256:3a14baeabcebd4662f12f4bff03e0574a2369a2e41baf829e6fb4a24c95cf88b",
"sha256:436a503eda41f6adb08f292f40a3784fce0a5f351b6ae7b19a911904db53af93",
"sha256:465ff1d427ed42c31e456dbbd9edab3552be18a0edaef7450c5b3e6fee745052",
"sha256:4a1a5ea2fa4b53191637b162873a82822d92a85a08beefe28296b8eb5cf2fea5",
"sha256:4a4f23a08fbccbe40ecdb5384d807bcb469ea71dd87e6be2e80b036b8e6d47df",
"sha256:77a2fc622a1f2d08a707673c9be5769d521f03d867d305f172bb417fa7882754",
"sha256:8014c06a9ed7b78ba81beff3ae71acd78c212390f8ed839e9ce22735880bd5b4",
"sha256:83af04029bcb4b56c852e5876fef71340dcb465fa44fc99f80bac72e10fb0b74",
"sha256:86c0d2587f56776f25d52cca8e275adf495c8e01933fbfc2ca23b124610ab761",
"sha256:9305d7cbc802aaefac5c75a3df725f2654797369f32b18d4d0adb382dfab6c09",
"sha256:9b5ddbed85ec73293695d7116589d956ef0dd3fcf7bf3b2a3bc1e8e54c1d543a",
"sha256:a3d2cc0cb0b988dbfd0d11f7fac34058b25a6ce533ed5b8e88d6cb315e77d54a",
"sha256:ab1db8f3e96570d9f7ebc45133ce2574804b2280499baade178e163d022107b5",
"sha256:b039f51bca1ddd70234cc3f84f94f42ad43861b931bdfb497f887c60c39a6565",
"sha256:b287ddf4cafcfb632974907d1e7862119e36bb758228bdb07dd247553e4cdfc0",
"sha256:b6b2b26590304d97ef2af28d153ee99ace6fe0806934f4618edfc87216c77f91",
"sha256:c4c6004d410c77bfa5389ae9485498ce32805447a67afbfe8db0d247a5c88fa1",
"sha256:c606bff0978ee4858d86d40f6b6ab0c4cac4474f627bd054683dc03a4fc1a366",
"sha256:c8220c521a408b41c4f14036004a621ed0d965941286b978cd2ea2623fabd755",
"sha256:cb07184a4bfad304831f0a88b1c13fbd8cf9fcdf1f11e71c477dd6d7b1b078a0",
"sha256:cf3911fba0c47fc1313b5783183cda301032b14637a0b7a336766ae46998c7ee",
"sha256:d0972f062c73956332e9681dfdb133168618f0abfecc96e89f0205ac89cd454b",
"sha256:d1dd3eb8edd354083f5d27b968c5a17854c41347ba5a480b520be85ec1a8495c",
"sha256:d51c7ed810fce1e50464088c37cc8da05534de8afb12a732500827ebcc480081",
"sha256:d8940b5104588d6313315e037f0f5ed68d2e5f62ccc1c429d3cff11d2ba6de3f",
"sha256:de4f88f823037a71ea5ef3c1041d96b8a68d73343133edda684fd42f575bd9d7"
],
"index": "pypi",
"version": "==2.7.4"
},
"pyasn1": {
"hashes": [
"sha256:2f57960dc7a2820ea5a1782b872d974b639aa3b448ac6628d1ecc5d0fe3986f2",
"sha256:3651774ca1c9726307560792877db747ba5e8a844ea1a41feb7670b319800ab3",
"sha256:602fda674355b4701acd7741b2be5ac188056594bf1eecf690816d944e52905e",
"sha256:8fb265066eac1d3bb5015c6988981b009ccefd294008ff7973ed5f64335b0f2d",
"sha256:9334cb427609d2b1e195bb1e251f99636f817d7e3e1dffa150cb3365188fb992",
"sha256:9a15cc13ff6bf5ed29ac936ca941400be050dff19630d6cd1df3fb978ef4c5ad",
"sha256:a66dcda18dbf6e4663bde70eb30af3fc4fe1acb2d14c4867a861681887a5f9a2",
"sha256:ba77f1e8d7d58abc42bfeddd217b545fdab4c1eeb50fd37c2219810ad56303bf",
"sha256:cdc8eb2eaafb56de66786afa6809cd9db2df1b3b595dcb25aa5b9dc61189d40a",
"sha256:d01fbba900c80b42af5c3fe1a999acf61e27bf0e452e0f1ef4619065e57622da",
"sha256:f281bf11fe204f05859225ec2e9da7a7c140b65deccd8a4eb0bc75d0bd6949e0",
"sha256:fb81622d8f3509f0026b0683fe90fea27be7284d3826a5f2edf97f69151ab0fc"
],
"version": "==0.4.3"
},
"pycparser": {
"hashes": [
"sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226"
],
"version": "==2.18"
},
"pyparsing": {
"hashes": [
"sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
"sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07",
"sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18",
"sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e",
"sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5",
"sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58",
"sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
],
"version": "==2.2.0"
},
"python-baseconv": {
"hashes": [
"sha256:1b98b11d0d1c00bf1165d62b0d183c8d2d496ae5baaa0991c0d4ffef079772d6"
],
"index": "pypi",
"version": "==1.2.1"
},
"python-jose": {
"hashes": [
"sha256:e06dd2e5e9125da79b519ff2652b8c666d64a5ea228fcd9862e0b29a534ccc53",
"sha256:e8255fb3cc524c04f4c790547a6215468f2a32d3a866424175523359e69f3aeb"
],
"index": "pypi",
"version": "==3.0.0"
},
"python-magic": {
"hashes": [
"sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375",
"sha256:f3765c0f582d2dfc72c15f3b5a82aecfae9498bd29ca840d72f37d7bd38bfcd5"
],
"index": "pypi",
"version": "==0.4.15"
},
"python-slugify": {
"hashes": [
"sha256:5dbb360b882b2dabe0471a1a92f604504d83c2a73c71f2098d004ab62e695534"
],
"index": "pypi",
"version": "==1.2.5"
},
"pytz": {
"hashes": [
"sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555",
"sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749"
],
"version": "==2018.4"
},
"pyup-django": {
"hashes": [
"sha256:f02242b4c7a8926bf9118054429dcaf84e5708a050548abcd3b2b9de4a7570b9",
"sha256:fe84cef39c41d5feb24e307d6c8a55454db50df4c6955fa6a890a42b6e58650e"
],
"index": "pypi",
"version": "==0.4.0"
},
"pyyaml": {
"hashes": [
"sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
"sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
"sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
"sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
"sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
"sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
"sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
"sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
"sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
"sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
"sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
"sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
"sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
"sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
],
"index": "pypi",
"version": "==3.12"
},
"qrcode": {
"hashes": [
"sha256:037b0db4c93f44586e37f84c3da3f763874fcac85b2974a69a98e399ac78e1bf",
"sha256:de4ffc15065e6ff20a551ad32b6b41264f3c75275675406ddfa8e3530d154be3"
],
"index": "pypi",
"version": "==6.0"
},
"rcssmin": {
"hashes": [
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
],
"version": "==1.0.6"
},
"redis": {
"hashes": [
"sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb",
"sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"
],
"version": "==2.10.6"
},
"requests": {
"hashes": [
"sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
"sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
],
"version": "==2.18.4"
},
"rjsmin": {
"hashes": [
"sha256:dd9591aa73500b08b7db24367f8d32c6470021f39d5ab4e50c7c02e4401386f1"
],
"version": "==1.0.12"
},
"ronkyuu": {
"hashes": [
"sha256:5aa77b39d301bc174ab99ba8a53954627771cb501651a12103c58f51b32e84bf",
"sha256:85b25fef7f5fb0c93afd5377ea35b5ff72b2458f926bafdf10f0c9a1e19cab10"
],
"index": "pypi",
"version": "==0.6"
},
"rq": {
"hashes": [
"sha256:4f494d8554d45748b8fb91454a18e2293f305230eaec45b740c94a280aeaa682",
"sha256:f997d28c5e69aef38e9162572f78c424a7deff4cd00efae125085d4c1fbcc15b"
],
"version": "==0.11.0"
},
"rsa": {
"hashes": [
"sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5",
"sha256:43f682fea81c452c98d09fc316aae12de6d30c4b5c84226642cf8f8fd1c93abd"
],
"version": "==3.4.2"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"sqlparse": {
"hashes": [
"sha256:ce028444cfab83be538752a2ffdb56bc417b7784ff35bb9a3062413717807dec",
"sha256:d9cf190f51cbb26da0412247dfe4fb5f4098edb73db84e02f9fc21fdca31fed4"
],
"version": "==0.2.4"
},
"unidecode": {
"hashes": [
"sha256:72f49d3729f3d8f5799f710b97c1451c5163102e76d64d20e170aedbbd923582",
"sha256:8c33dd588e0c9bc22a76eaa0c715a5434851f726131bd44a6c26471746efabf5"
],
"version": "==1.0.22"
},
"urllib3": {
"hashes": [
"sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
"sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
],
"version": "==1.22"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
],
"version": "==0.5.1"
},
"xrd": {
"hashes": [
"sha256:51d01f732b5b5b7983c5179ffaed864408d95a667b3a6630fe27aa7528274089"
],
"index": "pypi",
"version": "==0.1"
}
},
"develop": {
"argh": {
"hashes": [
"sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3",
"sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65"
],
"version": "==0.26.2"
},
"atomicwrites": {
"hashes": [
"sha256:240831ea22da9ab882b551b31d4225591e5e447a68c5e188db5b89ca1d487585",
"sha256:a24da68318b08ac9c9c45029f4a10371ab5b20e4226738e150e6e7c571630ae6"
],
"version": "==1.1.5"
},
"attrs": {
"hashes": [
"sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
"sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b"
],
"version": "==18.1.0"
},
"docopt": {
"hashes": [
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
],
"version": "==0.6.2"
},
"jedi": {
"hashes": [
"sha256:1972f694c6bc66a2fac8718299e2ab73011d653a6d8059790c3476d2353b99ad",
"sha256:5861f6dc0c16e024cbb0044999f9cf8013b292c05f287df06d3d991a87a4eb89"
],
"version": "==0.12.0"
},
"more-itertools": {
"hashes": [
"sha256:2b6b9893337bfd9166bee6a62c2b0c9fe7735dcf85948b387ec8cba30e85d8e8",
"sha256:6703844a52d3588f951883005efcf555e49566a48afd4db4e965d69b883980d3",
"sha256:a18d870ef2ffca2b8463c0070ad17b5978056f403fb64e3f15fe62a52db21cc0"
],
"version": "==4.2.0"
},
"mypy": {
"hashes": [
"sha256:1b899802a89b67bb68f30d788bba49b61b1f28779436f06b75c03495f9d6ea5c",
"sha256:f472645347430282d62d1f97d12ccb8741f19f1572b7cf30b58280e4e0818739"
],
"index": "pypi",
"version": "==0.610"
},
"parso": {
"hashes": [
"sha256:cdef26e8adc10d589f3ec4eb444bd0a29f3f1eb6d72a4292ab8afcb9d68976a6",
"sha256:f0604a40b96e062b0fd99cf134cc2d5cdf66939d0902f8267d938b0d5b26707f"
],
"version": "==0.2.1"
},
"pathtools": {
"hashes": [
"sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"
],
"version": "==0.1.2"
},
"pluggy": {
"hashes": [
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
"sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
"sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
],
"version": "==0.6.0"
},
"prompt-toolkit": {
"hashes": [
"sha256:1df952620eccb399c53ebb359cc7d9a8d3a9538cb34c5a1344bdbeb29fbcc381",
"sha256:3f473ae040ddaa52b52f97f6b4a493cfa9f5920c255a12dc56a7d34397a398a4",
"sha256:858588f1983ca497f1cf4ffde01d978a3ea02b01c8a26a8bbc5cd2e66d816917"
],
"version": "==1.0.15"
},
"ptpython": {
"hashes": [
"sha256:55d7cfad50a096f5922c4fdf8cea068a7ec9257418064b437c617ff2f120f81a",
"sha256:816da300f620fb88ba97c7962062c8c178d6693b1db19c184660156b1af91bdc",
"sha256:a78b27a85c5dbe9d89376e7f3aa70a9d8fa15cb45ee5f73a3cc3963b9b528ac1"
],
"index": "pypi",
"version": "==0.41"
},
"py": {
"hashes": [
"sha256:29c9fab495d7528e80ba1e343b958684f4ace687327e6f789a94bf3d1915f881",
"sha256:983f77f3331356039fdd792e9220b7b8ee1aa6bd2b25f567a963ff1de5a64f6a"
],
"version": "==1.5.3"
},
"pygments": {
"hashes": [
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
"sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
],
"version": "==2.2.0"
},
"pytest": {
"hashes": [
"sha256:26838b2bc58620e01675485491504c3aa7ee0faf335c37fcd5f8731ca4319591",
"sha256:32c49a69566aa7c333188149ad48b58ac11a426d5352ea3d8f6ce843f88199cb"
],
"version": "==3.6.1"
},
"pytest-django": {
"hashes": [
"sha256:534505e0261cc566279032d9d887f844235342806fd63a6925689670fa1b29d7",
"sha256:7501942093db2250a32a4e36826edfc542347bb9b26c78ed0649cdcfd49e5789"
],
"index": "pypi",
"version": "==3.2.1"
},
"pyyaml": {
"hashes": [
"sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
"sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
"sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
"sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
"sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
"sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
"sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
"sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
"sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
"sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
"sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
"sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
"sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
"sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
],
"index": "pypi",
"version": "==3.12"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"typed-ast": {
"hashes": [
"sha256:0948004fa228ae071054f5208840a1e88747a357ec1101c17217bfe99b299d58",
"sha256:25d8feefe27eb0303b73545416b13d108c6067b846b543738a25ff304824ed9a",
"sha256:29464a177d56e4e055b5f7b629935af7f49c196be47528cc94e0a7bf83fbc2b9",
"sha256:2e214b72168ea0275efd6c884b114ab42e316de3ffa125b267e732ed2abda892",
"sha256:3e0d5e48e3a23e9a4d1a9f698e32a542a4a288c871d33ed8df1b092a40f3a0f9",
"sha256:519425deca5c2b2bdac49f77b2c5625781abbaf9a809d727d3a5596b30bb4ded",
"sha256:57fe287f0cdd9ceaf69e7b71a2e94a24b5d268b35df251a88fef5cc241bf73aa",
"sha256:668d0cec391d9aed1c6a388b0d5b97cd22e6073eaa5fbaa6d2946603b4871efe",
"sha256:68ba70684990f59497680ff90d18e756a47bf4863c604098f10de9716b2c0bdd",
"sha256:6de012d2b166fe7a4cdf505eee3aaa12192f7ba365beeefaca4ec10e31241a85",
"sha256:79b91ebe5a28d349b6d0d323023350133e927b4de5b651a8aa2db69c761420c6",
"sha256:8550177fa5d4c1f09b5e5f524411c44633c80ec69b24e0e98906dd761941ca46",
"sha256:a8034021801bc0440f2e027c354b4eafd95891b573e12ff0418dec385c76785c",
"sha256:bc978ac17468fe868ee589c795d06777f75496b1ed576d308002c8a5756fb9ea",
"sha256:c05b41bc1deade9f90ddc5d988fe506208019ebba9f2578c622516fd201f5863",
"sha256:c9b060bd1e5a26ab6e8267fd46fc9e02b54eb15fffb16d112d4c7b1c12987559",
"sha256:edb04bdd45bfd76c8292c4d9654568efaedf76fe78eb246dde69bdb13b2dad87",
"sha256:f19f2a4f547505fe9072e15f6f4ae714af51b5a681a97f187971f50c283193b6"
],
"version": "==1.1.0"
},
"watchdog": {
"hashes": [
"sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162"
],
"index": "pypi",
"version": "==0.8.3"
},
"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):
date_hierarchy = 'created'
list_display = ('title', 'id', 'kind', 'created')
list_filter = ('kind',)
inlines = (
SyndicationInline,
)
date_hierarchy = "created"
list_display = ("title", "id", "kind", "created")
list_filter = ("kind",)
inlines = (SyndicationInline,)
admin.site.register(Cat)

View file

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

34
entries/from_url.py Normal file
View file

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

View file

@ -0,0 +1,15 @@
{% extends 'lemoncurry/layout.html' %}
{% block head %}
<link rel="shortlink" href="{{ entry.short_url }}" />
{% endblock %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
{% endblock %}
{% import 'entries/h-entry.html' as h %}
{% block main %}
<div class="entry">
{{ h.hEntry(entry, indent_width=8) }}
</div>
{% endblock %}

View file

@ -0,0 +1,55 @@
{% macro hEntry(entry, indent_width) -%}
{%- set i = ' ' * indent_width -%}
<article class="h-entry media">
{{i}}<aside class="info">
{{i}}<a class="p-author h-card" href="{{ entry.author.url }}">
{{i}}{% if entry.author.avatar %}<img class="u-photo img-fluid" src="{{ entry.author.avatar.url }}" alt="{{ entry.author.name }}" />{% endif %}
{{i}}<span class="p-name sr-only">{{ entry.author.name }}</span>
{{i}}</a>
{{i}}<a class="u-uid u-url" href="{{ entry.url }}">
{{i}}<time class="dt-published media" datetime="{{ entry.published.isoformat() }}" title="{{ entry.published.isoformat() }}">
{{i}}<i class="fas fa-fw fa-calendar" aria-hidden="true"></i>
{{i}}<div class="media-body">{{ entry.published | ago }}</div>
{{i}}</time>
{{i}}</a>
{{i}}<time class="dt-updated media" datetime="{{ entry.updated.isoformat() }}" title="{{ entry.updated.isoformat() }}"{% if (entry.updated | ago) == (entry.published | ago) %} hidden{% endif %}>
{{i}}<i class="fas fa-fw fa-pencil-alt" aria-hidden="true"></i>
{{i}}<div class="media-body">{{ entry.updated | ago }}</div>
{{i}}</time>
{{i}}<a class="u-url media" href="{{ entry.short_url }}">
{{i}}<i class="fas fa-fw fa-link" aria-hidden="true"></i>
{{i}}<div class="media-body">{{ entry.short_url | friendly_url }}</div>
{{i}}</a>
{{i}}</aside>
{{i}}<div class="card media-body">
{% if entry.photo %}
{{i}}<img class="card-img-top u-photo" src="{{ entry.photo.url }}" />
{% endif %}
{{i}}<div class="card-body">
{% if entry.name %}
{{i}}<h4 class="card-title p-name">{{ entry.name }}</h4>
{% endif %}
{{i}}<div class="e-content">
{{i}}{{ entry.content | markdown }}
{{i}}</div>
{% for c in entry.cats.all() %}
{{i}}<a class="p-category card-link" href="{{ c.url }}">
{{i}}<i class="fas fa-paw" aria-hidden="true"></i>
{{i}}{{ c.name }}
{{i}}</a>
{% endfor %}
{% for s in entry.syndications.all() %}
{{i}}<a class="u-syndication card-link" href="{{ s.url }}">
{{i}}<i class="{{ s.site.icon }}" aria-hidden="true"></i>
{{i}}{{ s.site.domain }}
{{i}}</a>
{% endfor %}
{{i}}</div>
{{i}}</div>
{{i}}<script class="p-json-ld" type="application/ld+json">{{ entry.json_ld | tojson }}</script>
{{i}}</article>
{%- endmacro %}

View file

@ -0,0 +1,20 @@
{% extends 'lemoncurry/layout.html' %}
{% block html_attr %}
class="h-feed"{{ super() }}
{%- endblock %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
{% endblock %}
{% import 'entries/h-entry.html' as h %}
{% block main %}
<ol class="list-unstyled entries">
{% for entry in entries %}
<li>
{{ h.hEntry(entry, indent_width=10) }}
</li>
{% endfor %}
</ol>
{% endblock %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,29 @@
# Generated by Django 2.0.6 on 2018-06-28 10:44
import computed_property.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("entries", "0011_auto_20171120_1108"),
]
operations = [
migrations.AlterModelOptions(
name="syndication",
options={"ordering": ["domain"]},
),
migrations.RemoveField(
model_name="syndication",
name="profile",
),
migrations.AddField(
model_name="syndication",
name="domain",
field=computed_property.fields.ComputedCharField(
compute_from="calc_domain", default="", editable=False, max_length=255
),
preserve_default=False,
),
]

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

@ -1,20 +1,23 @@
from computed_property import ComputedCharField
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.contrib.sites.models import Site as DjangoSite
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from itertools import groupby
from mf2util import interpret
from slugify import slugify
from textwrap import shorten
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
from lemonshort.short_url import short_url
from meta.models import ModelMeta
from model_utils.models import TimeStampedModel
from users.models import Profile
from users.models import Site
from . import kinds
from lemoncurry import requests, utils
ENTRY_KINDS = [(k.id, k.id) for k in kinds.all]
@ -30,38 +33,33 @@ class Cat(models.Model):
slug = models.CharField(max_length=255, unique=True)
def __str__(self):
return '#' + self.name
return "#" + self.name
@property
def url(self):
return reverse('entries:cat', args=(self.slug,))
return reverse("entries:cat", args=(self.slug,))
class Meta:
ordering = ('name',)
ordering = ("name",)
class EntryManager(models.Manager):
def get_queryset(self):
qs = super(EntryManager, self).get_queryset()
return (qs
.select_related('author')
.prefetch_related('cats', 'syndications'))
return qs.select_related("author").prefetch_related("cats", "syndications")
class Entry(ModelMeta, TimeStampedModel):
objects = EntryManager()
kind = models.CharField(
max_length=30,
choices=ENTRY_KINDS,
db_index=True,
default=ENTRY_KINDS[0][0]
max_length=30, choices=ENTRY_KINDS, db_index=True, default=ENTRY_KINDS[0][0]
)
name = models.CharField(max_length=100, blank=True)
photo = models.ImageField(blank=True)
content = models.TextField()
cats = models.ManyToManyField(Cat, related_name='entries')
cats = models.ManyToManyField(Cat, related_name="entries")
in_reply_to = models.CharField(max_length=255, blank=True)
like_of = models.CharField(max_length=255, blank=True)
@ -69,7 +67,7 @@ class Entry(ModelMeta, TimeStampedModel):
author = models.ForeignKey(
get_user_model(),
related_name='entries',
related_name="entries",
on_delete=models.CASCADE,
)
@ -77,10 +75,7 @@ class Entry(ModelMeta, TimeStampedModel):
def reply_context(self):
if not self.in_reply_to:
return None
return interpret(
requests.mf2(self.in_reply_to).to_dict(),
self.in_reply_to
)
return interpret(requests.mf2(self.in_reply_to).to_dict(), self.in_reply_to)
@property
def published(self):
@ -91,35 +86,29 @@ class Entry(ModelMeta, TimeStampedModel):
return self.modified
_metadata = {
'description': 'excerpt',
'image': 'image_url',
'twitter_creator': 'twitter_creator',
'og_profile_id': 'og_profile_id',
"description": "excerpt",
"image": "image_url",
"twitter_creator": "twitter_creator",
"og_profile_id": "og_profile_id",
}
@property
def title(self):
if self.name:
return self.name
return shorten(
utils.to_plain(self.paragraphs[0]),
width=100,
placeholder=''
)
return shorten(utils.to_plain(self.paragraphs[0]), width=100, placeholder="")
@property
def excerpt(self):
try:
return utils.to_plain(self.paragraphs[0 if self.name else 1])
except IndexError:
return ' '
return " "
@property
def paragraphs(self):
lines = self.content.splitlines()
return [
"\n".join(para) for k, para in groupby(lines, key=bool) if k
]
return ["\n".join(para) for k, para in groupby(lines, key=bool) if k]
@property
def twitter_creator(self):
@ -134,23 +123,41 @@ class Entry(ModelMeta, TimeStampedModel):
return self.photo.url if self.photo else self.author.avatar_url
def __str__(self):
return '{0} {1}: {2}'.format(self.kind, self.id, self.title)
return "{0} {1}: {2}".format(self.kind, self.id, self.title)
def get_absolute_url(self):
return self.absolute_url
@property
def absolute_url(self):
base = 'https://' + Site.objects.get_current().domain
base = "https://" + DjangoSite.objects.get_current().domain
return urljoin(base, self.url)
@property
def affected_urls(self):
base = "https://" + DjangoSite.objects.get_current().domain
kind = kinds.from_id[self.kind]
urls = {
self.url,
reverse("entries:index", kwargs={"kind": kind}),
reverse("entries:atom_by_kind", kwargs={"kind": kind}),
reverse("entries:rss_by_kind", kwargs={"kind": kind}),
} | {cat.url for cat in self.cats.all()}
if kind.on_home:
urls |= {
reverse("home:index"),
reverse("entries:atom"),
reverse("entries:rss"),
}
return {urljoin(base, u) for u in urls}
@property
def url(self):
kind = kinds.from_id[self.kind]
args = [kind, self.id]
if kind.slug:
args.append(self.slug)
return reverse('entries:entry', args=args)
return reverse("entries:entry", args=args)
@property
def short_url(self):
@ -162,49 +169,58 @@ class Entry(ModelMeta, TimeStampedModel):
@property
def json_ld(self):
base = 'https://' + Site.objects.get_current().domain
base = "https://" + DjangoSite.objects.get_current().domain
url = urljoin(base, self.url)
posting = {
'@context': 'http://schema.org',
'@type': 'BlogPosting',
'@id': url,
'url': url,
'mainEntityOfPage': url,
'author': {
'@type': 'Person',
'url': urljoin(base, self.author.url),
'name': self.author.name,
"@context": "http://schema.org",
"@type": "BlogPosting",
"@id": url,
"url": url,
"mainEntityOfPage": url,
"author": {
"@type": "Person",
"url": urljoin(base, self.author.url),
"name": self.author.name,
},
'headline': self.title,
'description': self.excerpt,
'datePublished': self.created.isoformat(),
'dateModified': self.modified.isoformat(),
"headline": self.title,
"description": self.excerpt,
"datePublished": self.created.isoformat(),
"dateModified": self.modified.isoformat(),
}
if self.photo:
posting['image'] = (urljoin(base, self.photo.url), )
posting["image"] = (urljoin(base, self.photo.url),)
return posting
class Meta:
verbose_name_plural = 'entries'
ordering = ['-created']
class SyndicationManager(models.Manager):
def get_queryset(self):
qs = super(SyndicationManager, self).get_queryset()
return qs.select_related('profile__site')
verbose_name_plural = "entries"
ordering = ["-created"]
class Syndication(models.Model):
objects = SyndicationManager()
entry = models.ForeignKey(
Entry,
related_name='syndications',
on_delete=models.CASCADE
Entry, related_name="syndications", on_delete=models.CASCADE
)
profile = models.ForeignKey(Profile, on_delete=models.CASCADE)
url = models.CharField(max_length=255)
domain = ComputedCharField(
compute_from="calc_domain",
max_length=255,
)
def calc_domain(self):
domain = urlparse(self.url).netloc
if domain.startswith("www."):
domain = domain[4:]
return domain
@cached_property
def site(self):
d = self.domain
try:
return Site.objects.get(domain=d)
except Site.DoesNotExist:
return Site(name=d, domain=d, icon="fas fa-newspaper")
class Meta:
ordering = ['profile']
ordering = ["domain"]

View file

@ -1,6 +1,8 @@
from django.core.paginator import Paginator
from django.shortcuts import redirect
from lemoncurry.middleware import ResponseException
def paginate(queryset, reverse, page):
class Page:
@ -18,7 +20,7 @@ def paginate(queryset, reverse, page):
# If the first page was requested, redirect to the clean version of the URL
# with no page suffix.
if page == 1:
return redirect(Page(1).url)
raise ResponseException(redirect(Page(1).url))
paginator = Paginator(queryset, 10)
entries = paginator.page(page or 1)

View file

@ -10,12 +10,40 @@ ol.entries, div.entry
&:last-child
margin-bottom 0
.card.h-entry
.h-entry.media
> aside.info
display flex
flex-direction column
align-items flex-start
font-size 0.8rem
margin-right 0.4rem
flex-basis 7rem
max-width 10%
a.p-author
align-self center
text-align center
img.u-photo
border-radius .25rem
max-height 3em
> *
margin-bottom .25rem
.media
align-items baseline
max-width 10rem
> :first-child
margin-right 2px
display none
@media (min-width $sm)
display inline-block
> .card
flex 1
.e-content
ul
list-style-type disc
ul, ol
margin-bottom 1rem
padding-left 1.1rem
ul
list-style-type circle
ul, ol
@ -26,14 +54,8 @@ ol.entries, div.entry
max-width 100%
> :last-child
margin-bottom 0
.card-footer
text-align center
> *
display inline-block
margin-right 1rem
&:last-child
margin-right 0
.h-card > img
height 1em
vertical-align baseline
.card-link
display inline-block
font-size 0.8rem
margin-left 0
margin-right 1.25rem

View file

@ -1,17 +0,0 @@
{% extends 'lemoncurry/layout.html' %}
{% load absolute_url static %}
{% block head %}
<link rel="shortlink" href="{{ entry.short_url }}" />
<link rel="alternate" type="application/json+oembed" href="https://wirres.net/oembed/oembed.php?url={{ uri | absolute_url | urlencode }}" />
{% endblock %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
{% endblock %}
{% block main %}
<div class="entry">
{% include 'entries/h-entry.html' %}
</div>
{% endblock %}

View file

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

View file

@ -1,17 +0,0 @@
{% extends 'lemoncurry/layout.html' %}
{% load static %}
{% block html_class %}h-feed{% endblock %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
{% endblock %}
{% block main %}
<ol class="list-unstyled entries">
{% for entry in entries %}
<li>
{% include 'entries/h-entry.html' %}
</li>
{% endfor %}
</ol>
{% endblock %}

View file

@ -3,27 +3,27 @@ import pytest
@pytest.mark.django_db
def test_atom(client):
res = client.get('/atom')
res = client.get("/atom")
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
def test_rss(client):
res = client.get('/rss')
res = client.get("/rss")
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
def test_atom_by_kind(client):
res = client.get('/notes/atom')
res = client.get("/notes/atom")
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
def test_rss_by_kind(client):
res = client.get('/notes/rss')
res = client.get("/notes/rss")
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 lemoncurry import breadcrumbs as crumbs
register_converter(kinds.EntryKindConverter, 'kind')
register_converter(kinds.EntryKindConverter, "kind")
def to_pat(*args):
return '^{0}$'.format(''.join(args))
return "^{0}$".format("".join(args))
def prefix(route):
return app_name + ':' + route
return app_name + ":" + route
id = r'/(?P<id>\d+)'
kind = r'(?P<kind>{0})'.format('|'.join(k.plural for k in kinds.all))
page = r'(?:/page/(?P<page>\d+))?'
slug = r'/(?P<slug>[^/]+)'
id = r"/(?P<id>\d+)"
kind = r"(?P<kind>{0})".format("|".join(k.plural for k in kinds.all))
page = r"(?:/page/(?P<page>\d+))?"
slug = r"/(?P<slug>[^/]+)"
slug_opt = '(?:' + slug + ')?'
slug_opt = "(?:" + slug + ")?"
app_name = 'entries'
app_name = "entries"
urlpatterns = (
path('atom', feeds.AtomHomeEntries(), name='atom'),
path('rss', feeds.RssHomeEntries(), name='rss'),
path('cats/<slug:slug>', lists.by_cat, name='cat'),
path('cats/<slug:slug>/page/<int:page>', lists.by_cat, name='cat'),
path('<kind:kind>', lists.by_kind, name='index'),
path('<kind:kind>/page/<int:page>', lists.by_kind, name='index'),
path('<kind:kind>/atom', feeds.AtomByKind(), name='atom_by_kind'),
path('<kind:kind>/rss', feeds.RssByKind(), name='rss_by_kind'),
path('<kind:kind>/<int:id>', perma.entry, name='entry'),
path('<kind:kind>/<int:id>/<slug:slug>', perma.entry, name='entry'),
path("atom", feeds.AtomHomeEntries(), name="atom"),
path("rss", feeds.RssHomeEntries(), name="rss"),
path("cats/<slug:slug>", lists.by_cat, name="cat"),
path("cats/<slug:slug>/page/<int:page>", lists.by_cat, name="cat"),
path("<kind:kind>", lists.by_kind, name="index"),
path("<kind:kind>/page/<int:page>", lists.by_kind, name="index"),
path("<kind:kind>/atom", feeds.AtomByKind(), name="atom_by_kind"),
path("<kind:kind>/rss", feeds.RssByKind(), name="rss_by_kind"),
path("<kind:kind>/<int:id>", perma.entry, name="entry"),
path("<kind:kind>/<int:id>/<slug:slug>", perma.entry, name="entry"),
)
class IndexCrumb(crumbs.Crumb):
def __init__(self):
super().__init__(prefix('index'), parent='home:index')
super().__init__(prefix("index"), parent="home:index")
@property
def kind(self):
return self.match.kwargs['kind']
return self.match.kwargs["kind"]
@property
def label(self):
@ -51,9 +50,9 @@ class IndexCrumb(crumbs.Crumb):
@property
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(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):
def add_root_elements(self, handler):
super().add_root_elements(handler)
handler.startElement('link', {'rel': 'hub', 'href': settings.PUSH_HUB})
handler.endElement('link')
handler.startElement("link", {"rel": "hub", "href": settings.PUSH_HUB})
handler.endElement("link")
class EntriesFeed(Feed):
@ -79,7 +79,7 @@ class RssHomeEntries(EntriesFeed):
return Site.objects.get_current().name
def link(self):
return reverse('home:index')
return reverse("home:index")
def description(self):
return "content from {0}".format(

View file

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

View file

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

5
gunicorn.py Normal file
View file

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

107
home/jinja2/home/index.html Normal file
View file

@ -0,0 +1,107 @@
{% extends 'lemoncurry/layout.html' %}
{% block html_attr %}
class="h-feed"{{ super() }}
{%- endblock %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{{ static('home/css/index.styl') }}" />
<link rel="stylesheet" type="text/stylus" href="{{ static('entries/css/h-entry.styl') }}" />
{% endblock %}
{% block head %}
{% for key in user.keys.all() %}
<link rel="pgpkey" href="{{ key.file.url }}" />
{% endfor %}
{% endblock %}
{% block main %}
<aside class="author">
<article class="h-card p-author card">
<a class="u-uid u-url" href="{{ user.full_url }}">
{% if user.avatar %}
<img class="u-photo card-img-top" src="{{ user.avatar.url }}" alt="{{ user.name }}" />
{% endif %}
</a>
<div class="card-body">
<h4 class="p-name card-title">
<span class="p-given-name">{{ user.first_name }}</span> <span class="p-family-name">{{ user.last_name }}</span>
</h4>
{% if user.note %}
<div class="p-note">
{{ user.note | markdown }}
</div>
{% endif %}
</div>
<div class="card-footer">
<ul class="profiles">
<li>
<a class="u-email" rel="me" href="mailto:{{ user.email }}">
<i class="fas fa-envelope" aria-hidden="true"></i>
{{ user.email }}
</a>
</li>
{% if user.xmpp %}
<li>
<a class="u-impp" rel="me" href="xmpp:{{ user.xmpp }}">
<i class="openwebicons-xmpp" aria-hidden="true"></i>
{{ user.xmpp }}
</a>
</li>
{% endif %}
</ul>
</div>
{% if user.keys.exists() %}
<div class="card-footer">
<ul class="profiles">
{% for key in user.keys.all() %}
<a class="u-key" href="{{ key.file.url }}">
<i class="fas fa-key" aria-hidden="true"></i>
{{ key.pretty_print() }}
</a>
{% endfor %}
</ul>
</div>
{% endif %}
{% if user.profiles.exists() %}
<div class="card-footer">
<ul class="profiles">
{% for profile in user.profiles.all() %}
<a class="u-url" rel="me" href="{{ profile.url }}" title="{{ profile }}">
<i class="{{ profile.site.icon }}" aria-hidden="true"></i>
<span class="sr-only">{{ profile }}</span>
</a>
{% endfor %}
</ul>
</div>
{% endif %}
<script class="p-json-ld" type="application/ld+json">{{ user.json_ld | tojson }}</script>
</article>
</aside>
{% import 'entries/h-entry.html' as h %}
<ol class="list-unstyled entries">
{% for entry in entries %}
<li>
{{ h.hEntry(entry, indent_width=10) }}
</li>
{% endfor %}
</ol>
{% endblock %}
{% block foot %}
<script type="text/javascript">
tippy('.profiles [title]', {
arrow: true,
content: function(element) {
return element.getAttribute('title');
}
});
</script>
{% endblock %}

View file

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

View file

@ -1,8 +1,3 @@
$sm = 576px
$md = 768px
$lg = 992px
$xl = 1200px
main
flex-direction column
align-items center

View file

@ -1,70 +0,0 @@
{% extends 'lemoncurry/layout.html' %}
{% load jsonify markdown static %}
{% block html_class %}h-feed{% endblock %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{% static 'home/css/index.styl' %}" />
<link rel="stylesheet" type="text/stylus" href="{% static 'entries/css/h-entry.styl' %}" />
{% endblock %}
{% block head %}{% for key in user.keys.all %}<link rel="pgpkey" href="{{ key.file.url }}" />{% endfor %}{% endblock %}
{% block main %}
<aside class="author">
<article class="h-card card p-author">
<a class="u-uid u-url" href="{{ uri }}">
{% if user.avatar %}<img class="card-img-top u-photo" src="{{ user.avatar.url }}" alt="{{ user.first_name }} {{ user.last_name }}" />{% endif %}
</a>
<div class="card-body">
<h4 class="card-title p-name">
<span class="p-given-name">{{ user.first_name }}</span> <span class="p-family-name">{{ user.last_name }}</span>
</h4>
{% if user.note %}<div class="p-note">{{ user.note | markdown }}</div>{% endif %}
</div>
<div class="card-footer">
<ul class="profiles">
<li><a class="u-email" rel="me" href="mailto:{{ user.email }}">
<i class="fas fa-envelope"></i> {{ user.email }}
</a></li>
{% if user.xmpp %}<li><a class="u-impp" rel="me" href="xmpp:{{ user.xmpp }}">
<i class="openwebicons-xmpp" aria-hidden="true"></i> {{ user.xmpp }}
</a></li>{% endif %}
</ul>
</div>
{% if user.keys.exists %}<div class="card-footer">
<ul class="profiles">
{% for key in user.keys.all %}<li>
<a class="u-key" href="{{ key.file.url }}">
<i class="fas fa-key"></i> {{ key.pretty_print }}
</a>
</li>
{% endfor %}
</ul>
</div>{% endif %}
{% if user.profiles.exists %}<div class="card-footer">
<ul class="profiles">
{% for profile in user.profiles.all %}<li>
<a class="u-url" rel="me" href="{{ profile.url }}" title="{{ profile }}"><i class="{{ profile.site.icon }}" aria-hidden="true"></i><span class="sr-only">{{ profile }}</span></a>
</li>{% endfor %}
</ul>
</div>{% endif %}
<script class="p-json-ld" type="application/ld+json">{{ user.json_ld | jsonify }}</script>
</article>
</aside>
<ol class="list-unstyled entries">
{% for entry in entries %}
<li>
{% include 'entries/h-entry.html' %}
</li>
{% endfor %}
</ol>
{% endblock %}
{% block foot %}
<script type="text/javascript">
tippy('.profiles [title]', {arrow: true});
</script>
{% endblock %}

View file

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

View file

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

View file

@ -9,8 +9,10 @@
<form class="card" method="post" action="{{ url('lemonauth:indie_approve') }}">
<h4 class="card-header h-x-app">
{% if app %}
<img class="u-logo p-name" src="{{ app.logo[0] }}" alt="{{ app.name[0] }}">
sign in to {{ app.name[0] }} (<a class="u-url code" href="{{ params.client_id }}">{{ params.client_id }}</a>)?
{% if app.logo is defined %}
<img class="u-logo" src="{{ app.logo[0] }}" alt="{{ app.name[0] }}" />
{% endif %}
sign in to <span class="p-name">{{ app.name[0] }}</span> (<a class="u-url code" href="{{ params.client_id }}">{{ params.client_id }}</a>)?
{% else %}
sign in to <a class="u-url p-name code" href="{{ params.client_id }}">{{ params.client_id }}</a>?
{% endif %}
@ -60,15 +62,22 @@
</div>
<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 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>
{% endblock %}
{% block foot %}
<script type="text/javascript">
tippy('[data-tippy-theme]', {arrow: true});
tippy('[data-tippy-html]', {
arrow: true,
allowHTML: true,
maxWidth: 500,
content: function(element) {
return document.querySelector(element.getAttribute('data-tippy-html')).innerHTML;
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,68 @@
{% extends 'lemoncurry/layout.html' %}
{% block main %}
<div class="container">
<div class="card-columns">
{% for _, c in clients | dictsort %}
<div class="h-x-app card">
{% if c.app.logo %}
<img class="u-photo card-img-top" src="{{ c.app.logo[0] }}" alt="{{ c.app.name[0] }}" />
{% endif %}
<div class="card-body">
<h5 class="card-title">
<span class="p-name">{{ c.app.name[0] if c.app else (c.id | friendly_url) }}</span>
<span class="badge badge-light">
<span class="p-count">{{ c.count }}</span>
{{ 'tokens' if c.count > 1 else 'token' }}
</span>
</h5>
<h6 class="card-subtitle mb-2">
<a class="u-url" href="{{ c.id }}">{{ c.id }}</a>
</h6>
<p class="card-text">this client has access to the scopes:</p>
</div>
<ul class="list-group list-group-flush">
{% for scope in c.scopes %}
<li class="p-scope list-group-item">{{ scope }}</li>
{% endfor %}
</ul>
<form class="card-footer text-right" action="{{ url('lemonauth:tokens_revoke', kwargs={'client_id': c.id}) }}">
{{ csrf_input }}
<button type="submit" class="btn btn-danger">
<i class="fas fa-ban" aria-hidden="true"></i>
revoke access
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block foot %}
<script type="text/javascript">
$('form').on('submit', function(e) {
e.preventDefault();
var $f = $(this);
if ($f.data('deleting')) return;
$f.data('deleting', true);
$f.find('button').prop({disabled: true})
.find('[data-fa-i2svg]').removeClass('fa-ban').addClass('fa-circle-notch fa-spin');
$.ajax({
headers: {'X-CSRFToken': $f.find('[name="csrfmiddlewaretoken"]').val()},
method: 'DELETE',
url: $f.attr('action'),
}).then(function() {
var $app = $f.parent('.h-x-app');
return $app.hide(function() {
$app.remove();
});
});
});
</script>
{% endblock %}

View file

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

View file

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

View file

@ -0,0 +1,120 @@
# Generated by Django 2.0.6 on 2018-06-12 04:51
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import randomslugfield.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("lemonauth", "0002_delete_indieauthcode"),
]
operations = [
migrations.CreateModel(
name="IndieAuthCode",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
randomslugfield.fields.RandomSlugField(
blank=True,
editable=False,
length=30,
max_length=30,
primary_key=True,
serialize=False,
unique=True,
),
),
("client_id", models.URLField()),
("scope", models.TextField(blank=True)),
("redirect_uri", models.URLField()),
(
"response_type",
model_utils.fields.StatusField(
choices=[("id", "id"), ("code", "code")],
default="id",
max_length=100,
no_check_for_status=True,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Token",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
randomslugfield.fields.RandomSlugField(
blank=True,
editable=False,
length=30,
max_length=30,
primary_key=True,
serialize=False,
unique=True,
),
),
("client_id", models.URLField()),
("scope", models.TextField(blank=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]

62
lemonauth/models.py Normal file
View file

@ -0,0 +1,62 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.timezone import now
from randomslugfield import RandomSlugField
from model_utils import Choices
from model_utils.fields import StatusField
from model_utils.models import TimeStampedModel
class AuthSecret(TimeStampedModel):
"""
An AuthSecret is a model with an unguessable primary key, suitable for
sharing with external sites for secure authentication.
AuthSecret is primarily used to factor out the many similarities between
authorisation codes and tokens in IndieAuth - the two contain many
identical fields, but just a few differences.
"""
id = RandomSlugField(primary_key=True, length=30)
client_id = models.URLField()
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
scope = models.TextField(blank=True)
@property
def me(self):
return self.user.full_url
def __contains__(self, scope):
return scope in self.scope.split(" ")
class Meta:
abstract = True
class IndieAuthCode(AuthSecret):
"""
An IndieAuthCode is an authorisation code that a client must provide to us
to complete the IndieAuth process.
Codes are single-use, and if unused will be expired automatically after
thirty seconds.
"""
redirect_uri = models.URLField()
RESPONSE_TYPE = Choices("id", "code")
response_type = StatusField(choices_name="RESPONSE_TYPE")
@property
def expired(self):
return self.created + timedelta(seconds=30) < now()
class Token(AuthSecret):
"""
A Token grants a client long-term authorisation - it will not expire unless
explicitly revoked by the user.
"""
pass

View file

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

View file

@ -1,78 +1,46 @@
from jose import jwt
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from django.conf import settings
from micropub.views import error
from micropub import error
from .models import IndieAuthCode, Token
def auth(request):
if 'HTTP_AUTHORIZATION' in request.META:
auth = request.META.get('HTTP_AUTHORIZATION').split(' ')
if auth[0] != 'Bearer':
return error.bad_req('auth type {0} not supported'.format(auth[0]))
def auth(request) -> Token:
if "HTTP_AUTHORIZATION" in request.META:
auth = request.META.get("HTTP_AUTHORIZATION").split(" ")
if auth[0] != "Bearer":
raise error.bad_req("auth type {0} not supported".format(auth[0]))
if len(auth) != 2:
return error.bad_req(
'invalid Bearer auth format, must be Bearer <token>'
)
raise error.bad_req("invalid Bearer auth format, must be Bearer <token>")
token = auth[1]
elif 'access_token' in request.POST:
token = request.POST.get('access_token')
elif 'access_token' in request.GET:
token = request.GET.get('access_token')
elif "access_token" in request.POST:
token = request.POST.get("access_token")
elif "access_token" in request.GET:
token = request.GET.get("access_token")
else:
return error.unauthorized()
raise error.unauthorized()
try:
token = decode(token)
except Exception as e:
return error.forbidden()
token = Token.objects.get(pk=token)
except Token.DoesNotExist:
raise error.forbidden()
return MicropubToken(token)
class MicropubToken:
def __init__(self, tok):
self.user = get_user_model().objects.get(pk=tok['uid'])
self.client = tok['cid']
self.scope = tok['sco']
self.me = self.user.full_url
self.scopes = self.scope.split(' ')
def __contains__(self, scope):
return scope in self.scopes
def encode(payload):
return jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
def decode(token):
return jwt.decode(token, settings.SECRET_KEY, algorithms=('HS256',))
return token
def gen_auth_code(req):
code = {
'uid': req.user.id,
'cid': req.POST['client_id'],
'uri': req.POST['redirect_uri'],
'typ': req.POST.get('response_type', 'id'),
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(seconds=30),
}
if 'scope' in req.POST:
code['sco'] = ' '.join(req.POST.getlist('scope'))
return encode(code)
code = IndieAuthCode()
code.user = req.user
code.client_id = req.POST["client_id"]
code.redirect_uri = req.POST["redirect_uri"]
code.response_type = req.POST.get("response_type", "id")
if "scope" in req.POST:
code.scope = " ".join(req.POST.getlist("scope"))
code.save()
return code.id
def gen_token(code):
tok = {
'uid': code['uid'],
'cid': code['cid'],
'sco': code['sco'],
'iat': datetime.utcnow(),
}
return encode(tok)
tok = Token()
tok.user = code.user
tok.client_id = code.client_id
tok.scope = code.scope
tok.save()
return tok.id

View file

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

View file

@ -2,3 +2,4 @@ from .login import login
from .logout import logout
from .indie import IndieView, approve as indie_approve
from .token import TokenView
from .tokens import TokensListView, TokensRevokeView

View file

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

View file

@ -2,11 +2,11 @@ import django.contrib.auth.views
from otp_agents.forms import OTPAuthenticationForm
from lemoncurry import breadcrumbs
breadcrumbs.add(route='lemonauth:login', label='log in', parent='home:index')
breadcrumbs.add(route="lemonauth:login", label="log in", parent="home:index")
login = django.contrib.auth.views.LoginView.as_view(
authentication_form=OTPAuthenticationForm,
extra_context={'title': 'log in'},
template_name='lemonauth/login.html',
extra_context={"title": "log in"},
template_name="lemonauth/login.html",
redirect_authenticated_user=True,
)

View file

@ -1,49 +1,48 @@
from django.contrib.auth import get_user_model
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from urllib.parse import urljoin
from .. import tokens
from ..models import IndieAuthCode
from lemoncurry import utils
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(csrf_exempt, name="dispatch")
class TokenView(View):
def get(self, req):
token = tokens.auth(req)
if hasattr(token, 'content'):
return token
res = {
'me': token.me,
'client_id': token.client,
'scope': token.scope,
"me": token.me,
"client_id": token.client_id,
"scope": token.scope,
}
return utils.choose_type(req, res)
def post(self, req):
post = req.POST
try:
code = tokens.decode(post.get('code'))
except Exception:
return utils.forbid('invalid auth code')
code = IndieAuthCode.objects.get(pk=post.get("code"))
except IndieAuthCode.DoesNotExist:
return utils.forbid("invalid auth code")
code.delete()
if code.expired:
return utils.forbid("invalid auth code")
if code['typ'] != 'code':
return utils.bad_req(
'this endpoint only supports response_type=code'
)
if code['cid'] != post.get('client_id'):
return utils.forbid('client id did not match')
if code['uri'] != post.get('redirect_uri'):
return utils.forbid('redirect uri did not match')
if code.response_type != "code":
return utils.bad_req("this endpoint only supports response_type=code")
if "client_id" in post and code.client_id != post["client_id"]:
return utils.forbid("client id did not match")
if code.redirect_uri != post.get("redirect_uri"):
return utils.forbid("redirect uri did not match")
user = get_user_model().objects.get(pk=code['uid'])
me = urljoin(utils.origin(req), user.url)
if me != post.get('me'):
return utils.forbid('me did not match')
if "me" in post and code.me != post["me"]:
return utils.forbid("me did not match")
return utils.choose_type(req, {
'access_token': tokens.gen_token(code),
'me': me,
'scope': code['sco'],
})
return utils.choose_type(
req,
{
"access_token": tokens.gen_token(code),
"me": code.me,
"scope": code.scope,
},
)

View file

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

View file

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

View file

@ -0,0 +1,9 @@
from django.http import HttpResponse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views import View
class TokensRevokeView(LoginRequiredMixin, View):
def delete(self, request, client_id: str):
request.user.token_set.filter(client_id=client_id).delete()
return HttpResponse(status=204)

Binary file not shown.

View file

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

View file

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

View file

@ -1,25 +0,0 @@
from django.contrib.staticfiles.storage import staticfiles_storage
from django.urls import reverse
from jinja2 import Environment
from compressor.contrib.jinja2ext import CompressorExtension
from django_activeurl.ext.django_jinja import ActiveUrl
from entries.kinds import all as entry_kinds
from .utils import load_package_json
def environment(**options):
env = Environment(
extensions=[ActiveUrl, CompressorExtension],
trim_blocks=True,
lstrip_blocks=True,
**options
)
env.globals.update({
'entry_kinds': entry_kinds,
'package': load_package_json(),
'static': staticfiles_storage.url,
'url': reverse,
})
return env

View file

@ -0,0 +1,43 @@
from django.contrib.staticfiles.storage import staticfiles_storage
from django.conf import settings
from django.urls import reverse
from jinja2 import Environment
from compressor.contrib.jinja2ext import CompressorExtension
from django_activeurl.ext.django_jinja import ActiveUrl
from entries.kinds import all as entry_kinds
from wellknowns.favicons import icons as favicons
from .ago import ago
from .markdown import markdown
from ..theme import color as theme_color
from ..utils import friendly_url, load_package_json
def environment(**options):
env = Environment(
extensions=[ActiveUrl, CompressorExtension],
trim_blocks=True,
lstrip_blocks=True,
**options
)
env.filters.update(
{
"ago": ago,
"friendly_url": friendly_url,
"markdown": markdown,
}
)
env.globals.update(
{
"entry_kinds": entry_kinds,
"favicons": favicons,
"package": load_package_json(),
"settings": settings,
"static": staticfiles_storage.url,
"theme_color": theme_color,
"url": reverse,
}
)
return env

9
lemoncurry/jinja2/ago.py Normal file
View file

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

View file

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

View file

@ -1,37 +1,58 @@
<!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>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ request.site.name }}</title>
{% if atom is defined %}
<link rel="alternate" type="application/atom+xml" href="{{ atom }}" />
{% endif %}
{% if rss is defined %}
<link rel="alternate" type="application/rss+xml" href="{{ rss }}" />
{% endif %}
{% block head %}{% endblock %}
<link rel="stylesheet" type="text/css" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/monokai.min.css"
integrity="sha384-bHqbpRh/XW+phptvH9nQvMKHwPH1ZbOxpIeAB2D2OIEL4Ni7aZzZgMFpsRra+v1g" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/openwebicons@1.4.3/css/openwebicons.min.css"
integrity="sha384-Ljk0G9f8GyEhAzrdHNkQc89A/Kpq+sy09gejdAPyMyTDnPe4aDfS/ppZ/rDGM0Y9" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/tippy.js@2.5.2/dist/tippy.css"
integrity="sha384-JsezPuW/bNd38848K5/8rIEbB+23QGQ8faCF8xEmjFT3i1qujgGiewfYevzMO3J1" crossorigin="anonymous">
<base href="{{ request.build_absolute_uri(url('home:index')) }}" />
<link rel="authorization_endpoint" href="{{ url('lemonauth:indie') }}" />
<link rel="canonical" href="{{ request.build_absolute_uri() }}" />
<link rel="hub" href="{{ settings.PUSH_HUB }}" />
<link rel="manifest" href="{{ url('wellknowns:manifest') }}" />
<link rel="micropub" href="{{ url('micropub:micropub') }}" />
<link rel="token_endpoint" href="{{ url('lemonauth:token') }}" />
<meta name="generator" content="{{ package.name }} {{ package.version }}" />
<meta name="theme-color" content="{{ theme_color(3) }}" />
{% for i in favicons %}
<link rel="{{ i.rel }}" type="{{ i.mime }}" sizes="{{ i.sizes }}" href="{{ i.url }}" />
{% endfor %}
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/monokai.min.css"
integrity="sha384-88Jvj9Q2LiBDwL7w3yciRTcH5q2zzvMFYIm4xX9/evqxJsxA33Xk9XYKcvUlPITo" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/openwebicons@1.6.0/css/openwebicons.min.css"
integrity="sha384-NkRWM9o4Kfak7GwS+un+sProBBpj02vc/e1EoXvdCUSdRk0muOfkKJ5NtpueAuka" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/tippy.js@3.4.1/dist/tippy.css"
integrity="sha384-hm3Wtrva6FibonAOqHHXSpMxvGbz2g7l5FK5avbuNviir5MK6Ap4o3EOohztzHHm" crossorigin="anonymous">
{% compress css %}
<link rel="stylesheet" type="text/stylus" href="{{ static('lemoncurry/css/layout.styl') }}">
{% block styles %}{% endblock %}
{% endcompress %}
<script type="text/javascript" defer src="https://use.fontawesome.com/releases/v5.0.13/js/all.js"
integrity="sha384-xymdQtn1n3lH2wcu0qhcdaOpQwyoarkgLVxC/wZ5q7h9gHtxICrpcaSUfygqZGOe" crossorigin="anonymous"></script>
<script type="text/javascript" defer src="https://kit.fontawesome.com/a3aade9b41.js" crossorigin="anonymous"></script>
</head>
<body{% block body_attr %}{% endblock %}>
<header>
<nav class="navbar navbar-expand-md navbar-dark">
<a class="navbar-brand" ref="home" href="{{ url('home:index') }}">{{ request.site.name }}</a>
<nav class="navbar navbar-expand-md"><div class="container-fluid">
<a class="navbar-brand" rel="home" href="{{ url('home:index') }}">{{ request.site.name }}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% activeurl %}<div class="collapse navbar-collapse" id="navbar">
{% activeurl %}
<div class="collapse navbar-collapse" id="navbar">
<ul class="navbar-nav">
{% for kind in entry_kinds %}
<li class="nav-item">
@ -45,6 +66,12 @@
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url('lemonauth:tokens') }}">
<i class="fas fa-cookie-bite fa-fw" aria-hidden="true"></i>
tokens
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url('admin:index') }}">
<i class="fas fa-cog fa-fw" aria-hidden="true"></i>
@ -66,9 +93,10 @@
</li>
{% endif %}
</ul>
</div>{% endactiveurl %}
</div>
{% endactiveurl %}
</nav>
</div></nav>
</header>
<main>
@ -78,19 +106,54 @@
<footer>
<p>all content licensed under <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">cc by-sa 4.0</a></p>
{% if entries is defined and entries.has_other_pages() %}
<nav>
<ul class="pagination">
{% if entries.prev %}
<li class="page-item">
<a class="page-link" rel="prev" href="{{ entries.prev.url }}">
<i class="fas fa-step-backward" aria-hidden="true"></i> <span class="sr-only">previous page</span>
</a>
</li>
{% endif %}
{% for page in entries.pages %}
{% if page.current %}
<li class="page-item active">
<span class="page-link">{{ page.i }} <span class="sr-only">(current page)</span></span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ page.url }}">{{ page.i }}</a>
</li>
{% endif %}
{% endfor %}
{% if entries.next %}
<li class="page-item">
<a class="page-link" rel="next" href="{{ entries.next.url }}">
<i class="fas fa-step-forward" aria-hidden="true"></i> <span class="sr-only">next page</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<p>powered by <a rel="code-repository" href="{{ package.repository }}/src/tag/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
</footer>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" crossorigin="anonymous"
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" crossorigin="anonymous"
integrity="sha384-ZeLYJ2PNSQjvogWP559CDAf02Qb8FE5OyQicqtz/+UhZutbrwyr87Be7NPH/RgyC"></script>
<script src="https://unpkg.com/tippy.js@2.5.2/dist/tippy.standalone.min.js" crossorigin="anonymous"
integrity="sha384-VEMCz3fC5atUNN+ezSHq2AZIBciT3aWGEZsStnW58gtO9PYb3wenWsYNoxLTbi/M"></script>
<script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"
integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT"></script>
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js" crossorigin="anonymous"
integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp"></script>
<script src="https://unpkg.com/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"></script>
<script src="https://unpkg.com/tippy.js@6.3.7/dist/tippy-bundle.umd.js" crossorigin="anonymous"
integrity="sha384-dtMr4wkcxQWUqsJFgElu4AttgIhOsjr2vYIzP2mv0MZbD/uJ6OHxFdbgE3MOKabN"></script>
{% compress js %}
<script type="text/javascript">
hljs.initHighlightingOnLoad();

View file

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

16
lemoncurry/middleware.py Normal file
View file

@ -0,0 +1,16 @@
from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
class ResponseException(Exception):
def __init__(self, response: HttpResponse) -> None:
self.response = response
class ResponseExceptionMiddleware(MiddlewareMixin):
def process_exception(
self, request: HttpRequest, exception: Exception
) -> HttpResponse:
if isinstance(exception, ResponseException):
return exception.response
raise exception

View file

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

View file

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

View file

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

View file

@ -4,19 +4,19 @@ from os.path import join
from .base import *
from .base import BASE_DIR, DATABASES
ALLOWED_HOSTS = ['00dani.me']
ALLOWED_HOSTS = ["00dani.me"]
DEBUG = False
SECRET_KEY = environ['DJANGO_SECRET_KEY']
SERVER_EMAIL = 'lemoncurry@00dani.me'
SECRET_KEY = environ["DJANGO_SECRET_KEY"]
SERVER_EMAIL = "lemoncurry@00dani.me"
# 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')
MEDIA_ROOT = join(BASE_DIR, '..', 'media')
STATIC_URL = 'https://cdn.00dani.me/'
MEDIA_URL = STATIC_URL + 'media/'
META_SITE_DOMAIN = '00dani.me'
META_FB_APPID = '145311792869199'
STATIC_ROOT = join(BASE_DIR, "..", "static")
MEDIA_ROOT = join(BASE_DIR, "..", "media")
STATIC_URL = "https://cdn.00dani.me/"
MEDIA_URL = STATIC_URL + "m/"
META_SITE_DOMAIN = "00dani.me"
META_FB_APPID = "145311792869199"

View file

@ -1,5 +1,8 @@
from .base import *
ALLOWED_HOSTS = ['*']
ALLOWED_HOSTS = ["*"]
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

@ -1,14 +1,11 @@
$monokai_bg = #272822
$sm = 576px
$md = 768px
$lg = 992px
$xl = 1200px
html
background-color $base00
a
color $base0D
text-decoration none
&:hover
color $base0C
@ -27,19 +24,21 @@ code, pre, .code, .pre
border-color $base00
color $base07
.list-group-item
background-color $base03
[class^="openwebicons-"], [class*=" openwebicons-"]
&::before
text-decoration none
line-height 1
for placement in top bottom left right
.tippy-popper[x-placement^={placement}] .tippy-tooltip.dark-theme .tippy-arrow
border-{placement}-color $base03
.tippy-tooltip.dark-theme
.tippy-box[data-theme~='dark']
background-color $base03
color $base04
text-align center
for placement in top bottom left right
&[data-placement^={placement}] > .tippy-arrow::before
border-{placement}-color $base03
body
display flex
@ -66,7 +65,7 @@ body
> main
padding 2rem
padding 2rem 1rem
width 100%
flex 1
display flex
@ -105,6 +104,12 @@ ul.pagination
background-color $base02
border 1px solid rgba(0,0,0,.125)
.media
display flex
> .media-body
flex-grow 1
margin-left 3px
.card
background-color $base02

View file

@ -6,9 +6,20 @@ const {safeLoad} = require('js-yaml');
const themePath = join(__dirname, '..', '..', 'base16-materialtheme-scheme', 'material-darker.yaml');
const breakpoints = {
sm: 576,
md: 768,
lg: 992,
xl: 1200,
};
module.exports = function() {
const theme = safeLoad(readFileSync(themePath, 'utf8'));
return function(style) {
for (let key in breakpoints) {
style.define('$' + key, new stylus.nodes.Unit(breakpoints[key], 'px'));
}
for (let i = 0; i < 16; i++) {
const key = 'base0' + i.toString(16).toUpperCase();
const hex = theme[key];

View file

@ -1,134 +0,0 @@
{% load analytical compress favicon lemoncurry_tags meta static theme_colour %}<!doctype html>
<html dir="ltr" lang="en" class="{% block html_class %}{% endblock %}">
<head{% meta_namespaces %}>{% site_name as site_name %}{% request_uri request as uri %}{% request_origin request as origin %}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<base href="{{ origin }}" />
<link rel="canonical" href="{{ uri }}" />
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ site_name }}</title>
{% analytical_head_top %}
{% if atom %}<link rel="alternate" type="application/atom+xml" href="{{ atom }}" />{% endif %}
{% if rss %}<link rel="alternate" type="application/rss+xml" href="{{ rss }}" /> {% endif %}
{% block head %}{% endblock %}
<link rel="authorization_endpoint" href="{{ origin }}{% url 'lemonauth:indie' %}" />
<link rel="token_endpoint" href="{{ origin }}{% url 'lemonauth:token' %}" />
<link rel="micropub" href="{{ origin }}{% url 'micropub:micropub' %}" />
<link rel="openid.delegate" href="{{ uri }}" />
<link rel="openid.server" href="https://openid.indieauth.com/openid" />
<link rel="hub" href="{% get_push_hub %}" />
<link rel="self" href="{{ uri }}" />
<link rel="manifest" href="{% url 'wellknowns:manifest' %}" />
<meta name="theme-color" content="{% theme_colour 2 %}" />
{% get_package_json as package %}
<meta name="generator" content="{{ package.name }} {{ package.version }}" />
<meta property="og:url" content="{{ uri }}" />
<meta property="og:title" content="{% firstof title site_name %}" />
{% include 'meta/meta.html' %}
{% get_favicons 'favicon/' %}
<link rel="stylesheet" type="text/css" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous" />
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/monokai.min.css"
integrity="sha384-bHqbpRh/XW+phptvH9nQvMKHwPH1ZbOxpIeAB2D2OIEL4Ni7aZzZgMFpsRra+v1g" crossorigin="anonymous" />
<link rel="stylesheet" type="text/css" href="https://unpkg.com/openwebicons@1.4.3/css/openwebicons.min.css"
integrity="sha384-Ljk0G9f8GyEhAzrdHNkQc89A/Kpq+sy09gejdAPyMyTDnPe4aDfS/ppZ/rDGM0Y9" crossorigin="anonymous" />
<link rel="stylesheet" type="text/css" href="https://unpkg.com/tippy.js@2.5.2/dist/tippy.css"
integrity="sha384-JsezPuW/bNd38848K5/8rIEbB+23QGQ8faCF8xEmjFT3i1qujgGiewfYevzMO3J1" crossorigin="anonymous" />
{% compress css %}
<link rel="stylesheet" type="text/stylus" href="{% static 'lemoncurry/css/layout.styl' %}" />
{% block styles %}{% endblock %}
{% endcompress %}
<script type="text/javascript" defer src="https://use.fontawesome.com/releases/v5.0.13/js/all.js"
integrity="sha384-xymdQtn1n3lH2wcu0qhcdaOpQwyoarkgLVxC/wZ5q7h9gHtxICrpcaSUfygqZGOe" crossorigin="anonymous"></script>
{% analytical_head_bottom %}
</head>
<body{% block body_attr %}{% endblock %}>
{% analytical_body_top %}
<header>
<nav class="navbar navbar-expand-md navbar-dark">
<a class="navbar-brand" rel="home" href="{% url 'home:index' %}">{% site_name %}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar">
{% block nav_left %}{% nav_left request %}{% endblock %}
{% block nav_right %}{% nav_right request %}{% endblock %}
</div>
</nav>
{% if request.resolver_match.view_name %}
{% nav_crumbs request.resolver_match %}
{% endif %}
</header>
<main>
{% block main %}{% endblock %}
</main>
<footer>
<p>all content licensed under <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">cc by-sa 4.0</a></p>
{% if entries.has_other_pages %}
<nav>
<ul class="pagination">
{% if entries.prev %}
<li class="page-item">
<a class="page-link" rel="prev" href="{{ entries.prev.url }}">
<i class="fas fa-step-backward"></i><span class="sr-only">previous page</span>
</a>
</li>
{% endif %}
{% for page in entries.pages %}
{% if page.current %}
<li class="page-item active">
<span class="page-link">{{ page.i }} <span class="sr-only">(current page)</span></span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ page.url }}">{{ page.i }}</a>
</li>
{% endif %}
{% endfor %}
{% if entries.next %}
<li class="page-item">
<a class="page-link" rel="next" href="{{ entries.next.url }}">
<i class="fas fa-step-forward"></i><span class="sr-only">next page</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<p>powered by <a rel="code-repository" href="{{ package.repository }}/tree/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
</footer>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" crossorigin="anonymous"
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" crossorigin="anonymous"
integrity="sha384-ZeLYJ2PNSQjvogWP559CDAf02Qb8FE5OyQicqtz/+UhZutbrwyr87Be7NPH/RgyC"></script>
<script src="https://unpkg.com/tippy.js@2.5.2/dist/tippy.standalone.min.js" crossorigin="anonymous"
integrity="sha384-VEMCz3fC5atUNN+ezSHq2AZIBciT3aWGEZsStnW58gtO9PYb3wenWsYNoxLTbi/M"></script>
{% compress js %}
<script type="text/javascript">
hljs.initHighlightingOnLoad();
</script>
{% block foot %}{% endblock %}
{% endcompress %}
{% analytical_body_bottom %}
</body>
</html>

View file

@ -1,11 +0,0 @@
{% load jsonify %}{% if crumbs %}
<nav class="breadcrumbs" aria-label="breadcrumb" role="navigation">
<ol class="breadcrumb">
{% for crumb in crumbs %}
<li class="breadcrumb-item"><a href="{{ crumb.url }}">{{ crumb.label }}</a></li>
{% endfor %}
<li class="breadcrumb-item active" aria-current="page">{% firstof current.label title %}</li>
</ol>
<script type="application/ld+json">{{ breadcrumb_list | jsonify }}</script>
</nav>
{% endif %}

View file

@ -1,8 +0,0 @@
{% load activeurl %}{% activeurl %}<ul class="navbar-nav">
{% for item in items %}
<li class="nav-item"><a class="nav-link" href="{{ item.url }}">
<i class="{{ item.icon }} fa-fw"></i>
{{ item.label }}
</a></li>
{% endfor %}
</ul>{% endactiveurl %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,12 +4,14 @@ from yaml import safe_load
path = join(
settings.BASE_DIR,
'lemoncurry', 'static',
'base16-materialtheme-scheme', 'material-darker.yaml',
"lemoncurry",
"static",
"base16-materialtheme-scheme",
"material-darker.yaml",
)
with open(path, 'r') as f:
with open(path, "r") as f:
theme = safe_load(f)
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
sections = {
'entries': EntriesSitemap,
'home': HomeSitemap,
"entries": EntriesSitemap,
"home": HomeSitemap,
}
maps = {'sitemaps': sections}
maps = {"sitemaps": sections}
urlpatterns = (
path('', include('home.urls')),
path('', include('entries.urls')),
path('', include('users.urls')),
path('.well-known/', include('wellknowns.urls')),
path('admin/doc/', include('django.contrib.admindocs.urls')),
path('admin/', admin.site.urls),
path('auth/', include('lemonauth.urls')),
path('favicon.ico', RedirectView.as_view(
url=settings.MEDIA_URL + 'favicon/favicon.ico')),
path('micropub', include('micropub.urls')),
path('s/', include('lemonshort.urls')),
path('webmention', include('webmention.urls')),
path('django-rq/', include('django_rq.urls')),
path('sitemap.xml', sitemap.index, maps, name='sitemap'),
path('sitemaps/<section>.xml', sitemap.sitemap, maps,
name='django.contrib.sitemaps.views.sitemap'),
path("", include("home.urls")),
path("", include("entries.urls")),
path("", include("users.urls")),
path(".well-known/", include("wellknowns.urls")),
path("admin/doc/", include("django.contrib.admindocs.urls")),
path("admin/", admin.site.urls),
path("auth/", include("lemonauth.urls")),
path(
"favicon.ico",
RedirectView.as_view(url=settings.MEDIA_URL + "favicon/favicon.ico"),
),
path("micropub", include("micropub.urls")),
path("s/", include("lemonshort.urls")),
path("webmention", include("webmention.urls")),
path("django-rq/", include("django_rq.urls")),
path("sitemap.xml", sitemap.index, maps, name="sitemap"),
path(
"sitemaps/<section>.xml",
sitemap.sitemap,
maps,
name="django.contrib.sitemaps.views.sitemap",
),
) # type: Tuple[URLPattern, ...]
if settings.DEBUG:
import debug_toolbar
urlpatterns += (
path('__debug__/', include(debug_toolbar.urls)),
)
urlpatterns += (path("__debug__/", include(debug_toolbar.urls)),)

View file

@ -6,24 +6,44 @@ from django.http import HttpResponse, JsonResponse
from django.http import HttpResponseForbidden, HttpResponseBadRequest
from django.utils.html import strip_tags
from os.path import join
from types import SimpleNamespace
from urllib.parse import urlencode, urljoin
from typing import Any, Dict, Optional
from urllib.parse import urlencode, urljoin, urlparse
from .templatetags.markdown import markdown
cache = SimpleNamespace(package_json=None)
class PackageJson:
data: Optional[Dict[str, Any]]
def __init__(self) -> None:
self.data = None
def load(self) -> Dict[str, Any]:
if self.data is None:
with open(join(settings.BASE_DIR, "package.json")) as f:
self.data = json.load(f)
assert self.data is not None
return self.data
def load_package_json():
if cache.package_json:
return cache.package_json
with open(join(settings.BASE_DIR, 'package.json')) as f:
cache.package_json = json.load(f)
return cache.package_json
PACKAGE = PackageJson()
def friendly_url(url):
if "//" not in url:
url = "//" + url
(scheme, netloc, path, params, q, fragment) = urlparse(url)
if path == "/":
return netloc
return "{}\u200B{}".format(netloc, path)
def load_package_json() -> Dict[str, Any]:
return PACKAGE.load()
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):
@ -36,19 +56,18 @@ def uri(request):
def form_encoded_response(content):
return HttpResponse(
urlencode(content),
content_type='application/x-www-form-urlencoded'
urlencode(content), content_type="application/x-www-form-urlencoded"
)
REPS = {
'application/x-www-form-urlencoded': form_encoded_response,
'application/json': JsonResponse,
"application/x-www-form-urlencoded": form_encoded_response,
"application/json": JsonResponse,
}
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())
if type:
return reps[type](content)
@ -56,11 +75,11 @@ def choose_type(request, content, reps=REPS):
def bad_req(message):
return HttpResponseBadRequest(message, content_type='text/plain')
return HttpResponseBadRequest(message, content_type="text/plain")
def forbid(message):
return HttpResponseForbidden(message, content_type='text/plain')
return HttpResponseForbidden(message, content_type="text/plain")
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):
name = 'lemonshort'
name = "lemonshort"

View file

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

View file

@ -2,7 +2,7 @@ from django.apps import apps
from django.conf import settings
from typing import Any, Dict, Type
from .convert import id_to_abc
from .convert import AbcIdConverter
prefixes = {} # type: Dict[Type[Any], str]
@ -11,7 +11,7 @@ def short_url(entity):
if not prefixes:
for k, m in settings.SHORTEN_MODELS.items():
prefixes[apps.get_model(m)] = k
base = '/'
if hasattr(settings, 'SHORT_BASE_URL'):
base = "/"
if hasattr(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
def test_abc_to_id():
def test_to_python():
samples = {
'A': 0,
'B': 1,
'Y': 24,
'a': 26,
'b': 27,
'y': 50,
'BA': 52,
'BAB': 2705,
"A": 0,
"B": 1,
"Y": 24,
"a": 26,
"b": 27,
"y": 50,
"BA": 52,
"BAB": 2705,
}
converter = convert.AbcIdConverter()
for abc, id in samples.items():
assert convert.abc_to_id(abc) == id
assert converter.to_python(abc) == id
def test_id_to_abc():
samples = {
1: 'B',
24: 'Y',
26: 'a',
52: 'BA',
78: 'Ba',
104: 'CA',
130: 'Ca',
1: "B",
24: "Y",
26: "a",
52: "BA",
78: "Ba",
104: "CA",
130: "Ca",
}
converter = convert.AbcIdConverter()
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.urls import path
from django.urls import path, register_converter
from .convert import AbcIdConverter
from .views import unshort
app_name = 'lemonshort'
register_converter(AbcIdConverter, "abc_id")
app_name = "lemonshort"
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()
)

View file

@ -1,9 +1,9 @@
from django.apps import apps
from django.shortcuts import get_object_or_404, redirect
from .convert import abc_to_id
from .convert import AbcIdConverter
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)

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):
name = 'micropub'
name = "micropub"

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