Initial commit (users and statuses)
This commit is contained in:
commit
d77dcf62b4
|
@ -0,0 +1,2 @@
|
|||
*.psql
|
||||
*.sqlite3
|
|
@ -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,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
|
@ -0,0 +1,3 @@
|
|||
class Config:
|
||||
|
||||
pass
|
|
@ -0,0 +1,5 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
def config_context(request):
|
||||
return {"config": {"site_name": settings.SITE_NAME}}
|
|
@ -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"))
|
|
@ -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),
|
||||
}
|
|
@ -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()
|
|
@ -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
|
|
@ -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
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.
|
@ -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,0 +1,8 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from statuses.models import Status
|
||||
|
||||
|
||||
@admin.register(Status)
|
||||
class StatusAdmin(admin.ModelAdmin):
|
||||
pass
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class StatusesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "statuses"
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1 @@
|
|||
from .status import Status # noqa
|
|
@ -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,
|
||||
)
|
|
@ -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,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()
|
|
@ -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"]
|
|
@ -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),
|
||||
]
|
|
@ -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()
|
|
@ -0,0 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Page Not Found</h1>
|
||||
<p>Sorry about that.</p>
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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,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
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "users"
|
|
@ -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
|
|
@ -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
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,3 @@
|
|||
from .identity import Identity # noqa
|
||||
from .user import User # noqa
|
||||
from .user_event import UserEvent # noqa
|
|
@ -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/"
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1 @@
|
|||
from .auth import * # noqa
|
|
@ -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
|
|
@ -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}",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
Loading…
Reference in New Issue