Initial commit (users and statuses)

pull/1/head
Andrew Godwin 2022-11-05 14:17:27 -06:00
commit d77dcf62b4
76 zmienionych plików z 1328 dodań i 0 usunięć

2
.gitignore vendored 100644
Wyświetl plik

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

Wyświetl plik

@ -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 100644
Wyświetl plik

6
core/apps.py 100644
Wyświetl plik

@ -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 100644
Wyświetl plik

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

5
core/context.py 100644
Wyświetl plik

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

11
core/forms.py 100644
Wyświetl plik

@ -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 100644
Wyświetl plik

@ -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 100755
Wyświetl plik

@ -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 100644
Wyświetl plik

@ -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

Wyświetl plik

@ -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);
}

File diff suppressed because one or more lines are too long

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -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;
}

Wyświetl plik

Wyświetl plik

@ -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 100644
Wyświetl plik

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

Wyświetl plik

@ -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",
),
),
],
),
]

Wyświetl plik

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

Wyświetl plik

@ -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,
)

Wyświetl plik

Wyświetl plik

@ -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(".")

Wyświetl plik

16
takahe/asgi.py 100644
Wyświetl plik

@ -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 100644
Wyświetl plik

@ -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 100644
Wyświetl plik

@ -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 100644
Wyświetl plik

@ -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()

Wyświetl plik

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

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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 %}

Wyświetl plik

18
users/admin.py 100644
Wyświetl plik

@ -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 100644
Wyświetl plik

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

Wyświetl plik

@ -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

Wyświetl plik

@ -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
),
),
],
),
]

Wyświetl plik

Wyświetl plik

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

Wyświetl plik

@ -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/"

Wyświetl plik

@ -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

Wyświetl plik

@ -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 100644
Wyświetl plik

@ -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)

Wyświetl plik

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

Wyświetl plik

@ -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

Wyświetl plik

@ -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}",
},
],
}
)