Compare commits

..

311 commits
v1.7.3 ... main

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

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

The upgrade also tried to pull in Django 4, which many of the plugins
I'm using can't cope with yet, so it needed to be convinced not to do
that.
2022-04-29 14:54:49 +10:00
db0d6e28a3
makemigrations for minor tweaks to User and Entry 2022-03-12 15:27:59 +11:00
2f8d62649e
1.11.0 2022-03-12 15:16:43 +11:00
683adc1b46 Use proper path converter for lemonshort 2022-03-12 15:04:05 +11:00
cfeb206154 Fix dev settings to use .lo instead of .dev 2022-03-12 15:03:26 +11:00
c5c0f4258b Set DEFAULT_AUTO_FIELD to AutoField 2022-03-12 15:03:01 +11:00
73addc2f75 Remove unncessary default_app_config settings 2022-03-12 15:02:26 +11:00
0ca50252dd
Add mypy types for libraries that have them now 2022-02-22 12:35:38 +11:00
8d79be07da
Do a pipenv update to get patched Django again lol 2022-02-22 12:33:31 +11:00
37d5a7a20d
Do a pipenv update to get patched Django 2021-08-22 23:24:58 +10:00
76496e7169
Harmlessly ignore bad params to Libravatar
I was throwing 400s when bad parameters are provided, but the spec
actually says you're supposed to just ignore them entirely.
2021-08-22 23:08:45 +10:00
7fcc3c8788
1.10.3 2019-05-06 08:55:11 +10:00
4436db7d83
Bump up Font Awesome to 5.8.1 2019-05-06 08:46:06 +10:00
d017c642eb
Bump up Bootstrap to 4.3.1 2019-05-06 08:44:21 +10:00
7c5f311af9 Merge branch 'details' of BenLubar/lemoncurry into master 2019-05-05 18:36:52 -04:00
73155f399b
allow details tags 2019-05-05 00:12:49 -05:00
e540f7b784
Do a yarn upgrade c: 2019-01-25 10:21:25 +11:00
0e8f816d0e
Remove deprecated pre-commit hook autopep8-wrapper 2019-01-24 12:29:38 +11:00
1bf0d8478a
Placate a deprecation warning from Django by importing 'static' from a different module 2019-01-24 12:28:55 +11:00
594947852f
1.10.2 2019-01-17 12:29:22 +11:00
b318ed5b06
Fix broken Tippy tooltips caused by changes to Tippy's API in version 3 2019-01-17 12:29:16 +11:00
012aed42b1
1.10.1 2019-01-17 11:55:56 +11:00
5c10bafb7d
Bump up the versions of Highlight.js, Tippy.js, and OpenWebIcons as well 2019-01-17 11:55:47 +11:00
e660221265
Upgrade Font Awesome to 5.6.3 2019-01-17 11:48:40 +11:00
e23ca7d215
Upgrade Bootstrap to 4.2.1 2019-01-17 11:47:53 +11:00
95b02269bb
Perform pre-commit autoupdate 2019-01-17 11:44:15 +11:00
ce07ba8cdc
Perform a pipenv update since everything is old 2019-01-17 11:43:30 +11:00
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
4fd2ff826a
Support Libravatar matching by OpenID URL as well as by email address 2018-07-11 13:13:12 +10:00
6efcc450a3
Fix the tests by ensuring lemoncurry.settings.test is always loaded regardless of the environment 2018-07-11 13:06:40 +10:00
dc7442cfb6
Add a migration which just sets help text on users.User fields 2018-07-11 13:02:25 +10:00
9c708b8c89
Don't preload_app when running with Gunicorn since apparently that breaks database access 2018-07-11 13:01:00 +10:00
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
639e1ec9c6
Add Gunicorn config file so we can use server hooks 2018-07-05 11:09:35 +10:00
a35072bbc3
1.10.0 2018-07-05 09:06:27 +10:00
da5ca5edea
Ignore the .env file, since I wanna use it 2018-07-03 16:07:15 +10:00
1e4df2d1b5
Implement the Micropub source query internally rather than by simply parsing the visible content 2018-07-03 10:18:24 +10:00
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
065619772e
Use ResponseException for various places rather than needing to check the return value for responseness 2018-07-03 09:51:51 +10:00
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
1d4be082cf
Refactor the 'find an entry based on a URL' behaviour into a utility function 2018-07-03 09:19:50 +10:00
2d643b48c6
Allow GIFs to be sent to the Micropub media endpoint 2018-07-03 08:45:45 +10:00
bab7097fa3
Properly send webmentions after deleting an entry :3 2018-07-02 15:30:32 +10:00
fa8419976d
Enable support for deleting entries through Micropub :D 2018-07-02 15:08:13 +10:00
427dcde672
Make lots of improvements to the narrow-screen layout 2018-07-01 15:26:55 +10:00
580c61e924
Adjust sizing of p-author photo and spacing inside entries 2018-07-01 15:01:14 +10:00
6c9b6eb061
Shrink the precision of 'ago' datetimes so they stay compact 2018-07-01 14:56:24 +10:00
6d7b5db482
Restore favicon links in the page <head> 2018-07-01 14:53:21 +10:00
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
c8e0b9c5fb
Save any provided syndications for a new entry when creating it 2018-06-28 21:07:24 +10:00
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
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
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
f7d7936499
Whoops, make sure ago actually emits the /correct/ relative timestamps using the right timezone 2018-06-28 13:03:53 +10:00
c8faa30724
Switch to another relative-date-formatting library which supports tiny abbreviated formats 2018-06-28 12:57:09 +10:00
0d1d102f47
Lots of spacing adjustments so that the new entry layout doesn't suck on mobile 2018-06-28 12:25:22 +10:00
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
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
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
dee64f130e
Switch to a less bright theme-color since base0A didn't work so well 2018-06-28 11:23:56 +10:00
bc8d7923b4
Restore the <base> and rel="canonical" URLs to the layout <head> 2018-06-28 11:19:32 +10:00
dec5ef153b
Set a theme-color in the template again, so mobile Chrome uses it properly 2018-06-28 11:10:56 +10:00
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
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
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
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
bb91d3c6b6
Resilently handle IndieAuth clients that don't have a logo in their h-x-app 2018-06-25 18:01:29 +10:00
b32412f4fd
Add a bunch of <link> tags I forgot about, oops 2018-06-25 13:53:11 +10:00
ce0bf28725
1.9.10 2018-06-25 10:45:45 +10:00
77816b6c5d
Complete migration to Jinja2 by porting the home page template 2018-06-25 10:43:45 +10:00
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
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
fa66fbbf1e
Bump Tippy.js to 2.5.3 and Font Awesome to 5.1.0 2018-06-22 12:40:30 +10:00
bc433f235f
Use a mypy-friendly approach to loading and caching the package.json file 2018-06-22 12:31:03 +10:00
2a38c8d21b
Bump versions with pipenv update 2018-06-22 12:14:28 +10:00
4bc7fde36b
Oops, I accidentally used a 'ref' attribute instead of 'rel' :3 2018-06-19 16:49:41 +10:00
5042f3bda7
Port the entries-by-kind feed over to Jinja2, wasn't too tricky c: 2018-06-19 16:46:54 +10:00
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
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
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
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
8f416cb5d7
1.9.9 2018-06-12 12:27:53 +10:00
5f9aca20c4
Throw out support for AMP, because AMP sucks anyway 2018-06-12 12:27:35 +10:00
59e40b551b
Fix bad indent and a few actual errors in README.md 2018-06-12 12:16:18 +10:00
9586f59592
Write a big ol' README.md 2018-06-12 12:10:13 +10:00
a585ab038b
Bump npm library versions using yarn upgrade 2018-06-12 11:18:15 +10:00
81baf59085
Bump library versions using pipenv lock 2018-06-12 11:15:33 +10:00
d7abc468b9
Fix URL syntax for linking to the current lemoncurry version in the repo, so it works with Gitea 2018-06-12 11:01:59 +10:00
67f8ec6fae
Throw away now-unused lemonauth templates, since they've all been ported across to Jinja2 2018-06-12 11:01:25 +10:00
24bc7816f1
Relocate official repository to git.00dani.me 2018-06-12 10:52:49 +10:00
f0678c3379
Add the missing navbars to the Jinja2 version of the layout 2018-06-12 10:47:53 +10:00
e94a856a08
Add caching and smarter resizing to the Libravatar endpoint 2018-06-08 14:47:34 +10:00
2c90114b9d
Add pickle support to the MSGPackModernSerializer - if a type can't be serialized to native MessagePack, then pickle it 2018-06-08 14:46:41 +10:00
0860f37ac0
Use the full URI of the current page as the OpenID delegate, rather than just the origin 2018-06-05 12:54:00 +10:00
8932317f08
Allow sizes up to 512 when requesting libravatars, since that's what the spec says actually and I just misread it I guess? 2018-06-05 10:14:24 +10:00
f551a5214a
Add pytest and mypy steps to the pre-commit hooks to avoid committing clearly broken code 2018-06-04 10:32:24 +10:00
d91676289b
Satisfy autopep8 by adding a newline between two methods 2018-06-04 10:16:09 +10:00
121789febe
pipenv update - it re-added msgpack-python again and I'm just gonna leave it there tbh 2018-06-04 10:07:09 +10:00
276ce34ae9
Upgrade pre-commit hooks, enable the executable shebang check as well 2018-06-04 10:06:36 +10:00
5ce0af0731 Simplify the GitLab cache config, should be more reliable this way? 2018-06-04 09:39:32 +10:00
4c0be4ce8b Load Postgres parameters from the environment so that it can work with GitLab CI 2018-06-04 09:32:47 +10:00
f7fbf49e1e Update the .gitlab-ci.yml to install dev packages and cache properly 2018-06-04 09:21:23 +10:00
c9f66eb91c
Install mypy and make the minimum changes necessary for it to pass, albeit using --ignore-missing-imports 2018-05-29 09:37:28 +10:00
142e3eff2b
Ugh. Fix the new MessagePack serialiser to interface reliably with the msgpack library (gotta pass some flags and stuff) - also, actually depend on msgpack to make sure we have it 2018-05-28 21:33:20 +10:00
8d4444cfb9
Provide our own MessagePack serialiser class for django-redis, since the included one doesn't work properly with msgpack <= 0.5.4 2018-05-28 21:18:18 +10:00
b59962a119
Start porting stuff from Django templates to Jinja2 - I've only done the lemonauth templates so far, and the layout is missing some meta stuff (hoping to reduce how much of that is needed) but it works 2018-05-28 21:15:02 +10:00
335db51ffc
1.9.8 2018-05-28 17:52:39 +10:00
a62522d36a
Make some performance tweaks - persistent database connections, static file hashing, conditional GET support (ETags and stuff) 2018-05-28 17:52:24 +10:00
15e5219e3a
Install Werkzeug so that runserver_plus is available 2018-05-28 17:49:57 +10:00
a0bc5bacd8
Remove unused openwebicons and tippy.js symlinks 2018-05-28 17:49:09 +10:00
4aba1034e7
Explicitly mark the environment variables as global 2018-05-28 11:53:46 +10:00
a35a1283da
Try to preserve both the pip and pipenv caches in Travis builds 2018-05-28 11:50:15 +10:00
382a79e6c7
Run 'pipenv lock' to fix up Pipfile.lock - should make Travis pass again 2018-05-28 11:30:22 +10:00
24843cc31b
Add gevent==1.3.1 hash for Linux wheel as well (fixes Travis build) 2018-05-28 11:16:19 +10:00
pyup.io bot
ffd367c6ca Update pytest from 3.5.1 to 3.6.0 (#5) 2018-05-28 11:09:35 +10:00
73f0d4a7c3
Merge pull request #4 from 00dani/pyup-update-parso-0.2.0-to-0.2.1
Update parso to 0.2.1
2018-05-22 09:14:15 +10:00
42f96b0ead
Merge pull request #3 from 00dani/pyup-update-gevent-1.3.0-to-1.3.1
Update gevent to 1.3.1
2018-05-22 09:13:54 +10:00
pyup-bot
42c7b9d854 Update parso from 0.2.0 to 0.2.1 2018-05-21 21:11:35 +10:00
pyup-bot
7cd5e19c1e Update gevent from 1.3.0 to 1.3.1 2018-05-19 00:26:31 +10:00
bbb860148d
Upgrade to Font Awesome 5.0.13 2018-05-16 15:54:37 +10:00
c405fc0dbb
Load Tippy and the OpenWeb icons from unpkg rather than bundling them 2018-05-16 15:50:19 +10:00
d65757f7df
Merge pull request #2 from 00dani/pyup-update-gevent-1.2.2-to-1.3.0
Update gevent to 1.3.0
2018-05-14 10:48:00 +10:00
pyup-bot
9e57d04ad5 Update gevent from 1.2.2 to 1.3.0 2018-05-14 10:41:05 +10:00
e5c02b1fc6
Configure pyup to look at my Pipfiles 2018-05-14 10:40:44 +10:00
742296d5dd
Do a --dev install when running on Travis, so that pytest is available :o 2018-05-14 10:38:09 +10:00
4a208a91b6
Push pytest-django, and also ptpython, into dev-packages 2018-05-11 14:57:04 +10:00
4dd7a6dcc4
Switch to psycopg2-binary - don't actually need it, libpq is available in all my environments, but it'll shush some warnings 2018-05-11 14:04:38 +10:00
aec98120ab
Whoops, include an updated date on items in Atom/RSS but do it correctly? Also made a few other minor improvements to the feeds 2018-05-11 13:45:26 +10:00
03b2668969
Update django-model-utils 2018-05-11 13:41:14 +10:00
46c2224a4f
Replace the previous WebFinger implementation with a fairly simple forwarder to Bridgy Fed, so that Bridgy Fed will work eventually 2018-05-11 13:23:47 +10:00
9f3cbac3c4
Advertise the WebSub hub inside all Atom feeds, to make sure subscribers know it exists 2018-05-11 12:52:29 +10:00
39d0a64c34
Nicely handle the unlikely case where the h-card contains absolutely no PGP keys and/or social profiles 2018-05-10 14:23:05 +10:00
9fb2e8552f
Switch the dev environment from SQLite to also use Postgres, so that Postgres-specific data types can be used 2018-05-10 14:20:09 +10:00
eb20cc1d21
Add a Link header to the Micropub create response indicating the entry's shortlink 2018-05-10 13:42:59 +10:00
86f4da306c
Fix creating posts with Micropub to use the new entry kind handling 2018-05-10 13:42:32 +10:00
04b3b7f806
Drop the name-tests-test hook since it doesn't understand the test directory structure being used here 2018-05-09 21:37:13 +10:00
dd0951cc82
Add tests for the Atom/RSS feeds so I won't accidentally break 'em without realising again 2018-05-09 21:36:21 +10:00
4945b40810
Add Travis config file 2018-05-09 21:06:42 +10:00
c55f437885
Add pyup-django to warn me if Django's dangerously outdated 2018-05-09 21:00:30 +10:00
b32cefe762
Ban CORS requests to the siteadmin and to the auth forms, just in case 2018-05-09 01:29:30 +10:00
30c4c8ec8f
whoops, patch the RSS/Atom feeds to work with the refactored handling of entry kinds 2018-05-08 18:10:08 +10:00
4d974a5364
Create a basic but functional micropub media endpoint :o 2018-05-07 22:28:48 +10:00
0d5387823d
Upgrade to Bootstrap 4.1.1 2018-05-07 21:01:20 +10:00
467ba19704
Update to Font Awesome 5.0.12 2018-05-07 20:59:23 +10:00
9edb0571d8
1.9.7 2018-05-04 16:10:34 +10:00
e5bad72e36
Add docstring to webmention:status endpoint 2018-05-04 13:50:50 +10:00
18ca8545e6
Bump package versions in Pipfile.lock, pulls in fix for this Django bug https://code.djangoproject.com/ticket/29296 2018-05-04 13:32:09 +10:00
d4b8581793
Add docs to a bunch of stuff in users.models 2018-05-04 13:20:14 +10:00
1b660d8af5
Migrate users.urls from url() to re_path() 2018-05-04 12:42:48 +10:00
2fc4a5e23e
Change the webmention acceptance URL to /webmentions - it feels a little more RESTful 2018-05-04 12:40:39 +10:00
30634f9ec2
Port webmention.urls to use path() 2018-05-04 12:34:52 +10:00
0239f7e031
Port the global lemoncurry.urls URLconf to use path() 2018-05-04 12:31:44 +10:00
70e57e4155
Port the urls for lemonauth, lemonshort, micropub, and wellknowns to path() 2018-05-04 12:19:54 +10:00
e9c46f23db
Switch home.urls and entries.urls over to modern path() definitions, with param conversion and such 2018-05-04 12:13:52 +10:00
0b43aad50e
Upgrade to Font Awesome v5.0.11 2018-05-04 11:30:14 +10:00
70d4579448
Support oEmbed using the wirres.net mf2-to-oEmbed proxy :3 2018-05-01 15:23:57 +10:00
c40372a020
Add cute little Tipper.js tooltips to the profiles on the home h-card 2018-05-01 14:15:37 +10:00
ffd0d3384e
Upgrade to Tippy.js v2, along with upgrading its dependency Popper.js 2018-05-01 14:00:16 +10:00
6f3f613cc8
Fix the AMP template to use the new favicon support instead 2018-04-28 13:22:30 +10:00
34ea2441bb
Set the From address for lemoncurry error emails 2018-04-27 11:43:13 +10:00
8c9977439e
Add some extra blocks to lemoncurry/layout.html for overriding in special cases 2018-04-26 13:47:05 +10:00
8cbe51277e
Install django-extensions for lots more manage.py commands 2018-04-26 13:35:31 +10:00
b649a79afc
Load the lemoncurry app FIRST so it can override templates and stuff from third-party apps 2018-04-26 10:40:11 +10:00
8dd5fc0f50
Whoops, use a path relative to the project dir to load the theme module into Stylus 2018-04-26 10:32:01 +10:00
1d079abd19
Set myself as a Django admin, so I get emails about production server errors 2018-04-26 10:27:11 +10:00
092cd5ca18
Explicitly depend on gevent, since apparently depending on it as a Gunicorn 'extra' might not install it :/ 2018-04-26 10:22:02 +10:00
6f84023f8c
1.9.6 2018-04-25 14:49:50 +10:00
78e6d76693
Force the Django admin to go through the normal lemonauth:login view rather than its own login 2018-04-25 14:49:02 +10:00
0936bcb311
Clean up lemoncurry.settings.base in accordance with flake8 2018-04-24 13:05:25 +10:00
01d5745fa7
Change the cache serialiser from pickle to MessagePack for way better performance 2018-04-24 12:46:36 +10:00
0f95cfa0bc
Switch from django-redis-cache to django-redis, which does the same thing but is actively maintained 2018-04-24 12:45:05 +10:00
9d30534d1d
Explicitly depend on the gevent extra for Gunicorn, since we make a lot of HTTP requests and so absolutely need async workers 2018-04-24 11:31:30 +10:00
23ab0c4329
Add a meta[name='generator'] tag to the layout 2018-04-21 14:03:09 +10:00
132da5d69b
Add /favicon.ico redirection 2018-04-20 12:48:01 +10:00
1654ceecf3
Switch from django-favicon-plus to django-super-favicon, it performs better and doesn't require a DB table 2018-04-20 12:35:51 +10:00
025910029e
Add support for the django.contrib.admindocs feature 2018-04-20 11:21:00 +10:00
236a32ebc1
Reorganise the meta tags at the top of the layout in accordance with best practices - the charset needs to be as early as possible 2018-04-20 10:41:04 +10:00
d3c79a4cc3
Use django-sites for the absolute_url template tag - this actually reduces the number of queries for whatever reason 2018-04-20 10:39:52 +10:00
3142c6073c
Add pre-commit hooks for checking common problems 2018-04-19 10:25:23 +10:00
1cf0449371
Rename a few variables under the webmention app to satisfy linters 2018-04-19 10:16:02 +10:00
5252c59910
Bump the pluggy==0.6.0 hash because PyPI has just gone through a migration that accidentally changed some hashes 2018-04-19 10:14:13 +10:00
fceda5c698
Upgrade jQuery from 3.2.1 to 3.3.1 2018-04-17 15:23:46 +10:00
7cc173420d
Upgrade from Bootstrap 4.0.0 to Bootstrap 4.1.0 2018-04-17 15:23:18 +10:00
2d6751cf0c
Bump Popper.js to 1.12.9 and Font Awesome to 5.0.10 2018-04-12 08:37:01 +10:00
baeae5cacd
bump Font Awesome to 5.0.9 2018-03-29 16:08:32 +11:00
93c45f8a90
1.9.5 2018-03-23 13:17:24 +11:00
418f501afa
Use a native checkbox on the login page too 2018-03-23 13:17:11 +11:00
7e50300942
Switch the scopes checkboxen from Bootstrap's custom ones to browser-native ones, since the custom one wasn't getting us much 2018-03-23 13:14:34 +11:00
43348a89da
Add support for serving users' avatars through the Libravatar API 2018-03-23 12:56:13 +11:00
6fb289727c
Make Entry.get_absolute_url actually return an absolute URL, always 2018-03-21 22:56:33 +11:00
0fb62f5962
1.9.4 2018-03-21 22:03:21 +11:00
365110544c
Upgrade Django to 2.0.3, yay! 2018-03-21 22:02:27 +11:00
452dd3f47d
Oops, update the list of routes that's pinged on micropub creation to use the new names for entry feeds 2018-03-21 22:02:01 +11:00
098284a617
Remove django-shorturls with my own implementation, since it's incompatible with Django 2 and unmaintained 2018-03-21 21:50:40 +11:00
27e0cb9a34
Install the lovely Highlight.js for automatic syntax highlighting of code blocks 2018-03-21 16:35:50 +11:00
f0cf3b3a68
Patch a few small deprecated operations so that the site works under Django 2.0 as well 2018-03-21 16:14:07 +11:00
169f0687cb
Start implementing webmention receiving :o the status page is ugly and there's no actual verification yet, but good start at least ;) 2018-03-19 21:11:04 +11:00
b3fb0a8600
Add the lemoncurry logo - yes, it's just FA's lemon layered over a dark circle 2018-03-08 15:37:52 +11:00
480658fefe
1.9.3 2018-03-08 14:35:14 +11:00
91f649fcb2
Bump Font Awesome to v5.0.8 2018-03-08 14:34:52 +11:00
d5f36bcfbe
Use consistent IDs for each input of the login form 2018-03-08 14:24:32 +11:00
039b6a1914
Make the permalink views 404 if you try to load a non-existent entry 2018-03-08 14:13:45 +11:00
756e3478d8
Remove extraneous debugging print() 2018-03-08 14:08:21 +11:00
7d677734f3
Patch the 'remember this browser' checkbox to work with the current Bootstrap and django-otp-agents versions 2018-03-08 13:56:15 +11:00
9580068c5b
Refactor how the routing for different kinds of entry works - this will make implementing webmentions easier, hopefully? 2018-03-08 13:49:02 +11:00
c359b7640e
Refactor the 'entries' views into a package rather than just one module, so more views can be added without clutter 2018-03-07 15:46:21 +11:00
cf0aea4f73
Extend the copyright range in the license to cover 2018 as well 2018-03-06 22:30:49 +11:00
20fb7dbc6e
Delete some stub modules with no actual functionality 2018-03-06 22:30:10 +11:00
920c938200
Bump Font Awesome to v5.0.7 2018-02-27 12:13:26 +11:00
dcb9833b0d
Apparently you can't use a Boolean true in gitlab-ci.yml environment variables config 2018-02-26 13:07:12 +11:00
345c9f9885
Store the CI caches inside the project dir since that's the only place GitLab CI is allowed to cache 2018-02-26 13:05:28 +11:00
58d8ca2a25
Attempt to cache the packages installed by pip and pipenv to dramatically speed up the pipeline 2018-02-26 12:58:34 +11:00
6ceb800723
Turns out stages are isolated from each other, so we need to do the build and the test in one script 2018-02-26 12:51:14 +11:00
247bce0996
Don't collectstatic, it's not needed and it won't work without Yarn being run, which introduces another dependency 2018-02-26 12:43:43 +11:00
1759be4d8c
Yet another try - use python:3.6, install submodules, use build and test stages, etc. 2018-02-26 12:37:08 +11:00
ea0ef22b16
Use a CI image that already has pipenv installed 2018-02-26 12:24:22 +11:00
227c43c81e
Switch to the python:3.6 image for CI, to make sure we get Python 3.6 2018-02-26 12:17:57 +11:00
65cba59a72
Add initial .gitlab-ci.yml, which hopefully works correctly 2018-02-26 12:14:52 +11:00
9d1c9646d9
Bump Bootstrap from beta2 to the final 4.0.0 release 2018-02-19 15:32:19 +11:00
52ffbab671
1.9.2 2018-02-06 16:25:19 +11:00
45daf529f8
Switch the preferred password hash from PBKDF2 to the newer and more secure Argon2 2018-02-06 16:18:15 +11:00
cbc24d4774
Add support for tests that use django.test.Client and that require a database, and use this support to test some of the wellknowns.views modules 2018-02-06 16:12:36 +11:00
380afe9831
Update the client ID used for Paw's autogenerated JWTs - use https://paw.cloud/ instead of https://00dani.me/ for accuracy 2018-02-05 09:59:50 +11:00
39b2e40e32
Introduce some unit tests in lemoncurry.tests - only testing a few of the really easy things so far ;) 2018-02-02 15:08:41 +11:00
dffa2d9d50
Install pytest for designing and running automated test suites 2018-02-02 15:06:59 +11:00
1cfab95201
1.9.1 2018-01-29 16:16:30 +11:00
9befd27a26
Whoops, make our new UserManager extend the Django core one, since it turns out it's absolutely mandatory 2018-01-29 16:16:21 +11:00
70703c5ceb
Simplify the environment stuff: just have one group, since it doesn't make sense to switch the host and the secret separately 2018-01-29 12:48:00 +11:00
b0bde64882
Add a nice little Paw.app project file to help me test out Micropub routes 2018-01-29 12:37:06 +11:00
5c50eadb20
1.9.0 2018-01-29 11:03:20 +11:00
b5604ac8d1
Require the 'create' scope in the token to be present in order to create new entries 2018-01-29 11:01:57 +11:00
9f733125a7
Refactor micropub, add basic support for querying - source works great, the other two not so much 2018-01-29 10:28:46 +11:00
a6a5264477
Merge branch 'master' into develop
All this pulls in is disabling Google Analytics in production. I wanted
to get the branches back in sync.
2018-01-29 09:15:22 +11:00
e5b7d0fe95
Bump Font Awesome to v5.0.6 2018-01-29 08:49:55 +11:00
b2b017c1bd
Whoops, make sure we correctly fetch the user to display entries for on the home page if nobody is logged in 2018-01-29 08:48:56 +11:00
f6a0adfb56
Add rel="shortlink" on the AMP pages too 2018-01-24 14:09:26 +11:00
e5a44fd38c
Dramatically improve AMP support, adding all the missing fields so that the AMP version still parses to a reasonable h-entry 2018-01-24 14:04:19 +11:00
75e08aa1b2
Reduce number of queries required to render an entries list, especially the home page 2018-01-24 13:18:22 +11:00
957fc4ada7
Lock Django to 1.11 in Pipfile, so it won't accidentally upgrade to 2 when I try to install unrelated packages 2018-01-24 12:57:10 +11:00
870cdf5c25
Turn off Google Analytics, since I don't really care about it and people don't like it 2018-01-15 16:37:55 +11:00
40ead1bbe1
Provide simple support for fetching entries in AMP format - can't handle images yet and needs some other tweaks, but works nicely so far c: 2018-01-13 14:49:38 +11:00
3e4f55fa9c
Add <cite> to ALLOWED_TAGS, because honestly 2018-01-11 11:41:36 +11:00
501c8c3ee3
Whoops, wind back to Django 1.11 in Pipfile.lock, since django-favicon-plus doesn't work with Django 2 yet 2018-01-11 11:35:23 +11:00
ce46abeb3d
1.8.4 2018-01-11 10:33:21 +11:00
0c9adb0288
Upgrade to Font Awesome v5.0.4 2018-01-11 10:33:13 +11:00
0fd65d3c2b
Enable CORS using django-cors-headers 2018-01-11 09:32:45 +11:00
6072bf64f3
1.8.3 2018-01-11 08:44:29 +11:00
c4b7e56c35
Upgrade to Font Awesome v5.0.3 2018-01-11 08:43:51 +11:00
e7b577ef87
1.8.2 2017-12-21 11:32:14 +11:00
2add0c3d15
Bump FA to 5.0.2 since that's out now 2017-12-21 11:31:04 +11:00
e15e4c72fe
Add missing aria-hidden attribute to icons that aren't or might not be Font Awesome ones, since the automatic aria-hidden attributes are only added to FA icons 2017-12-21 11:27:17 +11:00
447e91f1f1
Make sure the content doesn't stretch wider than the display, so mobile users can access the site correctly 2017-12-19 17:02:30 +11:00
777878610f
1.8.1 2017-12-19 15:51:01 +11:00
5e524cb4f2
Process the WebSub pings for each feed separately, because the hub.url[] syntax doesn't seem to actually work 2017-12-19 15:44:42 +11:00
778bd7d872
Tweak the code block style again - simpler and more robust CSS 2017-12-19 14:53:05 +11:00
17f3779596
Style code blocks nicely 2017-12-19 14:41:51 +11:00
cd075a8ce2
Add <pre> to the list of acceptable tags so that code blocks work 2017-12-19 14:38:06 +11:00
7b4f5d3ac1
Unwrap 'html' content passed to micropub 2017-12-19 13:54:07 +11:00
ff96b732af
1.8.0 2017-12-19 13:39:56 +11:00
afc3b45edd
Remove an extraneous print call 2017-12-18 16:33:18 +11:00
d30f1bc334
Normalise formencoded Micropub requests to JSON form, and handle all requests as JSON, so now JSON request bodies work too 2017-12-18 16:32:29 +11:00
29a3f740a9
Add a function to convert form-encoded micropub requests into JSON micropub requests - I'll be changing the actual endpoint to accept JSON and use this to keep accepting form-encoded as well 2017-12-18 11:56:04 +11:00
14723b03ff
Fix regression - the verification tooltips weren't working with FA 5 2017-12-18 10:53:45 +11:00
87f04ce988
Use a more helpful page title on IndieAuth authorisation pages 2017-12-18 10:53:17 +11:00
b89405ed88
Dramatically improved processing of Micropub tokens which supports both the Authorization header and the access_token field approaches 2017-12-18 09:51:06 +11:00
e5f2e9d537
Oops, bump up the entries-per-page to ten again - it was lowered to make testing the behaviour easier 2017-12-15 12:34:36 +11:00
a0db1bfb47
Use the same paginate function on the entry-kind feeds and cat feeds, for identical pagination everywhere c: 2017-12-15 12:34:02 +11:00
4033837b91
Refactor the pagination logic into a reusable module so I can go add it to the other feed pages 2017-12-15 12:25:55 +11:00
f9e6f1dde3
Display the pagination on the home page at least - gotta do the same thing on other feed pages as well but this one is working nicely :) 2017-12-15 11:55:29 +11:00
2a65644813
Switch from custom pagination to django.core.paginator, since it can do things like counts and 'is there a next page' more easily 2017-12-13 09:56:18 +11:00
2d2159ee58
Enable simple entry pagination - each h-feed page has a rel=next pointing to the next page of the feed, but there's no visible link yet 2017-12-12 18:35:13 +11:00
ea241577f1
Smarter generation of OGP/Schema.org/etc. metadata, with HTML tags stripped out so the result looks nice and clean 2017-12-11 13:30:46 +11:00
b8a74443c9
1.7.4 2017-12-11 12:33:16 +11:00
acb5bc97a9
Oops, still generate a shortlink when the published and updated timestamps match 2017-12-11 12:33:04 +11:00
5fa4066d7a
Wrap the u-uid link in the h-card around my avatar rather than just having it be hidden, since invisible info in is a microformats2 antipattern 2017-12-11 12:28:39 +11:00
ddf4099639
Remove a little bit of spurious whitespace 2017-12-11 11:11:32 +11:00
179 changed files with 5196 additions and 2124 deletions

