Internal Server Error
17 |The server encountered an error trying to process your request.
18 |├── .buildpacks ├── .dockerignore ├── .github └── workflows │ ├── docker-dev.yml │ ├── docker-release.yml │ ├── test-docs.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── Aptfile ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── activities ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── pruneposts.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_hashtag.py │ ├── 0003_postattachment_null_thumb.py │ ├── 0004_emoji_post_emojis.py │ ├── 0005_post_type_timeline_urls.py │ ├── 0006_fanout_subject_identity_alter_fanout_type.py │ ├── 0007_post_stats.py │ ├── 0008_state_and_post_indexes.py │ ├── 0009_alter_timelineevent_index_together.py │ ├── 0010_stator_indexes.py │ ├── 0011_postinteraction_value_alter_postinteraction_type.py │ ├── 0012_in_reply_to_index.py │ ├── 0013_postattachment_author.py │ ├── 0014_post_content_vector_gin.py │ ├── 0015_alter_postinteraction_type.py │ ├── 0016_index_together_migration.py │ ├── 0017_stator_next_change.py │ ├── 0018_timelineevent_dismissed.py │ ├── 0019_alter_postattachment_focal_x_and_more.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── emoji.py │ ├── fan_out.py │ ├── hashtag.py │ ├── post.py │ ├── post_attachment.py │ ├── post_interaction.py │ ├── post_types.py │ └── timeline_event.py ├── services │ ├── __init__.py │ ├── post.py │ ├── search.py │ └── timeline.py ├── templatetags │ ├── __init__.py │ ├── activity_tags.py │ └── opengraph.py └── views │ ├── __init__.py │ ├── admin │ └── __init__.py │ ├── compose.py │ ├── debug.py │ ├── posts.py │ └── timelines.py ├── api ├── __init__.py ├── admin.py ├── apps.py ├── decorators.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_remove_token_code_token_revoked_alter_token_token_and_more.py │ ├── 0003_token_push_subscription.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── application.py │ ├── authorization.py │ └── token.py ├── pagination.py ├── schemas.py ├── urls.py └── views │ ├── __init__.py │ ├── accounts.py │ ├── announcements.py │ ├── apps.py │ ├── bookmarks.py │ ├── emoji.py │ ├── filters.py │ ├── follow_requests.py │ ├── instance.py │ ├── lists.py │ ├── media.py │ ├── notifications.py │ ├── oauth.py │ ├── polls.py │ ├── preferences.py │ ├── push.py │ ├── search.py │ ├── statuses.py │ ├── suggestions.py │ ├── tags.py │ ├── timelines.py │ └── trends.py ├── core ├── __init__.py ├── admin.py ├── apps.py ├── context.py ├── decorators.py ├── exceptions.py ├── files.py ├── html.py ├── htmx.py ├── json.py ├── ld.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_domain_config.py │ └── __init__.py ├── models │ ├── __init__.py │ └── config.py ├── sentry.py ├── signatures.py ├── snowflake.py ├── templatetags │ ├── __init__.py │ └── mail_tags.py ├── uploads.py ├── uris.py └── views.py ├── development.env ├── docker ├── Dockerfile ├── docker-compose.yml ├── mime.types ├── nginx.conf ├── nginx.conf.d │ ├── .gitignore │ └── default.conf.tpl └── run.sh ├── docs ├── Makefile ├── _templates │ └── sidebar │ │ └── brand.html ├── conf.py ├── contributing.rst ├── contributors.rst ├── domains.rst ├── extensions │ └── canonical_fix.py ├── features.rst ├── index.rst ├── installation.rst ├── interoperability.rst ├── make.bat ├── moderation.rst ├── releases │ ├── 0.10.rst │ ├── 0.11.rst │ ├── 0.3.rst │ ├── 0.4.rst │ ├── 0.5.rst │ ├── 0.6.rst │ ├── 0.7.rst │ ├── 0.8.rst │ ├── 0.9.rst │ ├── index.rst │ └── next.rst ├── requirements.txt ├── stator.rst ├── tuning.rst └── upgrading.rst ├── manage.py ├── mediaproxy ├── __init__.py ├── apps.py └── views.py ├── requirements-dev.txt ├── requirements.txt ├── runtime.txt ├── setup.cfg ├── static ├── css │ └── style.css ├── fonts │ ├── font_awesome │ │ ├── all.min.css │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.woff2 │ │ ├── fa-solid-900.ttf │ │ ├── fa-solid-900.woff2 │ │ ├── fa-v4compatibility.ttf │ │ └── fa-v4compatibility.woff2 │ └── raleway │ │ ├── Raleway-Black.woff2 │ │ ├── Raleway-BlackItalic.woff2 │ │ ├── Raleway-Bold.woff2 │ │ ├── Raleway-BoldItalic.woff2 │ │ ├── Raleway-ExtraBold.woff2 │ │ ├── Raleway-ExtraBoldItalic.woff2 │ │ ├── Raleway-ExtraLight.woff2 │ │ ├── Raleway-ExtraLightItalic.woff2 │ │ ├── Raleway-Italic.woff2 │ │ ├── Raleway-Light.woff2 │ │ ├── Raleway-LightItalic.woff2 │ │ ├── Raleway-Medium.woff2 │ │ ├── Raleway-MediumItalic.woff2 │ │ ├── Raleway-Regular.woff2 │ │ ├── Raleway-SemiBold.woff2 │ │ ├── Raleway-SemiBoldItalic.woff2 │ │ ├── Raleway-Thin.woff2 │ │ ├── Raleway-ThinItalic.woff2 │ │ └── raleway.css ├── img │ ├── apps │ │ ├── elk.svg │ │ ├── fedilab.png │ │ ├── ivory.webp │ │ ├── sengi.png │ │ ├── toot.png │ │ └── tusky.png │ ├── blank-emoji-128.png │ ├── fjords-banner-600.jpg │ ├── fjords-banner-900.jpg │ ├── icon-1024.png │ ├── icon-128.png │ ├── icon-32.png │ ├── icon-admin-512.png │ ├── icon-admin.svg │ ├── icon.svg │ ├── logo-1024.png │ ├── logo-128.png │ ├── logo.svg │ ├── missing.png │ ├── unknown-icon-128.png │ └── unknown_icon.svg ├── js │ ├── htmx.min.js │ ├── hyperscript.min.js │ └── takahe.min.js └── robots.txt ├── stator ├── __init__.py ├── admin.py ├── apps.py ├── exceptions.py ├── graph.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── runstator.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_stats_delete_statorerror.py │ └── __init__.py ├── models.py ├── runner.py ├── tests │ └── test_graph.py └── views.py ├── takahe ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── templates ├── 403.html ├── 404.html ├── 500.html ├── _announcements.html ├── _footer.html ├── _opengraph.html ├── about.html ├── activities │ ├── _event.html │ ├── _identity.html │ ├── _image_viewer.html │ ├── _mini_post.html │ ├── _post.html │ ├── _type_question.html │ ├── _type_unknown.html │ ├── compose.html │ ├── debug_json.html │ ├── home.html │ ├── notifications.html │ ├── post.html │ ├── post_delete.html │ └── tag.html ├── admin │ ├── _menu.html │ ├── _pagination.html │ ├── announcement_create.html │ ├── announcement_delete.html │ ├── announcement_edit.html │ ├── announcements.html │ ├── base_main.html │ ├── domain_create.html │ ├── domain_delete.html │ ├── domain_edit.html │ ├── domains.html │ ├── emoji.html │ ├── emoji_create.html │ ├── federation.html │ ├── federation_blocklist.html │ ├── federation_edit.html │ ├── hashtag_edit.html │ ├── hashtags.html │ ├── identities.html │ ├── identity_edit.html │ ├── invite_create.html │ ├── invite_view.html │ ├── invites.html │ ├── report_view.html │ ├── reports.html │ ├── settings.html │ ├── stator.html │ ├── user_edit.html │ └── users.html ├── api │ ├── oauth_authorize.html │ ├── oauth_code.html │ └── oauth_error.html ├── auth │ ├── login.html │ ├── perform_reset.html │ ├── perform_reset_success.html │ ├── signup.html │ ├── signup_success.html │ ├── trigger_reset.html │ └── trigger_reset_success.html ├── base.html ├── base_plain.html ├── emails │ ├── _body_content.html │ ├── _button.html │ ├── _footer.html │ ├── account_new.html │ ├── account_new.txt │ ├── base.html │ ├── password_reset.html │ ├── password_reset.txt │ ├── report_new.html │ └── report_new.txt ├── flatpage.html ├── forms │ ├── _field.html │ └── _json_name_value_list.html ├── identity │ ├── _identity_banner.html │ ├── _tabs.html │ ├── create.html │ ├── follows.html │ ├── search.html │ └── view.html ├── robots.txt ├── settings │ ├── _menu.html │ ├── base.html │ ├── delete.html │ ├── follows.html │ ├── import_export.html │ ├── login_security.html │ ├── migrate_in.html │ ├── profile.html │ ├── settings.html │ ├── token_create.html │ ├── token_edit.html │ └── tokens.html └── users │ ├── report.html │ └── report_sent.html ├── test.env ├── tests ├── activities │ ├── models │ │ ├── test_emoji.py │ │ ├── test_post.py │ │ ├── test_post_attachment.py │ │ ├── test_post_interaction.py │ │ ├── test_post_targets.py │ │ ├── test_post_types.py │ │ └── test_timeline_event.py │ ├── services │ │ └── test_post.py │ ├── templatetags │ │ └── test_activity_tags.py │ └── views │ │ └── test_compose.py ├── api │ ├── notifications.py │ ├── test_accounts.py │ ├── test_apps.py │ ├── test_instance.py │ ├── test_likes.py │ ├── test_polls.py │ ├── test_search.py │ ├── test_statuses.py │ └── test_tokens.py ├── conftest.py ├── core │ ├── test_html.py │ ├── test_ld.py │ └── test_signatures.py └── users │ ├── models │ ├── test_domain.py │ ├── test_follow.py │ ├── test_identity.py │ └── test_system_actor.py │ ├── services │ └── test_domain.py │ └── views │ ├── settings │ └── test_privacy.py │ ├── test_activitypub.py │ ├── test_auth.py │ ├── test_domains.py │ └── test_import_export.py └── users ├── __init__.py ├── admin.py ├── apps.py ├── context.py ├── decorators.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ └── pruneidentities.py ├── middleware.py ├── migrations ├── 0001_initial.py ├── 0002_identity_discoverable.py ├── 0003_identity_followers_etc.py ├── 0004_identity_admin_notes_identity_restriction_and_more.py ├── 0005_report.py ├── 0006_identity_actor_type.py ├── 0007_remove_invite_email_invite_expires_invite_uses.py ├── 0008_follow_boosts.py ├── 0009_state_and_post_indexes.py ├── 0010_domain_state.py ├── 0011_announcement.py ├── 0012_block_states.py ├── 0013_stator_indexes.py ├── 0014_domain_notes.py ├── 0015_bookmark.py ├── 0016_hashtagfollow.py ├── 0017_identity_featured_collection_uri.py ├── 0018_index_together_migration.py ├── 0019_stator_next_change.py ├── 0020_alter_identity_local.py ├── 0021_identity_aliases.py ├── 0022_follow_request.py └── __init__.py ├── models ├── __init__.py ├── announcement.py ├── block.py ├── bookmark.py ├── domain.py ├── follow.py ├── hashtag_follow.py ├── identity.py ├── inbox_message.py ├── invite.py ├── password_reset.py ├── report.py ├── system_actor.py ├── user.py └── user_event.py ├── schemas.py ├── services ├── __init__.py ├── announcement.py ├── domain.py ├── identity.py └── user.py ├── shortcuts.py └── views ├── __init__.py ├── activitypub.py ├── admin ├── __init__.py ├── announcements.py ├── domains.py ├── emoji.py ├── federation.py ├── generic.py ├── hashtags.py ├── identities.py ├── invites.py ├── reports.py ├── settings.py ├── stator.py └── users.py ├── announcements.py ├── auth.py ├── base.py ├── identity.py └── settings ├── __init__.py ├── delete.py ├── follows.py ├── import_export.py ├── interface.py ├── migration.py ├── posting.py ├── profile.py ├── security.py ├── settings_page.py └── tokens.py /.buildpacks: -------------------------------------------------------------------------------- 1 | heroku-community/apt 2 | heroku/python 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.psql 2 | *.sqlite3 3 | .env 4 | .git 5 | .mypy_cache 6 | .pre-commit-config.yaml 7 | .venv 8 | /fly.* 9 | /static-collected 10 | /takahe/local_settings.py 11 | __pycache__/ 12 | media 13 | notes.md 14 | venv 15 | virtualenv 16 | -------------------------------------------------------------------------------- /.github/workflows/docker-dev.yml: -------------------------------------------------------------------------------- 1 | name: Publish Development Image 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "37 3 * * *" 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Push Docker image to Docker Hub 11 | if: github.repository_owner == 'jointakahe' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repo 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v2 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v2 22 | with: 23 | version: v0.9.1 24 | 25 | - name: Log in to Docker Hub 26 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 27 | with: 28 | username: ${{ secrets.DOCKER_USERNAME }} 29 | password: ${{ secrets.DOCKER_TOKEN }} 30 | 31 | - name: Extract metadata (tags, labels) for Docker 32 | id: meta 33 | uses: docker/metadata-action@57396166ad8aefe6098280995947635806a0e6ea 34 | with: 35 | images: jointakahe/takahe-dev 36 | tags: | 37 | type=edge,branch=main 38 | type=sha 39 | 40 | - name: Build and push Docker image 41 | uses: docker/build-push-action@175d02bffea74695e96b351069ac938b338802f9 42 | with: 43 | context: . 44 | file: docker/Dockerfile 45 | push: true 46 | platforms: "linux/amd64,linux/arm64" 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | -------------------------------------------------------------------------------- /.github/workflows/docker-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Push Docker image to Docker Hub 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up QEMU 17 | uses: docker/setup-qemu-action@v2 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v2 21 | with: 22 | version: v0.9.1 23 | 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@57396166ad8aefe6098280995947635806a0e6ea 33 | with: 34 | images: jointakahe/takahe 35 | tags: | 36 | type=pep440,pattern={{version}} 37 | type=pep440,pattern={{major}}.{{minor}} 38 | 39 | - name: Build and push Docker image 40 | uses: docker/build-push-action@175d02bffea74695e96b351069ac938b338802f9 41 | with: 42 | context: . 43 | file: docker/Dockerfile 44 | push: true 45 | platforms: "linux/amd64,linux/arm64" 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /.github/workflows/test-docs.yml: -------------------------------------------------------------------------------- 1 | name: Test Documentation Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test_docs: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 5 9 | strategy: 10 | matrix: 11 | python-version: ["3.11"] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | cache: pip 19 | - name: Install dependencies for docs 20 | run: | 21 | python -m pip install -r docs/requirements.txt 22 | - name: Build documentation 23 | run: | 24 | cd docs && make clean && make html 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | *.psql 4 | *.pyc 5 | *.sqlite3 6 | .DS_Store 7 | .idea/* 8 | .venv 9 | .vscode 10 | /*.env* 11 | /build 12 | /cache/ 13 | /docs/_build 14 | /fly.* 15 | /media/ 16 | /static-collected 17 | /takahe/local_settings.py 18 | __pycache__/ 19 | api-test.* 20 | notes.md 21 | notes.py 22 | venv/ 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-case-conflict 6 | - id: check-merge-conflict 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: file-contents-sorter 10 | args: ["--ignore-case", "--unique"] 11 | files: ^(\.gitignore|\.dockerignore|requirements[-\w]*.txt)$ 12 | - id: mixed-line-ending 13 | args: ["--fix=lf"] 14 | - id: pretty-format-json 15 | - id: trailing-whitespace 16 | 17 | - repo: https://github.com/asottile/pyupgrade 18 | rev: "v3.15.0" 19 | hooks: 20 | - id: pyupgrade 21 | args: [--py311-plus] 22 | 23 | - repo: https://github.com/adamchainz/django-upgrade 24 | rev: "1.15.0" 25 | hooks: 26 | - id: django-upgrade 27 | args: [--target-version, "4.2"] 28 | 29 | - repo: https://github.com/psf/black-pre-commit-mirror 30 | rev: 23.11.0 31 | hooks: 32 | - id: black 33 | 34 | - repo: https://github.com/pycqa/isort 35 | rev: 5.12.0 36 | hooks: 37 | - id: isort 38 | args: ["--profile=black"] 39 | 40 | - repo: https://github.com/pycqa/flake8 41 | rev: 6.1.0 42 | hooks: 43 | - id: flake8 44 | 45 | - repo: https://github.com/pre-commit/mirrors-mypy 46 | rev: v1.6.1 47 | hooks: 48 | - id: mypy 49 | exclude: "^tests/" 50 | additional_dependencies: 51 | [types-pyopenssl, types-mock, types-cachetools, types-python-dateutil] 52 | 53 | - repo: https://github.com/rtts/djhtml 54 | rev: 3.0.6 55 | hooks: 56 | - id: djhtml 57 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # Set the version of Python and other tools you might need 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3.11" 8 | 9 | # Build documentation in the docs/ directory with Sphinx 10 | sphinx: 11 | configuration: docs/conf.py 12 | builder: dirhtml 13 | 14 | # Optionally declare the Python requirements required to build your docs 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt 18 | -------------------------------------------------------------------------------- /Aptfile: -------------------------------------------------------------------------------- 1 | build-essential 2 | libpq-dev 3 | libxml2-dev 4 | libxslt1-dev 5 | zlib1g-dev 6 | python3-dev 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Andrew Godwin 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation and/or 11 | other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: image docs compose_build compose_up compose_down 2 | 3 | image: 4 | docker build -t takahe -f docker/Dockerfile . 5 | 6 | docs: 7 | cd docs/ && make html 8 | 9 | compose_build: 10 | docker-compose -f docker/docker-compose.yml build 11 | 12 | compose_up: 13 | docker-compose -f docker/docker-compose.yml up 14 | 15 | compose_down: 16 | docker-compose -f docker/docker-compose.yml down 17 | 18 | # Development Setup 19 | .venv: 20 | python3 -m venv .venv 21 | . .venv/bin/activate 22 | python3 -m pip install -r requirements-dev.txt 23 | 24 | .git/hooks/pre-commit: .venv 25 | python3 -m pre_commit install 26 | 27 | .env: 28 | cp development.env .env 29 | 30 | _PHONY: setup_local 31 | setup_local: .venv .env .git/hooks/pre-commit 32 | 33 | _PHONY: startdb stopdb 34 | startdb: 35 | docker compose -f docker/docker-compose.yml up db -d 36 | 37 | stopdb: 38 | docker compose -f docker/docker-compose.yml stop db 39 | 40 | _PHONY: superuser 41 | createsuperuser: setup_local startdb 42 | python3 -m manage createsuperuser 43 | 44 | _PHONY: test 45 | test: setup_local 46 | python3 -m pytest 47 | 48 | # Active development 49 | _PHONY: migrations server stator 50 | migrations: setup_local startdb 51 | python3 -m manage migrate 52 | 53 | runserver: setup_local startdb 54 | python3 -m manage runserver 55 | 56 | runstator: setup_local startdb 57 | python3 -m manage runstator 58 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn takahe.wsgi:application --workers 8 2 | worker: python manage.py runstator 3 | release: python manage.py migrate 4 | -------------------------------------------------------------------------------- /activities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/activities/__init__.py -------------------------------------------------------------------------------- /activities/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ActivitiesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "activities" 7 | -------------------------------------------------------------------------------- /activities/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/activities/management/__init__.py -------------------------------------------------------------------------------- /activities/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/activities/management/commands/__init__.py -------------------------------------------------------------------------------- /activities/migrations/0003_postattachment_null_thumb.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2022-12-01 23:42 2 | 3 | import functools 4 | 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | from django.db import migrations, models 8 | 9 | import core.uploads 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [ 14 | ("activities", "0002_hashtag"), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name="postattachment", 20 | name="created", 21 | field=models.DateTimeField( 22 | auto_now_add=True, default=django.utils.timezone.now 23 | ), 24 | preserve_default=False, 25 | ), 26 | migrations.AddField( 27 | model_name="postattachment", 28 | name="thumbnail", 29 | field=models.ImageField( 30 | blank=True, 31 | null=True, 32 | upload_to=functools.partial( 33 | core.uploads.upload_namer, *("attachment_thumbnails",), **{} 34 | ), 35 | ), 36 | ), 37 | migrations.AddField( 38 | model_name="postattachment", 39 | name="updated", 40 | field=models.DateTimeField(auto_now=True), 41 | ), 42 | migrations.AlterField( 43 | model_name="postattachment", 44 | name="post", 45 | field=models.ForeignKey( 46 | blank=True, 47 | null=True, 48 | on_delete=django.db.models.deletion.CASCADE, 49 | related_name="attachments", 50 | to="activities.post", 51 | ), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /activities/migrations/0006_fanout_subject_identity_alter_fanout_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-18 00:20 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("users", "0005_report"), 10 | ("activities", "0005_post_type_timeline_urls"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="fanout", 16 | name="subject_identity", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="subject_fan_outs", 22 | to="users.identity", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /activities/migrations/0007_post_stats.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-31 20:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("activities", "0006_fanout_subject_identity_alter_fanout_type"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="stats", 15 | field=models.JSONField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /activities/migrations/0009_alter_timelineevent_index_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-14 19:01 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0011_announcement"), 9 | ("activities", "0008_state_and_post_indexes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterIndexTogether( 14 | name="timelineevent", 15 | index_together={ 16 | ("identity", "type", "subject_post", "subject_identity"), 17 | ("identity", "type", "subject_identity"), 18 | ("identity", "created"), 19 | }, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /activities/migrations/0010_stator_indexes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-04 05:23 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0013_stator_indexes"), 9 | ("activities", "0009_alter_timelineevent_index_together"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterIndexTogether( 14 | name="emoji", 15 | index_together={("state_ready", "state_locked_until", "state")}, 16 | ), 17 | migrations.AlterIndexTogether( 18 | name="fanout", 19 | index_together={("state_ready", "state_locked_until", "state")}, 20 | ), 21 | migrations.AlterIndexTogether( 22 | name="hashtag", 23 | index_together={("state_ready", "state_locked_until", "state")}, 24 | ), 25 | migrations.AlterIndexTogether( 26 | name="post", 27 | index_together={("state_ready", "state_locked_until", "state")}, 28 | ), 29 | migrations.AlterIndexTogether( 30 | name="postattachment", 31 | index_together={("state_ready", "state_locked_until", "state")}, 32 | ), 33 | migrations.AlterIndexTogether( 34 | name="postinteraction", 35 | index_together={ 36 | ("type", "identity", "post"), 37 | ("state_ready", "state_locked_until", "state"), 38 | }, 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /activities/migrations/0011_postinteraction_value_alter_postinteraction_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-02-14 22:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("activities", "0010_stator_indexes"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="postinteraction", 14 | name="value", 15 | field=models.CharField(blank=True, max_length=50, null=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="postinteraction", 19 | name="type", 20 | field=models.CharField( 21 | choices=[("like", "Like"), ("boost", "Boost"), ("vote", "Vote")], 22 | max_length=100, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /activities/migrations/0012_in_reply_to_index.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-05 17:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("activities", "0011_postinteraction_value_alter_postinteraction_type"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="post", 14 | name="in_reply_to", 15 | field=models.CharField( 16 | blank=True, db_index=True, max_length=500, null=True 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /activities/migrations/0013_postattachment_author.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-12 22:14 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("users", "0015_bookmark"), 10 | ("activities", "0012_in_reply_to_index"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="postattachment", 16 | name="author", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="attachments", 22 | to="users.identity", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /activities/migrations/0014_post_content_vector_gin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-29 18:49 2 | 3 | import django.contrib.postgres.indexes 4 | import django.contrib.postgres.search 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("activities", "0013_postattachment_author"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddIndex( 15 | model_name="post", 16 | index=django.contrib.postgres.indexes.GinIndex( 17 | django.contrib.postgres.search.SearchVector( 18 | "content", config="english" 19 | ), 20 | name="content_vector_gin", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /activities/migrations/0015_alter_postinteraction_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-24 08:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("activities", "0014_post_content_vector_gin"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="postinteraction", 14 | name="type", 15 | field=models.CharField( 16 | choices=[ 17 | ("like", "Like"), 18 | ("boost", "Boost"), 19 | ("vote", "Vote"), 20 | ("pin", "Pin"), 21 | ], 22 | max_length=100, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /activities/migrations/0018_timelineevent_dismissed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-07-09 17:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("activities", "0017_stator_next_change"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="timelineevent", 14 | name="dismissed", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /activities/migrations/0019_alter_postattachment_focal_x_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-10-30 07:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("activities", "0018_timelineevent_dismissed"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="postattachment", 14 | name="focal_x", 15 | field=models.FloatField(blank=True, null=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="postattachment", 19 | name="focal_y", 20 | field=models.FloatField(blank=True, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /activities/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/activities/migrations/__init__.py -------------------------------------------------------------------------------- /activities/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .emoji import Emoji, EmojiStates # noqa 2 | from .fan_out import FanOut, FanOutStates # noqa 3 | from .hashtag import Hashtag, HashtagStates # noqa 4 | from .post import Post, PostStates # noqa 5 | from .post_attachment import PostAttachment, PostAttachmentStates # noqa 6 | from .post_interaction import PostInteraction, PostInteractionStates # noqa 7 | from .timeline_event import TimelineEvent # noqa 8 | -------------------------------------------------------------------------------- /activities/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .post import PostService # noqa 2 | from .search import SearchService # noqa 3 | from .timeline import TimelineService # noqa 4 | -------------------------------------------------------------------------------- /activities/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/activities/templatetags/__init__.py -------------------------------------------------------------------------------- /activities/templatetags/activity_tags.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from urllib.parse import urlencode 3 | 4 | from django import template 5 | from django.utils import timezone 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.filter 11 | def timedeltashort(value: datetime.datetime): 12 | """ 13 | A more compact version of timesince 14 | """ 15 | if not value: 16 | return "" 17 | delta = timezone.now() - value 18 | seconds = int(delta.total_seconds()) 19 | sign = "-" if seconds < 0 else "" 20 | seconds = abs(seconds) 21 | days = abs(delta.days) 22 | if seconds < 60: 23 | text = f"{seconds:0n}s" 24 | elif seconds < 60 * 60: 25 | minutes = seconds // 60 26 | text = f"{minutes:0n}m" 27 | elif seconds < 60 * 60 * 24: 28 | hours = seconds // (60 * 60) 29 | text = f"{hours:0n}h" 30 | elif days < 365: 31 | text = f"{days:0n}d" 32 | else: 33 | years = max(days // 365.25, 1) 34 | text = f"{years:0n}y" 35 | return sign + text 36 | 37 | 38 | @register.filter 39 | def timedeltashortenddate(value: datetime.datetime): 40 | """ 41 | Formatter for end dates - timedeltashort but it adds "ended ... ago" or 42 | "left" depending on the direction. 43 | """ 44 | output = timedeltashort(value) 45 | if output.startswith("-"): 46 | return f"{output[1:]} left" 47 | else: 48 | return f"Ended {output} ago" 49 | 50 | 51 | @register.simple_tag(takes_context=True) 52 | def urlparams(context, **kwargs): 53 | """ 54 | Generates a URL parameter string the same as the current page but with 55 | the given items changed. 56 | """ 57 | params = dict(context["request"].GET.items()) 58 | for name, value in kwargs.items(): 59 | if value: 60 | params[name] = value 61 | elif name in params: 62 | del params[name] 63 | return urlencode(params) 64 | -------------------------------------------------------------------------------- /activities/templatetags/opengraph.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def dict_merge(base: dict, defaults: dict): 8 | """ 9 | Merges two input dictionaries, returning the merged result. 10 | 11 | `input|dict_merge:defaults` 12 | 13 | The defaults are overridden by any key present in the `input` dict. 14 | """ 15 | if not (isinstance(base, dict) or isinstance(defaults, dict)): 16 | raise ValueError("Filter inputs must be dictionaries") 17 | 18 | result = {} 19 | 20 | result.update(defaults) 21 | result.update(base) 22 | 23 | return result 24 | -------------------------------------------------------------------------------- /activities/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/activities/views/__init__.py -------------------------------------------------------------------------------- /activities/views/admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/activities/views/admin/__init__.py -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/api/__init__.py -------------------------------------------------------------------------------- /api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from api.models import Application, Token 4 | 5 | 6 | @admin.register(Application) 7 | class ApplicationAdmin(admin.ModelAdmin): 8 | list_display = ["id", "name", "website", "created"] 9 | 10 | 11 | @admin.register(Token) 12 | class TokenAdmin(admin.ModelAdmin): 13 | list_display = ["id", "user", "application", "created"] 14 | -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "api" 7 | -------------------------------------------------------------------------------- /api/decorators.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from functools import wraps 3 | 4 | from django.http import JsonResponse 5 | 6 | 7 | def identity_required(function): 8 | """ 9 | Makes sure the token is tied to an identity, not an app only. 10 | """ 11 | 12 | @wraps(function) 13 | def inner(request, *args, **kwargs): 14 | # They need an identity 15 | if not request.identity: 16 | return JsonResponse({"error": "identity_token_required"}, status=401) 17 | return function(request, *args, **kwargs) 18 | 19 | # This is for the API only 20 | inner.csrf_exempt = True 21 | 22 | return inner 23 | 24 | 25 | def scope_required(scope: str, requires_identity=True): 26 | """ 27 | Asserts that the token we're using has the provided scope 28 | """ 29 | 30 | def decorator(function: Callable): 31 | @wraps(function) 32 | def inner(request, *args, **kwargs): 33 | if not request.token: 34 | if request.identity: 35 | # They're just logged in via cookie - give full access 36 | pass 37 | else: 38 | return JsonResponse( 39 | {"error": "identity_token_required"}, status=401 40 | ) 41 | elif not request.token.has_scope(scope): 42 | return JsonResponse({"error": "out_of_scope_for_token"}, status=403) 43 | # They need an identity 44 | if not request.identity and requires_identity: 45 | return JsonResponse({"error": "identity_token_required"}, status=401) 46 | return function(request, *args, **kwargs) 47 | 48 | inner.csrf_exempt = True # type:ignore 49 | return inner 50 | 51 | return decorator 52 | -------------------------------------------------------------------------------- /api/middleware.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from api.models import Token 4 | 5 | 6 | class ApiTokenMiddleware: 7 | """ 8 | Adds request.user and request.identity if an API token appears. 9 | Also nukes request.session so it can't be used accidentally. 10 | """ 11 | 12 | def __init__(self, get_response): 13 | self.get_response = get_response 14 | 15 | def __call__(self, request): 16 | auth_header = request.headers.get("authorization", None) 17 | request.token = None 18 | request.identity = None 19 | if auth_header and auth_header.startswith("Bearer "): 20 | token_value = auth_header[7:] 21 | if token_value == "__app__": 22 | # Special client app token value 23 | pass 24 | else: 25 | try: 26 | token = Token.objects.get(token=token_value, revoked=None) 27 | except Token.DoesNotExist: 28 | return HttpResponse("Invalid Bearer token", status=400) 29 | request.user = token.user 30 | request.identity = token.identity 31 | request.token = token 32 | request.session = None 33 | response = self.get_response(request) 34 | return response 35 | -------------------------------------------------------------------------------- /api/migrations/0003_token_push_subscription.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-07-15 17:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0002_remove_token_code_token_revoked_alter_token_token_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="token", 14 | name="push_subscription", 15 | field=models.JSONField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/api/migrations/__init__.py -------------------------------------------------------------------------------- /api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .application import Application # noqa 2 | from .authorization import Authorization # noqa 3 | from .token import Token # noqa 4 | -------------------------------------------------------------------------------- /api/models/application.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from django.db import models 4 | 5 | 6 | class Application(models.Model): 7 | """ 8 | OAuth applications 9 | """ 10 | 11 | client_id = models.CharField(max_length=500) 12 | client_secret = models.CharField(max_length=500) 13 | 14 | redirect_uris = models.TextField() 15 | scopes = models.TextField() 16 | 17 | name = models.CharField(max_length=500) 18 | website = models.CharField(max_length=500, blank=True, null=True) 19 | 20 | created = models.DateTimeField(auto_now_add=True) 21 | updated = models.DateTimeField(auto_now=True) 22 | 23 | @classmethod 24 | def create( 25 | cls, 26 | client_name: str, 27 | redirect_uris: str, 28 | website: str | None, 29 | scopes: str | None = None, 30 | ): 31 | client_id = "tk-" + secrets.token_urlsafe(16) 32 | client_secret = secrets.token_urlsafe(40) 33 | 34 | return cls.objects.create( 35 | name=client_name, 36 | website=website, 37 | client_id=client_id, 38 | client_secret=client_secret, 39 | redirect_uris=redirect_uris, 40 | scopes=scopes or "read", 41 | ) 42 | -------------------------------------------------------------------------------- /api/models/authorization.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Authorization(models.Model): 5 | """ 6 | An authorization code as part of the OAuth flow 7 | """ 8 | 9 | application = models.ForeignKey( 10 | "api.Application", 11 | on_delete=models.CASCADE, 12 | related_name="authorizations", 13 | ) 14 | 15 | user = models.ForeignKey( 16 | "users.User", 17 | blank=True, 18 | null=True, 19 | on_delete=models.CASCADE, 20 | related_name="authorizations", 21 | ) 22 | 23 | identity = models.ForeignKey( 24 | "users.Identity", 25 | blank=True, 26 | null=True, 27 | on_delete=models.CASCADE, 28 | related_name="authorizations", 29 | ) 30 | 31 | code = models.CharField(max_length=128, blank=True, null=True, unique=True) 32 | token = models.OneToOneField( 33 | "api.Token", 34 | blank=True, 35 | null=True, 36 | on_delete=models.CASCADE, 37 | ) 38 | 39 | scopes = models.JSONField() 40 | redirect_uri = models.TextField(blank=True, null=True) 41 | valid_for_seconds = models.IntegerField(default=60) 42 | 43 | created = models.DateTimeField(auto_now_add=True) 44 | updated = models.DateTimeField(auto_now=True) 45 | -------------------------------------------------------------------------------- /api/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/api/views/__init__.py -------------------------------------------------------------------------------- /api/views/announcements.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from hatchway import api_view 3 | 4 | from api import schemas 5 | from api.decorators import scope_required 6 | from users.models import Announcement 7 | from users.services import AnnouncementService 8 | 9 | 10 | @scope_required("read:notifications") 11 | @api_view.get 12 | def announcement_list(request) -> list[schemas.Announcement]: 13 | return [ 14 | schemas.Announcement.from_announcement(a, request.user) 15 | for a in AnnouncementService(request.user).visible() 16 | ] 17 | 18 | 19 | @scope_required("write:notifications") 20 | @api_view.post 21 | def announcement_dismiss(request, pk: str): 22 | announcement = get_object_or_404(Announcement, pk=pk) 23 | AnnouncementService(request.user).mark_seen(announcement) 24 | -------------------------------------------------------------------------------- /api/views/apps.py: -------------------------------------------------------------------------------- 1 | from hatchway import QueryOrBody, api_view 2 | 3 | from api import schemas 4 | from api.decorators import scope_required 5 | from api.models import Application 6 | 7 | 8 | @api_view.post 9 | def add_app( 10 | request, 11 | client_name: QueryOrBody[str], 12 | redirect_uris: QueryOrBody[str], 13 | scopes: QueryOrBody[None | str] = None, 14 | website: QueryOrBody[None | str] = None, 15 | ) -> schemas.Application: 16 | application = Application.create( 17 | client_name=client_name, 18 | website=website, 19 | redirect_uris=redirect_uris, 20 | scopes=scopes, 21 | ) 22 | return schemas.Application.from_application(application) 23 | 24 | 25 | @scope_required("read") 26 | @api_view.get 27 | def verify_credentials( 28 | request, 29 | ) -> schemas.Application: 30 | return schemas.Application.from_application_no_keys(request.token.application) 31 | -------------------------------------------------------------------------------- /api/views/bookmarks.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from hatchway import api_view 3 | 4 | from activities.models import Post 5 | from activities.services import TimelineService 6 | from api import schemas 7 | from api.decorators import scope_required 8 | from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult 9 | 10 | 11 | @scope_required("read:bookmarks") 12 | @api_view.get 13 | def bookmarks( 14 | request: HttpRequest, 15 | max_id: str | None = None, 16 | since_id: str | None = None, 17 | min_id: str | None = None, 18 | limit: int = 20, 19 | ) -> list[schemas.Status]: 20 | queryset = TimelineService(request.identity).bookmarks() 21 | paginator = MastodonPaginator() 22 | pager: PaginationResult[Post] = paginator.paginate( 23 | queryset, 24 | min_id=min_id, 25 | max_id=max_id, 26 | since_id=since_id, 27 | limit=limit, 28 | ) 29 | return PaginatingApiResponse( 30 | schemas.Status.map_from_post(pager.results, request.identity), 31 | request=request, 32 | include_params=["limit"], 33 | ) 34 | -------------------------------------------------------------------------------- /api/views/emoji.py: -------------------------------------------------------------------------------- 1 | from hatchway import api_view 2 | 3 | from activities.models import Emoji 4 | from api.schemas import CustomEmoji 5 | 6 | 7 | @api_view.get 8 | def emojis(request) -> list[CustomEmoji]: 9 | return [ 10 | CustomEmoji.from_emoji(e) for e in Emoji.objects.usable().filter(local=True) 11 | ] 12 | -------------------------------------------------------------------------------- /api/views/filters.py: -------------------------------------------------------------------------------- 1 | from hatchway import api_view 2 | 3 | from api.decorators import identity_required 4 | 5 | 6 | @identity_required 7 | @api_view.get 8 | def list_filters(request): 9 | return [] 10 | -------------------------------------------------------------------------------- /api/views/lists.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from hatchway import api_view 3 | 4 | from api import schemas 5 | from api.decorators import scope_required 6 | 7 | 8 | @scope_required("read:lists") 9 | @api_view.get 10 | def get_lists(request: HttpRequest) -> list[schemas.List]: 11 | # We don't implement this yet 12 | return [] 13 | -------------------------------------------------------------------------------- /api/views/polls.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from hatchway import Schema, api_view 3 | 4 | from activities.models import Post, PostInteraction 5 | from api import schemas 6 | from api.decorators import scope_required 7 | 8 | 9 | class PostVoteSchema(Schema): 10 | choices: list[int] 11 | 12 | 13 | @scope_required("read:statuses") 14 | @api_view.get 15 | def get_poll(request, id: str) -> schemas.Poll: 16 | post = get_object_or_404(Post, pk=id, type=Post.Types.question) 17 | return schemas.Poll.from_post(post, identity=request.identity) 18 | 19 | 20 | @scope_required("write:statuses") 21 | @api_view.post 22 | def vote_poll(request, id: str, details: PostVoteSchema) -> schemas.Poll: 23 | post = get_object_or_404(Post, pk=id, type=Post.Types.question) 24 | PostInteraction.create_votes(post, request.identity, details.choices) 25 | post.refresh_from_db() 26 | return schemas.Poll.from_post(post, identity=request.identity) 27 | -------------------------------------------------------------------------------- /api/views/preferences.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from hatchway import api_view 3 | 4 | from api import schemas 5 | from api.decorators import scope_required 6 | 7 | 8 | @scope_required("read:accounts") 9 | @api_view.get 10 | def preferences(request: HttpRequest) -> dict: 11 | # Ideally this should just return Preferences; maybe hatchway needs a way to 12 | # indicate response models should be serialized by alias? 13 | return schemas.Preferences.from_identity(request.identity).dict(by_alias=True) 14 | -------------------------------------------------------------------------------- /api/views/suggestions.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from hatchway import api_view 3 | 4 | from api import schemas 5 | from api.decorators import scope_required 6 | 7 | 8 | @scope_required("read") 9 | @api_view.get 10 | def suggested_users( 11 | request: HttpRequest, 12 | limit: int = 10, 13 | offset: int | None = None, 14 | ) -> list[schemas.Account]: 15 | # We don't implement this yet 16 | return [] 17 | -------------------------------------------------------------------------------- /api/views/trends.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from hatchway import api_view 3 | 4 | from api import schemas 5 | from api.decorators import scope_required 6 | 7 | 8 | @scope_required("read") 9 | @api_view.get 10 | def trends_tags( 11 | request: HttpRequest, 12 | limit: int = 10, 13 | offset: int | None = None, 14 | ) -> list[schemas.Tag]: 15 | # We don't implement this yet 16 | return [] 17 | 18 | 19 | @scope_required("read") 20 | @api_view.get 21 | def trends_statuses( 22 | request: HttpRequest, 23 | limit: int = 10, 24 | offset: int | None = None, 25 | ) -> list[schemas.Status]: 26 | # We don't implement this yet 27 | return [] 28 | 29 | 30 | @scope_required("read") 31 | @api_view.get 32 | def trends_links( 33 | request: HttpRequest, 34 | limit: int = 10, 35 | offset: int | None = None, 36 | ) -> list: 37 | # We don't implement this yet 38 | return [] 39 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/core/__init__.py -------------------------------------------------------------------------------- /core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from core.models import Config 5 | 6 | 7 | class ConfigOptionsTypeFilter(admin.SimpleListFilter): 8 | title = _("config options type") 9 | parameter_name = "type" 10 | 11 | def lookups(self, request, model_admin): 12 | return ( 13 | ("system", _("System")), 14 | ("identity", _("Identity")), 15 | ("user", _("User")), 16 | ) 17 | 18 | def queryset(self, request, queryset): 19 | match self.value(): 20 | case "system": 21 | return queryset.filter(user__isnull=True, identity__isnull=True) 22 | case "identity": 23 | return queryset.exclude(identity__isnull=True) 24 | case "user": 25 | return queryset.exclude(user__isnull=True) 26 | case _: 27 | return queryset 28 | 29 | 30 | @admin.register(Config) 31 | class ConfigAdmin(admin.ModelAdmin): 32 | list_display = ["id", "key", "user", "identity"] 33 | list_filter = (ConfigOptionsTypeFilter,) 34 | -------------------------------------------------------------------------------- /core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from pyld import jsonld 3 | 4 | from core.ld import builtin_document_loader 5 | 6 | 7 | class CoreConfig(AppConfig): 8 | default_auto_field = "django.db.models.BigAutoField" 9 | name = "core" 10 | 11 | def ready(self) -> None: 12 | jsonld.set_document_loader(builtin_document_loader) 13 | -------------------------------------------------------------------------------- /core/context.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from core.models import Config 4 | 5 | 6 | def config_context(request): 7 | return { 8 | "config": Config.system, 9 | "allow_migration": settings.SETUP.ALLOW_USER_MIGRATION, 10 | "top_section": request.path.strip("/").split("/")[0], 11 | "opengraph_defaults": { 12 | "og:site_name": Config.system.site_name, 13 | "og:type": "website", 14 | "og:title": Config.system.site_name, 15 | "og:url": request.build_absolute_uri(), 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /core/exceptions.py: -------------------------------------------------------------------------------- 1 | class ActivityPubError(BaseException): 2 | """ 3 | A problem with an ActivityPub message 4 | """ 5 | 6 | 7 | class ActivityPubFormatError(ActivityPubError): 8 | """ 9 | A problem with an ActivityPub message's format/keys 10 | """ 11 | 12 | 13 | class ActorMismatchError(ActivityPubError): 14 | """ 15 | The actor is not authorised to do the action we saw 16 | """ 17 | -------------------------------------------------------------------------------- /core/htmx.py: -------------------------------------------------------------------------------- 1 | class HTMXMixin: 2 | template_name_htmx: str | None = None 3 | 4 | def get_template_name(self): 5 | if self.request.htmx and self.template_name_htmx: 6 | return self.template_name_htmx 7 | else: 8 | return self.template_name 9 | -------------------------------------------------------------------------------- /core/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from httpx import Response 4 | 5 | JSON_CONTENT_TYPES = [ 6 | "application/json", 7 | "application/ld+json", 8 | "application/activity+json", 9 | ] 10 | 11 | 12 | def json_from_response(response: Response) -> dict | None: 13 | content_type, *parameters = ( 14 | response.headers.get("Content-Type", "invalid").lower().split(";") 15 | ) 16 | 17 | if content_type not in JSON_CONTENT_TYPES: 18 | return None 19 | 20 | charset = None 21 | 22 | for parameter in parameters: 23 | key, value = parameter.split("=") 24 | if key.strip() == "charset": 25 | charset = value.strip() 26 | 27 | if charset: 28 | return json.loads(response.content.decode(charset)) 29 | else: 30 | # if no charset informed, default to 31 | # httpx json for encoding inference 32 | return response.json() 33 | -------------------------------------------------------------------------------- /core/migrations/0002_domain_config.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-29 18:49 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("users", "0016_hashtagfollow"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("core", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterUniqueTogether( 17 | name="config", 18 | unique_together=set(), 19 | ), 20 | migrations.AddField( 21 | model_name="config", 22 | name="domain", 23 | field=models.ForeignKey( 24 | blank=True, 25 | null=True, 26 | on_delete=django.db.models.deletion.CASCADE, 27 | related_name="configs", 28 | to="users.domain", 29 | ), 30 | ), 31 | migrations.AlterUniqueTogether( 32 | name="config", 33 | unique_together={("key", "user", "identity", "domain")}, 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/core/migrations/__init__.py -------------------------------------------------------------------------------- /core/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import Config # noqa 2 | -------------------------------------------------------------------------------- /core/sentry.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from django.conf import settings 4 | 5 | SENTRY_ENABLED = False 6 | try: 7 | if settings.SETUP.SENTRY_DSN: 8 | import sentry_sdk 9 | 10 | SENTRY_ENABLED = True 11 | except ImportError: 12 | pass 13 | 14 | 15 | def noop(*args, **kwargs): 16 | pass 17 | 18 | 19 | @contextmanager 20 | def noop_context(*args, **kwargs): 21 | yield 22 | 23 | 24 | if SENTRY_ENABLED: 25 | configure_scope = sentry_sdk.configure_scope 26 | push_scope = sentry_sdk.push_scope 27 | set_context = sentry_sdk.set_context 28 | set_tag = sentry_sdk.set_tag 29 | start_transaction = sentry_sdk.start_transaction 30 | start_span = sentry_sdk.start_span 31 | else: 32 | configure_scope = noop_context 33 | push_scope = noop_context 34 | set_context = noop 35 | set_tag = noop 36 | start_transaction = noop_context 37 | start_span = noop_context 38 | 39 | 40 | def set_takahe_app(name: str): 41 | set_tag("takahe.app", name) 42 | 43 | 44 | def scope_clear(scope): 45 | if scope: 46 | scope.clear() 47 | 48 | 49 | def set_transaction_name(scope, name: str): 50 | if scope: 51 | scope.set_transaction_name(name) 52 | -------------------------------------------------------------------------------- /core/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/core/templatetags/__init__.py -------------------------------------------------------------------------------- /core/templatetags/mail_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library, Template 2 | 3 | register = Library() 4 | 5 | 6 | @register.inclusion_tag("emails/_body_content.html", takes_context=True) 7 | def email_body_content(context, content): 8 | template = Template(content) 9 | return {"content": template.render(context)} 10 | 11 | 12 | @register.inclusion_tag("emails/_button.html", takes_context=True) 13 | def email_button(context, button_text, button_link): 14 | text_template = Template(button_text) 15 | link_template = Template(button_link) 16 | return { 17 | "button_text": text_template.render(context), 18 | "button_link": link_template.render(context), 19 | } 20 | 21 | 22 | @register.inclusion_tag("emails/_footer.html", takes_context=True) 23 | def email_footer(context, content): 24 | template = Template(content) 25 | return {"content": template.render(context)} 26 | -------------------------------------------------------------------------------- /development.env: -------------------------------------------------------------------------------- 1 | # docker-hosted postgres 2 | TAKAHE_DATABASE_SERVER="postgres://postgres:insecure_password@localhost:5433/takahe" 3 | # If you are using a locally-hosted postgres you can comment the above 4 | # and uncomment the following line 5 | # TAKAHE_DATABASE_SERVER="postgres://postgres@localhost/takahe" 6 | TAKAHE_DEBUG=true 7 | TAKAHE_SECRET_KEY="insecure_secret" 8 | TAKAHE_CSRF_TRUSTED_ORIGINS=["http://127.0.0.1:8000", "https://127.0.0.1:8000"] 9 | TAKAHE_USE_PROXY_HEADERS=true 10 | TAKAHE_EMAIL_SERVER="console://console" 11 | TAKAHE_MAIN_DOMAIN="example.com" 12 | TAKAHE_ENVIRONMENT="development" 13 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG IMAGE_HOST=python 2 | ARG IMAGE_LABEL=3.11-slim-bullseye 3 | 4 | FROM ${IMAGE_HOST}:${IMAGE_LABEL} 5 | 6 | ENV PYTHONUNBUFFERED=1 7 | 8 | COPY requirements.txt requirements.txt 9 | 10 | RUN apt-get update \ 11 | && apt-get install -y --no-install-recommends \ 12 | libpq5 \ 13 | libxslt1.1 \ 14 | nginx \ 15 | busybox \ 16 | netcat \ 17 | gcc \ 18 | libc6-dev \ 19 | libpq-dev \ 20 | # Required to build lxml on arm64. 21 | libxslt1-dev \ 22 | zlib1g-dev \ 23 | postgresql-client \ 24 | && python3 -m pip install --no-cache-dir --upgrade -r requirements.txt \ 25 | && apt-get purge -y --auto-remove \ 26 | gcc \ 27 | libc6-dev \ 28 | libpq-dev \ 29 | libxslt1-dev \ 30 | zlib1g-dev \ 31 | && rm -rf /var/lib/apt/lists/* 32 | 33 | RUN mkdir -p /cache 34 | 35 | # Python mimetypes package is missing some common mappings 36 | COPY docker/mime.types /etc/mime.types 37 | 38 | # Configure nginx 39 | COPY docker/nginx.conf /etc/nginx/ 40 | COPY docker/nginx.conf.d/* /etc/nginx/conf.d/ 41 | 42 | COPY . /takahe 43 | 44 | # Sanity-test the nginx config 45 | RUN nginx -t 46 | 47 | WORKDIR /takahe 48 | 49 | RUN TAKAHE_DATABASE_SERVER="postgres://x@example.com/x" TAKAHE_SECRET_KEY="takahe" TAKAHE_MAIN_DOMAIN="static.test" python3 manage.py collectstatic --noinput 50 | 51 | EXPOSE 8000 52 | 53 | # Set some sensible defaults 54 | ENV GUNICORN_CMD_ARGS="--workers 8" 55 | 56 | CMD ["bash", "docker/run.sh"] 57 | -------------------------------------------------------------------------------- /docker/mime.types: -------------------------------------------------------------------------------- 1 | image/webp webp 2 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | daemon off; 2 | error_log /dev/stdout warn; 3 | 4 | events { 5 | worker_connections 4096; 6 | } 7 | 8 | http { 9 | 10 | sendfile on; 11 | tcp_nopush on; 12 | types_hash_max_size 2048; 13 | 14 | include /etc/nginx/mime.types; 15 | gzip on; 16 | 17 | include /etc/nginx/conf.d/*.conf; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /docker/nginx.conf.d/.gitignore: -------------------------------------------------------------------------------- 1 | *.development.conf 2 | -------------------------------------------------------------------------------- /docker/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set up cache size and nameserver subs 4 | # Nameservers are taken from /etc/resolv.conf - if the IP contains ":", it's IPv6 and must be enclosed in [] for nginx 5 | CACHE_SIZE="${TAKAHE_NGINX_CACHE_SIZE:-1g}" 6 | NAMESERVER=`cat /etc/resolv.conf | grep "nameserver" | awk '{print ($2 ~ ":") ? "["$2"]" : $2}' | tr '\n' ' '` 7 | if [ -z "$NAMESERVER" ]; then 8 | NAMESERVER="9.9.9.9 149.112.112.112" 9 | fi 10 | sed "s/__CACHESIZE__/${CACHE_SIZE}/g" /etc/nginx/conf.d/default.conf.tpl | sed "s/__NAMESERVER__/${NAMESERVER}/g" > /etc/nginx/conf.d/default.conf 11 | 12 | # Run nginx and gunicorn 13 | nginx & 14 | 15 | gunicorn takahe.wsgi:application -b 0.0.0.0:8001 $GUNICORN_EXTRA_CMD_ARGS & 16 | 17 | # Wait for any process to exit 18 | wait -n 19 | 20 | # Exit with status of process that exited first 21 | exit $? 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_templates/sidebar/brand.html: -------------------------------------------------------------------------------- 1 | 2 | {% block brand_content %} 3 | {%- if logo_url %} 4 | 16 | 7 | {%- endif %} 8 | {%- if theme_light_logo and theme_dark_logo %} 9 | 13 | {%- endif %} 14 | {% endblock brand_content %} 15 |
24 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | import pathlib 7 | import sys 8 | 9 | sys.path.insert(0, str(pathlib.Path(__file__).parent / "extensions")) 10 | 11 | # -- Project information ----------------------------------------------------- 12 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 13 | 14 | project = "Takahē" 15 | copyright = "2022, Andrew Godwin" 16 | author = "Andrew Godwin, Jamie Bliss" 17 | 18 | # -- General configuration --------------------------------------------------- 19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | extensions: list = ["canonical_fix"] 22 | 23 | templates_path = ["_templates"] 24 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 25 | 26 | 27 | # -- Options for HTML output ------------------------------------------------- 28 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 29 | 30 | html_theme = "furo" 31 | html_static_path = ["_static"] 32 | html_logo = "../static/img/logo-128.png" 33 | html_favicon = "../static/img/icon-32.png" 34 | -------------------------------------------------------------------------------- /docs/contributors.rst: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | 4 | Takahē would not be where it is today without the efforts of its contributors. 5 | If you're interested in becoming a contributor, see our :doc:`contributing` 6 | page! 7 | 8 | 9 | Creator & Main Developer 10 | ------------------------ 11 | 12 | * `Andrew GodwinI'm Sorry. I'm afraid I can't do that.
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base_plain.html" %} 2 | 3 | {% block content %} 4 |Sorry about that.
7 |The server encountered an error trying to process your request.
18 |To continue, provide this code to your application:
9 | 10 |Error: {{ error }}
9 |{{ content }}
3 | {% endautoescape %} 4 | -------------------------------------------------------------------------------- /templates/emails/_button.html: -------------------------------------------------------------------------------- 1 | {% autoescape off %} 2 |
6 |
|
14 |
9 | {{ field.help_text|safe|linebreaksbr }} 10 |
11 | {% endif %} 12 | {{ field.errors }} 13 | {% if field.field.widget.input_type == "file" and field.value and not hide_existing %} 14 |There are no domains available for this user account.
17 |No posts were found that match your search.
21 | {% endfor %} 22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /templates/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | 3 | # Don't allow any bot to crawl tags. 4 | Disallow: /tags/ 5 | Disallow: /tags/* 6 | 7 | # Don't allow bots to crawl through the proxy 8 | Disallow: /proxy/* 9 | 10 | {% for user_agent in user_agents %} 11 | User-agent: {{user_agent}} 12 | Disallow: / 13 | {% endfor %} 14 | -------------------------------------------------------------------------------- /templates/settings/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %} 4 | 5 | {% block body_class %}wide{% endblock %} 6 | 7 | {% block content %} 8 |{{ alias.handle }} Remove Alias |
You have no aliases. |
Like test.
" 16 | 17 | status_id = response["id"] 18 | 19 | # Like it 20 | response = api_client.post(f"/api/v1/statuses/{status_id}/favourite").json() 21 | assert response["favourited"] is True 22 | 23 | # Check if it's displaying at likes endpoint 24 | response = api_client.get("/api/v1/favourites").json() 25 | assert response[0]["id"] == status_id 26 | 27 | 28 | @pytest.mark.django_db 29 | def test_unlike(api_client): 30 | # Add a post 31 | response = api_client.post( 32 | "/api/v1/statuses", 33 | content_type="application/json", 34 | data={ 35 | "status": "Like test.", 36 | "visibility": "public", 37 | }, 38 | ).json() 39 | assert response["content"] == "Like test.
" 40 | 41 | status_id = response["id"] 42 | 43 | # Like it 44 | response = api_client.post(f"/api/v1/statuses/{status_id}/favourite").json() 45 | assert response["favourited"] is True 46 | 47 | # Unlike it 48 | response = api_client.post(f"/api/v1/statuses/{status_id}/unfavourite").json() 49 | assert response["favourited"] is False 50 | 51 | # Unliked post should not display at the endpoint 52 | response = api_client.get("/api/v1/favourites").json() 53 | assert len(response) == 0 54 | -------------------------------------------------------------------------------- /tests/api/test_polls.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from django.utils import timezone 5 | 6 | from activities.models import Post 7 | from core.ld import format_ld_date 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_get_poll(api_client): 12 | response = api_client.post( 13 | "/api/v1/statuses", 14 | content_type="application/json", 15 | data={ 16 | "status": "Hello, world!", 17 | "poll": { 18 | "options": ["Option 1", "Option 2"], 19 | "expires_in": 300, 20 | }, 21 | }, 22 | ).json() 23 | 24 | id = response["id"] 25 | 26 | response = api_client.get( 27 | f"/api/v1/polls/{id}", 28 | ).json() 29 | 30 | assert response["id"] == id 31 | assert response["voted"] 32 | 33 | 34 | @pytest.mark.django_db 35 | def test_vote_poll(api_client, identity2): 36 | post = Post.create_local( 37 | author=identity2, 38 | content="Test Question
", 39 | question={ 40 | "type": "Question", 41 | "mode": "oneOf", 42 | "options": [ 43 | {"name": "Option 1", "type": "Note", "votes": 0}, 44 | {"name": "Option 2", "type": "Note", "votes": 0}, 45 | ], 46 | "voter_count": 0, 47 | "end_time": format_ld_date(timezone.now() + timedelta(1)), 48 | }, 49 | ) 50 | 51 | response = api_client.post( 52 | f"/api/v1/polls/{post.id}/votes", 53 | content_type="application/json", 54 | data={ 55 | "choices": [0], 56 | }, 57 | ).json() 58 | 59 | assert response["id"] == str(post.id) 60 | assert response["voted"] 61 | assert response["votes_count"] == 1 62 | assert response["own_votes"] == [0] 63 | -------------------------------------------------------------------------------- /tests/api/test_tokens.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.django_db 5 | def test_has_scope(api_token): 6 | """ 7 | Tests has_scope on the Token model 8 | """ 9 | assert api_token.has_scope("read") 10 | assert api_token.has_scope("read:statuses") 11 | assert not api_token.has_scope("destroyearth") 12 | -------------------------------------------------------------------------------- /tests/users/models/test_system_actor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test.client import RequestFactory 3 | from pytest_httpx import HTTPXMock 4 | 5 | from core.signatures import HttpSignature 6 | from users.models import SystemActor 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_system_actor_signed(config_system, httpx_mock: HTTPXMock): 11 | """ 12 | Tests that the system actor signs requests properly 13 | """ 14 | system_actor = SystemActor() 15 | system_actor.generate_keys() 16 | # Send a fake outbound request 17 | httpx_mock.add_response() 18 | system_actor.signed_request( 19 | method="get", 20 | uri="http://example.com/test-actor", 21 | ) 22 | # Retrieve it and construct a fake request object 23 | outbound_request = httpx_mock.get_request() 24 | fake_request = RequestFactory().get( 25 | path="/test-actor", 26 | HTTP_HOST="example.com", 27 | HTTP_DATE=outbound_request.headers["date"], 28 | HTTP_SIGNATURE=outbound_request.headers["signature"], 29 | HTTP_ACCEPT=outbound_request.headers["accept"], 30 | ) 31 | # Verify that 32 | HttpSignature.verify_request(fake_request, system_actor.public_key) 33 | -------------------------------------------------------------------------------- /tests/users/services/test_domain.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from users.models import Domain 4 | from users.services import DomainService 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_block(): 9 | DomainService.block(["block1.example.com", "block2.example.com"]) 10 | 11 | assert Domain.objects.filter(blocked=True).count() == 2 12 | -------------------------------------------------------------------------------- /tests/users/views/settings/test_privacy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_django.asserts import assertContains, assertNotContains 3 | 4 | from core.models.config import Config 5 | from users.models import Follow 6 | 7 | 8 | @pytest.mark.django_db 9 | def test_stats(client, identity, other_identity): 10 | """ 11 | Tests that follow stats are visible 12 | """ 13 | Follow.objects.create(source=other_identity, target=identity) 14 | Config.set_identity(identity, "visible_follows", True) 15 | response = client.get(identity.urls.view) 16 | assertContains(response, "1 Follower", status_code=200) 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_visible_follows_disabled(client, identity): 21 | """ 22 | Tests that disabling visible follows hides it from profile 23 | """ 24 | Config.set_identity(identity, "visible_follows", True) 25 | response = client.get(identity.urls.view) 26 | assertContains(response, "Follower", status_code=200) 27 | Config.set_identity(identity, "visible_follows", False) 28 | response = client.get(identity.urls.view) 29 | assertNotContains(response, "Follower", status_code=200) 30 | -------------------------------------------------------------------------------- /tests/users/views/test_domains.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | 4 | from users.views.admin.domains import DomainValidator 5 | 6 | VALID_DOMAINS = [ 7 | "takahe.social", 8 | "subdomain.takahe.social", 9 | "another.subdomain.takahe.social", 10 | "jointakahe.org", 11 | "xn--c6h.com", 12 | "takahe.xn--social", 13 | "example.com", 14 | "www.example.com", 15 | "example.co.uk", 16 | ] 17 | 18 | INVALID_DOMAINS = [ 19 | "example.c", 20 | "example,com", 21 | "example,com.com", 22 | "example", 23 | ".com", 24 | "example.com/example", 25 | "-example.com", 26 | "example-.com", 27 | "example.com-", 28 | "https://example.com", 29 | ] 30 | 31 | 32 | @pytest.mark.parametrize("domain", VALID_DOMAINS) 33 | def test_domain_validation_accepts_valid_domains(domain): 34 | """ 35 | Tests that the domain validator works in positive cases 36 | """ 37 | DomainValidator()(domain) 38 | 39 | 40 | @pytest.mark.parametrize("domain", INVALID_DOMAINS) 41 | def test_domain_validation_raises_exception_for_invalid_domains(domain): 42 | """ 43 | Tests that the domain validator works in negative cases 44 | """ 45 | with pytest.raises(ValidationError): 46 | DomainValidator()(domain) 47 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/users/__init__.py -------------------------------------------------------------------------------- /users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.db.models.signals import post_migrate 3 | 4 | 5 | class UsersConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "users" 8 | 9 | def data_init(self, **kwargs): 10 | """ 11 | Runs after migrations or flushes to insert anything we need for first 12 | boot (or post upgrade). 13 | """ 14 | # Generate the server actor keypair if needed 15 | from users.models import SystemActor 16 | 17 | SystemActor.generate_keys_if_needed() 18 | 19 | def ready(self) -> None: 20 | post_migrate.connect(self.data_init, sender=self) 21 | -------------------------------------------------------------------------------- /users/context.py: -------------------------------------------------------------------------------- 1 | from users.services import AnnouncementService 2 | 3 | 4 | def user_context(request): 5 | return { 6 | "announcements": ( 7 | AnnouncementService(request.user).visible() 8 | if request.user.is_authenticated 9 | else AnnouncementService.visible_anonymous() 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /users/decorators.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import user_passes_test 2 | 3 | 4 | def moderator_required(function): 5 | return user_passes_test( 6 | lambda user: user.is_authenticated and (user.admin or user.moderator) 7 | )(function) 8 | 9 | 10 | def admin_required(function): 11 | return user_passes_test(lambda user: user.is_authenticated and user.admin)(function) 12 | -------------------------------------------------------------------------------- /users/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/users/management/__init__.py -------------------------------------------------------------------------------- /users/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/users/management/commands/__init__.py -------------------------------------------------------------------------------- /users/middleware.py: -------------------------------------------------------------------------------- 1 | from users.models import Domain 2 | 3 | 4 | class DomainMiddleware: 5 | """ 6 | Tries to attach a Domain object to every incoming request, if one matches. 7 | """ 8 | 9 | def __init__(self, get_response): 10 | self.get_response = get_response 11 | 12 | def __call__(self, request): 13 | request.domain = None 14 | if "host" in request.headers: 15 | request.domain = Domain.get_domain(request.headers["host"]) 16 | response = self.get_response(request) 17 | return response 18 | -------------------------------------------------------------------------------- /users/migrations/0002_identity_discoverable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2022-11-26 01:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="identity", 14 | name="discoverable", 15 | field=models.BooleanField(default=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /users/migrations/0003_identity_followers_etc.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2022-11-27 22:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0002_identity_discoverable"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="identity", 14 | name="followers_uri", 15 | field=models.CharField(blank=True, max_length=500, null=True), 16 | ), 17 | migrations.AddField( 18 | model_name="identity", 19 | name="following_uri", 20 | field=models.CharField(blank=True, max_length=500, null=True), 21 | ), 22 | migrations.AddField( 23 | model_name="identity", 24 | name="metadata", 25 | field=models.JSONField(blank=True, null=True), 26 | ), 27 | migrations.AddField( 28 | model_name="identity", 29 | name="pinned", 30 | field=models.JSONField(blank=True, null=True), 31 | ), 32 | migrations.AddField( 33 | model_name="identity", 34 | name="shared_inbox_uri", 35 | field=models.CharField(blank=True, max_length=500, null=True), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /users/migrations/0004_identity_admin_notes_identity_restriction_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-17 01:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0003_identity_followers_etc"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="identity", 14 | name="admin_notes", 15 | field=models.TextField(blank=True, null=True), 16 | ), 17 | migrations.AddField( 18 | model_name="identity", 19 | name="restriction", 20 | field=models.IntegerField( 21 | choices=[(0, "None"), (1, "Limited"), (2, "Blocked")], default=0 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name="identity", 26 | name="sensitive", 27 | field=models.BooleanField(default=False), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /users/migrations/0006_identity_actor_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-20 06:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0005_report"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="identity", 14 | name="actor_type", 15 | field=models.CharField(default="person", max_length=100), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /users/migrations/0007_remove_invite_email_invite_expires_invite_uses.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-22 06:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0006_identity_actor_type"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="invite", 14 | name="email", 15 | ), 16 | migrations.AddField( 17 | model_name="invite", 18 | name="expires", 19 | field=models.DateTimeField(blank=True, null=True), 20 | ), 21 | migrations.AddField( 22 | model_name="invite", 23 | name="uses", 24 | field=models.IntegerField(blank=True, null=True), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /users/migrations/0008_follow_boosts.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-29 19:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0007_remove_invite_email_invite_expires_invite_uses"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="follow", 14 | name="boosts", 15 | field=models.BooleanField( 16 | default=True, help_text="Also follow boosts from this user" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /users/migrations/0013_stator_indexes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-04 05:23 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0012_block_states"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterIndexTogether( 13 | name="block", 14 | index_together={("state_ready", "state_locked_until", "state")}, 15 | ), 16 | migrations.AlterIndexTogether( 17 | name="domain", 18 | index_together={("state_ready", "state_locked_until", "state")}, 19 | ), 20 | migrations.AlterIndexTogether( 21 | name="follow", 22 | index_together={("state_ready", "state_locked_until", "state")}, 23 | ), 24 | migrations.AlterIndexTogether( 25 | name="identity", 26 | index_together={("state_ready", "state_locked_until", "state")}, 27 | ), 28 | migrations.AlterIndexTogether( 29 | name="inboxmessage", 30 | index_together={("state_ready", "state_locked_until", "state")}, 31 | ), 32 | migrations.AlterIndexTogether( 33 | name="passwordreset", 34 | index_together={("state_ready", "state_locked_until", "state")}, 35 | ), 36 | migrations.AlterIndexTogether( 37 | name="report", 38 | index_together={("state_ready", "state_locked_until", "state")}, 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /users/migrations/0014_domain_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-06 21:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0013_stator_indexes"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="domain", 14 | name="notes", 15 | field=models.TextField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /users/migrations/0015_bookmark.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-11 00:11 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("activities", "0012_in_reply_to_index"), 10 | ("users", "0014_domain_notes"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Bookmark", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("created", models.DateTimeField(auto_now_add=True)), 27 | ( 28 | "identity", 29 | models.ForeignKey( 30 | on_delete=django.db.models.deletion.CASCADE, 31 | related_name="bookmarks", 32 | to="users.identity", 33 | ), 34 | ), 35 | ( 36 | "post", 37 | models.ForeignKey( 38 | on_delete=django.db.models.deletion.CASCADE, 39 | related_name="bookmarks", 40 | to="activities.post", 41 | ), 42 | ), 43 | ], 44 | options={ 45 | "unique_together": {("identity", "post")}, 46 | }, 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /users/migrations/0016_hashtagfollow.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-11 19:50 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("activities", "0012_in_reply_to_index"), 10 | ("users", "0015_bookmark"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="HashtagFollow", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("created", models.DateTimeField(auto_now_add=True)), 27 | ( 28 | "hashtag", 29 | models.ForeignKey( 30 | on_delete=django.db.models.deletion.CASCADE, 31 | related_name="followers", 32 | to="activities.hashtag", 33 | ), 34 | ), 35 | ( 36 | "identity", 37 | models.ForeignKey( 38 | on_delete=django.db.models.deletion.CASCADE, 39 | related_name="hashtag_follows", 40 | to="users.identity", 41 | ), 42 | ), 43 | ], 44 | options={ 45 | "unique_together": {("identity", "hashtag")}, 46 | }, 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /users/migrations/0017_identity_featured_collection_uri.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-23 20:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0016_hashtagfollow"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="identity", 14 | name="featured_collection_uri", 15 | field=models.CharField(blank=True, max_length=500, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /users/migrations/0020_alter_identity_local.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-07-07 20:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0019_stator_next_change"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="identity", 14 | name="local", 15 | field=models.BooleanField(db_index=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /users/migrations/0021_identity_aliases.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-07-22 17:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0020_alter_identity_local"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="identity", 14 | name="aliases", 15 | field=models.JSONField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /users/migrations/0022_follow_request.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-04 01:38 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0021_identity_aliases"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RunSQL( 13 | "UPDATE users_follow SET state = 'pending_approval' WHERE state = 'local_requested';" 14 | ), 15 | migrations.RunSQL( 16 | "UPDATE users_follow SET state = 'accepting' WHERE state = 'remote_requested';" 17 | ), 18 | migrations.RunSQL( 19 | "DELETE FROM users_follow WHERE state not in ('accepted', 'accepting', 'pending_approval', 'unrequested');" 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jointakahe/takahe/d45f22c9c2f5e4feec9f9692c36b8385d51ec981/users/migrations/__init__.py -------------------------------------------------------------------------------- /users/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .announcement import Announcement # noqa 2 | from .block import Block, BlockStates # noqa 3 | from .bookmark import Bookmark # noqa 4 | from .domain import Domain # noqa 5 | from .follow import Follow, FollowStates # noqa 6 | from .hashtag_follow import HashtagFollow # noqa 7 | from .identity import Identity, IdentityStates # noqa 8 | from .inbox_message import InboxMessage, InboxMessageStates # noqa 9 | from .invite import Invite # noqa 10 | from .password_reset import PasswordReset # noqa 11 | from .report import Report # noqa 12 | from .system_actor import SystemActor # noqa 13 | from .user import User # noqa 14 | from .user_event import UserEvent # noqa 15 | -------------------------------------------------------------------------------- /users/models/bookmark.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Bookmark(models.Model): 5 | """ 6 | A (private) bookmark of a Post by an Identity 7 | """ 8 | 9 | identity = models.ForeignKey( 10 | "users.Identity", 11 | on_delete=models.CASCADE, 12 | related_name="bookmarks", 13 | ) 14 | post = models.ForeignKey( 15 | "activities.Post", 16 | on_delete=models.CASCADE, 17 | related_name="bookmarks", 18 | ) 19 | 20 | created = models.DateTimeField(auto_now_add=True) 21 | 22 | class Meta: 23 | unique_together = [("identity", "post")] 24 | 25 | def __str__(self): 26 | return f"#{self.id}: {self.identity} → {self.post}" 27 | 28 | @classmethod 29 | def for_identity(cls, identity, posts=None, field="id"): 30 | """ 31 | Returns a set of bookmarked Post IDs for the given identity. If `posts` is 32 | specified, it is used to filter bookmarks matching those in the list. 33 | """ 34 | if identity is None: 35 | return set() 36 | queryset = cls.objects.filter(identity=identity) 37 | if posts: 38 | queryset = queryset.filter(post_id__in=[getattr(p, field) for p in posts]) 39 | return set(queryset.values_list("post_id", flat=True)) 40 | -------------------------------------------------------------------------------- /users/models/hashtag_follow.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django.db import models 4 | 5 | 6 | class HashtagFollowQuerySet(models.QuerySet): 7 | def by_hashtags(self, hashtags: list[str]): 8 | return self.filter(hashtag_id__in=hashtags) 9 | 10 | def by_identity(self, identity): 11 | return self.filter(identity=identity) 12 | 13 | 14 | class HashtagFollowManager(models.Manager): 15 | def get_queryset(self): 16 | return HashtagFollowQuerySet(self.model, using=self._db) 17 | 18 | def by_hashtags(self, hashtags: list[str]): 19 | return self.get_queryset().by_hashtags(hashtags) 20 | 21 | def by_identity(self, identity): 22 | return self.get_queryset().by_identity(identity) 23 | 24 | 25 | class HashtagFollow(models.Model): 26 | identity = models.ForeignKey( 27 | "users.Identity", 28 | on_delete=models.CASCADE, 29 | related_name="hashtag_follows", 30 | ) 31 | hashtag = models.ForeignKey( 32 | "activities.Hashtag", 33 | on_delete=models.CASCADE, 34 | related_name="followers", 35 | db_index=True, 36 | ) 37 | 38 | created = models.DateTimeField(auto_now_add=True) 39 | 40 | objects = HashtagFollowManager() 41 | 42 | class Meta: 43 | unique_together = [("identity", "hashtag")] 44 | 45 | def __str__(self): 46 | return f"#{self.id}: {self.identity} → {self.hashtag_id}" 47 | 48 | ### Alternate fetchers/constructors ### 49 | 50 | @classmethod 51 | def maybe_get(cls, identity, hashtag) -> Optional["HashtagFollow"]: 52 | """ 53 | Returns a hashtag follow if it exists between identity and hashtag 54 | """ 55 | try: 56 | return HashtagFollow.objects.get(identity=identity, hashtag=hashtag) 57 | except HashtagFollow.DoesNotExist: 58 | return None 59 | -------------------------------------------------------------------------------- /users/models/invite.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import urlman 4 | from django.db import models 5 | from django.utils import timezone 6 | 7 | 8 | class Invite(models.Model): 9 | """ 10 | An invite token, good for one signup. 11 | """ 12 | 13 | # Should always be lowercase 14 | token = models.CharField(max_length=500, unique=True) 15 | 16 | # Admin note about this code 17 | note = models.TextField(null=True, blank=True) 18 | 19 | # Uses remaining (null means "infinite") 20 | uses = models.IntegerField(null=True, blank=True) 21 | 22 | # Expiry date 23 | expires = models.DateTimeField(null=True, blank=True) 24 | 25 | created = models.DateTimeField(auto_now_add=True) 26 | updated = models.DateTimeField(auto_now=True) 27 | 28 | class urls(urlman.Urls): 29 | admin = "/admin/invites/" 30 | admin_create = "{admin}create/" 31 | admin_view = "{admin}{self.pk}/" 32 | 33 | @classmethod 34 | def create_random(cls, uses=None, expires=None, note=None): 35 | return cls.objects.create( 36 | token="".join( 37 | random.choice("abcdefghkmnpqrstuvwxyz23456789") for i in range(20) 38 | ), 39 | uses=uses, 40 | expires=expires, 41 | note=note, 42 | ) 43 | 44 | @property 45 | def valid(self): 46 | if self.uses is not None: 47 | if self.uses <= 0: 48 | return False 49 | if self.expires is not None: 50 | return self.expires >= timezone.now() 51 | return True 52 | -------------------------------------------------------------------------------- /users/models/user_event.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class UserEvent(models.Model): 5 | """ 6 | Tracks major events that happen to users 7 | """ 8 | 9 | class EventType(models.TextChoices): 10 | created = "created" 11 | reset_password = "reset_password" 12 | banned = "banned" 13 | 14 | user = models.ForeignKey( 15 | "users.User", 16 | on_delete=models.CASCADE, 17 | related_name="events", 18 | ) 19 | 20 | date = models.DateTimeField(auto_now_add=True) 21 | type = models.CharField(max_length=100, choices=EventType.choices) 22 | data = models.JSONField(blank=True, null=True) 23 | -------------------------------------------------------------------------------- /users/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class NodeInfoServices(BaseModel): 7 | inbound: list[str] 8 | outbound: list[str] 9 | 10 | 11 | class NodeInfoSoftware(BaseModel): 12 | name: str 13 | version: str = "unknown" 14 | 15 | 16 | class NodeInfoUsage(BaseModel): 17 | users: dict[str, int | None] | None 18 | local_posts: int = Field(default=0, alias="localPosts") 19 | 20 | 21 | class NodeInfo(BaseModel): 22 | version: Literal["2.0"] 23 | software: NodeInfoSoftware 24 | protocols: list[str] | None 25 | open_registrations: bool = Field(alias="openRegistrations") 26 | usage: NodeInfoUsage 27 | 28 | metadata: dict[str, Any] | None 29 | 30 | class Config: 31 | extra = "ignore" 32 | -------------------------------------------------------------------------------- /users/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .announcement import AnnouncementService # noqa 2 | from .domain import DomainService # noqa 3 | from .identity import IdentityService # noqa 4 | from .user import UserService # noqa 5 | -------------------------------------------------------------------------------- /users/services/announcement.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | from users.models import Announcement, User 5 | 6 | 7 | class AnnouncementService: 8 | """ 9 | Handles viewing and dismissing announcements 10 | """ 11 | 12 | def __init__(self, user: User): 13 | self.user = user 14 | 15 | @classmethod 16 | def visible_queryset(cls) -> models.QuerySet[Announcement]: 17 | """ 18 | Common visibility query 19 | """ 20 | now = timezone.now() 21 | return Announcement.objects.filter( 22 | models.Q(start__lte=now) | models.Q(start__isnull=True), 23 | models.Q(end__gte=now) | models.Q(end__isnull=True), 24 | published=True, 25 | ).order_by("-start", "-created") 26 | 27 | @classmethod 28 | def visible_anonymous(cls) -> models.QuerySet[Announcement]: 29 | """ 30 | Returns all announcements marked as being showable to all visitors 31 | """ 32 | return cls.visible_queryset().filter(include_unauthenticated=True) 33 | 34 | def visible(self) -> models.QuerySet[Announcement]: 35 | """ 36 | Returns all announcements that are currently valid and should be shown 37 | to a given user. 38 | """ 39 | return self.visible_queryset().exclude(seen=self.user) 40 | 41 | def mark_seen(self, announcement: Announcement): 42 | """ 43 | Marks an announcement as seen by the user 44 | """ 45 | announcement.seen.add(self.user) 46 | -------------------------------------------------------------------------------- /users/services/domain.py: -------------------------------------------------------------------------------- 1 | from users.models import Domain 2 | 3 | 4 | class DomainService: 5 | """ 6 | High-level domain handling methods 7 | """ 8 | 9 | @classmethod 10 | def block(cls, domains: list[str]) -> None: 11 | domains_to_block = Domain.objects.filter(domain__in=domains) 12 | domains_to_block.update(blocked=True) 13 | 14 | already_blocked = domains_to_block.values_list("domain", flat=True) 15 | domains_to_create = [] 16 | for domain in domains: 17 | if domain not in already_blocked: 18 | domains_to_create.append( 19 | Domain(domain=domain, blocked=True, local=False) 20 | ) 21 | 22 | Domain.objects.bulk_create(domains_to_create) 23 | -------------------------------------------------------------------------------- /users/services/user.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | from users.models import PasswordReset, User 5 | 6 | 7 | class UserService: 8 | """ 9 | High-level user handling methods 10 | """ 11 | 12 | @classmethod 13 | def admins(cls) -> models.QuerySet[User]: 14 | return User.objects.filter(admin=True) 15 | 16 | @classmethod 17 | def moderators(cls) -> models.QuerySet[User]: 18 | return User.objects.filter(models.Q(moderator=True) | models.Q(admin=True)) 19 | 20 | @classmethod 21 | def create(cls, email: str) -> User: 22 | """ 23 | Creates a new user 24 | """ 25 | # Make the new user 26 | user = User.objects.create(email=email) 27 | # Auto-promote the user to admin if that setting is set 28 | if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL: 29 | user.admin = True 30 | user.save() 31 | # Send them a password reset email 32 | PasswordReset.create_for_user(user) 33 | return user 34 | -------------------------------------------------------------------------------- /users/shortcuts.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | 3 | from users.models import Domain, Identity 4 | 5 | 6 | def by_handle_or_404(request, handle, local=True, fetch=False) -> Identity: 7 | """ 8 | Retrieves an Identity by its long or short handle. 9 | Domain-sensitive, so it will understand short handles on alternate domains. 10 | """ 11 | if "@" not in handle: 12 | if "host" not in request.headers: 13 | raise Http404("No hostname available") 14 | username = handle 15 | domain_instance = Domain.get_domain(request.headers["host"]) 16 | if domain_instance is None: 17 | raise Http404("No matching domains found") 18 | domain = domain_instance.domain 19 | else: 20 | username, domain = handle.split("@", 1) 21 | if not Domain.is_valid_domain(domain): 22 | raise Http404("Invalid domain") 23 | # Resolve the domain to the display domain 24 | domain_instance = Domain.get_domain(domain) 25 | if domain_instance is None: 26 | domain_instance = Domain.get_remote_domain(domain) 27 | domain = domain_instance.domain 28 | identity = Identity.by_username_and_domain( 29 | username, 30 | domain_instance, 31 | local=local, 32 | fetch=fetch, 33 | ) 34 | if identity is None: 35 | raise Http404(f"No identity for handle {handle}") 36 | if identity.blocked: 37 | raise Http404("Blocked user") 38 | return identity 39 | 40 | 41 | def by_handle_for_user_or_404(request, handle): 42 | """ 43 | Retrieves an identity the local user can control via their handle, or 44 | raises a 404. 45 | """ 46 | identity = by_handle_or_404(request, handle, local=True, fetch=False) 47 | if not identity.users.filter(id=request.user.id).exists(): 48 | raise Http404("Current user does not own identity") 49 | return identity 50 | -------------------------------------------------------------------------------- /users/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import * # noqa 2 | -------------------------------------------------------------------------------- /users/views/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from django.views.generic import RedirectView 3 | 4 | from users.decorators import admin_required 5 | from users.views.admin.announcements import ( # noqa 6 | AnnouncementCreate, 7 | AnnouncementDelete, 8 | AnnouncementEdit, 9 | AnnouncementPublish, 10 | AnnouncementsRoot, 11 | AnnouncementUnpublish, 12 | ) 13 | from users.views.admin.domains import ( # noqa 14 | DomainCreate, 15 | DomainDelete, 16 | DomainEdit, 17 | Domains, 18 | ) 19 | from users.views.admin.emoji import ( # noqa 20 | EmojiCopyLocal, 21 | EmojiCreate, 22 | EmojiDelete, 23 | EmojiEnable, 24 | EmojiRoot, 25 | ) 26 | from users.views.admin.federation import ( # noqa 27 | FederationBlocklist, 28 | FederationEdit, 29 | FederationRoot, 30 | ) 31 | from users.views.admin.hashtags import HashtagEdit, HashtagEnable, Hashtags # noqa 32 | from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa 33 | from users.views.admin.invites import InviteCreate, InvitesRoot, InviteView # noqa 34 | from users.views.admin.reports import ReportsRoot, ReportView # noqa 35 | from users.views.admin.settings import ( # noqa 36 | BasicSettings, 37 | PoliciesSettings, 38 | TuningSettings, 39 | ) 40 | from users.views.admin.stator import Stator # noqa 41 | from users.views.admin.users import UserEdit, UsersRoot # noqa 42 | 43 | 44 | @method_decorator(admin_required, name="dispatch") 45 | class AdminRoot(RedirectView): 46 | pattern_name = "admin_basic" 47 | -------------------------------------------------------------------------------- /users/views/admin/generic.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import View 2 | from django.views.generic.detail import SingleObjectMixin 3 | from django_htmx.http import HttpResponseClientRefresh 4 | 5 | 6 | class HTMXActionView(SingleObjectMixin, View): 7 | """ 8 | Generic view that performs an action when called via HTMX and then causes 9 | a full page refresh. 10 | """ 11 | 12 | def post(self, request, pk): 13 | self.action(self.get_object()) 14 | return HttpResponseClientRefresh() 15 | 16 | def action(self, instance): 17 | raise NotImplementedError() 18 | -------------------------------------------------------------------------------- /users/views/admin/stator.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from django.views.generic import TemplateView 3 | 4 | from stator.models import StatorModel, Stats 5 | from users.decorators import admin_required 6 | 7 | 8 | @method_decorator(admin_required, name="dispatch") 9 | class Stator(TemplateView): 10 | template_name = "admin/stator.html" 11 | 12 | def get_context_data(self): 13 | return { 14 | "model_stats": { 15 | model._meta.verbose_name_plural.title(): Stats.get_for_model(model) 16 | for model in StatorModel.subclasses 17 | }, 18 | "section": "stator", 19 | } 20 | -------------------------------------------------------------------------------- /users/views/announcements.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.http import HttpResponse 3 | from django.shortcuts import get_object_or_404 4 | from django.utils.decorators import method_decorator 5 | from django.views.generic import View 6 | 7 | from users.models import Announcement 8 | from users.services import AnnouncementService 9 | 10 | 11 | @method_decorator(login_required, name="dispatch") 12 | class AnnouncementDismiss(View): 13 | """ 14 | Dismisses an announcement for the current user 15 | """ 16 | 17 | def post(self, request, id): 18 | announcement = get_object_or_404(Announcement, pk=id) 19 | AnnouncementService(request.user).mark_seen(announcement) 20 | # In the UI we replace it with nothing anyway 21 | return HttpResponse("") 22 | -------------------------------------------------------------------------------- /users/views/base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.utils.decorators import method_decorator 3 | 4 | from users.shortcuts import by_handle_for_user_or_404 5 | 6 | 7 | @method_decorator(login_required, name="dispatch") 8 | class IdentityViewMixin: 9 | """ 10 | A mixin that requires that the view has a "handle" kwarg that resolves 11 | to a valid identity that the current user has. 12 | """ 13 | 14 | def dispatch(self, request, *args, **kwargs): 15 | self.identity = by_handle_for_user_or_404(request, kwargs["handle"]) 16 | self.post_identity_setup() 17 | return super().dispatch(request, *args, **kwargs) 18 | 19 | def post_identity_setup(self): 20 | pass 21 | 22 | def get_context_data(self, **kwargs): 23 | context = super().get_context_data(**kwargs) 24 | context["identity"] = self.identity 25 | return context 26 | -------------------------------------------------------------------------------- /users/views/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.shortcuts import redirect 3 | from django.utils.decorators import method_decorator 4 | from django.views.generic import View 5 | 6 | from users.views.settings.delete import DeleteIdentity # noqa 7 | from users.views.settings.follows import FollowsPage # noqa 8 | from users.views.settings.import_export import ( # noqa 9 | CsvBlocks, 10 | CsvFollowers, 11 | CsvFollowing, 12 | CsvMutes, 13 | ImportExportPage, 14 | ) 15 | from users.views.settings.interface import InterfacePage # noqa 16 | from users.views.settings.migration import MigrateInPage # noqa 17 | from users.views.settings.posting import PostingPage # noqa 18 | from users.views.settings.profile import ProfilePage # noqa 19 | from users.views.settings.security import SecurityPage # noqa 20 | from users.views.settings.settings_page import SettingsPage # noqa 21 | from users.views.settings.tokens import TokenCreate, TokenEdit, TokensRoot # noqa 22 | 23 | 24 | @method_decorator(login_required, name="dispatch") 25 | class SettingsRoot(View): 26 | """ 27 | Redirects to a root settings page (varying on if there is an identity 28 | in the URL or not) 29 | """ 30 | 31 | def get(self, request, handle: str | None = None): 32 | if handle: 33 | return redirect("settings_profile", handle=handle) 34 | return redirect("settings_security") 35 | -------------------------------------------------------------------------------- /users/views/settings/delete.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from django.shortcuts import redirect 4 | from django.views.generic import FormView 5 | 6 | from users.views.base import IdentityViewMixin 7 | 8 | 9 | class DeleteIdentity(IdentityViewMixin, FormView): 10 | template_name = "settings/delete.html" 11 | extra_context = {"section": "delete"} 12 | 13 | class form_class(forms.Form): 14 | confirmation = forms.CharField( 15 | help_text="Write the word DELETE in this box if you wish to delete this account", 16 | required=True, 17 | ) 18 | 19 | def clean_confirmation(self): 20 | value = self.cleaned_data.get("confirmation") 21 | if value.lower() != "delete": 22 | raise forms.ValidationError("You must write DELETE here") 23 | return value 24 | 25 | def form_valid(self, form): 26 | self.identity.mark_deleted() 27 | messages.success( 28 | self.request, 29 | f"The identity {self.identity.handle} is now being deleted.", 30 | ) 31 | return redirect("/") 32 | -------------------------------------------------------------------------------- /users/views/settings/interface.py: -------------------------------------------------------------------------------- 1 | from users.views.settings.settings_page import UserSettingsPage 2 | 3 | 4 | class InterfacePage(UserSettingsPage): 5 | section = "interface" 6 | 7 | options = { 8 | "light_theme": { 9 | "title": "Light Theme", 10 | "help_text": "Use a light theme when you are logged in to the web interface", 11 | }, 12 | } 13 | 14 | layout = { 15 | "Appearance": ["light_theme"], 16 | } 17 | -------------------------------------------------------------------------------- /users/views/settings/posting.py: -------------------------------------------------------------------------------- 1 | from activities.models.post import Post 2 | from users.views.settings.settings_page import SettingsPage 3 | 4 | 5 | class PostingPage(SettingsPage): 6 | section = "posting" 7 | 8 | options = { 9 | "default_post_visibility": { 10 | "title": "Default Post Visibility", 11 | "help_text": "Visibility to use as default for new posts.", 12 | "choices": Post.Visibilities.choices, 13 | }, 14 | "expand_content_warnings": { 15 | "title": "Expand content warnings", 16 | "help_text": "If content warnings should be expanded by default (not honoured by all clients)", 17 | }, 18 | } 19 | 20 | layout = { 21 | "Posting": ["default_post_visibility", "expand_content_warnings"], 22 | } 23 | -------------------------------------------------------------------------------- /users/views/settings/security.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.decorators import login_required 3 | from django.utils.decorators import method_decorator 4 | from django.views.generic import FormView 5 | 6 | 7 | @method_decorator(login_required, name="dispatch") 8 | class SecurityPage(FormView): 9 | """ 10 | Lets the identity's profile be edited 11 | """ 12 | 13 | template_name = "settings/login_security.html" 14 | extra_context = {"section": "security"} 15 | 16 | class form_class(forms.Form): 17 | email = forms.EmailField( 18 | disabled=True, 19 | help_text="Your email address cannot be changed yet.", 20 | ) 21 | 22 | def get_initial(self): 23 | return {"email": self.request.user.email} 24 | 25 | template_name = "settings/login_security.html" 26 | --------------------------------------------------------------------------------