Initial commit (users and statuses)

This commit is contained in:
Andrew Godwin 2022-11-05 14:17:27 -06:00
commit d77dcf62b4
76 changed files with 1328 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.psql
*.sqlite3

37
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,37 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.3.0
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-yaml
- id: end-of-file-fixer
- id: file-contents-sorter
args: ["--ignore-case"]
files: "^.gitignore$"
- id: mixed-line-ending
args: ["--fix=lf"]
- id: trailing-whitespace
- id: pretty-format-json
- repo: https://github.com/psf/black
rev: 22.10.0
hooks:
- id: black
args: ["--target-version=py37"]
- repo: https://github.com/pycqa/isort
rev: 5.10.1
hooks:
- id: isort
args: ["--profile=black"]
- repo: https://gitlab.com/pycqa/flake8
rev: 5.0.4
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.982
hooks:
- id: mypy

0
core/__init__.py Normal file
View file

6
core/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"

3
core/config.py Normal file
View file

@ -0,0 +1,3 @@
class Config:
pass

5
core/context.py Normal file
View file

@ -0,0 +1,5 @@
from django.conf import settings
def config_context(request):
return {"config": {"site_name": settings.SITE_NAME}}

11
core/forms.py Normal file
View file

@ -0,0 +1,11 @@
from crispy_forms.helper import FormHelper as BaseFormHelper
from crispy_forms.layout import Submit
class FormHelper(BaseFormHelper):
submit_text = "Submit"
def __init__(self, form=None, submit_text=None):
super().__init__(form)
self.add_input(Submit("submit", submit_text or "Submit"))

21
core/views.py Normal file
View file

@ -0,0 +1,21 @@
from django.views.generic import TemplateView
from statuses.views.home import Home
from users.models import Identity
def homepage(request):
if request.user.is_authenticated:
return Home.as_view()(request)
else:
return LoggedOutHomepage.as_view()(request)
class LoggedOutHomepage(TemplateView):
template_name = "index.html"
def get_context_data(self):
return {
"identities": Identity.objects.filter(local=True),
}

22
manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "takahe.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

6
requirements.txt Normal file
View file

@ -0,0 +1,6 @@
django~=4.1
pyld~=2.0.3
pillow~=9.3.0
urlman~=2.0.1
django-crispy-forms~=1.14
cryptography~=38.0

227
static/css/style.css Normal file
View file

@ -0,0 +1,227 @@
/* Reset CSS */
*,
*::before,
*::after {
box-sizing: border-box;
}
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd,
menu {
margin: 0;
}
ul[role='list'],
ol[role='list'] {
list-style: none;
}
html:focus-within {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
text-rendering: optimizeSpeed;
line-height: 1.5;
font-family: sans-serif;
}
a:not([class]) {
text-decoration-skip-ink: auto;
}
img,
picture {
max-width: 100%;
display: block;
}
input,
button,
textarea,
select {
font: inherit;
}
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Base template styling */
:root {
--color-input-border: #000;
--color-input-border-active: #444b5d;
--color-button-main: #444b5d;
--color-button-main-hover: #515d7c;
--color-bg1: #191b22;
--color-bg2: #282c37;
--color-bg3: #444b5d;
--color-text-duller: #5f6983;
--color-text-dull: #99a;
--color-text-error: rgb(155, 111, 111);
--color-text-main: #DDDDDD;
}
body {
background-color: var(--color-bg1);
color: white;
}
header {
width: 750px;
margin: 0 auto;
display: flex;
padding: 0 0 20px 0;
}
header h1 {
background: var(--color-fg2);
padding: 10px 7px 7px 7px;
font-size: 130%;
height: 2.2em;
color: var(--color-fg1);
}
header a {
color: inherit;
text-decoration: none;
}
header menu {
flex-grow: 1;
display: flex;
list-style-type: none;
justify-content: flex-end;
}
header menu li {
padding: 20px 10px 7px 10px;
color: #eee;
}
main {
width: 750px;
margin: 20px auto;
}
/* "Modal" boxes */
.modal {
background: var(--color-bg2);
max-width: 500px;
margin: 0 auto;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.3);
border-radius: 5px;
}
.modal h1 {
color: var(--color-fg1);
background: var(--color-bg3);
font-family: "Raleway";
position: relative;
padding: 5px 8px 4px 10px;
font-size: 100%;
letter-spacing: 0.05em;
text-transform: uppercase;
border-radius: 5px 5px 0 0;
}
.modal .option {
display: block;
padding: 20px 30px;
color: var(--color-text-main);
text-decoration: none;
border-left: 3px solid transparent;
}
.modal a.option:hover {
border-left: 3px solid var(--color-text-dull);
}
.modal .option.empty {
text-align: center;
color: var(--color-text-dull);
}
.modal form {
padding: 10px 10px 1px 10px;
}
/* Forms */
form .control-group {
margin: 0 0 15px 0;
}
form .asteriskField {
display: none;
}
form label {
text-transform: uppercase;
font-size: 110%;
color: var(--color-text-dull);
letter-spacing: 0.05em;
}
form label.requiredField::after {
content: " (required)";
font-size: 80%;
color: var(--color-text-duller);
}
form .help-block {
color: var(--color-text-error);
padding: 4px 0 0 0;
}
form input {
width: 100%;
padding: 4px 6px;
background: var(--color-bg1);
border: 1px solid var(--color-input-border);
border-radius: 3px;
color: var(--color-text-main);
}
form input:focus {
outline: none;
border: 1px solid var(--color-input-border-active);
}
form input[type=submit] {
width: 100%;
padding: 4px 6px;
margin: 0 0 10px;
background: var(--color-button-main);
border: 0;
border-radius: 3px;
color: var(--color-text-main);
cursor: pointer;
}
form input[type=submit]:hover {
background: var(--color-button-main-hover);
}

