summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-18 08:28:15 -0700
committerAndrew Godwin2022-11-18 11:28:16 -0700
commit81de10b70c85c5222b17d8c4358a8aa8812f2559 (patch)
tree8e028b62d3a883294caedc82c5870f23273e2032
parent1b44a253316a84f40070264ea8134c86d1223441 (diff)
downloadtakahe-81de10b70c85c5222b17d8c4358a8aa8812f2559.tar.gz
takahe-81de10b70c85c5222b17d8c4358a8aa8812f2559.tar.bz2
takahe-81de10b70c85c5222b17d8c4358a8aa8812f2559.zip
Migration reset, start of docs, env vars
-rw-r--r--.gitignore1
-rw-r--r--activities/migrations/0001_initial.py239
-rw-r--r--activities/migrations/0002_fan_out.py103
-rw-r--r--activities/migrations/0003_alter_post_object_uri.py18
-rw-r--r--activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py126
-rw-r--r--activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py48
-rw-r--r--activities/migrations/0006_alter_post_hashtags.py18
-rw-r--r--activities/migrations/0007_post_edited.py18
-rw-r--r--activities/migrations/0008_postattachment.py69
-rw-r--r--activities/migrations/0009_alter_postattachment_file.py28
-rw-r--r--core/apps.py3
-rw-r--r--core/migrations/0001_initial.py14
-rw-r--r--core/migrations/0002_alter_config_image.py28
-rw-r--r--core/models/config.py2
-rw-r--r--docs/Makefile20
-rw-r--r--docs/conf.py26
-rw-r--r--docs/index.rst13
-rw-r--r--docs/installation.rst76
-rw-r--r--docs/make.bat35
-rw-r--r--requirements.txt1
-rw-r--r--static/css/style.css4
-rw-r--r--stator/management/commands/runstator.py3
-rw-r--r--stator/migrations/0001_initial.py2
-rw-r--r--takahe/settings/base.py59
-rw-r--r--takahe/urls.py5
-rw-r--r--templates/admin/domain_create.html2
-rw-r--r--templates/admin/domain_edit.html2
-rw-r--r--templates/admin/domains.html3
-rw-r--r--templates/auth/login.html1
-rw-r--r--templates/auth/perform_reset_success.html7
-rw-r--r--templates/settings/profile.html4
-rw-r--r--users/migrations/0001_initial.py79
-rw-r--r--users/migrations/0002_identity_public_key_id.py18
-rw-r--r--users/migrations/0003_user_last_seen_alter_identity_domain.py34
-rw-r--r--users/migrations/0004_passwordreset.py60
-rw-r--r--users/migrations/0005_invite.py32
-rw-r--r--users/models/domain.py7
-rw-r--r--users/models/password_reset.py2
-rw-r--r--users/views/activitypub.py59
-rw-r--r--users/views/admin/domains.py52
-rw-r--r--users/views/auth.py5
-rw-r--r--users/views/identity.py32
-rw-r--r--users/views/settings.py4
43 files changed, 681 insertions, 681 deletions
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")
-
-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")
+ sys.exit(1)
+
+
+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("@<handle>/", identity.ViewIdentity.as_view()),
- path("@<handle>/actor/", activitypub.Actor.as_view()),
- path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
+ path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/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 @@
<fieldset>
<legend>Access Control</legend>
{% include "forms/_field.html" with field=form.public %}
+ {% include "forms/_field.html" with field=form.default %}
</fieldset>
<div class="buttons">
+ <a href="{% url "admin_domains" %}" class="button secondary left">Back</a>
<button>Create</button>
</div>
</form>
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 @@
<fieldset>
<legend>Access Control</legend>
{% include "forms/_field.html" with field=form.public %}
+ {% include "forms/_field.html" with field=form.default %}
</fieldset>
<div class="buttons">
+ <a href="{{ domain.urls.root }}" class="button secondary left">Back</a>
<a href="{{ domain.urls.delete }}" class="button delete">Delete</a>
<button>Save</button>
</div>
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 %}
</small>
</span>
+ {% if domain.default %}
+ <span class="pill">Default</span>
+ {% endif %}
</a>
{% empty %}
<p class="option empty">You have no domains set up.</p>
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 %}
</fieldset>
<div class="buttons">
+ <a href="{% url "trigger_reset" %}" class="secondary button left">Forgot Password</a>
<button>Login</button>
</div>
</form>
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 %}
<form>
<fieldset>
- <legend>Password Reset</legend>
+ <legend>Password Set</legend>
<p>
- Your password for <tt>{{ email }}</tt> has been reset!
+ Your password for <tt>{{ email }}</tt> has been set. You can
+ now <a href="/auth/login/">login</a>.
</p>
</fieldset>
</form>
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 @@
</fieldset>
<fieldset>
<legend>Images</legend>
- {% 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 %}
</fieldset>
<div class="buttons">
<a href="{{ request.identity.urls.view }}" class="button secondary left">View Profile</a>
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)),
(
@@ -112,6 +116,25 @@ class Migration(migrations.Migration):
},
),
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=[
(
@@ -147,6 +170,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):