From 81de10b70c85c5222b17d8c4358a8aa8812f2559 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 18 Nov 2022 08:28:15 -0700 Subject: [PATCH] Migration reset, start of docs, env vars --- .gitignore | 1 + activities/migrations/0001_initial.py | 239 +++++++++++++++++- activities/migrations/0002_fan_out.py | 103 -------- .../migrations/0003_alter_post_object_uri.py | 18 -- ...st_published_alter_fanout_type_and_more.py | 126 --------- ...ost_hashtags_alter_fanout_type_and_more.py | 48 ---- .../migrations/0006_alter_post_hashtags.py | 18 -- activities/migrations/0007_post_edited.py | 18 -- activities/migrations/0008_postattachment.py | 69 ----- .../0009_alter_postattachment_file.py | 28 -- core/apps.py | 3 - core/migrations/0001_initial.py | 14 +- core/migrations/0002_alter_config_image.py | 28 -- core/models/config.py | 2 +- docs/Makefile | 20 ++ docs/conf.py | 26 ++ docs/index.rst | 13 + docs/installation.rst | 76 ++++++ docs/make.bat | 35 +++ requirements.txt | 1 + static/css/style.css | 4 + stator/management/commands/runstator.py | 3 + stator/migrations/0001_initial.py | 2 +- takahe/settings/base.py | 55 +++- takahe/urls.py | 5 +- templates/admin/domain_create.html | 2 + templates/admin/domain_edit.html | 2 + templates/admin/domains.html | 3 + templates/auth/login.html | 1 + templates/auth/perform_reset_success.html | 7 +- templates/settings/profile.html | 4 +- users/migrations/0001_initial.py | 79 +++++- .../migrations/0002_identity_public_key_id.py | 18 -- ...03_user_last_seen_alter_identity_domain.py | 34 --- users/migrations/0004_passwordreset.py | 60 ----- users/migrations/0005_invite.py | 32 --- users/models/domain.py | 7 +- users/models/password_reset.py | 2 +- users/views/activitypub.py | 59 ++++- users/views/admin/domains.py | 52 ++-- users/views/auth.py | 5 + users/views/identity.py | 32 ++- users/views/settings.py | 4 +- 43 files changed, 679 insertions(+), 679 deletions(-) delete mode 100644 activities/migrations/0002_fan_out.py delete mode 100644 activities/migrations/0003_alter_post_object_uri.py delete mode 100644 activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py delete mode 100644 activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py delete mode 100644 activities/migrations/0006_alter_post_hashtags.py delete mode 100644 activities/migrations/0007_post_edited.py delete mode 100644 activities/migrations/0008_postattachment.py delete mode 100644 activities/migrations/0009_alter_postattachment_file.py delete mode 100644 core/migrations/0002_alter_config_image.py create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/make.bat delete mode 100644 users/migrations/0002_identity_public_key_id.py delete mode 100644 users/migrations/0003_user_last_seen_alter_identity_domain.py delete mode 100644 users/migrations/0004_passwordreset.py delete mode 100644 users/migrations/0005_invite.py diff --git a/.gitignore b/.gitignore index 5f0eef3..7266b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ *.sqlite3 .venv /*.env +/docs/_build /media/ notes.md diff --git a/activities/migrations/0001_initial.py b/activities/migrations/0001_initial.py index 0b350ef..19f3026 100644 --- a/activities/migrations/0001_initial.py +++ b/activities/migrations/0001_initial.py @@ -1,9 +1,16 @@ -# Generated by Django 4.1.3 on 2022-11-11 20:02 +# Generated by Django 4.1.3 on 2022-11-18 17:49 + +import functools import django.db.models.deletion +import django.utils.timezone from django.db import migrations, models +import activities.models.fan_out import activities.models.post +import activities.models.post_attachment +import activities.models.post_interaction +import core.uploads import stator.models @@ -42,7 +49,12 @@ class Migration(migrations.Migration): ), ), ("local", models.BooleanField()), - ("object_uri", models.CharField(blank=True, max_length=500, null=True)), + ( + "object_uri", + models.CharField( + blank=True, max_length=500, null=True, unique=True + ), + ), ( "visibility", models.IntegerField( @@ -63,26 +75,222 @@ class Migration(migrations.Migration): "in_reply_to", models.CharField(blank=True, max_length=500, null=True), ), + ("hashtags", models.JSONField(blank=True, null=True)), + ("published", models.DateTimeField(default=django.utils.timezone.now)), + ("edited", models.DateTimeField(blank=True, null=True)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), ( "author", models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, - related_name="statuses", + related_name="posts", to="users.identity", ), ), ( "mentions", models.ManyToManyField( - related_name="posts_mentioning", to="users.identity" + blank=True, related_name="posts_mentioning", to="users.identity" ), ), ( "to", models.ManyToManyField( - related_name="posts_to", to="users.identity" + blank=True, related_name="posts_to", to="users.identity" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="PostInteraction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[ + ("new", "new"), + ("fanned_out", "fanned_out"), + ("undone", "undone"), + ("undone_fanned_out", "undone_fanned_out"), + ], + default="new", + graph=activities.models.post_interaction.PostInteractionStates, + max_length=100, + ), + ), + ( + "object_uri", + models.CharField( + blank=True, max_length=500, null=True, unique=True + ), + ), + ( + "type", + models.CharField( + choices=[("like", "Like"), ("boost", "Boost")], max_length=100 + ), + ), + ("published", models.DateTimeField(default=django.utils.timezone.now)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="interactions", + to="users.identity", + ), + ), + ( + "post", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="interactions", + to="activities.post", + ), + ), + ], + options={ + "index_together": {("type", "identity", "post")}, + }, + ), + migrations.CreateModel( + name="PostAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[("new", "new"), ("fetched", "fetched")], + default="new", + graph=activities.models.post_attachment.PostAttachmentStates, + max_length=100, + ), + ), + ("mimetype", models.CharField(max_length=200)), + ( + "file", + models.FileField( + blank=True, + null=True, + upload_to=functools.partial( + core.uploads.upload_namer, *("attachments",), **{} + ), + ), + ), + ("remote_url", models.CharField(blank=True, max_length=500, null=True)), + ("name", models.TextField(blank=True, null=True)), + ("width", models.IntegerField(blank=True, null=True)), + ("height", models.IntegerField(blank=True, null=True)), + ("focal_x", models.IntegerField(blank=True, null=True)), + ("focal_y", models.IntegerField(blank=True, null=True)), + ("blurhash", models.TextField(blank=True, null=True)), + ( + "post", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attachments", + to="activities.post", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="FanOut", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[("new", "new"), ("sent", "sent")], + default="new", + graph=activities.models.fan_out.FanOutStates, + max_length=100, + ), + ), + ( + "type", + models.CharField( + choices=[ + ("post", "Post"), + ("interaction", "Interaction"), + ("undo_interaction", "Undo Interaction"), + ], + max_length=100, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fan_outs", + to="users.identity", + ), + ), + ( + "subject_post", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="fan_outs", + to="activities.post", + ), + ), + ( + "subject_post_interaction", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="fan_outs", + to="activities.postinteraction", ), ), ], @@ -107,10 +315,11 @@ class Migration(migrations.Migration): models.CharField( choices=[ ("post", "Post"), - ("mention", "Mention"), - ("like", "Like"), - ("follow", "Follow"), ("boost", "Boost"), + ("mentioned", "Mentioned"), + ("liked", "Liked"), + ("followed", "Followed"), + ("boosted", "Boosted"), ], max_length=100, ), @@ -140,15 +349,25 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name="timeline_events_about_us", + related_name="timeline_events", to="activities.post", ), ), + ( + "subject_post_interaction", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="timeline_events", + to="activities.postinteraction", + ), + ), ], options={ "index_together": { - ("identity", "type", "subject_post", "subject_identity"), ("identity", "type", "subject_identity"), + ("identity", "type", "subject_post", "subject_identity"), }, }, ), diff --git a/activities/migrations/0002_fan_out.py b/activities/migrations/0002_fan_out.py deleted file mode 100644 index f3b626e..0000000 --- a/activities/migrations/0002_fan_out.py +++ /dev/null @@ -1,103 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-12 05:36 - -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - -import activities.models.fan_out -import stator.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0001_initial"), - ("activities", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="post", - name="authored", - field=models.DateTimeField(default=django.utils.timezone.now), - ), - migrations.AlterField( - model_name="post", - name="author", - field=models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="posts", - to="users.identity", - ), - ), - migrations.AlterField( - model_name="post", - name="mentions", - field=models.ManyToManyField( - blank=True, related_name="posts_mentioning", to="users.identity" - ), - ), - migrations.AlterField( - model_name="post", - name="to", - field=models.ManyToManyField( - blank=True, related_name="posts_to", to="users.identity" - ), - ), - migrations.CreateModel( - name="FanOut", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("state_ready", models.BooleanField(default=True)), - ("state_changed", models.DateTimeField(auto_now_add=True)), - ("state_attempted", models.DateTimeField(blank=True, null=True)), - ("state_locked_until", models.DateTimeField(blank=True, null=True)), - ( - "state", - stator.models.StateField( - choices=[("new", "new"), ("sent", "sent")], - default="new", - graph=activities.models.fan_out.FanOutStates, - max_length=100, - ), - ), - ( - "type", - models.CharField( - choices=[("post", "Post"), ("boost", "Boost")], max_length=100 - ), - ), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "identity", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="fan_outs", - to="users.identity", - ), - ), - ( - "subject_post", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="fan_outs", - to="activities.post", - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/activities/migrations/0003_alter_post_object_uri.py b/activities/migrations/0003_alter_post_object_uri.py deleted file mode 100644 index 4f98bc9..0000000 --- a/activities/migrations/0003_alter_post_object_uri.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-13 03:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("activities", "0002_fan_out"), - ] - - operations = [ - migrations.AlterField( - model_name="post", - name="object_uri", - field=models.CharField(blank=True, max_length=500, null=True, unique=True), - ), - ] diff --git a/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py b/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py deleted file mode 100644 index 7972f18..0000000 --- a/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py +++ /dev/null @@ -1,126 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-14 00:41 - -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - -import activities.models.post_interaction -import stator.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0002_identity_public_key_id"), - ("activities", "0003_alter_post_object_uri"), - ] - - operations = [ - migrations.RenameField( - model_name="post", - old_name="authored", - new_name="published", - ), - migrations.AlterField( - model_name="fanout", - name="type", - field=models.CharField( - choices=[("post", "Post"), ("interaction", "Interaction")], - max_length=100, - ), - ), - migrations.AlterField( - model_name="timelineevent", - name="subject_post", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="timeline_events", - to="activities.post", - ), - ), - migrations.CreateModel( - name="PostInteraction", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("state_ready", models.BooleanField(default=True)), - ("state_changed", models.DateTimeField(auto_now_add=True)), - ("state_attempted", models.DateTimeField(blank=True, null=True)), - ("state_locked_until", models.DateTimeField(blank=True, null=True)), - ( - "state", - stator.models.StateField( - choices=[("new", "new"), ("fanned_out", "fanned_out")], - default="new", - graph=activities.models.post_interaction.PostInteractionStates, - max_length=100, - ), - ), - ( - "object_uri", - models.CharField( - blank=True, max_length=500, null=True, unique=True - ), - ), - ( - "type", - models.CharField( - choices=[("like", "Like"), ("boost", "Boost")], max_length=100 - ), - ), - ("published", models.DateTimeField(default=django.utils.timezone.now)), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "identity", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="interactions", - to="users.identity", - ), - ), - ( - "post", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="interactions", - to="activities.post", - ), - ), - ], - options={ - "index_together": {("type", "identity", "post")}, - }, - ), - migrations.AddField( - model_name="fanout", - name="subject_post_interaction", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="fan_outs", - to="activities.postinteraction", - ), - ), - migrations.AddField( - model_name="timelineevent", - name="subject_post_interaction", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="timeline_events", - to="activities.postinteraction", - ), - ), - ] diff --git a/activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py b/activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py deleted file mode 100644 index 07d5cca..0000000 --- a/activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-16 20:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "activities", - "0004_rename_authored_post_published_alter_fanout_type_and_more", - ), - ] - - operations = [ - migrations.AddField( - model_name="post", - name="hashtags", - field=models.JSONField(default=[]), - ), - migrations.AlterField( - model_name="fanout", - name="type", - field=models.CharField( - choices=[ - ("post", "Post"), - ("interaction", "Interaction"), - ("undo_interaction", "Undo Interaction"), - ], - max_length=100, - ), - ), - migrations.AlterField( - model_name="timelineevent", - name="type", - field=models.CharField( - choices=[ - ("post", "Post"), - ("boost", "Boost"), - ("mentioned", "Mentioned"), - ("liked", "Liked"), - ("followed", "Followed"), - ("boosted", "Boosted"), - ], - max_length=100, - ), - ), - ] diff --git a/activities/migrations/0006_alter_post_hashtags.py b/activities/migrations/0006_alter_post_hashtags.py deleted file mode 100644 index b6149ea..0000000 --- a/activities/migrations/0006_alter_post_hashtags.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-17 04:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("activities", "0005_post_hashtags_alter_fanout_type_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="post", - name="hashtags", - field=models.JSONField(blank=True, null=True), - ), - ] diff --git a/activities/migrations/0007_post_edited.py b/activities/migrations/0007_post_edited.py deleted file mode 100644 index d4a661f..0000000 --- a/activities/migrations/0007_post_edited.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-17 04:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("activities", "0006_alter_post_hashtags"), - ] - - operations = [ - migrations.AddField( - model_name="post", - name="edited", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/activities/migrations/0008_postattachment.py b/activities/migrations/0008_postattachment.py deleted file mode 100644 index 168ed58..0000000 --- a/activities/migrations/0008_postattachment.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-17 05:42 - -import django.db.models.deletion -from django.db import migrations, models - -import activities.models.post_attachment -import stator.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("activities", "0007_post_edited"), - ] - - operations = [ - migrations.CreateModel( - name="PostAttachment", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("state_ready", models.BooleanField(default=True)), - ("state_changed", models.DateTimeField(auto_now_add=True)), - ("state_attempted", models.DateTimeField(blank=True, null=True)), - ("state_locked_until", models.DateTimeField(blank=True, null=True)), - ( - "state", - stator.models.StateField( - choices=[("new", "new"), ("fetched", "fetched")], - default="new", - graph=activities.models.post_attachment.PostAttachmentStates, - max_length=100, - ), - ), - ("mimetype", models.CharField(max_length=200)), - ( - "file", - models.FileField( - blank=True, null=True, upload_to="attachments/%Y/%m/%d/" - ), - ), - ("remote_url", models.CharField(blank=True, max_length=500, null=True)), - ("name", models.TextField(blank=True, null=True)), - ("width", models.IntegerField(blank=True, null=True)), - ("height", models.IntegerField(blank=True, null=True)), - ("focal_x", models.IntegerField(blank=True, null=True)), - ("focal_y", models.IntegerField(blank=True, null=True)), - ("blurhash", models.TextField(blank=True, null=True)), - ( - "post", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="attachments", - to="activities.post", - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/activities/migrations/0009_alter_postattachment_file.py b/activities/migrations/0009_alter_postattachment_file.py deleted file mode 100644 index 0a250c3..0000000 --- a/activities/migrations/0009_alter_postattachment_file.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-18 01:40 - -import functools - -from django.db import migrations, models - -import core.uploads - - -class Migration(migrations.Migration): - - dependencies = [ - ("activities", "0008_postattachment"), - ] - - operations = [ - migrations.AlterField( - model_name="postattachment", - name="file", - field=models.FileField( - blank=True, - null=True, - upload_to=functools.partial( - core.uploads.upload_namer, *("attachments",), **{} - ), - ), - ), - ] diff --git a/core/apps.py b/core/apps.py index 54693d5..6098f6b 100644 --- a/core/apps.py +++ b/core/apps.py @@ -9,7 +9,4 @@ class CoreConfig(AppConfig): name = "core" def ready(self) -> None: - from core.models import Config - - Config.system = Config.load_system() jsonld.set_document_loader(builtin_document_loader) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 2c4731f..900260f 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,16 +1,20 @@ -# Generated by Django 4.1.3 on 2022-11-16 21:23 +# Generated by Django 4.1.3 on 2022-11-18 17:49 + +import functools import django.db.models.deletion from django.conf import settings from django.db import migrations, models +import core.uploads + class Migration(migrations.Migration): initial = True dependencies = [ - ("users", "0002_identity_public_key_id"), + ("users", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -32,7 +36,11 @@ class Migration(migrations.Migration): ( "image", models.ImageField( - blank=True, null=True, upload_to="config/%Y/%m/%d/" + blank=True, + null=True, + upload_to=functools.partial( + core.uploads.upload_namer, *("config",), **{} + ), ), ), ( diff --git a/core/migrations/0002_alter_config_image.py b/core/migrations/0002_alter_config_image.py deleted file mode 100644 index 86dcebb..0000000 --- a/core/migrations/0002_alter_config_image.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-18 01:40 - -import functools - -from django.db import migrations, models - -import core.uploads - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="config", - name="image", - field=models.ImageField( - blank=True, - null=True, - upload_to=functools.partial( - core.uploads.upload_namer, *("config",), **{} - ), - ), - ), - ] diff --git a/core/models/config.py b/core/models/config.py index 2a27d2e..57d9e55 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -160,7 +160,7 @@ class Config(models.Model): site_icon: UploadedImage = static("img/icon-128.png") site_banner: UploadedImage = static("img/fjords-banner-600.jpg") - signup_allowed: bool = False + signup_allowed: bool = True signup_invite_only: bool = False signup_text: str = "" diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..73e4692 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,26 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "Takahē" +copyright = "2022, Andrew Godwin" +author = "Andrew Godwin" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions: list = [] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "alabaster" +html_static_path = ["_static"] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..a50c369 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,13 @@ +Takahē +====== + + +Welcome to the Takahē documentation! Takahē is an ActivityPub server, designed +for low- to medium-size installations, and with the ability to serve multiple +domains at once. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..9c39a9d --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,76 @@ +Installation +============ + +We recommend running using the Docker/OCI image; this contains all of the +necessary dependencies and static file handling preconfigured for you. + +All configuration is done via either environment variables, or online through +the web interface. + + +Prerequisites +------------- + +* SSL support (Takahē *requires* HTTPS) +* Something that can run Docker/OCI images ("serverless" platforms are fine!) +* A PostgreSQL 14 (or above) database +* One of these to store uploaded images and media: + * Amazon S3 + * Google Cloud Storage + * Writable local directory (must be accessible by all running copies!) + + +Environment Variables +--------------------- + +All of these variables are *required* for a working installation, and should +be provided from the first boot. + +* ``PGHOST``, ``PGPORT``, ``PGUSER``, ``PGDATABASE``, and ``PGPASSWORD`` are the + standard PostgreSQL environment variables for configuring your database. + +* ``TAKAHE_MEDIA_BACKEND`` must be one of ``local``, ``s3`` or ``gcs``. + + * If it is set to ``local``, you must also provide ``TAKAHE_MEDIA_ROOT``, + the path to the local media directory, and ``TAKAHE_MEDIA_URL``, a + fully-qualified URL prefix that serves that directory. + + * If it is set to ``gcs``, you must also provide ``TAKAHE_MEDIA_BUCKET``, + the name of the bucket to store files in. + + * If it is set to ``s3``, you must also provide ``TAKAHE_MEDIA_BUCKET``, + the name of the bucket to store files in. + +* ``TAKAHE_MAIN_DOMAIN`` should be the domain name (without ``https://``) that + will be used for default links (such as in emails). It does *not* need to be + the same as any domain you are hosting user accounts on. + +* ``TAKAHE_EMAIL_HOST`` and ``TAKAHE_EMAIL_PORT`` (along with + ``TAKAHE_EMAIL_USER`` and ``TAKAHE_EMAIL_PASSWORD``, if needed) should point + to an SMTP server Takahe can use for sending email. Email is *required*, to + allow account creation and password resets. + + * If you are using SendGrid, you can just set an API key in + ``TAKAHE_EMAIL_SENDGRID_KEY`` instead. + +* ``TAKAHE_EMAIL_FROM`` is the email address that emails from the system will + appear to come from. + +* ``TAKAHE_AUTO_ADMIN_EMAIL`` should be an email address that you would like to + be automatically promoted to administrator when it signs up. You only need + this for initial setup, and can unset it after that if you like. + + +Making An Admin Account +----------------------- + +Once the webserver is up and working, go to the "create account" flow and +create a new account using the email you specified in +``TAKAHE_AUTO_ADMIN_EMAIL``. + +Once you set your password using the link emailed to you, you will have an +admin account. + +If your email settings have a problem and you don't get the email, don't worry; +fix them and then follow the "reset my password" flow on the login screen, and +you'll get another password reset email that you can use. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..954237b --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/requirements.txt b/requirements.txt index ce82854..3b0cb1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ psycopg2~=2.9.5 bleach~=5.0.1 pydantic~=1.10.2 django-htmx~=1.13.0 +django-storages[google,boto3]~=1.13.1 diff --git a/static/css/style.css b/static/css/style.css index eba0e4d..5f35cc2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -549,6 +549,10 @@ form .buttons { margin: -20px 0 15px 0; } +form p+.buttons { + margin-top: 0; +} + .right-column form .buttons { margin: 5px 10px 5px 0; } diff --git a/stator/management/commands/runstator.py b/stator/management/commands/runstator.py index a77192e..eaa2585 100644 --- a/stator/management/commands/runstator.py +++ b/stator/management/commands/runstator.py @@ -4,6 +4,7 @@ from asgiref.sync import async_to_sync from django.apps import apps from django.core.management.base import BaseCommand +from core.models import Config from stator.models import StatorModel from stator.runner import StatorRunner @@ -22,6 +23,8 @@ class Command(BaseCommand): parser.add_argument("model_labels", nargs="*", type=str) def handle(self, model_labels: List[str], concurrency: int, *args, **options): + # Cache system config + Config.system = Config.load_system() # Resolve the models list into names models = cast( List[Type[StatorModel]], diff --git a/stator/migrations/0001_initial.py b/stator/migrations/0001_initial.py index f7d652e..8dcfc07 100644 --- a/stator/migrations/0001_initial.py +++ b/stator/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.3 on 2022-11-10 05:56 +# Generated by Django 4.1.3 on 2022-11-18 17:49 from django.db import migrations, models diff --git a/takahe/settings/base.py b/takahe/settings/base.py index 614bfd2..d2e30c3 100644 --- a/takahe/settings/base.py +++ b/takahe/settings/base.py @@ -1,5 +1,7 @@ import os +import sys from pathlib import Path +from typing import Optional BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -56,11 +58,11 @@ WSGI_APPLICATION = "takahe.wsgi.application" DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", - "HOST": os.environ.get("POSTGRES_HOST", "localhost"), - "PORT": os.environ.get("POSTGRES_PORT", 5432), - "NAME": os.environ.get("POSTGRES_DB", "takahe"), - "USER": os.environ.get("POSTGRES_USER", "postgres"), - "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), + "HOST": os.environ.get("PGHOST", "localhost"), + "PORT": os.environ.get("PGPORT", 5432), + "NAME": os.environ.get("PGDATABASE", "takahe"), + "USER": os.environ.get("PGUSER", "postgres"), + "PASSWORD": os.environ.get("PGPASSWORD"), } } @@ -109,12 +111,47 @@ STATICFILES_DIRS = [ ALLOWED_HOSTS = ["*"] +### User-configurable options, pulled from the environment ### + MAIN_DOMAIN = os.environ["TAKAHE_MAIN_DOMAIN"] if "/" in MAIN_DOMAIN: print("TAKAHE_MAIN_DOMAIN should be just the domain name - no https:// or path") + sys.exit(1) -EMAIL_FROM = os.environ["TAKAHE_EMAIL_FROM"] -# Note that this MUST be a fully qualified URL in production -MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/") -MEDIA_ROOT = os.environ.get("TAKAHE_MEDIA_ROOT", BASE_DIR / "media") +if os.environ.get("TAKAHE_EMAIL_CONSOLE_ONLY"): + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + EMAIL_FROM = "test@example.com" +else: + EMAIL_FROM = os.environ["TAKAHE_EMAIL_FROM"] + if "TAKAHE_EMAIL_SENDGRID_KEY" in os.environ: + EMAIL_HOST = "smtp.sendgrid.net" + EMAIL_PORT = 587 + EMAIL_HOST_USER: Optional[str] = "apikey" + EMAIL_HOST_PASSWORD: Optional[str] = os.environ["TAKAHE_EMAIL_SENDGRID_KEY"] + EMAIL_USE_TLS = True + else: + EMAIL_HOST = os.environ["TAKAHE_EMAIL_HOST"] + EMAIL_PORT = int(os.environ["TAKAHE_EMAIL_PORT"]) + EMAIL_HOST_USER = os.environ.get("TAKAHE_EMAIL_USER") + EMAIL_HOST_PASSWORD = os.environ.get("TAKAHE_EMAIL_PASSWORD") + EMAIL_USE_SSL = EMAIL_PORT == 465 + EMAIL_USE_TLS = EMAIL_PORT == 587 + +AUTO_ADMIN_EMAIL = os.environ.get("TAKAHE_AUTO_ADMIN_EMAIL") + +# Set up media storage +MEDIA_BACKEND = os.environ.get("TAKAHE_MEDIA_BACKEND", None) +if MEDIA_BACKEND == "local": + # Note that this MUST be a fully qualified URL in production + MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/") + MEDIA_ROOT = os.environ.get("TAKAHE_MEDIA_ROOT", BASE_DIR / "media") +elif MEDIA_BACKEND == "gcs": + DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage" + GS_BUCKET_NAME = os.environ["TAKAHE_MEDIA_BUCKET"] +elif MEDIA_BACKEND == "s3": + DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + AWS_STORAGE_BUCKET_NAME = os.environ["TAKAHE_MEDIA_BUCKET"] +else: + print("Unknown TAKAHE_MEDIA_BACKEND value") + sys.exit(1) diff --git a/takahe/urls.py b/takahe/urls.py index 8c01d64..1f1b203 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -86,8 +86,7 @@ urlpatterns = [ ), # Identity views path("@/", identity.ViewIdentity.as_view()), - path("@/actor/", activitypub.Actor.as_view()), - path("@/actor/inbox/", activitypub.Inbox.as_view()), + path("@/inbox/", activitypub.Inbox.as_view()), path("@/action/", identity.ActionIdentity.as_view()), # Posts path("compose/", posts.Compose.as_view(), name="compose"), @@ -109,6 +108,8 @@ urlpatterns = [ # Well-known endpoints path(".well-known/webfinger", activitypub.Webfinger.as_view()), path(".well-known/host-meta", activitypub.HostMeta.as_view()), + path(".well-known/nodeinfo", activitypub.NodeInfo.as_view()), + path("nodeinfo/2.0/", activitypub.NodeInfo2.as_view()), # Task runner path(".stator/runner/", stator.RequestRunner.as_view()), # Django admin diff --git a/templates/admin/domain_create.html b/templates/admin/domain_create.html index dcc57fa..23c1ebf 100644 --- a/templates/admin/domain_create.html +++ b/templates/admin/domain_create.html @@ -33,8 +33,10 @@
Access Control {% include "forms/_field.html" with field=form.public %} + {% include "forms/_field.html" with field=form.default %}
+ Back
diff --git a/templates/admin/domain_edit.html b/templates/admin/domain_edit.html index 59bb8a2..3e7f70b 100644 --- a/templates/admin/domain_edit.html +++ b/templates/admin/domain_edit.html @@ -13,8 +13,10 @@
Access Control {% include "forms/_field.html" with field=form.public %} + {% include "forms/_field.html" with field=form.default %}
+ Back Delete
diff --git a/templates/admin/domains.html b/templates/admin/domains.html index bb7d8e4..8ef09fe 100644 --- a/templates/admin/domains.html +++ b/templates/admin/domains.html @@ -14,6 +14,9 @@ {% if domain.service_domain %}({{ domain.service_domain }}){% endif %} + {% if domain.default %} + Default + {% endif %} {% empty %}

You have no domains set up.

diff --git a/templates/auth/login.html b/templates/auth/login.html index b3b0a05..80b003b 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -12,6 +12,7 @@ {% endfor %}
+ Forgot Password
diff --git a/templates/auth/perform_reset_success.html b/templates/auth/perform_reset_success.html index 001e5d7..3f5125a 100644 --- a/templates/auth/perform_reset_success.html +++ b/templates/auth/perform_reset_success.html @@ -1,13 +1,14 @@ {% extends "base.html" %} -{% block title %}Password Reset{% endblock %} +{% block title %}Password Set{% endblock %} {% block content %}
- Password Reset + Password Set

- Your password for {{ email }} has been reset! + Your password for {{ email }} has been set. You can + now login.

diff --git a/templates/settings/profile.html b/templates/settings/profile.html index 5c00557..12ea206 100644 --- a/templates/settings/profile.html +++ b/templates/settings/profile.html @@ -12,8 +12,8 @@
Images - {% include "forms/_field.html" with field=form.icon preview=request.identity.icon.url %} - {% include "forms/_field.html" with field=form.image preview=request.identity.image.url %} + {% include "forms/_field.html" with field=form.icon %} + {% include "forms/_field.html" with field=form.image %}
View Profile diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index a51ef00..d8ab363 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.3 on 2022-11-11 20:02 +# Generated by Django 4.1.3 on 2022-11-18 17:49 import functools @@ -6,10 +6,12 @@ import django.db.models.deletion from django.conf import settings from django.db import migrations, models +import core.uploads import stator.models import users.models.follow import users.models.identity import users.models.inbox_message +import users.models.password_reset class Migration(migrations.Migration): @@ -45,6 +47,7 @@ class Migration(migrations.Migration): ("deleted", models.BooleanField(default=False)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), + ("last_seen", models.DateTimeField(auto_now_add=True)), ], options={ "abstract": False, @@ -70,6 +73,7 @@ class Migration(migrations.Migration): ("local", models.BooleanField()), ("blocked", models.BooleanField(default=False)), ("public", models.BooleanField(default=False)), + ("default", models.BooleanField(default=False)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), ( @@ -111,6 +115,25 @@ class Migration(migrations.Migration): "abstract": False, }, ), + migrations.CreateModel( + name="Invite", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token", models.CharField(max_length=500, unique=True)), + ("email", models.EmailField(blank=True, max_length=254, null=True)), + ("note", models.TextField(blank=True, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), migrations.CreateModel( name="UserEvent", fields=[ @@ -146,6 +169,48 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="PasswordReset", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[("new", "new"), ("sent", "sent")], + default="new", + graph=users.models.password_reset.PasswordResetStates, + max_length=100, + ), + ), + ("token", models.CharField(max_length=500, unique=True)), + ("new_account", models.BooleanField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="password_resets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), migrations.CreateModel( name="Identity", fields=[ @@ -194,9 +259,7 @@ class Migration(migrations.Migration): blank=True, null=True, upload_to=functools.partial( - users.models.identity.upload_namer, - *("profile_images",), - **{}, + core.uploads.upload_namer, *("profile_images",), **{} ), ), ), @@ -206,14 +269,13 @@ class Migration(migrations.Migration): blank=True, null=True, upload_to=functools.partial( - users.models.identity.upload_namer, - *("background_images",), - **{}, + core.uploads.upload_namer, *("background_images",), **{} ), ), ), ("private_key", models.TextField(blank=True, null=True)), ("public_key", models.TextField(blank=True, null=True)), + ("public_key_id", models.TextField(blank=True, null=True)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), ("fetched", models.DateTimeField(blank=True, null=True)), @@ -224,6 +286,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name="identities", to="users.domain", ), ), @@ -302,7 +365,7 @@ class Migration(migrations.Migration): ("local_requested", "local_requested"), ("remote_requested", "remote_requested"), ("accepted", "accepted"), - ("undone_locally", "undone_locally"), + ("undone", "undone"), ("undone_remotely", "undone_remotely"), ], default="unrequested", diff --git a/users/migrations/0002_identity_public_key_id.py b/users/migrations/0002_identity_public_key_id.py deleted file mode 100644 index 3648c20..0000000 --- a/users/migrations/0002_identity_public_key_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-12 21:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="identity", - name="public_key_id", - field=models.TextField(blank=True, null=True), - ), - ] diff --git a/users/migrations/0003_user_last_seen_alter_identity_domain.py b/users/migrations/0003_user_last_seen_alter_identity_domain.py deleted file mode 100644 index b6c49d1..0000000 --- a/users/migrations/0003_user_last_seen_alter_identity_domain.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-17 04:18 - -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0002_identity_public_key_id"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="last_seen", - field=models.DateTimeField( - auto_now_add=True, default=django.utils.timezone.now - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="identity", - name="domain", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="identities", - to="users.domain", - ), - ), - ] diff --git a/users/migrations/0004_passwordreset.py b/users/migrations/0004_passwordreset.py deleted file mode 100644 index d996ff4..0000000 --- a/users/migrations/0004_passwordreset.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-18 01:40 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import stator.models -import users.models.password_reset - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0003_user_last_seen_alter_identity_domain"), - ] - - operations = [ - migrations.CreateModel( - name="PasswordReset", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("state_ready", models.BooleanField(default=True)), - ("state_changed", models.DateTimeField(auto_now_add=True)), - ("state_attempted", models.DateTimeField(blank=True, null=True)), - ("state_locked_until", models.DateTimeField(blank=True, null=True)), - ( - "state", - stator.models.StateField( - choices=[("new", "new"), ("sent", "sent")], - default="new", - graph=users.models.password_reset.PasswordResetStates, - max_length=100, - ), - ), - ("token", models.CharField(max_length=500, unique=True)), - ("new_account", models.BooleanField()), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="password_resets", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/users/migrations/0005_invite.py b/users/migrations/0005_invite.py deleted file mode 100644 index bb18841..0000000 --- a/users/migrations/0005_invite.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-18 06:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0004_passwordreset"), - ] - - operations = [ - migrations.CreateModel( - name="Invite", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("token", models.CharField(max_length=500, unique=True)), - ("email", models.EmailField(blank=True, max_length=254, null=True)), - ("note", models.TextField(blank=True, null=True)), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ], - ), - ] diff --git a/users/models/domain.py b/users/models/domain.py index 4743503..c238025 100644 --- a/users/models/domain.py +++ b/users/models/domain.py @@ -41,6 +41,9 @@ class Domain(models.Model): # should) public = models.BooleanField(default=False) + # If this is the default domain (shown as the default entry for new users) + default = models.BooleanField(default=False) + # Domains can also be linked to one or more users for their private use # This should be display domains ONLY users = models.ManyToManyField("users.User", related_name="domains", blank=True) @@ -52,7 +55,7 @@ class Domain(models.Model): root = "/admin/domains/" create = "/admin/domains/create/" edit = "/admin/domains/{self.domain}/" - delete = "/admin/domains/{self.domain}/delete/" + delete = "{edit}delete/" @classmethod def get_remote_domain(cls, domain: str) -> "Domain": @@ -81,7 +84,7 @@ class Domain(models.Model): return cls.objects.filter( models.Q(public=True) | models.Q(users__id=user.id), local=True, - ) + ).order_by("-default", "domain") def __str__(self): return self.domain diff --git a/users/models/password_reset.py b/users/models/password_reset.py index 628efa6..290b08d 100644 --- a/users/models/password_reset.py +++ b/users/models/password_reset.py @@ -12,7 +12,7 @@ from stator.models import State, StateField, StateGraph, StatorModel class PasswordResetStates(StateGraph): - new = State(try_interval=3) + new = State(try_interval=300) sent = State() new.transitions_to(sent) diff --git a/users/views/activitypub.py b/users/views/activitypub.py index 4660d7a..2719f17 100644 --- a/users/views/activitypub.py +++ b/users/views/activitypub.py @@ -1,18 +1,22 @@ import json from asgiref.sync import async_to_sync +from django.conf import settings from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import View +from activities.models import Post from core.ld import canonicalise +from core.models import Config from core.signatures import ( HttpSignature, LDSignature, VerificationError, VerificationFormatError, ) +from takahe import __version__ from users.models import Identity, InboxMessage from users.shortcuts import by_handle_or_404 @@ -37,6 +41,51 @@ class HostMeta(View): ) +class NodeInfo(View): + """ + Returns the well-known nodeinfo response, pointing to the 2.0 one + """ + + def get(self, request): + host = request.META.get("HOST", settings.MAIN_DOMAIN) + return JsonResponse( + { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": f"https://{host}/nodeinfo/2.0/", + } + ] + } + ) + + +class NodeInfo2(View): + """ + Returns the nodeinfo 2.0 response + """ + + def get(self, request): + # Fetch some user stats + local_identities = Identity.objects.filter(local=True).count() + local_posts = Post.objects.filter(local=True).count() + return JsonResponse( + { + "version": "2.0", + "software": {"name": "takahe", "version": __version__}, + "protocols": ["activitypub"], + "services": {"outbound": [], "inbound": []}, + "usage": { + "users": {"total": local_identities}, + "localPosts": local_posts, + }, + "openRegistrations": Config.system.signup_allowed + and not Config.system.signup_invite_only, + "metadata": {}, + } + ) + + class Webfinger(View): """ Services webfinger requests @@ -70,16 +119,6 @@ class Webfinger(View): ) -class Actor(View): - """ - Returns the AP Actor object - """ - - def get(self, request, handle): - identity = by_handle_or_404(self.request, handle) - return JsonResponse(canonicalise(identity.to_ap(), include_security=True)) - - @method_decorator(csrf_exempt, name="dispatch") class Inbox(View): """ diff --git a/users/views/admin/domains.py b/users/views/admin/domains.py index e1a011b..c42137c 100644 --- a/users/views/admin/domains.py +++ b/users/views/admin/domains.py @@ -41,6 +41,11 @@ class DomainCreate(FormView): widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), required=False, ) + default = forms.BooleanField( + help_text="If this is the default option for new identities", + widget=forms.Select(choices=[(True, "Yes"), (False, "No")]), + required=False, + ) domain_regex = re.compile( r"^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$" @@ -72,13 +77,22 @@ class DomainCreate(FormView): ) return self.cleaned_data["service_domain"] + def clean_default(self): + value = self.cleaned_data["default"] + if value and not self.cleaned_data.get("public"): + raise forms.ValidationError("A non-public domain cannot be the default") + return value + def form_valid(self, form): - Domain.objects.create( + domain = Domain.objects.create( domain=form.cleaned_data["domain"], service_domain=form.cleaned_data["service_domain"] or None, public=form.cleaned_data["public"], + default=form.cleaned_data["default"], local=True, ) + if domain.default: + Domain.objects.exclude(pk=domain.pk).update(default=False) return redirect(Domain.urls.root) @@ -88,21 +102,17 @@ class DomainEdit(FormView): template_name = "admin/domain_edit.html" extra_context = {"section": "domains"} - class form_class(forms.Form): - domain = forms.CharField( - help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", - disabled=True, - ) - service_domain = forms.CharField( - help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", - disabled=True, - required=False, - ) - public = forms.BooleanField( - help_text="If any user on this server can create identities here", - widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), - required=False, - ) + class form_class(DomainCreate.form_class): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["domain"].disabled = True + self.fields["service_domain"].disabled = True + + def clean_domain(self): + return self.cleaned_data["domain"] + + def clean_service_domain(self): + return self.cleaned_data["service_domain"] def dispatch(self, request, domain): self.domain = get_object_or_404( @@ -110,14 +120,17 @@ class DomainEdit(FormView): ) return super().dispatch(request) - def get_context_data(self): - context = super().get_context_data() + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) context["domain"] = self.domain return context def form_valid(self, form): self.domain.public = form.cleaned_data["public"] + self.domain.default = form.cleaned_data["default"] self.domain.save() + if self.domain.default: + Domain.objects.exclude(pk=self.domain.pk).update(default=False) return redirect(Domain.urls.root) def get_initial(self): @@ -125,6 +138,7 @@ class DomainEdit(FormView): "domain": self.domain.domain, "service_domain": self.domain.service_domain, "public": self.domain.public, + "default": self.domain.default, } @@ -150,4 +164,4 @@ class DomainDelete(TemplateView): if self.domain.identities.exists(): raise ValueError("Tried to delete domain with identities!") self.domain.delete() - return redirect("/settings/system/domains/") + return redirect("admin_domains") diff --git a/users/views/auth.py b/users/views/auth.py index a04b1b1..2257ea5 100644 --- a/users/views/auth.py +++ b/users/views/auth.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth.password_validation import validate_password from django.contrib.auth.views import LoginView, LogoutView from django.shortcuts import get_object_or_404, render @@ -50,6 +51,10 @@ class Signup(FormView): def form_valid(self, form): user = User.objects.create(email=form.cleaned_data["email"]) + # Auto-promote the user to admin if that setting is set + if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL: + user.admin = True + user.save() PasswordReset.create_for_user(user) if "invite_code" in form.cleaned_data: Invite.objects.filter(token=form.cleaned_data["invite_code"]).delete() diff --git a/users/views/identity.py b/users/views/identity.py index ae8e5b0..5524c4c 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -2,11 +2,12 @@ import string from django import forms from django.contrib.auth.decorators import login_required -from django.http import Http404 +from django.http import Http404, JsonResponse from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views.generic import FormView, TemplateView, View +from core.ld import canonicalise from core.models import Config from users.decorators import identity_required from users.models import Domain, Follow, FollowStates, Identity, IdentityStates @@ -14,16 +15,41 @@ from users.shortcuts import by_handle_or_404 class ViewIdentity(TemplateView): + """ + Shows identity profile pages, and also acts as the Actor endpoint when + approached with the right Accept header. + """ template_name = "identity/view.html" - def get_context_data(self, handle): + def get(self, request, handle): + # Make sure we understand this handle identity = by_handle_or_404( self.request, handle, local=False, fetch=True, ) + # If they're coming in looking for JSON, they want the actor + accept = request.META.get("HTTP_ACCEPT", "text/html").lower() + if ( + "application/json" in accept + or "application/ld" in accept + or "application/activity" in accept + ): + # Return actor info + return self.serve_actor(identity) + else: + # Show normal page + return super().get(request, identity=identity) + + def serve_actor(self, identity): + # If this not a local actor, redirect to their canonical URI + if not identity.local: + return redirect(identity.actor_uri) + return JsonResponse(canonicalise(identity.to_ap(), include_security=True)) + + def get_context_data(self, identity): posts = identity.posts.all()[:100] if identity.data_age > Config.system.identity_max_age: identity.transition_perform(IdentityStates.outdated) @@ -150,7 +176,7 @@ class CreateIdentity(FormView): domain = form.cleaned_data["domain"] domain_instance = Domain.get_domain(domain) new_identity = Identity.objects.create( - actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/actor/", + actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/", username=username.lower(), domain_id=domain, name=form.cleaned_data["name"], diff --git a/users/views/settings.py b/users/views/settings.py index fd138c2..1403821 100644 --- a/users/views/settings.py +++ b/users/views/settings.py @@ -147,8 +147,8 @@ class ProfilePage(FormView): return { "name": self.request.identity.name, "summary": self.request.identity.summary, - "icon": self.request.identity.icon.url, - "image": self.request.identity.image.url, + "icon": self.request.identity.icon and self.request.identity.icon.url, + "image": self.request.identity.image and self.request.identity.image.url, } def form_valid(self, form):