6
static/fonts/font_awesome/all.min.css vendored Executable file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,20 @@
@font-face {
font-family: 'Raleway';
src: url('Raleway-Bold.woff2');
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: 'Raleway';
src: url('Raleway-Regular.woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Raleway';
src: url('Raleway-Light.woff2');
font-weight: lighter;
font-style: normal;
}

0
statuses/__init__.py Normal file
View file

8
statuses/admin.py Normal file
View file

@ -0,0 +1,8 @@
from django.contrib import admin
from statuses.models import Status
@admin.register(Status)
class StatusAdmin(admin.ModelAdmin):
pass

6
statuses/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class StatusesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "statuses"

View file

@ -0,0 +1,56 @@
# Generated by Django 4.1.3 on 2022-11-05 19:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("users", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Status",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("local", models.BooleanField()),
("uri", models.CharField(blank=True, max_length=500, null=True)),
(
"visibility",
models.IntegerField(
choices=[
(0, "Public"),
(1, "Unlisted"),
(2, "Followers"),
(3, "Mentioned"),
],
default=0,
),
),
("text", models.TextField()),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
("deleted", models.DateTimeField(blank=True, null=True)),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="statuses",
to="users.identity",
),
),
],
),
]

View file

View file

@ -0,0 +1 @@
from .status import Status # noqa

35
statuses/models/status.py Normal file
View file

@ -0,0 +1,35 @@
from django.db import models
class Status(models.Model):
class StatusVisibility(models.IntegerChoices):
public = 0
unlisted = 1
followers = 2
mentioned = 3
identity = models.ForeignKey(
"users.Identity",
on_delete=models.PROTECT,
related_name="statuses",
)
local = models.BooleanField()
uri = models.CharField(max_length=500, blank=True, null=True)
visibility = models.IntegerField(
choices=StatusVisibility.choices,
default=StatusVisibility.public,
)
text = models.TextField()
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
deleted = models.DateTimeField(null=True, blank=True)
@classmethod
def create_local(cls, identity, text: str):
return cls.objects.create(
identity=identity,
text=text,
local=True,
)

View file

35
statuses/views/home.py Normal file
View file

@ -0,0 +1,35 @@
from django import forms
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from core.forms import FormHelper
from statuses.models import Status
from users.decorators import identity_required
@method_decorator(identity_required, name="dispatch")
class Home(FormView):
template_name = "statuses/home.html"
class form_class(forms.Form):
text = forms.CharField()
helper = FormHelper(submit_text="Post")
def get_context_data(self):
context = super().get_context_data()
context.update(
{
"statuses": self.request.identity.statuses.all()[:100],
}
)
return context
def form_valid(self, form):
Status.create_local(
identity=self.request.identity,
text=form.cleaned_data["text"],
)
return redirect(".")

0
takahe/__init__.py Normal file
View file

16
takahe/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for takahe project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "takahe.settings")
application = get_asgi_application()

115
takahe/settings.py Normal file
View file

