├── .github
└── workflows
│ └── packaging.yml
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── conftest.py
├── manage.py
├── pyproject.toml
├── src
└── webmention
│ ├── __init__.py
│ ├── admin.py
│ ├── checks.py
│ ├── middleware.py
│ ├── migrations
│ ├── 0001_initial.py
│ └── __init__.py
│ ├── models.py
│ ├── resolution.py
│ ├── urls.py
│ └── views.py
└── tests
├── __init__.py
├── settings.py
├── test_middleware.py
├── test_models.py
├── test_resolution.py
├── test_urls.py
└── test_views.py
/.github/workflows/packaging.yml:
--------------------------------------------------------------------------------
1 | name: Packaging
2 |
3 | on:
4 | - push
5 |
6 | jobs:
7 | format:
8 | name: Check formatting
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 |
13 | - uses: actions/setup-python@v5
14 | with:
15 | python-version: "3.12"
16 |
17 | - name: Install tox
18 | run: python -m pip install tox
19 |
20 | - name: Run black
21 | run: tox -e format
22 |
23 | lint:
24 | name: Lint
25 | runs-on: ubuntu-latest
26 | steps:
27 | - uses: actions/checkout@v4
28 |
29 | - uses: actions/setup-python@v5
30 | with:
31 | python-version: "3.12"
32 |
33 | - name: Install tox
34 | run: python -m pip install tox
35 |
36 | - name: Run flake8
37 | run: tox -e lint
38 |
39 | typecheck:
40 | name: Type check
41 | runs-on: ubuntu-latest
42 | steps:
43 | - uses: actions/checkout@v4
44 |
45 | - uses: actions/setup-python@v5
46 | with:
47 | python-version: "3.12"
48 |
49 | - name: Install tox
50 | run: python -m pip install tox
51 |
52 | - name: Run mypy
53 | run: tox -e typecheck
54 |
55 | test:
56 | name: Test
57 | runs-on: ubuntu-latest
58 | strategy:
59 | matrix:
60 | python:
61 | - version: "3.13"
62 | toxenv: "py313"
63 | - version: "3.12"
64 | toxenv: "py312"
65 | - version: "3.11"
66 | toxenv: "py311"
67 | - version: "3.10"
68 | toxenv: "py310"
69 | - version: "3.9"
70 | toxenv: "py39"
71 | steps:
72 | - uses: actions/checkout@v4
73 |
74 | - uses: actions/setup-python@v5
75 | with:
76 | python-version: ${{ matrix.python.version }}
77 |
78 | - name: Install tox
79 | run: python -m pip install tox
80 |
81 | - name: Run pytest
82 | run: tox -e ${{ matrix.python.toxenv }}
83 |
84 | build_source_dist:
85 | name: Build source distribution
86 | runs-on: ubuntu-latest
87 | steps:
88 | - uses: actions/checkout@v4
89 |
90 | - uses: actions/setup-python@v5
91 | with:
92 | python-version: "3.12"
93 |
94 | - name: Install build
95 | run: python -m pip install build
96 |
97 | - name: Run build
98 | run: python -m build --sdist
99 |
100 | - uses: actions/upload-artifact@v4
101 | with:
102 | path: dist/*.tar.gz
103 |
104 | publish:
105 | needs: [format, lint, typecheck, test]
106 | if: startsWith(github.ref, 'refs/tags')
107 | runs-on: ubuntu-latest
108 | environment: release
109 | permissions:
110 | id-token: write
111 | contents: write
112 | steps:
113 | - uses: actions/checkout@v4
114 |
115 | - name: Set up Python
116 | uses: actions/setup-python@v5
117 | with:
118 | python-version: 3.9
119 |
120 | - name: Install pypa/build
121 | run: python -m pip install build
122 |
123 | - name: Build distribution
124 | run: python -m build --outdir dist/
125 |
126 | - name: Publish distribution to Test PyPI
127 | uses: pypa/gh-action-pypi-publish@release/v1
128 | with:
129 | repository_url: https://test.pypi.org/legacy/
130 |
131 | - name: Publish distribution to PyPI
132 | uses: pypa/gh-action-pypi-publish@release/v1
133 |
134 | - name: Publish distribution to GitHub release
135 | uses: softprops/action-gh-release@v2
136 | with:
137 | files: |
138 | dist/django_webmention-*.whl
139 | dist/django_webmention-*.tar.gz
140 | env:
141 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
142 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | *.egg-info/
23 | .installed.cfg
24 | *.egg
25 | pip-wheel-metadata/
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 |
47 | # Translations
48 | *.mo
49 | *.pot
50 |
51 | # Django stuff:
52 | *.log
53 |
54 | # Sphinx documentation
55 | docs/_build/
56 |
57 | # PyBuilder
58 | target/
59 |
60 | # PyCharm
61 | .idea/
62 |
63 | # PyPI
64 | README.rst
65 |
66 | # pyenv
67 | .python-version
68 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | dist: xenial
2 | language: python
3 | cache: pip
4 |
5 | python:
6 | - '3.8'
7 | - '3.7'
8 | - '3.6'
9 |
10 | install:
11 | - pip install tox-travis
12 |
13 | script:
14 | - tox
15 |
16 | stages:
17 | - lint
18 | - test
19 | - name: deploy
20 | if: tag IS present
21 |
22 | jobs:
23 | include:
24 | - stage: lint
25 | script:
26 | - tox -e lint
27 |
28 | - stage: deploy
29 | before_deploy:
30 | python setup.py sdist bdist_wheel
31 | deploy:
32 | - provider: releases # GitHub releases
33 | api_key:
34 | secure: 'ONG/Gqo6zQvLTW2F4wIiu21fgDsBOSVWhuSgmzs98P6brIeSY4IqAYh6MH4d0XszfYOV1ytky3YjyoXbQsBdYBhyS6Psgw8XvJeLN/mRjr3gNmxZ0dEUiJcdnYePRLe7FG+gFB08FS+Aq4d9GqpithmBcHpGSmL6q5ZnnxC98OX/Ts46CrNiBCA6gg8klkOygk1VOJklJwWHK39yRZNuyUpKGk9WEf23BDpLoaqrnivwJODvMDCnGaAjTeeziCZlpGTdR75F6CViJkeqA9dq0cG6zy1i3q6Mf8zEBmmRJxFcrbW2pdDYgKiguGohTsxt2drhDQRTItbgu01pMXDtapX4WO51tEbEr2JjOq7Yv1L1VAyARFdM6E07s+JE5WLCWKxWlFxly0Vc58pRpd7wgP87lvGiPHSHPUVVeic9hzCbxMsl8iVzEO+MldZHHrgU9R0PQNW4LtpN0HGTTIyyu8cbGytfdd/ZoQVzW1cJMxuxBP8c4tj8uGlHQCRmxOy4pHhj3r2kyTLrbaR8VK2aLIMiosqylp1ZIDbmHSsSEYYDSrRsaXqEXXBaozqi/o9vZ0vnPMoHJ7L6FxR2c+1FjaIlmjnSUv6de5a9ytR6xoT8QYF4fbWA/U46yCJL7mboRXXqgqBwxHZNRrivnxBD3sfDSUT0Ykq3fegGGow2lfM='
35 | file_glob: true
36 | file:
37 | - dist/django-webmention-*-py3-none-any.whl
38 | - dist/django-webmention-*.tar.gz
39 | skip_cleanup: true
40 | on:
41 | tags: true
42 | python: '3.8'
43 |
44 | - &pypi # Production PyPI
45 | provider: pypi
46 | user: '__token__'
47 | password:
48 | secure: 'XXT3GDecB3QDePR5+3OotyUkz6esUmZK5InF4lJQ58SKU1e0R2CxgKivEKDDs88dFtiHdNnKwRknQ4/9WgQ5rhXuEZ/eiwmxue1xfa9284rfX6fAmnY1WCvnIonus82tmhCB6eSkQnRRu03+aLIilI4WYSb1rwkOx8Soa6rtRMbfGJNv8cAeDeHK2uM16WWgDVp+pZOO54r0a/j05SQk4VL/W5c9visFFnWKxZqBTK+C95ZAk1d4BoS+VHiYxzX4dImqSwomC4OZ4Df/lS7p/thabrC4T8n/KXBvI3LiM2u6glFM42rJ6MmFoadLaBwON4+OhInKJ+Pc8yWjKuQ34e07VZ97t3XXxEoSG3hrH0wHyTSx9tca8UwuIFqNP70DV9X8ePbINBCPCLKuJCOa5njUubU3MpXM9nwt71IjWCgZpxXj9mEYH0lPGDcR8kQLLGxxtoFL5oVrEDR6YrPoObuTokXMgXeLrNkcCkEKuzB6fPnH28hvXIshmixFygnz+fio4Z/CcE6A9oCp8hmFOAj+1ZPWJuiRZrQzuGV4S7/G6GHWhdI4S9W54A+3MU6QFhi2oum/IcG55C54eY0p04oFL87LaRhuy1SB7obr2i0871j2LR8tWzQIaaTgNww4cYPLKWnnYGXmiQxtkuwqT1VtCFvI2AufcDdL9x8bJ3U='
49 | skip_cleanup: true
50 | skip_existing: true
51 | on:
52 | tags: true
53 | python: '3.8'
54 |
55 | - <<: *pypi # Test PyPI
56 | server: 'https://test.pypi.org/legacy/'
57 | password:
58 | secure: 'Wgbk8W2KQfsaXFQBKNAAo7VvwuBIpGboYG+iaf/ImrkjDz1oLOy/P/d7qlEY79SJVdOAK5Orrf6Dp+vlKziiIcIIa+A0qfcLtNlT6nIhrx0h0l+N9/Hsliw1FAd1DhJA1a6qE8z3790ZHB6QXA8FWP2Vqd2iY05l3R3Ii0Rz+701WLENNi1+6iHiTdzLJJtcpvy9snzwdwLHLXJN2FDJbKRN1DJW2GE35OOxrhuZC8x8lUwsJujI1UbN5P16TWxVgNZz6Ob05hOkkRBaMDxXA9deSsk+wDqKoJaD2KWEr9GDzJLFTyw9ZOG3iN5eJ8IN011cXeJ3YneucHEkD+0xFjFF1oRzMkTw6Kga1+/IIzeUG8CeKR6RQ2V+1yI9Ou2qZmc975AlgPCbbfmBtOsoZZWsoQAPXrOyBpLhoiEHuqMglbhQDTz2jS9mgyYW5bXbhJhPrb7CwAtwjH1QpVlC2v0TTgwpGdY0KEhwKogm/cSudCOgKUHfU63AXJoIjAzTmsWbiCbkKrdQ+E7kHLVUxrPVennfueBQ0AXHrSRPkupYSzjeQFI4mE5y3NSxwDFgb3IZj8ingTKokHrO5W0JUBDlZEVlrT1HX0bcbu2V7sM9Iul1ZIWGAigOo+2izQSvxu1YMHvLX8ghjQagkOOFRXWiAzr++aKkqnxhJrgmjFM='
59 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [3.0.0] - 2023-01-03
8 | ### Added
9 | - Support for Django 4.0+
10 |
11 | ### Removed
12 | - Support for Django versions less than 2.2
13 | - Support for Python versions less than 3.7
14 |
15 | ## [2.0.2] - 2021-02-14
16 | ### Fixed
17 | - Store source content as a string rather than a bytes object
18 |
19 | ## [2.0.1] - 2020-08-25
20 | ### Fixed
21 | - Render HTML links in the Django admin by using `format_html` instead of the now-deprecated `allow_tags` attribute
22 |
23 | ## [2.0.0] - 2020-08-20
24 | ### Removed
25 | - Support for Python 3.5
26 |
27 | ### Added
28 | - Compatibility in the `include_webmention_information` decorator for versions of Django with new-style middleware
29 |
30 | ## [1.1.0] - 2019-07-22
31 | ### Changed
32 | - Use static `setup.cfg` for package metadata and tooling configuration
33 | - Use black code style
34 | - Lint with pyflakes
35 |
36 | ## [1.0.1] - 2018-04-12
37 | ### Fixed
38 | - Made `setup.py` aware that the README content type is, in fact, markdown
39 |
40 | ## [1.0.0] - 2018-04-12
41 | ### Added
42 | - Better documentation about testing
43 | - Coverage configuration
44 |
45 | ### Changed
46 | - Use markdown for PyPI README
47 |
48 | ## [0.1.0] - 2018-01-02
49 | ### Added
50 | - Mention use of `path()` over `url()` in README
51 | - Mention use of new-style `MIDDLEWARE` over old-style `MIDDLEWARE_CLASSES` in README
52 | - Add system check to detect presence of incorrect middleware configuration
53 | - Update imports and other syntax for forward compatibility with Django 1.10+ and Django 2.0+
54 |
55 | ## [0.0.4] - 2016-07-15
56 | ### Changed
57 | - Reworked the unit tests to be runnable under Travis CI to support continuous integration
58 |
59 | ## [0.0.3] - 2016-01-22
60 | ### Changed
61 | - Successful POST requests will now receive a 202 Accepted response rather than a 200 OK response
62 |
63 | ### Added
64 | - Django 1.9 in frameworks listed in setup.py
65 |
66 | ### Fixed
67 | - Errors in documentation
68 |
69 | ## [0.0.2] - 2015-07-11
70 | ### Added
71 | - Webmentions are now available for review in the admin console
72 | - Webmentions are now updated or invalidated when a new webmention notification request is sent
73 | - Thorough unit testing
74 |
75 | ## [0.0.1] - 2015-07-10
76 | ### Added
77 | - Pre-alpha initial release
78 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2015 Dane Hillard
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
4 | to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
5 | and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
11 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
12 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
13 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # django-webmention [](https://badge.fury.io/py/django-webmention) [](https://travis-ci.org/easy-as-python/django-webmention)
2 |
3 | [webmention](https://www.w3.org/TR/webmention/) for Django projects.
4 |
5 | ## What this project is
6 |
7 | This package provides a way to integrate [webmention endpoint discovery](https://www.w3.org/TR/webmention/#sender-discovers-receiver-webmention-endpoint) and [webmention receipts](https://www.w3.org/TR/webmention/#receiving-webmentions) into your project. Once you follow the installation instructions, you should be able to use something like [webmention.rocks](https://webmention.rocks/) to generate a test webmention and see it in the Django admin panel.
8 |
9 | Once you receive a webmention, you can click through to the page the webmention was sent from and see what people are saying about your site. Afterward, you can mark the webmention as reviewed in the Django admin so you can more easily see the latest webmentions you receive.
10 |
11 | Once you verify that you're receiving webmentions successfully, you can use the webmention information as you like. As an example, you could query the webmentions that are responses to a specific page and display them on that page.
12 |
13 | ## What this project isn't
14 |
15 | This package does not currently provide functionality for [sending webmentions](https://www.w3.org/TR/webmention/#sending-webmentions).
16 |
17 | ## Installation
18 |
19 | `$ pip install django-webmention`
20 |
21 | * Add `'webmention'` to `INSTALLED_APPS`
22 | * Run `python manage.py migrate webmention`
23 | * Add the URL patterns to your top-level `urls.py`
24 | * `path('webmention/', include('webmention.urls'))` for Django >= 3.2
25 |
26 | ## Usage
27 |
28 | * Include webmention information by either:
29 | * Installing the middleware in `settings.py` (affects all views)
30 | * Append `webmention.middleware.webmention_middleware` to your `MIDDLEWARE` settings
31 | * Decorating a specific view with `webmention.middleware.include_webmention_information`
32 | * View webmention responses in the Django admin interface and mark them as reviewed as needed
33 |
34 | ## Development
35 |
36 | ### Setup
37 |
38 | * Install [tox](https://tox.readthedocs.io)
39 |
40 | ### Running Tests
41 |
42 | You can run tests using `tox`:
43 |
44 | ```shell
45 | $ tox --parallel=auto
46 | ```
47 |
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | @pytest.fixture
5 | def test_source():
6 | return 'http://example.com'
7 |
8 |
9 | @pytest.fixture
10 | def test_target():
11 | return 'http://mysite.com'
12 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 |
5 | if __name__ == "__main__":
6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webmention.settings")
7 |
8 | from django.core.management import execute_from_command_line
9 |
10 | execute_from_command_line(sys.argv)
11 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "django-webmention"
3 | version = "3.0.0"
4 | description = "A pluggable implementation of webmention for Django projects"
5 | authors = [
6 | { name = "Dane Hillard", email = "github@danehillard.com" },
7 | ]
8 | license = { file = "LICENSE" }
9 | classifiers = [
10 | "Development Status :: 5 - Production/Stable",
11 | "Intended Audience :: Developers",
12 | "Framework :: Django",
13 | "Framework :: Django :: 3.2",
14 | "Framework :: Django :: 4.0",
15 | "Framework :: Django :: 4.1",
16 | "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
17 | "License :: OSI Approved :: MIT License",
18 | "Programming Language :: Python",
19 | "Programming Language :: Python :: 3 :: Only",
20 | "Programming Language :: Python :: 3",
21 | "Programming Language :: Python :: 3.9",
22 | "Programming Language :: Python :: 3.10",
23 | "Programming Language :: Python :: 3.11",
24 | "Programming Language :: Python :: 3.12",
25 | "Programming Language :: Python :: 3.13",
26 | ]
27 | dependencies = [
28 | "Django>=4.2.0",
29 | "requests>=2.32.3",
30 | ]
31 |
32 | [project.urls]
33 | Repository = "https://github.com/easy-as-python/django-webmention"
34 |
35 | [tool.setuptools.packages.find]
36 | where = ["src"]
37 | exclude = ["test*"]
38 |
39 | ######################
40 | # Tool configuration #
41 | ######################
42 |
43 | [tool.black]
44 | line-length = 120
45 | target-version = ["py39", "py310", "py311", "py312", "py313"]
46 |
47 | [tool.mypy]
48 | python_version = "3.9"
49 | warn_unused_configs = true
50 | show_error_context = true
51 | pretty = true
52 | namespace_packages = true
53 | check_untyped_defs = true
54 |
55 | [[tool.mypy.overrides]]
56 | module = [
57 | "django.core.urlresolvers",
58 | ]
59 | ignore_missing_imports = true
60 |
61 | [tool.coverage.run]
62 | branch = true
63 | omit = [
64 | "manage.py",
65 | "webmention/checks.py",
66 | "*test*",
67 | "*/migrations/*",
68 | "*/admin.py",
69 | "*/__init__.py",
70 | ]
71 |
72 | [tool.coverage.report]
73 | precision = 2
74 | show_missing = true
75 | skip_covered = true
76 |
77 | [tool.coverage.paths]
78 | source = [
79 | "src/webmention",
80 | "*/site-packages/webmention",
81 | ]
82 |
83 | [tool.pytest.ini_options]
84 | DJANGO_SETTINGS_MODULE = "tests.settings"
85 | testpaths = ["tests"]
86 | addopts = ["-ra", "-q", "--cov=webmention"]
87 | xfail_strict = true
88 |
89 | [tool.tox]
90 | envlist = [
91 | "py39-django4.2",
92 | "py39-django5.0",
93 | "py39-django5.1",
94 | "py310-django4.2",
95 | "py310-django5.0",
96 | "py310-django5.1",
97 | "py311-django4.2",
98 | "py311-django5.0",
99 | "py311-django5.1",
100 | "py312-django4.2",
101 | "py312-django5.0",
102 | "py312-django5.1",
103 | "py313-django4.2",
104 | "py313-django5.0",
105 | "py313-django5.1",
106 | ]
107 |
108 | [tool.tox.env_run_base]
109 | deps = [
110 | "pytest",
111 | "pytest-cov",
112 | "pytest-django",
113 | ]
114 | commands = [
115 | ["pytest", { replace = "posargs", default = [], extend = true }],
116 | ]
117 |
118 | [tool.tox.env."py39-django4.2"]
119 | deps = [
120 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
121 | "Django>=4.2,<4.3",
122 | ]
123 |
124 | [tool.tox.env."py39-django5.0"]
125 | deps = [
126 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
127 | "Django>=5.0,<5.1",
128 | ]
129 |
130 | [tool.tox.env."py39-django5.1"]
131 | deps = [
132 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
133 | "Django>=5.1,<5.2",
134 | ]
135 |
136 | [tool.tox.env."py310-django4.2"]
137 | deps = [
138 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
139 | "Django>=4.2,<4.3",
140 | ]
141 |
142 | [tool.tox.env."py310-django5.0"]
143 | deps = [
144 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
145 | "Django>=5.0,<5.1",
146 | ]
147 |
148 | [tool.tox.env."py310-django5.1"]
149 | deps = [
150 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
151 | "Django>=5.1,<5.2",
152 | ]
153 |
154 | [tool.tox.env."py311-django4.2"]
155 | deps = [
156 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
157 | "Django>=4.2,<4.3",
158 | ]
159 |
160 | [tool.tox.env."py311-django5.0"]
161 | deps = [
162 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
163 | "Django>=5.0,<5.1",
164 | ]
165 |
166 | [tool.tox.env."py311-django5.1"]
167 | deps = [
168 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
169 | "Django>=5.1,<5.2",
170 | ]
171 |
172 | [tool.tox.env."py312-django4.2"]
173 | deps = [
174 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
175 | "Django>=4.2,<4.3",
176 | ]
177 |
178 | [tool.tox.env."py312-django5.0"]
179 | deps = [
180 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
181 | "Django>=5.0,<5.1",
182 | ]
183 |
184 | [tool.tox.env."py312-django5.1"]
185 | deps = [
186 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
187 | "Django>=5.1,<5.2",
188 | ]
189 |
190 | [tool.tox.env."py313-django4.2"]
191 | deps = [
192 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
193 | "Django>=4.2,<4.3",
194 | ]
195 |
196 | [tool.tox.env."py313-django5.0"]
197 | deps = [
198 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
199 | "Django>=5.0,<5.1",
200 | ]
201 |
202 | [tool.tox.env."py313-django5.1"]
203 | deps = [
204 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
205 | "Django>=5.1,<5.2",
206 | ]
207 |
208 | [tool.tox.env.lint]
209 | skip_install = true
210 | deps = [
211 | "ruff",
212 | ]
213 | commands = [
214 | ["ruff", "check", { replace = "posargs", default = ["--diff", "src/webmention", "tests"], extend = true }],
215 | ]
216 |
217 | [tool.tox.env.format]
218 | skip_install = true
219 | deps = [
220 | "black",
221 | ]
222 | commands = [
223 | ["black", { replace = "posargs", default = ["--check", "--diff", "src/webmention", "tests"], extend = true }],
224 | ]
225 |
226 | [tool.tox.env.typecheck]
227 | deps = [
228 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true },
229 | "mypy",
230 | "django-types",
231 | "types-requests",
232 | ]
233 | commands = [
234 | ["mypy", { replace = "posargs", default = ["src/webmention", "tests"], extend = true }],
235 | ]
236 |
--------------------------------------------------------------------------------
/src/webmention/__init__.py:
--------------------------------------------------------------------------------
1 | from . import checks
2 |
3 | __all__ = ["checks"]
4 |
--------------------------------------------------------------------------------
/src/webmention/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from .models import WebMentionResponse
4 |
5 |
6 | class WebMentionResponseAdmin(admin.ModelAdmin):
7 | model = WebMentionResponse
8 |
9 | fields = [
10 | ("source_for_admin", "response_to_for_admin"),
11 | "response_body",
12 | ("date_created", "date_modified"),
13 | ("reviewed", "current"),
14 | ]
15 |
16 | readonly_fields = [
17 | "response_body",
18 | "source_for_admin",
19 | "response_to_for_admin",
20 | "date_created",
21 | "date_modified",
22 | "current",
23 | ]
24 |
25 | list_display = [
26 | "pk",
27 | "source_for_admin",
28 | "response_to_for_admin",
29 | "date_created",
30 | "date_modified",
31 | "reviewed",
32 | "current",
33 | ]
34 |
35 | list_editable = ["reviewed"]
36 |
37 | list_filter = ["reviewed", "current"]
38 |
39 | date_hierarchy = "date_modified"
40 |
41 |
42 | admin.site.register(WebMentionResponse, WebMentionResponseAdmin)
43 |
--------------------------------------------------------------------------------
/src/webmention/checks.py:
--------------------------------------------------------------------------------
1 | import django
2 | from django.conf import settings
3 | from django.core.checks import Error, register, Tags
4 |
5 |
6 | @register(Tags.compatibility)
7 | def new_style_middleware_check(app_configs, **kwargs):
8 | errors = []
9 |
10 | if django.VERSION[1] >= 10 or django.VERSION[0] > 1:
11 | installed_middlewares = getattr(settings, "MIDDLEWARE", []) or []
12 | if "webmention.middleware.WebMentionMiddleware" in installed_middlewares:
13 | errors.append(
14 | Error(
15 | "You are attempting to use an old-style middleware class in the MIDDLEWARE setting",
16 | hint="Either use MIDDLEWARE_CLASSES or use webmention.middleware.webmention_middleware instead",
17 | id="webmention.E001",
18 | )
19 | )
20 | return errors
21 |
--------------------------------------------------------------------------------
/src/webmention/middleware.py:
--------------------------------------------------------------------------------
1 | from django.utils.decorators import decorator_from_middleware
2 | from django.utils.deprecation import MiddlewareMixin
3 |
4 | try:
5 | from django.core.urlresolvers import reverse
6 | except ImportError:
7 | from django.urls import reverse
8 |
9 |
10 | def add_webmention_headers_to_response(request, response):
11 | link_header = '<{scheme}://{host}{path}>; rel="webmention"'.format(
12 | scheme=request.scheme, host=request.META.get("HTTP_HOST"), path=reverse("webmention:receive")
13 | )
14 | if not response.get("Link"):
15 | response["Link"] = link_header
16 | else:
17 | response["Link"] = ", ".join((response["Link"], link_header))
18 |
19 | return response
20 |
21 |
22 | class WebMentionMiddleware(MiddlewareMixin):
23 | def process_response(self, request, response):
24 | return add_webmention_headers_to_response(request, response)
25 |
26 |
27 | def webmention_middleware(get_response):
28 | def middleware(request):
29 | response = get_response(request)
30 | return add_webmention_headers_to_response(request, response)
31 |
32 | return middleware
33 |
34 |
35 | include_webmention_information = decorator_from_middleware(WebMentionMiddleware)
36 |
--------------------------------------------------------------------------------
/src/webmention/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import models, migrations
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = []
10 |
11 | operations = [
12 | migrations.CreateModel(
13 | name="WebMentionResponse",
14 | fields=[
15 | ("id", models.AutoField(verbose_name="ID", auto_created=True, primary_key=True, serialize=False)),
16 | ("response_body", models.TextField()),
17 | ("response_to", models.URLField()),
18 | ("source", models.URLField()),
19 | ("reviewed", models.BooleanField(default=False)),
20 | ("current", models.BooleanField(default=True)),
21 | ("date_created", models.DateTimeField(auto_now_add=True)),
22 | ("date_modified", models.DateTimeField(auto_now=True)),
23 | ],
24 | options={"verbose_name": "webmention", "verbose_name_plural": "webmentions"},
25 | )
26 | ]
27 |
--------------------------------------------------------------------------------
/src/webmention/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/easy-as-python/django-webmention/2ff415291199a733f399388a30427d6469c614ee/src/webmention/migrations/__init__.py
--------------------------------------------------------------------------------
/src/webmention/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.admin import display
2 | from django.db import models
3 | from django.utils.html import format_html
4 |
5 |
6 | class WebMentionResponse(models.Model):
7 | id: int
8 | response_body = models.TextField()
9 | response_to = models.URLField()
10 | source = models.URLField()
11 | reviewed = models.BooleanField(default=False)
12 | current = models.BooleanField(default=True)
13 | date_created = models.DateTimeField(auto_now_add=True)
14 | date_modified = models.DateTimeField(auto_now=True)
15 |
16 | class Meta:
17 | verbose_name = "webmention"
18 | verbose_name_plural = "webmentions"
19 |
20 | def __str__(self):
21 | return self.source
22 |
23 | @display(description="source")
24 | def source_for_admin(self):
25 | return format_html('{}', self.source, self.source)
26 |
27 | @display(description="response to")
28 | def response_to_for_admin(self):
29 | return format_html('{}', self.response_to, self.response_to)
30 |
31 | def invalidate(self):
32 | if self.id:
33 | self.current = False
34 | self.save()
35 |
36 | def update(self, source, target, response_body):
37 | self.response_body = response_body
38 | self.source = source
39 | self.response_to = target
40 | self.current = True
41 | self.save()
42 |
--------------------------------------------------------------------------------
/src/webmention/resolution.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 | from urllib.parse import urlparse
4 |
5 | try:
6 | from django.core.urlresolvers import resolve, Resolver404
7 | except ImportError:
8 | from django.urls import resolve, Resolver404
9 |
10 |
11 | def url_resolves(url):
12 | try:
13 | resolve(urlparse(url).path)
14 | except Resolver404:
15 | return False
16 | return True
17 |
18 |
19 | def fetch_and_validate_source(source, target):
20 | response = requests.get(source)
21 | if response.status_code == 200:
22 | if target in response.text:
23 | return response.text
24 | else:
25 | raise TargetNotFoundError("Source URL did not contain target URL")
26 | else:
27 | raise SourceFetchError("Could not fetch source URL")
28 |
29 |
30 | class SourceFetchError(Exception):
31 | pass
32 |
33 |
34 | class TargetNotFoundError(Exception):
35 | pass
36 |
--------------------------------------------------------------------------------
/src/webmention/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path
2 |
3 | from . import views
4 |
5 |
6 | app_name = "webmention"
7 |
8 |
9 | urlpatterns = [re_path(r"^receive$", views.receive, name="receive")]
10 |
--------------------------------------------------------------------------------
/src/webmention/views.py:
--------------------------------------------------------------------------------
1 | from django.views.decorators.csrf import csrf_exempt
2 | from django.views.decorators.http import require_POST
3 | from django.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse
4 |
5 | from .models import WebMentionResponse
6 | from .resolution import url_resolves, fetch_and_validate_source, SourceFetchError, TargetNotFoundError
7 |
8 |
9 | @csrf_exempt
10 | @require_POST
11 | def receive(request):
12 | if "source" in request.POST and "target" in request.POST:
13 | source = request.POST.get("source")
14 | target = request.POST.get("target")
15 | webmention = None
16 |
17 | if not url_resolves(target):
18 | return HttpResponseBadRequest("Target URL did not resolve to a resource on the server")
19 |
20 | try:
21 | try:
22 | webmention = WebMentionResponse.objects.get(source=source, response_to=target)
23 | except WebMentionResponse.DoesNotExist:
24 | webmention = WebMentionResponse()
25 |
26 | response_body = fetch_and_validate_source(source, target)
27 | webmention.update(source, target, response_body)
28 | return HttpResponse("The webmention was successfully received", status=202)
29 | except (SourceFetchError, TargetNotFoundError) as e:
30 | if webmention:
31 | webmention.invalidate()
32 | return HttpResponseBadRequest(str(e))
33 | except Exception as e:
34 | return HttpResponseServerError(str(e))
35 | else:
36 | return HttpResponseBadRequest("webmention source and/or target not in request")
37 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/easy-as-python/django-webmention/2ff415291199a733f399388a30427d6469c614ee/tests/__init__.py
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | SECRET_KEY = "key-for-testing"
2 | INSTALLED_APPS = ["webmention"]
3 |
4 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "tests.sqlite3"}}
5 |
6 | ROOT_URLCONF = "tests.test_urls"
7 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import Mock
2 |
3 | import pytest
4 |
5 | from django.urls import reverse
6 | from django.http import HttpResponse
7 |
8 | from webmention.middleware import WebMentionMiddleware
9 |
10 |
11 | @pytest.fixture
12 | def middleware():
13 | return WebMentionMiddleware(get_response=Mock())
14 |
15 |
16 | def test_process_request_creates_link_header(middleware):
17 | request = Mock()
18 | request.scheme = "http"
19 | request.META = {"HTTP_HOST": "example.com"}
20 |
21 | response = HttpResponse()
22 | response = middleware.process_response(request, response)
23 |
24 | expected_link_header = '<{scheme}://{host}{path}>; rel="webmention"'.format(
25 | scheme=request.scheme, host=request.META.get("HTTP_HOST"), path=reverse("webmention:receive")
26 | )
27 |
28 | assert "Link" in response
29 | assert response["Link"] == expected_link_header
30 |
31 |
32 | def test_process_request_appends_link_header(middleware):
33 | request = Mock()
34 | request.scheme = "http"
35 | request.META = {"HTTP_HOST": "example.com"}
36 |
37 | response = HttpResponse()
38 | original_link_header = '; rel="meta"'
39 | response["Link"] = original_link_header
40 | response = middleware.process_response(request, response)
41 |
42 | new_link_header = '<{scheme}://{host}{path}>; rel="webmention"'.format(
43 | scheme=request.scheme, host=request.META.get("HTTP_HOST"), path=reverse("webmention:receive")
44 | )
45 |
46 | expected_link_header = ", ".join((original_link_header, new_link_header))
47 |
48 | assert "Link" in response
49 | assert response["Link"] == expected_link_header
50 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | import pytest
4 |
5 | from webmention.models import WebMentionResponse
6 |
7 |
8 | @pytest.fixture
9 | def test_response_body():
10 | return "foo"
11 |
12 |
13 | @pytest.mark.django_db
14 | def test_str(test_source, test_target, test_response_body):
15 | webmention = WebMentionResponse.objects.create(
16 | source=test_source, response_to=test_target, response_body=test_response_body
17 | )
18 | webmention.save()
19 |
20 | assert str(webmention) == webmention.source
21 |
22 |
23 | @pytest.mark.django_db
24 | def test_source_for_admin(test_source, test_target, test_response_body):
25 | webmention = WebMentionResponse.objects.create(
26 | source=test_source, response_to=test_target, response_body=test_response_body
27 | )
28 | webmention.save()
29 |
30 | assert webmention.source_for_admin() == '{href}'.format(href=webmention.source)
31 |
32 |
33 | @pytest.mark.django_db
34 | def test_response_to_for_admin(test_source, test_target, test_response_body):
35 | webmention = WebMentionResponse.objects.create(
36 | source=test_source, response_to=test_target, response_body=test_response_body
37 | )
38 | webmention.save()
39 |
40 | assert webmention.response_to_for_admin() == '{href}'.format(href=webmention.response_to)
41 |
42 |
43 | @patch("webmention.models.WebMentionResponse.save")
44 | def test_invalidate_when_not_previously_saved(mock_save):
45 | webmention = WebMentionResponse()
46 | webmention.invalidate()
47 |
48 | assert not mock_save.called
49 |
50 |
51 | @pytest.mark.django_db
52 | def test_invalidate_when_previously_saved(test_source, test_target, test_response_body):
53 | webmention = WebMentionResponse.objects.create(
54 | source=test_source, response_to=test_target, response_body=test_response_body
55 | )
56 | webmention.save()
57 | webmention.invalidate()
58 |
59 | assert not webmention.current
60 |
61 |
62 | @patch("webmention.models.WebMentionResponse.save")
63 | def test_update_when_previously_invalid(mock_save, test_source, test_target, test_response_body):
64 | webmention = WebMentionResponse.objects.create(source="foo", response_to="bar", response_body="baz", current=False)
65 | assert mock_save.call_count == 1
66 | webmention.update(test_source, test_target, test_response_body)
67 |
68 | assert webmention.current
69 | assert webmention.source == test_source
70 | assert webmention.response_to == test_target
71 | assert webmention.response_body == test_response_body
72 | assert mock_save.call_count == 2
73 |
--------------------------------------------------------------------------------
/tests/test_resolution.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import Mock, patch
2 |
3 | import pytest
4 |
5 | try:
6 | from django.core.urlresolvers import Resolver404
7 | except ImportError:
8 | from django.urls import Resolver404
9 |
10 | from webmention.resolution import url_resolves, fetch_and_validate_source, SourceFetchError, TargetNotFoundError
11 |
12 |
13 | @patch("webmention.resolution.resolve")
14 | def test_url_resolves_when_resolves(mock_resolve, test_source, test_target):
15 | mock_resolve.return_value = "foo"
16 | assert url_resolves(test_target)
17 |
18 |
19 | @patch("webmention.resolution.resolve")
20 | def test_url_resolves_when_does_not_resolve(mock_resolve):
21 | mock_resolve.side_effect = Resolver404
22 | assert not url_resolves("http://example.com/page")
23 |
24 |
25 | @patch("requests.get")
26 | def test_fetch_and_validate_source_happy_path(mock_get, test_source, test_target):
27 | mock_response = Mock()
28 | mock_response.status_code = 200
29 | mock_response.text = '{href}'.format(href=test_target)
30 | mock_get.return_value = mock_response
31 |
32 | assert fetch_and_validate_source(test_source, test_target) == mock_response.text
33 |
34 |
35 | @patch("requests.get")
36 | def test_fetch_and_validate_source_when_source_unavailable(mock_get, test_source, test_target):
37 | mock_response = Mock()
38 | mock_response.status_code = 404
39 | mock_get.return_value = mock_response
40 |
41 | with pytest.raises(SourceFetchError):
42 | fetch_and_validate_source(test_source, test_target)
43 |
44 |
45 | @patch("requests.get")
46 | def test_fetch_and_validate_source_when_source_does_not_contain_target(mock_get, test_source, test_target):
47 | mock_response = Mock()
48 | mock_response.status_code = 200
49 | mock_response.text = "foo"
50 | mock_get.return_value = mock_response
51 |
52 | with pytest.raises(TargetNotFoundError):
53 | fetch_and_validate_source(test_source, test_target)
54 |
--------------------------------------------------------------------------------
/tests/test_urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path, include
2 |
3 | urlpatterns = [re_path(r"^webmention", include("webmention.urls", namespace="webmention"))]
4 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import Mock, patch
2 |
3 | import pytest
4 |
5 | from django.http import HttpResponseBadRequest, HttpResponse, HttpResponseServerError
6 |
7 | from webmention.views import receive
8 | from webmention.resolution import SourceFetchError, TargetNotFoundError
9 |
10 |
11 | def test_receive_when_source_not_in_post_data(test_target):
12 | request = Mock()
13 | request.method = "POST"
14 | request.POST = {"target": test_target}
15 |
16 | response = receive(request)
17 |
18 | assert isinstance(response, HttpResponseBadRequest)
19 |
20 |
21 | def test_receive_when_target_not_in_post_data(test_source):
22 | request = Mock()
23 | request.method = "POST"
24 | request.POST = {"source": test_source}
25 |
26 | response = receive(request)
27 |
28 | assert isinstance(response, HttpResponseBadRequest)
29 |
30 |
31 | @patch("webmention.views.url_resolves")
32 | def test_receive_when_target_does_not_resolve(mock_url_resolves, test_source, test_target):
33 | request = Mock()
34 | request.method = "POST"
35 | request.POST = {"source": test_source, "target": test_target}
36 |
37 | mock_url_resolves.return_value = False
38 | response = receive(request)
39 |
40 | mock_url_resolves.assert_called_once_with(test_target)
41 | assert isinstance(response, HttpResponseBadRequest)
42 |
43 |
44 | @pytest.mark.django_db
45 | @patch("webmention.views.WebMentionResponse.update")
46 | @patch("webmention.views.fetch_and_validate_source")
47 | @patch("webmention.views.url_resolves")
48 | def test_receive_happy_path(mock_url_resolves, mock_fetch_and_validate_source, mock_update, test_source, test_target):
49 | request = Mock()
50 | request.method = "POST"
51 | request.POST = {"source": test_source, "target": test_target}
52 |
53 | mock_url_resolves.return_value = True
54 | mock_fetch_and_validate_source.return_value = "foo"
55 | response = receive(request)
56 |
57 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target)
58 | mock_update.assert_called_once_with(test_source, test_target, mock_fetch_and_validate_source.return_value)
59 | mock_url_resolves.assert_called_once_with(test_target)
60 | assert isinstance(response, HttpResponse)
61 |
62 |
63 | @pytest.mark.django_db
64 | @patch("webmention.views.WebMentionResponse.invalidate")
65 | @patch("webmention.views.fetch_and_validate_source")
66 | @patch("webmention.views.url_resolves")
67 | def test_receive_when_source_unavailable(
68 | mock_url_resolves, mock_fetch_and_validate_source, mock_invalidate, test_source, test_target
69 | ):
70 | request = Mock()
71 | request.method = "POST"
72 | request.POST = {"source": test_source, "target": test_target}
73 |
74 | mock_url_resolves.return_value = True
75 | mock_fetch_and_validate_source.side_effect = SourceFetchError
76 | response = receive(request)
77 |
78 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target)
79 | mock_url_resolves.assert_called_once_with(test_target)
80 | assert mock_invalidate.call_count == 1
81 | assert isinstance(response, HttpResponseBadRequest)
82 |
83 |
84 | @pytest.mark.django_db
85 | @patch("webmention.views.WebMentionResponse.invalidate")
86 | @patch("webmention.views.fetch_and_validate_source")
87 | @patch("webmention.views.url_resolves")
88 | def test_receive_when_source_does_not_contain_target(
89 | mock_url_resolves, mock_fetch_and_validate_source, mock_invalidate, test_source, test_target
90 | ):
91 | request = Mock()
92 | request.method = "POST"
93 | request.POST = {"source": test_source, "target": test_target}
94 |
95 | mock_url_resolves.return_value = True
96 | mock_fetch_and_validate_source.side_effect = TargetNotFoundError
97 | response = receive(request)
98 |
99 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target)
100 | mock_url_resolves.assert_called_once_with(test_target)
101 | assert mock_invalidate.call_count == 1
102 | assert isinstance(response, HttpResponseBadRequest)
103 |
104 |
105 | @pytest.mark.django_db
106 | @patch("webmention.views.fetch_and_validate_source")
107 | @patch("webmention.views.url_resolves")
108 | def test_receive_when_general_exception_occurs(
109 | mock_url_resolves, mock_fetch_and_validate_source, test_source, test_target
110 | ):
111 | request = Mock()
112 | request.method = "POST"
113 | request.POST = {"source": test_source, "target": test_target}
114 |
115 | mock_url_resolves.return_value = True
116 | mock_fetch_and_validate_source.side_effect = Exception
117 | response = receive(request)
118 |
119 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target)
120 | mock_url_resolves.assert_called_once_with(test_target)
121 | assert isinstance(response, HttpResponseServerError)
122 |
--------------------------------------------------------------------------------