8
.gitignore vendored
View file

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

2
.gitmodules vendored
View file

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

View file

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

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2017 Danielle McLean Copyright (c) 2017 - 2024 Danielle McLean
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

49
Pipfile
View file

@ -1,49 +0,0 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[requires]
python_version = '3.6'
[packages]
django = "*"
django-compressor = "*"
gunicorn = "*"
"psycopg2" = "*"
pillow = "*"
python-memcached = "*"
django-favicon-plus = "*"
django-meta = "*"
django-redis-cache = "*"
django-activeurl = "*"
django-otp = "*"
qrcode = "*"
django-otp-agents = "*"
python-slugify = "*"
"mf2py" = "*"
markdown = "*"
bleach = "*"
django-debug-toolbar = "*"
xrd = "*"
django-push = "*"
pyyaml = "*"
django-annoying = "*"
django-shorturls = "*"
accept-types = "*"
django-analytical = "*"
django-model-utils = "*"
python-jose = "*"
django-rq = "*"
ronkyuu = "*"
cachecontrol = "*"
hiredis = "*"
"mf2util" = "*"
[dev-packages]

565
Pipfile.lock generated
View file

@ -1,565 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "89f67ee2a974377da512befef558bc9f3a6508ef6a7e8c69d28cfb3c4e7f64b8"
},
"host-environment-markers": {
"implementation_name": "cpython",
"implementation_version": "3.6.3",
"os_name": "posix",
"platform_machine": "x86_64",
"platform_python_implementation": "CPython",
"platform_release": "17.3.0",
"platform_system": "Darwin",
"platform_version": "Darwin Kernel Version 17.3.0: Sun Oct 29 19:57:25 PDT 2017; root:xnu-4570.30.85~19/RELEASE_X86_64",
"python_full_version": "3.6.3",
"python_version": "3.6",
"sys_platform": "darwin"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.python.org/simple",
"verify_ssl": true
}
]
},
"default": {
"accept-types": {
"hashes": [
"sha256:9ae86512bf3a3eaad6a2793617a34eb15b384593e6c28697bef9b15ac237017a"
],
"version": "==0.3.0"
},
"beautifulsoup4": {
"hashes": [
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11",
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76",
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89"
],
"version": "==4.6.0"
},
"bleach": {
"hashes": [
"sha256:7a316eac1eef1e98b9813636ebe05878aab1a658d2708047fb00fe2bcbc49f84",
"sha256:760a9368002180fb8a0f4ea48dc6275378e6f311c39d0236d7b904fca1f5ea0d"
],
"version": "==2.1.1"
},
"cachecontrol": {
"hashes": [
"sha256:a9fc50e216c7c101f4ec4312f012dea501c2859cb256c7a68186a172ab71f632"
],
"version": "==0.12.3"
},
"certifi": {
"hashes": [
"sha256:244be0d93b71e93fc0a0a479862051414d0e00e16435707e5bf5000f92e04694",
"sha256:5ec74291ca1136b40f0379e1128ff80e866597e4e2c1e755739a913bbc3613c0"
],
"version": "==2017.11.5"
},
"chardet": {
"hashes": [
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691",
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
],
"version": "==6.7"
},
"django": {
"hashes": [
"sha256:75ce405d60f092f6adf904058d023eeea0e6d380f8d9c36134bac73da736023d",
"sha256:8918e392530d8fc6965a56af6504229e7924c27265893f3949aa0529cd1d4b99"
],
"version": "==1.11.7"
},
"django-activeurl": {
"hashes": [
"sha256:7ebc4a34f91e18f29eb02bfac503057d69b4e1b6f9e8dd1297798387876e54da"
],
"version": "==0.1.11"
},
"django-agent-trust": {
"hashes": [
"sha256:962653c4eeab63715a6efd27649a00302082c9fb1d931e3df959e57605eb8c25",
"sha256:b262db89410b9901c32f27f7dd6697bf61bfcfdc01651fe40699d0b81ebc4fcc"
],
"version": "==0.3.0"
},
"django-analytical": {
"hashes": [
"sha256:9060a34a5d1882021a399a52c6af993f790deb7f9abf7dfbb84fe3033e0cb134",
"sha256:e7c8e026d0a10d41cffa06163fcee24870597cedba19d5fa2609d35dec6463fc"
],
"version": "==2.3.0"
},
"django-annoying": {
"hashes": [
"sha256:07267defd06e37ad287053de4ea8c83ab4aae8114628830b7c91b70b63494572",
"sha256:1884f1452e0b9542c7db2ed7d8cc728b9386bc20af7c8e03607fad31a28b7ead",
"sha256:5321e6e3481fc455818b935824d9cd78669a9bb6a964baf816d191745c8617a6"
],
"version": "==0.10.3"
},
"django-appconf": {
"hashes": [
"sha256:ddab987d14b26731352c01ee69c090a4ebfc9141ed223bef039d79587f22acd9",
"sha256:6a4d9aea683b4c224d97ab8ee11ad2d29a37072c0c6c509896dd9857466fb261"
],
"version": "==1.0.2"
},
"django-classy-tags": {
"hashes": [
"sha256:f6d12f5a4df3e387795a0d9ef2836af389cae9a1fbebda035dac043d4722b1f7",
"sha256:792f9161d0e22d55b4fab6fc297bab8ab072ffaa3075b227613a6d8473624db8"
],
"version": "==0.8.0"
},
"django-compat": {
"hashes": [
"sha256:b20fb26d15bbedbf26fb274eb400d6fad2a23655eb5741ae258d39557b5fc5a3"
],
"version": "==1.0.14"
},
"django-compressor": {
"hashes": [
"sha256:7732676cfb9d58498dfb522b036f75f3f253f72ea1345ac036434fdc418c2e57",
"sha256:9616570e5b08e92fa9eadc7a1b1b49639cce07ef392fc27c74230ab08075b30f"
],
"version": "==2.2"
},
"django-debug-toolbar": {
"hashes": [
"sha256:4af2a4e1e932dadbda197b18585962d4fc20172b4e5a479490bc659fe998864d",
"sha256:d9ea75659f76d8f1e3eb8f390b47fc5bad0908d949c34a8a3c4c87978eb40a0f"
],
"version": "==1.9.1"
},
"django-favicon-plus": {
"hashes": [
"sha256:824da4ecd3501a157d9538ed1b0672227b2a8a5a3d940bd075ba5b5c636fb400"
],
"version": "==0.0.7"
},
"django-meta": {
"hashes": [
"sha256:2a5b8d95099f69fb9736630c4fbf4fcc2972a1fcd9c708a5bb72dde22e84d8dd",
"sha256:4a7dc51c40fd6a097825040af29ee0e049f1fce29b006e39f266f80ba988bac6"
],
"version": "==1.4"
},
"django-model-utils": {
"hashes": [
"sha256:60ead1ba50e1353f38bde12ab8b4a80b6a0f825a8e53c348fe259548cbd1a312"
],
"version": "==3.0.0"
},
"django-otp": {
"hashes": [
"sha256:54f35d7a84d8c46f35d20b969f38ef1afc0fa7627e44c481e4ab5f66a8da187e",
"sha256:46fa6f2ae30a69a09bdc448b06a370c88d95fb0c3a9ba5771ca4d0d7740d56d7"
],
"version": "==0.4.1.1"
},
"django-otp-agents": {
"hashes": [
"sha256:4ca8fae30418e0a813840cee5068d2fb96e3759787a5820d54921b90c7beaa7a",
"sha256:8d9f26d5a186b059251bd03e1ab509b5861a678e463c49de9b0766080b2c16a5"
],
"version": "==0.3.0"
},
"django-push": {
"hashes": [
"sha256:88d9d57326c9b5f8485510527c780418da1a3c0c485dbb283281d6bf2ef6598d",
"sha256:7101f2d66ff7fd932fe379c70f4d03f74955634fbecb187f03c3e82cd55b8274"
],
"version": "==1.0"
},
"django-redis-cache": {
"hashes": [
"sha256:2b4e3510bbcaf3d331975717afd6f15a36fbaf7622504599d2727dc99f90c64d"
],
"version": "==1.7.1"
},
"django-rq": {
"hashes": [
"sha256:7cd517c3e243603c8e5b24bfc4797d57d751a4ba1b471124157e9822a83335a2",
"sha256:368051f0a3ef08670dd186ea0202cbc7644e199fd48fd31eace2e637e915a7f7"
],
"version": "==0.9.6"
},
"django-shorturls": {
"hashes": [
"sha256:382ff617b36fea04981b30457377a46f2034d0940a40a20c6f637c55ea0bda93"
],
"version": "==2.0.0"
},
"ecdsa": {
"hashes": [
"sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c",
"sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa"
],
"version": "==0.13"
},
"future": {
"hashes": [
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
],
"version": "==0.16.0"
},
"gunicorn": {
"hashes": [
"sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6",
"sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622"
],
"version": "==19.7.1"
},
"hiredis": {
"hashes": [
"sha256:ca958e13128e49674aa4a96f02746f5de5973f39b57297b84d59fd44d314d5b5"
],
"version": "==0.2.0"
},
"html5lib": {
"hashes": [
"sha256:08a3efc117a4fc8c82c3c6d10d6f58ae266428d57ed50258a1466d2cd88de745",
"sha256:0d5fd54d5b2b79b876007a70c033a4023577768d18022c15681c00561432a0f9"
],
"version": "==1.0b10"
},
"idna": {
"hashes": [
"sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4",
"sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f"
],
"version": "==2.6"
},
"isodate": {
"hashes": [
"sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81",
"sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8"
],
"version": "==0.6.0"
},
"lxml": {
"hashes": [
"sha256:41f59cbdab232f11680d5d4dec9f2e6782fd24d78e37ee833447702e34e675f4",
"sha256:e7e41d383f19bab9d57f5f3b18d158655bcd682e7e723f441b9e183e1e35a6b5",
"sha256:155521c337acecf8202091cff85bb9f709f238130ebadf04280fb1db11f5ad8b",
"sha256:d2c985d2460b81c6ca5feb8b86f1bc594ad59405d0bdf68626b85852b701553c",
"sha256:950e63387514aa1b881eba5ac6cb2ec51a118b3dafe99dd80ca19d8fb0142f30",
"sha256:470d7ce41e8047208ba1a376560bad17f1468df1f3097bc83902b26cfafdbb0c",
"sha256:e608839a5ee2180164424ccf279c8e2d9bbe8816d002c58fd97d6b621ba4aa94",
"sha256:87a66bcadac270fc010cb029022a93fc722bf1204a8b03e782d4c790f0edf7ca",
"sha256:2dedfeeecc2d5a939cf622602f5a1ce443ca82407f386880f739f1a9f08053ad",
"sha256:ba05732e4bcf59e948f61588851dcf620fd60d5bbd9d704203e5f59bbaa60219",
"sha256:2190266059fec3c5a55f9d6c30532c64c6d414d3228909c0af573fe4907e78d1",
"sha256:dd291debfaa535d9cb6cee8d7aca2328775e037d02d13f1634e57f49bc302cc4",
"sha256:29a36e354c39b2e24bc4ee103de53417ebb80f976a6ab9e8d093d559e2ac03e1",
"sha256:e37427d5a27eefbcfc48847e0b37f348113fac7280bc857421db39ffc6372570",
"sha256:b106d4d2383382399ad82108fd187e92f40b1c90f55c2d36bbcb1c44bcf940fc",
"sha256:0ee07da52d240f1dc3c83eef5cd5f1b7f018226c1121f2a54d446645779a6d17",
"sha256:3b33549fb8f91b38a7500078242b03cca513f3412a2cdae722e89bf83f95971d",
"sha256:4c12e90886d9c53ab434c8d0cebea122321cce19614c3c6b6d1a7700d7cc6212",
"sha256:79322000279cda10b53c374d53ca632ead3bc51c6aebf8e62c8fa93a4d08b750",
"sha256:6cba398eb37e0631e60e0e080c101cfe91769b2c8267105b64b4625e2581ea21",
"sha256:49a655956f8de69e1258bc0fcfc43eb3bd1e038655784d77d1869b4b81444e37",
"sha256:af8a5373241d09b8fc53e0490e1719ce5dc90a21b19db89b6596c1adcdd52270",
"sha256:e6b6698415c7e8d227a47a3b1038e1b37c2b438a1b48c2db7ad9e74ddbcd1149",
"sha256:155c916cf2645b4a8f2bd5d09065e92d1b67b8d464bdc001e0b524af84bedf6f",
"sha256:fa7320679ced5e25b20203d157280680fc84eb783b6cc650cb0c98e1858b7dd3",
"sha256:4187c4b0cefc3353181db048c51f42c489d9ac51e40b86c4851dc0671372971d",
"sha256:d5d29663e979e83b3fc361e97200f959cddb3a14797391d15273d84a5a8ae44b",
"sha256:940caef1ec7c78e0c34b0f6b94fe42d0f2022915ffc78643d28538a5cfd0f40e"
],
"version": "==4.1.1"
},
"markdown": {
"hashes": [
"sha256:73af797238b95768b3a9b6fe6270e250e5c09d988b8e5b223fd5efa4e06faf81"
],
"version": "==2.6.9"
},
"mf2py": {
"hashes": [
"sha256:021b675c0732bdbc3b8c153e1ee8e1f476c3d0ffc56a7908f9e9f90147c5fccd"
],
"version": "==1.0.5"
},
"mf2util": {
"hashes": [
"sha256:efb8ea1a275f16396993a3fbe32331b74a8f6985d3f7f47503641cf522f1f614"
],
"version": "==0.5.0"
},
"msgpack-python": {
"hashes": [
"sha256:637b012c9ea021de7a7a75d6ff5e82cfef6694babd7e14bb9a3adcb2a5bd52f0",
"sha256:658c1cd5dcf7786e0e7a6d523cd0c5b33f92e139e224bd73cb3a23ada618d2dc",
"sha256:920bbbaee07ad048a4d2b4160901b19775c61ef9439f856c74509e763a326249",
"sha256:e165006f7e3d2612f1bffe2f6f042ca317d8df724d8b72a39b14c2e46c67eaae",
"sha256:95d70edd50e3d2f6ea1189f77190e4a0172626e7405ddd1689f3f64814447cba",
"sha256:7e1b12ea0134460052fabcfaa0f488ec0fc21deb14832d66236fd2870757d8f1",
"sha256:8f36890251f20d96267618cf64735759d7ef7e91bc0b86b9480547d2d1397a68",
"sha256:1e68a277e4180baa7789be36f27f0891660205f6209f78a32282d3c422873d78",
"sha256:f52d9f96df952369fe4adcb0506e10c1c92d47f653f601a66da2a26a7e7141ea",
"sha256:58c9c1d7891a35bddc6ee5dbec10d347a7ae4983169c24fc5fc8a57ae792ca76",
"sha256:1a2b19df0f03519ec7f19f826afb935b202d8979b0856c6fb3dc28955799f886"
],
"version": "==0.4.8"
},
"olefile": {
"hashes": [
"sha256:61f2ca0cd0aa77279eb943c07f607438edf374096b66332fae1ee64a6f0f73ad"
],
"version": "==0.44"
},
"pillow": {
"hashes": [
"sha256:cc6a5ed5b8f9d2f25e4e42d562e0ec4df3ce838f9e9b9d9d9b65fac6fe93a4cc",
"sha256:54898190b538a6c8fa4228e866ff2e7609da1ba9fd1d9cc5dc8ca591d37ce0a8",
"sha256:a336596b06e062b92eb8201a3b5dff07ae01c3a5d08ce5539d2da49b123f2be6",
"sha256:922aeb050bd52d8ce9531ab57fd2440bfe975900e8700fec385fb741c3c557c7",
"sha256:6d814aa655d94c63547fc3208cb6ab886ff1a64c543b31f52658663b1bb3f011",
"sha256:e66080685863444738f08e13081c287e340b6e4f8bd674a2e0da967776ac6f46",
"sha256:575a9b3468c82f38be0419cd39d35001ae95a0cc5226534e45430035fecef583",
"sha256:4fb8ab0f8895fb946454ef6ffe806f49ee387095f2d6112ae24670e5fb8fbcd9",
"sha256:1d742642d01914b7e0cf6fd597a51f57d21fd68f794cf84803e03e72db78a261",
"sha256:59cef683d79b85d55a950c1e61dc7b6be0c45a5074692746354cd9a8ace1cd17",
"sha256:822e4fc261d12fa44d88dadee0e93d59663db94d962d4ffffbf09b1fe5e5be51",
"sha256:a6f43511c79bed431ec2b56e55150b5222c732cd9e5f80e77a44e068e94c71fc",
"sha256:2046a2001e2c413998951cc28aa0dbfd4cff846a12e24c2145d42630d5104094",
"sha256:39c7c9dcf64430091e30ef14d4191b4cae9b7b5ff29762357730aac4866fb189",
"sha256:f2d71951f473744ac617b645b62d0c4df5372ef4618c425646bfe5e2e8878e61",
"sha256:9adcfa2477b7e279ebeee75b49f535518201bbd7d26ca2ef1cf6751cb6e658e8",
"sha256:0e3b56364a2c772c961a8faad8a835d3f24d8848310de035c9e07cc006035cbc",
"sha256:92087cb92a968421f42235f7d8153f4766b6ba213a6efb36b8060f3c9d294569",
"sha256:53eaec751151b5713a15b1cd62b06d0fc16d72f56623c15448728c554c30770b",
"sha256:e595312f67962d6b4fde3b7dffaaaca4becefa522d677676bb57b0ec5f8f921a",
"sha256:dc32362d0cadf18c3aef7040455760106cafe7dd3c211dc27c507e746376bb56",
"sha256:759e5e3e99c4ac87b99e9288a75236c63173d1bb24c8d3f9d9d2c8332fceeb0a",
"sha256:b13106cb83a3b7d1a02fafb94bfafbc980465ba948b76ea1996245959c6783d2",
"sha256:9184b9788a9cf677e53626a4dc141136a22d349a5480479b98defd3cfb5015a4",
"sha256:be803fae6af36639524a0f6861a8cface67bbec66c3416c3eaf592f1d45b8b20",
"sha256:effa82e72f5064439a3d2c7ff615b999eb1c4d65bb1f1e6ee6e2ddb345b3e81e",
"sha256:9dc002a914cefa710dcb9fb204d34f6cd822662047a6038178f5fc9bfa7be961",
"sha256:7b3cf7a80608ed661b77793f64e1f2bd1e77136ad0b750aa2c81fac9c7e2c785",
"sha256:a9bad3405a642649e68568fe9832e8f6ae585354ab0b4ae250816ead11a553a2",
"sha256:4d3dbd93b131013a71b2e98530dd4945a03c7994d42381e44a921dd8bec300bc",
"sha256:9a1514bee2e32e0d4c0f55ba7a20f4387f883e37c7d2db64ca50449ffebe86cc",
"sha256:a9721fe1f6fdfe0c108ea81b1a05dc216f1ec5bb65ef1de1d85fd00494d019e0",
"sha256:e75d745306ec8aac0e6903358fdfc7fb6854febe551ed753ee7a1cad058b61bb",
"sha256:ccc9c1f5ba413fc5ee09bc78de7dd2ad8e189edb48f3bc38acedd04a7f43a0c1",
"sha256:150e24462fd106074a9a63417a55fbb0c633716cef9511f1bd7a773972de14f4",
"sha256:250d8470661fd657c2583672ab5139f40e7f2ef28ecdc90f87563af0b27f6fba",
"sha256:a97c715d44efd5b4aa8d739b8fad88b93ed79f1b33fc2822d5802043f3b1b527",
"sha256:dbefe5aa0882f00f12eceb3fb7df57105cd87fae767ca025db4685b7577c2390",
"sha256:62a7bbf0a1120ff07a99ddedd383779a8d80bd9d363f3964b2b43a26cef6ea50",
"sha256:42b4a67949085ddd4559c3c716a00a275fb45cb2c3a3aeec95c4b94419b7c243",
"sha256:0ac037e6c1746d63a1ea354f0d5974d8f3f984fc0333be373ad193711a89b1e9",
"sha256:8989cbf10ea07fc9982ec86116f6234bb3e44da481874ac94650d6176f60106f",
"sha256:77834551d3e928f3da922ce9dfb5c8db46758ea2f2922d4c5835a5b67a222aff",
"sha256:c00301e807084706bd46a1c56694ee235debe68eaf482c0186edfe07b93a9f6a",
"sha256:0163bd681d3488e2e9c26f4fbbfefcfb7f32259c431bfd2c3bc25574708a8b8c",
"sha256:223b06c337d8d60fb65af3b540ab1fa4644931d61d1fddf6e32f7a0e496685f2",
"sha256:1ab641cb7daf88e88ede8d3b89b7bd68a7099d8671160492d5e6845e24426080"
],
"version": "==4.3.0"
},
"psycopg2": {
"hashes": [
"sha256:594aa9a095de16614f703d759e10c018bdffeafce2921b8e80a0e8a0ebbc12e5",
"sha256:1cf5d84290c771eeecb734abe2c6c3120e9837eb12f99474141a862b9061ac51",
"sha256:0344b181e1aea37a58c218ccb0f0f771295de9aa25a625ed076e6996c6530f9e",
"sha256:25250867a4cd1510fb755ef9cb38da3065def999d8e92c44e49a39b9b76bc893",
"sha256:317612d5d0ca4a9f7e42afb2add69b10be360784d21ce4ecfbca19f1f5eadf43",
"sha256:9d6266348b15b4a48623bf4d3e50445d8e581da413644f365805b321703d0fac",
"sha256:ddca39cc55877653b5fcf59976d073e3d58c7c406ef54ae8e61ddf8782867182",
"sha256:988d2ec7560d42ef0ac34b3b97aad14c4f068792f00e1524fa1d3749fe4e4b64",
"sha256:7a9c6c62e6e05df5406e9b5235c31c376a22620ef26715a663cee57083b3c2ea",
"sha256:7a75565181e75ba0b9fb174b58172bf6ea9b4331631cfe7bafff03f3641f5d73",
"sha256:94e4128ba1ea56f02522fffac65520091a9de3f5c00da31539e085e13db4771b",
"sha256:92179bd68c2efe72924a99b6745a9172471931fc296f9bfdf9645b75eebd6344",
"sha256:b9358e203168fef7bfe9f430afaed3a2a624717a1d19c7afa7dfcbd76e3cd95c",
"sha256:009e0bc09a57dbef4b601cb8b46a2abad51f5274c8be4bba276ff2884cd4cc53",
"sha256:d3ac07240e2304181ffdb13c099840b5eb555efc7be9344503c0c03aa681de79",
"sha256:40fa5630cd7d237cd93c4d4b64b9e5ed9273d1cfce55241c7f9066f5db70629d",
"sha256:6c2f1a76a9ebd9ecf7825b9e20860139ca502c2bf1beabf6accf6c9e66a7e0c3",
"sha256:37f54452c7787dbdc0a634ca9773362b91709917f0b365ed14b831f03cbd34ba",
"sha256:8f5942a4daf1ffac42109dc4a72f786af4baa4fa702ede1d7c57b4b696c2e7d6",
"sha256:bf708455cd1e9fa96c05126e89a0c59b200d086c7df7bbafc7d9be769e4149a3",
"sha256:82c40ea3ac1555e0462803380609fbe8b26f52620f3d4f8eb480cfd8ceed8a14",
"sha256:207ba4f9125a0a4200691e82d5eee7ea1485708eabe99a07fc7f08696fae62f4",
"sha256:0cd4c848f0e9d805d531e44973c8f48962e20eb7fc0edac3db4f9dbf9ed5ab82",
"sha256:57baf63aeb2965ca4b52613ce78e968b6d2bde700c97f6a7e8c6c236b51ab83e",
"sha256:2954557393cfc9a5c11a5199c7a78cd9c0c793a047552d27b1636da50d013916",
"sha256:7c31dade89634807196a6b20ced831fbd5bec8a21c4e458ea950c9102c3aa96f",
"sha256:1286dd16d0e46d59fa54582725986704a7a3f3d9aca6c5902a7eceb10c60cb7e",
"sha256:697ff63bc5451e0b0db48ad205151123d25683b3754198be7ab5fcb44334e519",
"sha256:fc993c9331d91766d54757bbc70231e29d5ceb2d1ac08b1570feaa0c38ab9582",
"sha256:9d64fed2681552ed642e9c0cc831a9e95ab91de72b47d0cb68b5bf506ba88647",
"sha256:5c3213be557d0468f9df8fe2487eaf2990d9799202c5ff5cb8d394d09fad9b2a"
],
"version": "==2.7.3.2"
},
"pycrypto": {
"hashes": [
"sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c"
],
"version": "==2.6.1"
},
"python-jose": {
"hashes": [
"sha256:fed56224664af0ebc3947853f1bed23b5609f90c7b02e3dce5ef5757d0301664",
"sha256:18e19f200f37a8ee6247921572807cc63ee034abdbf6854df1ae7c1f505cabcc"
],
"version": "==1.4.0"
},
"python-memcached": {
"hashes": [
"sha256:2775829cb54b9e4c5b3bbd8028680f0c0ab695db154b9c46f0f074ff97540eb6"
],
"version": "==1.58"
},
"python-slugify": {
"hashes": [
"sha256:c3733135d3b184196fdb8844f6a74bbfb9cf6720d1dcce3254bdc434353f938f",
"sha256:57a385df7a1c6dbd15f7666eaff0ff29d3f60363b228b1197c5308ed3ba5f824"
],
"version": "==1.2.4"
},
"pytz": {
"hashes": [
"sha256:80af0f3008046b9975242012a985f04c5df1f01eed4ec1633d56cc47a75a6a48",
"sha256:feb2365914948b8620347784b6b6da356f31c9d03560259070b2f30cff3d469d",
"sha256:59707844a9825589878236ff2f4e0dc9958511b7ffaae94dc615da07d4a68d33",
"sha256:d0ef5ef55ed3d37854320d4926b04a4cb42a2e88f71da9ddfdacfde8e364f027",
"sha256:c41c62827ce9cafacd6f2f7018e4f83a6f1986e87bfd000b8cfbd4ab5da95f1a",
"sha256:8cc90340159b5d7ced6f2ba77694d946fc975b09f1a51d93f3ce3bb399396f94",
"sha256:dd2e4ca6ce3785c8dd342d1853dd9052b19290d5bf66060846e5dc6b8d6667f7",
"sha256:699d18a2a56f19ee5698ab1123bbcc1d269d061996aeb1eda6d89248d3542b82",
"sha256:fae4cffc040921b8a2d60c6cf0b5d662c1190fe54d718271db4eb17d44a185b7"
],
"version": "==2017.3"
},
"pyyaml": {
"hashes": [
"sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
"sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
"sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269",
"sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
"sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
"sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
"sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
"sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
"sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
"sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
"sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
"sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
"sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
"sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7"
],
"version": "==3.12"
},
"qrcode": {
"hashes": [
"sha256:60222a612b83231ed99e6cb36e55311227c395d0d0f62e41bb51ebbb84a9a22b",
"sha256:4115ccee832620df16b659d4653568331015c718a754855caf5930805d76924e"
],
"version": "==5.3"
},
"rcssmin": {
"hashes": [
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
],
"version": "==1.0.6"
},
"redis": {
"hashes": [
"sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb",
"sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"
],
"version": "==2.10.6"
},
"requests": {
"hashes": [
"sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
"sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
],
"version": "==2.18.4"
},
"rjsmin": {
"hashes": [
"sha256:dd9591aa73500b08b7db24367f8d32c6470021f39d5ab4e50c7c02e4401386f1"
],
"version": "==1.0.12"
},
"ronkyuu": {
"hashes": [
"sha256:5aa77b39d301bc174ab99ba8a53954627771cb501651a12103c58f51b32e84bf",
"sha256:85b25fef7f5fb0c93afd5377ea35b5ff72b2458f926bafdf10f0c9a1e19cab10"
],
"version": "==0.6"
},
"rq": {
"hashes": [
"sha256:aa9e73113713e3b2e8d633ebb5f8ceccf5c8c97c1aae4356dc3f46d446129f71",
"sha256:ae5b4507de1be3db4e737c3d59179c568b2906539f5eacaff218e5bb6b05937b"
],
"version": "==0.9.1"
},
"six": {
"hashes": [
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb",
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9"
],
"version": "==1.11.0"
},
"sqlparse": {
"hashes": [
"sha256:d9cf190f51cbb26da0412247dfe4fb5f4098edb73db84e02f9fc21fdca31fed4",
"sha256:ce028444cfab83be538752a2ffdb56bc417b7784ff35bb9a3062413717807dec"
],
"version": "==0.2.4"
},
"unidecode": {
"hashes": [
"sha256:61f807220eda0203a774a09f84b4304a3f93b5944110cc132af29ddb81366883",
"sha256:280a6ab88e1f2eb5af79edff450021a0d3f0448952847cd79677e55e58bad051"
],
"version": "==0.4.21"
},
"urllib3": {
"hashes": [
"sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
"sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
],
"version": "==1.22"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
],
"version": "==0.5.1"
},
"xrd": {
"hashes": [
"sha256:51d01f732b5b5b7983c5179ffaed864408d95a667b3a6630fe27aa7528274089"
],
"version": "==0.1"
}
},
"develop": {}
}