@ -0,0 +1,115 @@
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "insecure_secret"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"crispy_forms",
"core",
"statuses",
"users",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "takahe.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"core.context.config_context",
],
},
},
]
WSGI_APPLICATION = "takahe.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "users.User"
LOGIN_URL = "/auth/login/"
LOGOUT_URL = "/auth/logout/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
STATICFILES_DIRS = [
BASE_DIR / "static",
]
CRISPY_FAIL_SILENTLY = not DEBUG
SITE_NAME = "takahē"
DEFAULT_DOMAIN = "feditest.aeracode.org"
ALLOWED_DOMAINS = ["feditest.aeracode.org"]

22
takahe/urls.py Normal file
View file

@ -0,0 +1,22 @@
from django.contrib import admin
from django.urls import path
from core import views as core
from users.views import auth, identity
urlpatterns = [
path("", core.homepage),
# Authentication
path("auth/login/", auth.Login.as_view()),
path("auth/logout/", auth.Logout.as_view()),
# Identity views
path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/actor/", identity.Actor.as_view()),
# Identity selection
path("identity/select/", identity.SelectIdentity.as_view()),
path("identity/create/", identity.CreateIdentity.as_view()),
# Well-known endpoints
path(".well-known/webfinger/", identity.Webfinger.as_view()),
# Django admin
path("djadmin/", admin.site.urls),
]

16
takahe/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for takahe project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "takahe.settings")
application = get_wsgi_application()

6
templates/404.html Normal file
View file

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block content %}
<h1>Page Not Found</h1>
<p>Sorry about that.</p>
{% endblock %}

11
templates/auth/login.html Normal file
View file

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}Login{% endblock %}
{% block content %}
<section class="modal identities">
<h1>Login</h1>
{% crispy form form.helper %}
</section>
{% endblock %}

34
templates/base.html Normal file
View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %} - {{ config.site_name }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
{% load static %}
<link rel="stylesheet" href="{% static "css/style.css" %}" type="text/css" media="screen" />
<link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" />
<link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" />
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<header>
<h1><a href="/">{{ config.site_name }}</a></h1>
<menu>
<li>
{% if user.is_authenticated %}
{{ user.email }}
{% else %}
<a href="/auth/login/">Login</a>
{% endif %}
</li>
</menu>
</header>
<main>
{% block content %}
{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}Create Identity{% endblock %}
{% block content %}
<section class="modal identities">
<h1>Create Identity</h1>
{% crispy form form.helper %}
</section>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Select Identity{% endblock %}
{% block content %}
<section class="modal identities">
<h1>Select Identity</h1>
{% for identity in identities %}
<a class="option" href="{{ identity.urls.activate }}">{{ identity }}</a>
{% empty %}
<p class="option empty">You have no identities.</p>
{% endfor %}
<a href="/identity/create/" class="option new">
<i class="fa-solid fa-plus"></i> Create a new identity
</a>
</section>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}{{ identity }}{% endblock %}
{% block content %}
<h1>{{ identity }} <small>{{ identity.handle }}</small></h1>
{% for status in statuses %}
{% include "statuses/_status.html" %}
{% empty %}
No statuses yet.
{% endfor %}
{% endblock %}

9
templates/index.html Normal file
View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Welcome{% endblock %}
{% block content %}
{% for identity in identities %}
<a href="{{ identity.urls.view }}">{{ identity }}</a>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,10 @@
<div class="status">
<h3 class="author">
<a href="{{ status.identity.urls.view }}">
{{ status.identity }}
<small>{{ status.identity.short_handle }}</small>
</a>
</h3>
<time>{{ status.created | timesince }} ago</time>
{{ status.text | linebreaks }}
</div>

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}Home{% endblock %}
{% block content %}
{% crispy form form.helper %}
{% for status in statuses %}
{% include "statuses/_status.html" %}
{% empty %}
No statuses yet.
{% endfor %}
{% endblock %}

0
users/__init__.py Normal file
View file

18
users/admin.py Normal file
View file

@ -0,0 +1,18 @@
from django.contrib import admin
from users.models import Identity, User, UserEvent
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
pass
@admin.register(UserEvent)
class UserEventAdmin(admin.ModelAdmin):
pass
@admin.register(Identity)
class IdentityAdmin(admin.ModelAdmin):
pass

6
users/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "users"

39
users/decorators.py Normal file
View file

@ -0,0 +1,39 @@
from functools import wraps
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect
from users.models import Identity
def identity_required(function):
"""
Decorator for views that ensures an active identity is selected.
"""
@wraps(function)
def inner(request, *args, **kwargs):
# They do have to be logged in
if not request.user.is_authenticated:
return redirect_to_login(next=request.get_full_path())
# Try to retrieve their active identity
identity_id = request.session.get("identity_id")
if not identity_id:
identity = None
else:
try:
identity = Identity.objects.get(id=identity_id)
except Identity.DoesNotExist:
identity = None
# If there's no active one, try to auto-select one
if identity is None:
possible_identities = list(request.user.identities.all())
if len(possible_identities) != 1:
# OK, send them to the identity selection page to select/create one
return HttpResponseRedirect("/identity/select/")
identity = possible_identities[0]
request.identity = identity
request.session["identity_id"] = identity.pk
return function(request, *args, **kwargs)
return inner

View file

@ -0,0 +1,134 @@
# Generated by Django 4.1.3 on 2022-11-05 19:15
import functools
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import users.models.identity
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
("email", models.EmailField(max_length=254, unique=True)),
("admin", models.BooleanField(default=False)),
("moderator", models.BooleanField(default=False)),
("banned", models.BooleanField(default=False)),
("deleted", models.BooleanField(default=False)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateTimeField(auto_now_add=True)),
(
"type",
models.CharField(
choices=[
("created", "Created"),
("reset_password", "Reset Password"),
("banned", "Banned"),
],
max_length=100,
),
),
("data", models.JSONField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="events",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="Identity",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("handle", models.CharField(max_length=500, unique=True)),
("name", models.CharField(blank=True, max_length=500, null=True)),
("bio", models.TextField(blank=True, null=True)),
(
"profile_image",
models.ImageField(
upload_to=functools.partial(
users.models.identity.upload_namer,
*("profile_images",),
**{},
)
),
),
(
"background_image",
models.ImageField(
upload_to=functools.partial(
users.models.identity.upload_namer,
*("background_images",),
**{},
)
),
),
("local", models.BooleanField()),
("private_key", models.BinaryField(blank=True, null=True)),
("public_key", models.BinaryField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
("deleted", models.DateTimeField(blank=True, null=True)),
(
"users",
models.ManyToManyField(
related_name="identities", to=settings.AUTH_USER_MODEL
),
),
],
),
]

View file

3
users/models/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from .identity import Identity # noqa
from .user import User # noqa
from .user_event import UserEvent # noqa

79
users/models/identity.py Normal file
View file