90
README.md Normal file
View file

@ -0,0 +1,90 @@
lemoncurry (always all-lowercase) is a Django-based personal site designed to
operate as part of the [IndieWeb][]. It currently supports the following
IndieWeb specifications natively.
- All content is exposed using standard [microformats2][] markup, making it
easy for other sites and applications across the IndieWeb to consume.
- Additionally, the site owner's profiles are exposed using [rel-me][],
enabling independent verification of their identity across various services.
This permits [IndieAuth.com][] to authenticate the site's owner using a
social profile, such as a Twitter account. However, this functionality is not
necessary because lemoncurry also fully implements…
- [IndieAuth][], an protocol derived from OAuth 2.0 which enables the site's
owner to authorise access to their domain directly from the lemoncurry site
itself. Additionally, tokens for further access to the lemoncurry site may be
requested and issued, including customisable token scope as in OAuth.
- [Micropub][] is *partially* supported - using a token obtained through
IndieAuth, clients may post new content to the lemoncurry site using either
the form-encoded or JSON request formats. There is currently no support for
updating or deleting existing content through Micropub, although this is of
course planned.
- [Webmention][], used to enable rich commenting and social interaction between
separate IndieWeb sites, is partially supported. lemoncurry will correctly
*send* webmentions to all URLs mentioned in a published entry. However, it
currently does not expose an endpoint for *receiving* webmentions.
- [WebSub][] is also partially supported. When content is posted through
Micropub, WebSub is pinged as it should be - however, since only creating
*new* content through Micropub is supported, updates do not currently cause a
WebSub ping.
[IndieAuth]: https://www.w3.org/TR/indieauth/
[IndieAuth.com]: https://indieauth.com/
[IndieWeb]: https://indieweb.org/
[microformats2]: http://microformats.org/wiki/microformats2
[Micropub]: https://www.w3.org/TR/micropub/
[rel-me]: http://microformats.org/wiki/rel-me
[Webmention]: https://www.w3.org/TR/webmention/
[WebSub]: https://www.w3.org/TR/websub/
# Requirements
lemoncurry uses the following tools:
* [Pipenv][] - developed with Pipenv 2018.5.18, but should work with most versions.
* [Yarn][] - again, developed with Yarn 1.7.0, but should work with most versions.
As well as the following services:
* [PostgreSQL][] - create a database named `lemoncurry`. Socket auth is
recommended, so ensure the UNIX user you'll be running lemoncurry with has
access to that database. Alternatively, set the `POSTGRES_PASSWORD`
environment variable to use password auth.
* [Redis][] - lemoncurry expects to find Redis on port 6380, rather than the
standard port of 6379. Sorry about that.
If you're running in production, I'd recommend [Gunicorn][], which is already part
of lemoncurry's Pipfile. Ensure you run Gunicorn behind a secure reverse proxy,
such as [Nginx][].
If you're running in development, the usual Django `runserver` command should
be fine.
[Gunicorn]: https://gunicorn.org/
[Nginx]: https://nginx.org/en/
[Pipenv]: https://docs.pipenv.org/
[PostgreSQL]: https://www.postgresql.org/
[Redis]: https://redis.io/
[Yarn]: https://yarnpkg.org/
# Installation
Clone the repo recursively - since it uses Git submodules - and then install
both Python and Node dependencies.
```shellsession
$ git clone --recursive https://git.00dani.me/00dani/lemoncurry
$ cd lemoncurry
$ pipenv install --dev
$ yarn install
```
Once those steps complete, you should be able to perform the usual Django steps
to get a development server up and running. (If you'd prefer, you can use
`pipenv shell` to activate lemoncurry's virtualenv, rather than prefacing each
command with `pipenv run`. I like being explicit.)
```shellsession
$ pipenv run ./manage.py migrate
$ pipenv run ./manage.py collectstatic
$ pipenv run ./manage.py runserver 3000
```

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,19 +6,20 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
atomic = False
dependencies = [ dependencies = [
('entries', '0010_entry_tags'), ("entries", "0010_entry_tags"),
] ]
operations = [ operations = [
migrations.RenameModel( migrations.RenameModel(
old_name='Tag', old_name="Tag",
new_name='Cat', new_name="Cat",
), ),
migrations.RenameField( migrations.RenameField(
model_name='entry', model_name="entry",
old_name='tags', old_name="tags",
new_name='cats', new_name="cats",
), ),
] ]

View file

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

32
entries/pagination.py Normal file
View file

@ -0,0 +1,32 @@
from typing import Callable
from django.core.paginator import Page, Paginator
from django.shortcuts import redirect
from lemoncurry.middleware import ResponseException
def paginate(queryset, reverse: Callable[[int], str], page: int | None) -> Page:
def redirect_to_page(i: int):
raise ResponseException(redirect(reverse(i)))
def reversible(p: Page) -> Page:
p.reverse = reverse
return p
paginator = Paginator(queryset, 10)
# If no page number was specified, return page one.
if page is None:
return reversible(paginator.page(1))
# If the first page was explicitly requested, or the page number was negative, redirect to page one with no URL suffix.
if page <= 1:
redirect_to_page(1)
# If the page requested is larger than the last page, then redirect to the last page.
if page > paginator.num_pages:
redirect_to_page(paginator.num_pages)
# Just return the current page! Hooray!
return reversible(paginator.page(page))

View file

@ -1,4 +1,5 @@
ol.entries, div.entry ol.entries, div.entry
max-width 100%
display flex display flex
margin-bottom 0 margin-bottom 0
flex 1 flex 1
@ -9,12 +10,40 @@ ol.entries, div.entry
&:last-child &:last-child
margin-bottom 0 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 .e-content
ul ul
list-style-type disc list-style-type disc
ul, ol ul, ol
margin-bottom 1rem margin-bottom 1rem
padding-left 1.1rem
ul ul
list-style-type circle list-style-type circle
ul, ol ul, ol
@ -25,14 +54,8 @@ ol.entries, div.entry
max-width 100% max-width 100%
> :last-child > :last-child
margin-bottom 0 margin-bottom 0
.card-link
.card-footer
text-align center
> *
display inline-block display inline-block
margin-right 1rem font-size 0.8rem
&:last-child margin-left 0
margin-right 0 margin-right 1.25rem
.h-card > img
height 1em
vertical-align baseline

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View file