@ -0,0 +1,79 @@
import base64
import uuid
from functools import partial
import urlman
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from django.conf import settings
from django.db import models
from django.utils import timezone
def upload_namer(prefix, instance, filename):
"""
Names uploaded images etc.
"""
now = timezone.now()
filename = base64.b32encode(uuid.uuid4().bytes).decode("ascii")
return f"{prefix}/{now.year}/{now.month}/{now.day}/{filename}"
class Identity(models.Model):
"""
Represents both local and remote Fediverse identities (actors)
"""
# The handle includes the domain!
handle = models.CharField(max_length=500, unique=True)
name = models.CharField(max_length=500, blank=True, null=True)
bio = models.TextField(blank=True, null=True)
profile_image = models.ImageField(upload_to=partial(upload_namer, "profile_images"))
background_image = models.ImageField(
upload_to=partial(upload_namer, "background_images")
)
local = models.BooleanField()
users = models.ManyToManyField("users.User", related_name="identities")
private_key = models.TextField(null=True, blank=True)
public_key = models.TextField(null=True, blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
deleted = models.DateTimeField(null=True, blank=True)
@property
def short_handle(self):
if self.handle.endswith("@" + settings.DEFAULT_DOMAIN):
return self.handle.split("@", 1)[0]
return self.handle
@property
def domain(self):
return self.handle.split("@", 1)[1]
def generate_keypair(self):
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
self.private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
self.public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
self.save()
def __str__(self):
return self.name or self.handle
class urls(urlman.Urls):
view = "/@{self.short_handle}/"
actor = "{view}actor/"
inbox = "{actor}inbox/"
activate = "{view}activate/"

58
users/models/user.py Normal file
View file

@ -0,0 +1,58 @@
from typing import List
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.db import models
class UserManager(BaseUserManager):
"""
Custom user manager that understands emails
"""
def create_user(self, email, password=None):
user = self.create(email=email)
if password:
user.set_password(password)
user.save()
return user
def create_superuser(self, email, password=None):
user = self.create(email=email, admin=True)
if password:
user.set_password(password)
user.save()
return user
class User(AbstractBaseUser):
"""
Custom user model that only needs an email
"""
email = models.EmailField(unique=True)
admin = models.BooleanField(default=False)
moderator = models.BooleanField(default=False)
banned = models.BooleanField(default=False)
deleted = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
USERNAME_FIELD = "email"
EMAIL_FIELD = "email"
REQUIRED_FIELDS: List[str] = []
objects = UserManager()
@property
def is_active(self):
return not (self.deleted or self.banned)
@property
def is_superuser(self):
return self.admin
@property
def is_staff(self):
return self.admin

View file

@ -0,0 +1,22 @@
from django.db import models
class UserEvent(models.Model):
"""
Tracks major events that happen to users
"""
class EventType(models.TextChoices):
created = "created"
reset_password = "reset_password"
banned = "banned"
user = models.ForeignKey(
"users.User",
on_delete=models.CASCADE,
related_name="events",
)
date = models.DateTimeField(auto_now_add=True)
type = models.CharField(max_length=100, choices=EventType.choices)
data = models.JSONField(blank=True, null=True)

18
users/shortcuts.py Normal file
View file

@ -0,0 +1,18 @@
from django.conf import settings
from django.shortcuts import get_object_or_404
from users.models import Identity
def by_handle_or_404(request, handle, local=True):
"""
Retrieves an Identity by its long or short handle.
Domain-sensitive, so it will understand short handles on alternate domains.
"""
# TODO: Domain sensitivity
if "@" not in handle:
handle += "@" + settings.DEFAULT_DOMAIN
if local:
return get_object_or_404(Identity.objects.filter(local=True), handle=handle)
else:
return get_object_or_404(Identity, handle=handle)

1
users/views/__init__.py Normal file
View file

@ -0,0 +1 @@
from .auth import * # noqa

15
users/views/auth.py Normal file
View file

@ -0,0 +1,15 @@
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.views import LoginView, LogoutView
from core.forms import FormHelper
class Login(LoginView):
class form_class(AuthenticationForm):
helper = FormHelper(submit_text="Login")
template_name = "auth/login.html"
class Logout(LogoutView):
pass

132
users/views/identity.py Normal file
View file

@ -0,0 +1,132 @@
from django import forms
from django.conf import settings
from django.contrib.auth.decorators import login_required
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.forms import FormHelper
from users.models import Identity
from users.shortcuts import by_handle_or_404
class ViewIdentity(TemplateView):
template_name = "identity/view.html"
def get_context_data(self, handle):
identity = by_handle_or_404(self.request, handle, local=False)
statuses = identity.statuses.all()[:100]
return {
"identity": identity,
"statuses": statuses,
}
@method_decorator(login_required, name="dispatch")
class SelectIdentity(TemplateView):
template_name = "identity/select.html"
def get_context_data(self):
return {
"identities": Identity.objects.filter(users__pk=self.request.user.pk),
}
@method_decorator(login_required, name="dispatch")
class CreateIdentity(FormView):
template_name = "identity/create.html"
class form_class(forms.Form):
handle = forms.CharField()
name = forms.CharField()
helper = FormHelper(submit_text="Create")
def clean_handle(self):
# Remove any leading @
value = self.cleaned_data["handle"].lstrip("@")
# Don't allow custom domains here quite yet
if "@" in value:
raise forms.ValidationError(
"You are not allowed an @ sign in your handle"
)
# Ensure there is a domain on the end
if "@" not in value:
value += "@" + settings.DEFAULT_DOMAIN
# Check for existing users
if Identity.objects.filter(handle=value).exists():
raise forms.ValidationError("This handle is already taken")
return value
def form_valid(self, form):
new_identity = Identity.objects.create(
handle=form.cleaned_data["handle"],
name=form.cleaned_data["name"],
local=True,
)
new_identity.users.add(self.request.user)
new_identity.generate_keypair()
return redirect(new_identity.urls.view)
class Actor(View):
"""
Returns the AP Actor object
"""
def get(self, request, handle):
identity = by_handle_or_404(self.request, handle)
return JsonResponse(
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
"type": "Person",
"preferredUsername": "alice",
"inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}",
"publicKey": {
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key",
"owner": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
"publicKeyPem": identity.public_key,
},
}
)
class Webfinger(View):
"""
Services webfinger requests
"""
def get(self, request):
resource = request.GET.get("resource")
if not resource.startswith("acct:"):
raise Http404("Not an account resource")
handle = resource[5:]
identity = by_handle_or_404(request, handle)
return JsonResponse(
{
"subject": f"acct:{identity.handle}",
"aliases": [
f"https://{settings.DEFAULT_DOMAIN}/@{identity.short_handle}",
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.view}",
},
{
"rel": "self",
"type": "application/activity+json",
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
},
],
}
)