@ -1,14 +1,26 @@
from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.urls import reverse from django.urls import reverse
from django.utils.feedgenerator import Atom1Feed from django.utils.feedgenerator import Atom1Feed
from urllib.parse import urljoin
from lemoncurry.templatetags.markdown import markdown from lemoncurry.templatetags.markdown import markdown
from .kinds import on_home from ..kinds import on_home
from .models import Entry from ..models import Entry
class Atom1FeedWithHub(Atom1Feed):
def add_root_elements(self, handler):
super().add_root_elements(handler)
handler.startElement("link", {"rel": "hub", "href": settings.PUSH_HUB})
handler.endElement("link")
class EntriesFeed(Feed): class EntriesFeed(Feed):
item_guid_is_permalink = True
def item_link(self, entry):
return entry.absolute_url
def item_title(self, entry): def item_title(self, entry):
return entry.title return entry.title
@ -22,13 +34,12 @@ class EntriesFeed(Feed):
return entry.author.email return entry.author.email
def item_author_link(self, entry): def item_author_link(self, entry):
base = 'https://' + Site.objects.get_current().domain return entry.author.absolute_url
return urljoin(base, entry.author.url)
def item_pubdate(self, entry): def item_pubdate(self, entry):
return entry.published return entry.published
def item_updatedate(self, entry): def item_updateddate(self, entry):
return entry.updated return entry.updated
def item_categories(self, entry): def item_categories(self, entry):
@ -36,30 +47,30 @@ class EntriesFeed(Feed):
class RssByKind(EntriesFeed): class RssByKind(EntriesFeed):
def __init__(self, kind): def get_object(self, request, kind):
self.kind = kind return kind
def title(self): def title(self, kind):
return "{0} ~ {1}".format( return "{0} ~ {1}".format(
self.kind.plural, kind.plural,
Site.objects.get_current().name, Site.objects.get_current().name,
) )
def link(self): def link(self, kind):
return reverse('entries:' + self.kind.index) return kind.index
def description(self): def description(self, kind):
return "all {0} at {1}".format( return "all {0} at {1}".format(
self.kind.plural, kind.plural,
Site.objects.get_current().name, Site.objects.get_current().name,
) )
def items(self): def items(self, kind):
return Entry.objects.filter(kind=self.kind.id) return Entry.objects.filter(kind=kind.id)
class AtomByKind(RssByKind): class AtomByKind(RssByKind):
feed_type = Atom1Feed feed_type = Atom1FeedWithHub
subtitle = RssByKind.description subtitle = RssByKind.description
@ -68,7 +79,7 @@ class RssHomeEntries(EntriesFeed):
return Site.objects.get_current().name return Site.objects.get_current().name
def link(self): def link(self):
return reverse('home:index') return reverse("home:index")
def description(self): def description(self):
return "content from {0}".format( return "content from {0}".format(
@ -80,5 +91,5 @@ class RssHomeEntries(EntriesFeed):
class AtomHomeEntries(RssHomeEntries): class AtomHomeEntries(RssHomeEntries):
feed_type = Atom1Feed feed_type = Atom1FeedWithHub
subtitle = RssHomeEntries.description subtitle = RssHomeEntries.description

36
entries/views/lists.py Normal file
View file

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

14
entries/views/perma.py Normal file
View file

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

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): class HomeSitemap(sitemaps.Sitemap):
changefreq = 'daily' changefreq = "daily"
def items(self): def items(self):
return ('home:index',) return ("home:index",)
def location(self, item): def location(self, item):
return reverse(item) return reverse(item)

View file

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

View file

@ -1,64 +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 }}" hidden></a>
{% if user.avatar %}<img class="card-img-top u-photo" src="{{ user.avatar.url }}" alt="{{ user.first_name }} {{ user.last_name }}" />{% endif %}
<div class="card-body">
<h4 class="card-title p-name">
<span class="p-given-name">{{ user.first_name }}</span> <span class="p-family-name">{{ user.last_name }}</span>
</h4>
{% if user.note %}<div class="p-note">{{ user.note | markdown }}</div>{% endif %}
</div>
<div class="card-footer">
<ul class="profiles">
<li><a class="u-email" rel="me" href="mailto:{{ user.email }}">
<i class="fas fa-envelope"></i> {{ user.email }}
</a></li>
{% if user.xmpp %}<li><a class="u-impp" rel="me" href="xmpp:{{ user.xmpp }}">
<i class="openwebicons-xmpp"></i> {{ user.xmpp }}
</a></li>{% endif %}
</ul>
</div>
<div class="card-footer">
<ul class="profiles">
{% for key in user.keys.all %}<li>
<a class="u-key" href="{{ key.file.url }}">
<i class="fas fa-key"></i> {{ key.pretty_print }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="card-footer">
<ul class="profiles">
{% for profile in user.profiles.all %}<li>
<a class="u-url" rel="me" href="{{ profile.url }}" title="{{ profile }}"><i class="{{ profile.site.icon }}"></i> <span class="sr-only">{{ profile }}</span></a>
</li>{% endfor %}
</ul>
</div>
<script class="p-json-ld" type="application/ld+json">{{ user.json_ld | jsonify }}</script>
</article>
</aside>
<ol class="list-unstyled entries">
{% for entry in entries %}
<li>
{% include 'entries/h-entry.html' %}
</li>
{% endfor %}
</ol>
{% endblock %}

View file

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

View file

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

View file

@ -0,0 +1,83 @@
{% extends 'lemoncurry/layout.html' %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{{ static('lemonauth/css/indie.styl') }}">
{% endblock %}
{% block main %}
<div class="container">
<form class="card" method="post" action="{{ url('lemonauth:indie_approve') }}">
<h4 class="card-header h-x-app">
{% if app %}
{% if app.logo is defined %}
<img class="u-logo" src="{{ app.logo[0] }}" alt="{{ app.name[0] }}" />
{% endif %}
sign in to <span class="p-name">{{ app.name[0] }}</span> (<a class="u-url code" href="{{ params.client_id }}">{{ params.client_id }}</a>)?
{% else %}
sign in to <a class="u-url p-name code" href="{{ params.client_id }}">{{ params.client_id }}</a>?
{% endif %}
{% if verified %}
<span data-tooltip data-tippy-theme="dark success" data-tippy-html="#verified-success">
<i class="fas fa-check-circle verified-success"></i>
</span>
{% else %}
<span data-tooltip data-tippy-theme="dark warning" data-tippy-html="#verified-warning">
<i class="fas fa-question-circle verified-warning"></i>
</span>
{% endif %}
</h4>
<div class="card-body">
<p class="card-text">do you want to confirm your identity, <a class="code" href="{{ me }}">{{ me }}</a>, with this app?</p>
{% if params.response_type == 'code' %}
<p class="card-text">additionally, this app is requesting the following <i>scopes</i> - you can edit the scopes that will be granted to the app, if you wish</p>
<div class="card-text form-group">
{% for scope in scopes %}
<div class="form-check">
<input class="form-check-input" id="scopes-{{ scope }}" name="scope" type="checkbox" checked value="{{ scope }}" />
<label class="form-check-label" for="scopes-{{ scope }}">{{ scope }}</label>
</div>
{% endfor %}
</div>
{% endif %}
<p class="card-text"><small>you will be redirected to <a class="code" href="{{ redirect_uri }}">{{ redirect_uri }}</a> after authorising this app</small></p>
</div>
<div class="card-footer">
<button class="btn btn-success" type="submit">
<i class="fas fa-check"></i>
approve
</button>
</div>
{{ csrf_input }}
<input type="hidden" name="me" value="{{ me }}">
<input type="hidden" name="client_id" value="{{ params.client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
{% if params.state %}
<input type="hidden" name="state" value="{{ params.state }}">
{% endif %}
<input type="hidden" name="response_type" value="{{ params.response_type }}">
</form>
</div>
<div id="verified-success" hidden>
this client has been <strong>verified</strong> using <code>{{ '<link rel="redirect_uri">' | escape }}</code> <br/>- they are who they claim to be!
</div>
<div id="verified-warning" hidden>
this client could <strong>not</strong> be verified using <code>{{ '<link rel="redirect_uri">' | escape }}</code> <br/>- check the redirect uri carefully yourself!
</div>
{% endblock %}
{% block foot %}
<script type="text/javascript">
tippy('[data-tippy-html]', {
arrow: true,
allowHTML: true,
maxWidth: 500,
content: function(element) {
return document.querySelector(element.getAttribute('data-tippy-html')).innerHTML;
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,61 @@
{% extends 'lemoncurry/layout.html' %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{{ static('lemonauth/css/login.styl') }}">
{% endblock %}
{% block main %}
<div class="container">
{% if form.errors %}
<p class="alert alert-danger">
<strong>uh oh!</strong> your login details didn't match, please try again
</p>
{% elif next %}
{% if request.user.is_authenticated %}
<p class="alert alert-warning">
<strong>hang on!</strong> your account doesn't have access to this page :( to proceed, please log in to an account that does have access!
</p>
{% else %}
<p class="alert alert-warning">
<strong>oops!</strong> please log in to see this page
</p>
{% endif %}
{% endif %}
<form class="card" method="post" action="{{ url('lemonauth:login') }}">
<div class="card-body">
<div class="form-group">
<label for="{{ form.username.id_for_label }}">username</label>
<input class="form-control" type="text" autocomplete="username" required id="{{ form.username.auto_id }}" name="{{ form.username.name }}" value="{{ form.username.value() or '' }}">
</div>
<div class="form-group">
<label for="{{ form.password.id_for_label }}">password</label>
<input class="form-control" type="password" autocomplete="current-password" required id="{{ form.password.auto_id }}" name="{{ form.password.name }}">
</div>
<div class="form-group">
<label for="{{ form.otp_token.id_for_label }}">otp token</label>
<input class="form-control" type="text" required id="{{ form.otp_token.auto_id }}" name="{{ form.otp_token.name }}">
</div>
<div class="form-group form-check">
<input class="form-check-input" type="checkbox" id="{{ form.otp_trust_agent.auto_id }}" name="{{ form.otp_trust_agent.name }}">
<label for="{{ form.otp_trust_agent.id_for_label }}" class="form-check-label">
remember this browser (don't tick this on a public computer!)
</label>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
log in
</button>
</div>
{{ csrf_input }}
<input type="hidden" name="next" value="{{ next }}">
</form>
</div>
{% endblock %}

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

@ -1,28 +1,42 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-10-29 05:05 # Generated by Django 1.11.6 on 2017-10-29 05:05
from __future__ import unicode_literals from __future__ import unicode_literals
from typing import List, Tuple
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [] # type: List[Tuple[str, str]]
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='IndieAuthCode', name="IndieAuthCode",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('code', models.CharField(max_length=64, unique=True)), "id",
('me', models.CharField(max_length=255)), models.AutoField(
('client_id', models.CharField(max_length=255)), auto_created=True,
('redirect_uri', models.CharField(max_length=255)), primary_key=True,
('response_type', models.CharField(choices=[('id', 'id'), ('code', 'code')], default='id', max_length=4)), serialize=False,
('scope', models.CharField(blank=True, max_length=200)), 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('lemonauth', '0001_initial'), ("lemonauth", "0001_initial"),
] ]
operations = [ operations = [
migrations.DeleteModel( 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,13 +2,11 @@
img img
height 2em height 2em
margin-right .5em margin-right .5em
.tippy-tooltip .tippy-box
&.success-theme &[data-theme~='success']
color $base0B color $base0B
background-color $base03 &[data-theme~='warning']
&.warning-theme
color $base0A color $base0A
background-color $base03
.verified-success .verified-success
color $base0B color $base0B
.verified-warning .verified-warning

View file

@ -1,67 +0,0 @@
{% extends 'lemoncurry/layout.html' %}
{% load markdown static %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{% static 'lemonauth/css/indie.styl' %}" />
{% endblock %}
{% block main %}
<div class="container">
<form class="card" method="post" action="{% url 'lemonauth:indie_approve' %}">
<h4 class="card-header h-x-app">
{% if app %}<img class="u-logo p-name" src="{{ app.logo | first }}" alt="{{ app.name | first }}" />{% endif %}
sign in to
{% if app %}{{ app.name | first }}{% endif %}
{% if app %}({% endif %}<a class="u-url code{% if not app %} p-name{% endif %}" href="{{ params.client_id }}">{{ params.client_id }}</a>{% if app %}){% endif %}?
{% if verified %}
<i class="fas fa-check-circle verified-success" data-tooltip data-theme="success" data-html="#verified-success"></i>
{% else %}
<i class="fas fa-question-circle verified-warning" data-tooltip data-theme="warning" data-html="#verified-warning"></i>
{% endif %}
</h4>
<div class="card-body">
<p class="card-text">do you want to confirm your identity, <a class="code" href="{{ me }}">{{ me }}</a>, with this app?</p>
{% if params.response_type == 'code' %}
<p class="card-text">additionally, this app is requesting the following <i>scopes</i> - you can edit the scopes that will be granted to the app, if you wish</p>
<div class="custom-controls-stacked card-text">
{% for scope in scopes %}
<label class="custom-control custom-checkbox">
<input name="scope" type="checkbox" class="custom-control-input" checked value="{{ scope }}" />
<span class="custom-control-indicator"></span>
<span class="custom-control-description">{{ scope }}</span>
</label>
{% endfor %}
</div>
{% endif %}
<p class="card-text"><small>you will be redirected to <a class="code" href="{{ redirect_uri }}">{{ redirect_uri }}</a> after authorising this app</small></p>
</div>
<div class="card-footer">
<button class="btn btn-success" type="submit">
<i class="fas fa-check"></i>
approve
</button>
</div>
{% csrf_token %}
<input name="me" type="hidden" value="{{ me }}" />
<input name="client_id" type="hidden" value="{{ params.client_id }}" />
<input name="redirect_uri" type="hidden" value="{{ redirect_uri }}" />
{% if params.state %}<input name="state" type="hidden" value="{{ params.state }}" />{% endif %}
<input name="response_type" type="hidden" value="{{ params.response_type }}" />
</form>
</div>
<div id="verified-success" hidden>
this client has been <strong>verified</strong> using <code>{{ '<link rel="redirect_uri">' | force_escape }}</code> - they are who they claim to be!
</div>
<div id="verified-warning" hidden>
this client could <strong>not</strong> be verified using <code>{{ '<link rel="redirect_uri">' | force_escape }}</code> - check the redirect uri carefully yourself!
</div>
{% endblock %}
{% block foot %}
<script type="text/javascript">
tippy('[data-tooltip]', {arrow: true});
</script>
{% endblock %}

View file

@ -1,54 +0,0 @@
{% extends 'lemoncurry/layout.html' %}
{% load lemonauth_tags static %}
{% block styles %}
<link rel="stylesheet" type="text/stylus" href="{% static 'lemonauth/css/login.styl' %}" />
{% endblock %}
{% block main %}
<div class="container">
{% if form.errors %}
<p class="alert alert-danger">
<strong>Uh oh!</strong> Your username and password didn't match. Please try again.
</p>
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<p class="alert alert-warning">
<strong>Hang on!</strong> Your account doesn't have access to this page. To proceed, please log in to an account that does have access.
</p>
{% else %}
<p class="alert alert-warning">
<strong>Oops!</strong> Please log in to see this page.
</p>
{% endif %}
{% endif %}
<form class="card" method="post" action="{% url 'lemonauth:login' %}">
<div class="card-body">
{% form_field form.username %}
{% form_field form.password %}
{% form_field form.otp_token %}
<div class="form-group">
<label class="custom-control custom-checkbox">
<input name="{{ form.otp_agent_trust.name }}" class="custom-control-input" type="checkbox" />
<span class="custom-control-indicator"></span>
<span class="custom-control-description">remember this browser (don't tick this on a public computer!)</span>
</label>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
<i class="fas fa-sign-in-alt"></i>
log in
</button>
</div>
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}" />
</form>
</div>
{% endblock %}

View file

@ -1,6 +0,0 @@
<div class="form-group">
<label for="{{ field.id_for_label }}">{{ field.label | lower }}</label>
<input id="{{ field.id_for_label }}" class="form-control{% if field.errors %}is-invalid{% endif %}" type="{{ field.field.widget.input_type }}" required
name="{{ field.html_name }}"{% if field.value %} value="{{ field.value }}"{% endif %}{% if field.help_text %} aria-describedby="{{ field.id_for_label }}-help"{% endif %} />
{% if field.help_text %}<p id="{{ field.id_for_label }}-help" class="form-text">{{ field.help_text }}</p>{% endif %}
</div>

View file

@ -1,8 +0,0 @@
from django import template
register = template.Library()
@register.inclusion_tag('lemonauth/tags/form_field.html')
def form_field(field):
return {'field': field}

View file

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

View file

@ -1,11 +1,17 @@
from django.conf.urls import url from django.urls import path
from . import views from . import views
app_name = 'lemonauth' app_name = "lemonauth"
urlpatterns = [ urlpatterns = [
url('^login$', views.login, name='login'), path("login", views.login, name="login"),
url('^logout$', views.logout, name='logout'), path("logout", views.logout, name="logout"),
url('^indie$', views.IndieView.as_view(), name='indie'), path("indie", views.IndieView.as_view(), name="indie"),
url('^indie/approve$', views.indie_approve, name='indie_approve'), path("indie/approve", views.indie_approve, name="indie_approve"),
url('^token$', views.TokenView.as_view(), name='token'), path("token", views.TokenView.as_view(), name="token"),
path("tokens", views.TokensListView.as_view(), name="tokens"),
path(
"tokens/<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 .logout import logout
from .indie import IndieView, approve as indie_approve from .indie import IndieView, approve as indie_approve
from .token import TokenView from .token import TokenView
from .tokens import TokensListView, TokensRevokeView

View file

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

View file

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

View file

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

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)

BIN
lemoncurry.paw Normal file

Binary file not shown.

5
lemoncurry.svg Normal file
View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" version="1.1">
<circle id="backdrop" fill="#353535" cx="400" cy="400" r="400" />
<path id="lemon" fill="#FFCB6B" d="m 633.03789,166.963 c -23.094,-23.093 -54.39,-28.893 -75.091,-16.834 -58.906,34.312 -181.25,-53.076996 -321.073,86.746 -139.823004,139.823 -52.433,262.166 -86.745,321.07 -12.059,20.702 -6.26,51.999 16.833,75.093 23.095,23.095 54.392,28.891 75.095,16.832 58.901,-34.31 181.246,53.079 321.068,-86.743 139.822,-139.822 52.435,-262.167 86.746,-321.071 12.059,-20.702 6.261,-51.999 -16.833,-75.093 z m -245.157,72.559 c -58.189,14.547 -133.808,90.155 -148.358,148.358 -1.817,7.27 -8.342,12.124 -15.511,12.124 -1.284,0 -2.59,-0.156 -3.893,-0.481 -8.572,-2.144 -13.784,-10.83 -11.642,-19.403 17.424,-69.693 101.839,-154.19 171.642,-171.642 8.575,-2.143 17.261,3.069 19.403,11.642 2.142,8.573 -3.069,17.259 -11.641,19.402 z" />
</svg>

After

Width:  |  Height:  |  Size: 948 B

5
lemoncurry/admin.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib import admin
from otp_agents.decorators import otp_required
admin.site.login = otp_required(admin.site.login, accept_trusted_agent=True)

View file

@ -1,15 +1,50 @@
from django.urls import reverse
breadcrumbs = {} breadcrumbs = {}
class Crumb:
def __init__(self, route, label=None, parent=None):
self.route = route
self._label = label
self.parent = parent
@property
def label(self):
return self._label
def __eq__(self, other):
if hasattr(other, "route"):
return self.route == other.route
return self.route == other
def __hash__(self):
return hash(self.route)
def __repr__(self):
return "Crumb('{0}')".format(self.route)
def use_match(self, match):
self.match = match
@property
def url(self):
return reverse(self.route)
def add(route, label=None, parent=None): def add(route, label=None, parent=None):
breadcrumbs[route] = {'label': label, 'route': route, 'parent': parent} if not isinstance(route, Crumb):
route = Crumb(route, label, parent)
breadcrumbs[route.route] = route
def find(route): def find(match):
crumbs = [] crumbs = []
route = match.view_name
while route: while route:
crumb = breadcrumbs[route] crumb = breadcrumbs[route]
crumb.use_match(match)
crumbs.append(crumb) crumbs.append(crumb)
route = crumb['parent'] route = crumb.parent
crumbs.reverse() crumbs.reverse()
return crumbs return crumbs

7
lemoncurry/debug.py Normal file
View file

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

View file

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

@ -0,0 +1,165 @@
<!doctype html>
<html{% block html_attr %} dir="ltr" lang="en" data-bs-theme="dark"{% endblock %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ request.site.name }}</title>
{% if atom is defined %}
<link rel="alternate" type="application/atom+xml" href="{{ atom }}" />
{% endif %}
{% if rss is defined %}
<link rel="alternate" type="application/rss+xml" href="{{ rss }}" />
{% endif %}
{% block head %}{% endblock %}
<base href="{{ request.build_absolute_uri(url('home:index')) }}" />
<link rel="authorization_endpoint" href="{{ url('lemonauth:indie') }}" />
<link rel="canonical" href="{{ request.build_absolute_uri() }}" />
<link rel="hub" href="{{ settings.PUSH_HUB }}" />
<link rel="manifest" href="{{ url('wellknowns:manifest') }}" />
<link rel="micropub" href="{{ url('micropub:micropub') }}" />
<link rel="token_endpoint" href="{{ url('lemonauth:token') }}" />
<meta name="generator" content="{{ package.name }} {{ package.version }}" />
<meta name="theme-color" content="{{ theme_color(3) }}" />
{% for i in favicons %}
<link rel="{{ i.rel }}" type="{{ i.mime }}" sizes="{{ i.sizes }}" href="{{ i.url }}" />
{% endfor %}
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/monokai.min.css"
integrity="sha384-88Jvj9Q2LiBDwL7w3yciRTcH5q2zzvMFYIm4xX9/evqxJsxA33Xk9XYKcvUlPITo" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/openwebicons@1.6.0/css/openwebicons.min.css"
integrity="sha384-NkRWM9o4Kfak7GwS+un+sProBBpj02vc/e1EoXvdCUSdRk0muOfkKJ5NtpueAuka" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/tippy.js@3.4.1/dist/tippy.css"
integrity="sha384-hm3Wtrva6FibonAOqHHXSpMxvGbz2g7l5FK5avbuNviir5MK6Ap4o3EOohztzHHm" crossorigin="anonymous">
{% compress css %}
<link rel="stylesheet" type="text/stylus" href="{{ static('lemoncurry/css/layout.styl') }}">
{% block styles %}{% endblock %}
{% endcompress %}
<script type="text/javascript" defer src="https://kit.fontawesome.com/a3aade9b41.js" crossorigin="anonymous"></script>
</head>
<body{% block body_attr %}{% endblock %}>
<header>
<nav class="navbar navbar-expand-md"><div class="container-fluid">
<a class="navbar-brand" rel="home" href="{{ url('home:index') }}">{{ request.site.name }}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% activeurl %}
<div class="collapse navbar-collapse" id="navbar">
<ul class="navbar-nav">
{% for kind in entry_kinds %}
<li class="nav-item">
<a class="nav-link" href="{{ kind.index }}">
<i class="{{ kind.icon }} fa-fw" aria-hidden="true"></i>
{{ kind.plural }}
</a>
</li>
{% endfor %}
</ul>
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url('lemonauth:tokens') }}">
<i class="fas fa-cookie-bite fa-fw" aria-hidden="true"></i>
tokens
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url('admin:index') }}">
<i class="fas fa-cog fa-fw" aria-hidden="true"></i>
admin
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url('lemonauth:logout') }}">
<i class="fas fa-sign-out-alt fa-fw" aria-hidden="true"></i>
log out
</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url('lemonauth:login') }}">
<i class="fas fa-sign-in-alt fa-fw" aria-hidden="true"></i>
log in
</a>
</li>
{% endif %}
</ul>
</div>
{% endactiveurl %}
</div></nav>
</header>
<main>
{% block main %}
{% endblock %}
</main>
<footer>
<p>all content licensed under <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">cc by-sa 4.0</a></p>
{% if entries is defined and entries.has_other_pages() %}
<nav>
<ul class="pagination">
{% if entries.has_previous() %}
<li class="page-item">
<a class="page-link" rel="prev" href="{{ entries.reverse(entries.previous_page_number()) }}">
<i class="fas fa-step-backward" aria-hidden="true"></i> <span class="sr-only">previous page</span>
</a>
</li>
{% endif %}
{% for i in entries.paginator.page_range %}
{% if i == entries.number %}
<li class="page-item active">
<span class="page-link">{{ i }} <span class="sr-only">(current page)</span></span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ entries.reverse(i) }}">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
{% if entries.has_next() %}
<li class="page-item">
<a class="page-link" rel="next" href="{{ entries.reverse(entries.next_page_number()) }}">
<i class="fas fa-step-forward" aria-hidden="true"></i> <span class="sr-only">next page</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<p>powered by <a rel="code-repository" href="{{ package.repository }}/src/tag/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
</footer>
<script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"
integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT"></script>
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js" crossorigin="anonymous"
integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp"></script>
<script src="https://unpkg.com/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"></script>
<script src="https://unpkg.com/tippy.js@6.3.7/dist/tippy-bundle.umd.js" crossorigin="anonymous"
integrity="sha384-dtMr4wkcxQWUqsJFgElu4AttgIhOsjr2vYIzP2mv0MZbD/uJ6OHxFdbgE3MOKabN"></script>
{% compress js %}
<script type="text/javascript">
hljs.initHighlightingOnLoad();
</script>
{% block foot %}
{% endblock %}
{% endcompress %}
</body>
</html>

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

24
lemoncurry/msgpack.py Normal file
View file

@ -0,0 +1,24 @@
import msgpack
import pickle
from django_redis.serializers.base import BaseSerializer
def default(obj):
# Pickle anything that MessagePack can't handle itself.
return msgpack.ExtType(69, pickle.dumps(obj, protocol=4))
def ext_hook(code, data):
# Unpickle if we pickled - otherwise do nothing.
if code == 69:
return pickle.loads(data)
return msgpack.ExtType(code, data)
class MSGPackModernSerializer(BaseSerializer):
def dumps(self, value):
return msgpack.packb(value, default=default, use_bin_type=True)
def loads(self, value):
return msgpack.unpackb(value, ext_hook=ext_hook, raw=False)

View file

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

View file

@ -10,25 +10,29 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.11/ref/settings/ https://docs.djangoproject.com/en/1.11/ref/settings/
""" """
import os from os import environ, path
import re from typing import List
APPEND_SLASH = False
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) ADMINS = [
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ("dani", "dani@00dani.me"),
]
BASE_DIR = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww' SECRET_KEY = "6riil57g@r^wprf7mdy((+bs&(6l*phcn9&fd$l0@t-kzj+xww"
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS: List[str] = []
INTERNAL_IPS = ['127.0.0.1', '::1'] INTERNAL_IPS = ["127.0.0.1", "::1"]
# Settings to tighten up security - these can safely be on in dev mode too, # Settings to tighten up security - these can safely be on in dev mode too,
# since I dev using a local HTTPS server. # since I dev using a local HTTPS server.
@ -46,7 +50,7 @@ CSRF_COOKIE_SECURE = True
# Miscellanous headers to protect against attacks. # Miscellanous headers to protect against attacks.
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True SECURE_BROWSER_XSS_FILTER = True
X_FRAME_OPTIONS = 'DENY' X_FRAME_OPTIONS = "DENY"
# This technically isn't needed, since nginx doesn't let the app be accessed # This technically isn't needed, since nginx doesn't let the app be accessed
# over insecure HTTP anyway. Just for completeness! # over insecure HTTP anyway. Just for completeness!
@ -54,91 +58,104 @@ SECURE_SSL_REDIRECT = True
# We run behind nginx, so we need nginx to tell us whether we're using HTTPS or # We run behind nginx, so we need nginx to tell us whether we're using HTTPS or
# not. # not.
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "lemoncurry",
'django.contrib.auth', "pyup_django",
'django.contrib.contenttypes', "django.contrib.admin",
'django.contrib.humanize', "django.contrib.admindocs",
'django.contrib.sessions', "django.contrib.auth",
'django.contrib.sites', "django.contrib.contenttypes",
'django.contrib.sitemaps', "django.contrib.humanize",
'django.contrib.messages', "django.contrib.sessions",
'django.contrib.staticfiles', "django.contrib.sites",
"django.contrib.sitemaps",
'analytical', "django.contrib.messages",
'annoying', "django.contrib.staticfiles",
'compressor', "annoying",
'debug_toolbar', "compressor",
'django_activeurl', "computed_property",
'django_agent_trust', "corsheaders",
'django_otp', "debug_toolbar",
'django_otp.plugins.otp_totp', "django_activeurl",
'django_rq', "django_agent_trust",
'favicon', "django_extensions",
'meta', "django_otp",
'shorturls', "django_otp.plugins.otp_static",
"django_otp.plugins.otp_totp",
'lemoncurry', "django_rq",
'entries', "meta",
'home', "entries",
'lemonauth', "home",
'micropub', "lemonauth",
'users', "lemonshort",
'wellknowns', "micropub",
"users",
"webmention",
"wellknowns",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware', "debug_toolbar.middleware.DebugToolbarMiddleware",
'django.middleware.security.SecurityMiddleware', "django.middleware.http.ConditionalGetMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.middleware.security.SecurityMiddleware",
'django.middleware.common.CommonMiddleware', "django.contrib.admindocs.middleware.XViewMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "corsheaders.middleware.CorsMiddleware",
'django_otp.middleware.OTPMiddleware', "django.middleware.common.CommonMiddleware",
'django_agent_trust.middleware.AgentMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.sites.middleware.CurrentSiteMiddleware', "django_otp.middleware.OTPMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "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 = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.jinja2.Jinja2",
'DIRS': [], "APP_DIRS": True,
'APP_DIRS': True, "OPTIONS": {
'OPTIONS': { "environment": "lemoncurry.jinja2.environment",
'context_processors': [ },
'django.template.context_processors.debug', },
'django.template.context_processors.request', {
'django.contrib.auth.context_processors.auth', "BACKEND": "django.template.backends.django.DjangoTemplates",
'django.contrib.messages.context_processors.messages', "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 # Cache
# https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-CACHES # https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-CACHES
CACHES = { CACHES = {
'default': { "default": {
'BACKEND': 'redis_cache.RedisCache', "BACKEND": "django_redis.cache.RedisCache",
'LOCATION': '127.0.0.1:6380', "LOCATION": "redis://127.0.0.1:6380/0",
'KEY_PREFIX': 'lemoncurry', "KEY_PREFIX": "lemoncurry",
'OPTIONS': { "OPTIONS": {
'DB': 0, "SERIALIZER": "lemoncurry.msgpack.MSGPackModernSerializer",
'PARSER_CLASS': 'redis.connection.HiredisParser',
}, },
"VERSION": 2,
} }
} }
@ -146,44 +163,51 @@ CACHES = {
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases # https://docs.djangoproject.com/en/1.11/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.postgresql",
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), "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",
]
# Password validation # Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
PW_VALIDATOR_MODULE = "django.contrib.auth.password_validation"
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {"NAME": PW_VALIDATOR_MODULE + ".UserAttributeSimilarityValidator"},
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', {"NAME": PW_VALIDATOR_MODULE + ".MinimumLengthValidator"},
}, {"NAME": PW_VALIDATOR_MODULE + ".CommonPasswordValidator"},
{ {"NAME": PW_VALIDATOR_MODULE + ".NumericPasswordValidator"},
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
] ]
LOGIN_URL = 'lemonauth:login' LOGIN_URL = "lemonauth:login"
LOGIN_REDIRECT_URL = 'home:index' LOGIN_REDIRECT_URL = "home:index"
LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.11/topics/i18n/ # https://docs.djangoproject.com/en/1.11/topics/i18n/
LANGUAGE_CODE = 'en-au' LANGUAGE_CODE = "en-au"
TIME_ZONE = 'Australia/Sydney' TIME_ZONE = "Australia/Sydney"
USE_I18N = True USE_I18N = True
@ -195,20 +219,21 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/ # https://docs.djangoproject.com/en/1.11/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_ROOT = path.join(BASE_DIR, "static")
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', "django.contrib.staticfiles.finders.FileSystemFinder",
'django.contrib.staticfiles.finders.AppDirectoriesFinder', "django.contrib.staticfiles.finders.AppDirectoriesFinder",
'compressor.finders.CompressorFinder', "compressor.finders.CompressorFinder",
) )
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
COMPRESS_PRECOMPILERS = ( COMPRESS_PRECOMPILERS = (
('text/stylus', './node_modules/.bin/stylus {infile} -u ./lemoncurry/static/lemoncurry/css/theme -o {outfile}'), ("text/stylus", "npx stylus -u ./lemoncurry/static/lemoncurry/css/theme"),
) )
MEDIA_URL = STATIC_URL + 'media/' MEDIA_URL = STATIC_URL + "media/"
MEDIA_ROOT = os.path.join(STATIC_ROOT, 'media') MEDIA_ROOT = path.join(STATIC_ROOT, "media")
# django-contrib-sites # django-contrib-sites
# https://docs.djangoproject.com/en/dev/ref/contrib/sites/ # https://docs.djangoproject.com/en/dev/ref/contrib/sites/
@ -218,23 +243,27 @@ SITE_ID = 1
# https://pythonhosted.org/django-agent-trust/ # https://pythonhosted.org/django-agent-trust/
AGENT_COOKIE_SECURE = True AGENT_COOKIE_SECURE = True
# django-shorturls # django-cors-headers
# https://pypi.python.org/pypi/django-shorturls CORS_ORIGIN_ALLOW_ALL = True
CORS_URLS_REGEX = r"^/(?!admin|auth/(?:login|logout|indie)).*$"
# lemonshort
SHORT_BASE_URL = "/s/"
SHORTEN_MODELS = { SHORTEN_MODELS = {
'e': 'entries.entry', "e": "entries.entry",
} }
# django-meta # django-meta
# https://django-meta.readthedocs.io/en/latest/settings.html # https://django-meta.readthedocs.io/en/latest/settings.html
META_SITE_PROTOCOL = 'https' META_SITE_PROTOCOL = "https"
META_USE_SITES = True META_USE_SITES = True
META_USE_OG_PROPERTIES = True META_USE_OG_PROPERTIES = True
META_USE_TWITTER_PROPERTIES = True META_USE_TWITTER_PROPERTIES = True
# django-push # django-push
# https://django-push.readthedocs.io/en/latest/publisher.html # https://django-push.readthedocs.io/en/latest/publisher.html
PUSH_HUB = 'https://00dani.superfeedr.com/' PUSH_HUB = "https://00dani.superfeedr.com/"
# django-rq # django-rq
# https://github.com/ui/django-rq # https://github.com/ui/django-rq
RQ_QUEUES = {'default': {'USE_REDIS_CACHE': 'default'}} RQ_QUEUES = {"default": {"USE_REDIS_CACHE": "default"}}

View file

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

View file

@ -4,24 +4,19 @@ from os.path import join
from .base import * from .base import *
from .base import BASE_DIR, DATABASES from .base import BASE_DIR, DATABASES
ALLOWED_HOSTS = ['00dani.me'] ALLOWED_HOSTS = ["00dani.me"]
DEBUG = False DEBUG = False
SECRET_KEY = environ['DJANGO_SECRET_KEY'] SECRET_KEY = environ["DJANGO_SECRET_KEY"]
SERVER_EMAIL = "lemoncurry@00dani.me"
# Use Postgres instead of SQLite in production. # Authenticate as an app-specific Postgres user in production.
DATABASES['default'] = { DATABASES["default"]["USER"] = "lemoncurry"
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'lemoncurry',
'USER': 'lemoncurry',
}
SHORT_BASE_URL = 'https://nya.as/' SHORT_BASE_URL = "https://nya.as/"
STATIC_ROOT = join(BASE_DIR, '..', 'static') STATIC_ROOT = join(BASE_DIR, "..", "static")
MEDIA_ROOT = join(BASE_DIR, '..', 'media') MEDIA_ROOT = join(BASE_DIR, "..", "media")
STATIC_URL = 'https://cdn.00dani.me/' STATIC_URL = "https://cdn.00dani.me/"
MEDIA_URL = STATIC_URL + 'media/' MEDIA_URL = STATIC_URL + "m/"
META_SITE_DOMAIN = '00dani.me' META_SITE_DOMAIN = "00dani.me"
META_FB_APPID = '145311792869199' META_FB_APPID = "145311792869199"
GOOGLE_ANALYTICS_PROPERTY_ID = 'UA-109060843-1'

View file

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

View file

@ -1,19 +1,22 @@
$monokai_bg = #272822
html html
background-color $base00 background-color $base00
a a
color $base0D color $base0D
text-decoration none
&:hover &:hover
color $base0C color $base0C
code, kbd, pre, samp, .code, .kbd, .pre, .samp code, kbd, pre, samp, .code, .kbd, .pre, .samp
font-family SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace font-family SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace
code, .code code, pre, .code, .pre
padding .2rem .4rem padding .2rem .4rem
font-size 90% font-size 90%
color $base0A color $base0A
background-color $base00 background-color $monokai_bg
border-radius .25rem border-radius .25rem
.form-control, .form-control:focus .form-control, .form-control:focus
@ -21,12 +24,22 @@ code, .code
border-color $base00 border-color $base00
color $base07 color $base07
.list-group-item
background-color $base03
[class^="openwebicons-"], [class*=" openwebicons-"] [class^="openwebicons-"], [class*=" openwebicons-"]
&::before &::before
text-decoration none text-decoration none
line-height 1 line-height 1
.tippy-box[data-theme~='dark']
background-color $base03
color $base04
text-align center
for placement in top bottom left right
&[data-placement^={placement}] > .tippy-arrow::before
border-{placement}-color $base03
body body
display flex display flex
flex-direction column flex-direction column
@ -52,7 +65,7 @@ body
> main > main
padding 2rem padding 2rem 1rem
width 100% width 100%
flex 1 flex 1
display flex display flex
@ -60,13 +73,42 @@ body
> footer > footer
display flex display flex
justify-content space-evenly justify-content space-evenly
align-items center
margin 1rem margin 1rem
margin-top 0 margin-top 0
text-align center text-align center
> p > p, nav
margin-right 1rem margin 0 .5rem
&:last-child &:last-child
margin-right 0 margin 0
flex-wrap wrap
> nav
order -1
margin-bottom 1rem
width 100%
@media (min-width $md)
flex-wrap nowrap
> nav
order 0
margin-bottom 0
width unset
ul.pagination
margin 0
justify-content center
li.page-item
a.page-link
@extends a
.page-link
background-color $base02
border 1px solid rgba(0,0,0,.125)
.media
display flex
> .media-body
flex-grow 1
margin-left 3px
.card .card
background-color $base02 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 themePath = join(__dirname, '..', '..', 'base16-materialtheme-scheme', 'material-darker.yaml');
const breakpoints = {
sm: 576,
md: 768,
lg: 992,
xl: 1200,
};
module.exports = function() { module.exports = function() {
const theme = safeLoad(readFileSync(themePath, 'utf8')); const theme = safeLoad(readFileSync(themePath, 'utf8'));
return function(style) { 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++) { for (let i = 0; i < 16; i++) {
const key = 'base0' + i.toString(16).toUpperCase(); const key = 'base0' + i.toString(16).toUpperCase();
const hex = theme[key]; const hex = theme[key];

View file

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

View file

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

View file

@ -1,85 +0,0 @@
{% load analytical compress favtags lemoncurry_tags meta static theme_colour %}<!doctype html>
<html dir="ltr" lang="en" class="{% block html_class %}{% endblock %}">
<head{% meta_namespaces %}>{% site_name as site_name %}{% request_uri request as uri %}{% request_origin request as origin %}
{% analytical_head_top %}
<base href="{{ origin }}" />
<title class="p-name">{% if title %}{{ title }} ~ {% endif %}{{ site_name }}</title>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<link rel="canonical" href="{{ uri }}" />
{% if atom %}<link rel="alternate" type="application/atom+xml" href="{% url atom %}" />{% endif %}
{% if rss %}<link rel="alternate" type="application/rss+xml" href="{% url rss %}" /> {% endif %}
{% block head %}{% endblock %}
<link rel="authorization_endpoint" href="{{ origin }}{% url 'lemonauth:indie' %}" />
<link rel="token_endpoint" href="{{ origin }}{% url 'lemonauth:token' %}" />
<link rel="micropub" href="{{ origin }}{% url 'micropub:micropub' %}" />
<link rel="openid.delegate" href="{{ origin }}" />
<link rel="openid.server" href="https://openid.indieauth.com/openid" />
<link rel="hub" href="{% get_push_hub %}" />
<link rel="self" href="{{ uri }}" />
<link rel="manifest" href="{% url 'wellknowns:manifest' %}" />
<meta name="theme-color" content="{% theme_colour 2 %}" />
<meta property="og:url" content="{{ uri }}" />
<meta property="og:title" content="{% firstof title site_name %}" />
{% include 'meta/meta.html' %}
{% placeFavicon %}
<link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css"
integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb" crossorigin="anonymous" />
{% compress css %}
<link rel="stylesheet" type="text/css" href={% static 'openwebicons/css/openwebicons.css' %} />
<link rel="stylesheet" type="text/css" href={% static 'tippy.js/tippy.css' %} />
<link rel="stylesheet" type="text/stylus" href="{% static 'lemoncurry/css/layout.styl' %}" />
{% block styles %}{% endblock %}
{% endcompress %}
<script type="text/javascript" defer src="https://use.fontawesome.com/releases/v5.0.1/js/all.js"></script>
{% analytical_head_bottom %}
</head>
<body>
{% analytical_body_top %}
<header>
<nav class="navbar navbar-expand-md navbar-dark">
<a class="navbar-brand" rel="home" href="{% url 'home:index' %}">{% site_name %}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar">
{% nav_left request %}
{% nav_right request %}
</div>
</nav>
{% if request.resolver_match.view_name %}
{% nav_crumbs request.resolver_match.view_name %}
{% endif %}
</header>
<main>
{% block main %}{% endblock %}
</main>
<footer>
<p>all content licensed under <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">cc by-sa 4.0</a></p>
{% get_package_json as package %}
<p>powered by <a rel="code-repository" href="{{ package.repository }}/tree/v{{ package.version }}">{{ package.name }} {{ package.version }}</a></p>
</footer>
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" crossorigin="anonymous"
integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" crossorigin="anonymous"
integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js" crossorigin="anonymous"
integrity="sha384-alpBpkh1PFOepccYVYDB4do5UnbKysX5WZXm3XxPqe5iKTfUKjNkCk9SaVuEZflJ"></script>
{% compress js %}
<script src="{% static 'tippy.js/tippy.standalone.js' %}"></script>
{% block foot %}{% endblock %}
{% endcompress %}
{% analytical_body_bottom %}
</body>
</html>

View file

@ -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="{% url crumb.route %}">{{ crumb.label }}</a></li>
{% endfor %}
<li class="breadcrumb-item active" aria-current="page">{% firstof current.label title %}</li>
</ol>
<script type="application/ld+json">{{ breadcrumb_list | jsonify }}</script>
</nav>
{% endif %}

View file

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

@ -0,0 +1,12 @@
from django import template
from django.contrib.sites.models import Site
from urllib.parse import urljoin
register = template.Library()
@register.simple_tag
@register.filter(is_safe=True)
def absolute_url(url):
base = "https://" + Site.objects.get_current().domain
return urljoin(base, url)

View file

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

View file

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

View file

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

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