Compare commits

..

No commits in common. "master" and "add-key-for-user" have entirely different histories.

35 changed files with 120 additions and 650 deletions

8
.gitignore vendored
View File

@ -3,11 +3,3 @@
__pycache__ __pycache__
*.pyc *.pyc
*.egg-info *.egg-info
.bash_history
.viminfo
ttadmin/settings_live.py
ttadmin/static
venv/
build/
dist/

View File

@ -10,14 +10,13 @@ _Being an adminstrative tool written in Django for <https://tilde.town>_.
* [x] Helpdesk * [x] Helpdesk
* [x] User account management * [x] User account management
* [ ] Start/stop services * [ ] Start/stop services
* [ ] Cost reporting from DO * [ ] Cost reporting from AWS
* [ ] Status monitoring * [ ] Status monitoring
## Requirements ## Requirements
* Python 3.5+ * Python 3.5+
* PostgreSQL 9+ * PostgreSQL 9+
* Ubuntu or Debian
## Installation / setup ## Installation / setup

0
scripts/create_keyfile.py 100755 → 100644
View File

View File

@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""This script wraps the usermod command to allow user account renames via
sudoers."""
import os
import sys
import subprocess
def rename_user(old_username, new_username):
"""Given an old and a new username, renames user on disk with usermod.
Raises if the usermod call fails."""
args = [
'pkill',
'-u',
old_username]
subprocess.run(args, check=False)
# Rename user
args = [
'usermod',
'-l',
new_username,
'-m',
'-d',
os.path.join('/home', new_username),
old_username
]
subprocess.run(args, check=True)
# Rename their group
args = [
'groupmod',
'-n',
new_username,
old_username
]
subprocess.run(args, check=True)
def main(argv):
if len(argv) < 3:
print('[rename_user] Too few arguments passed.', file=sys.stderr)
return 1
try:
rename_user(argv[1], argv[2])
except subprocess.CalledProcessError as e:
print('[rename_user] {}'.format(e), file=sys.stderr)
return 2
return 0
if __name__ == '__main__':
exit(main(sys.argv))

View File

@ -1,12 +0,0 @@
#!/bin/bash
PORT=8888
INSTALL_ROOT=/town/src/tildetown-admin
APP_ROOT=$INSTALL_ROOT/ttadmin
VENV=/town/venvs/ttadmin
source $VENV/bin/activate
export DJANGO_SETTINGS_MODULE=settings_live
cd $APP_ROOT
gunicorn -t120 -b0.0.0.0:$PORT ttadmin.wsgi

View File

@ -15,13 +15,10 @@
* create ttadmin user * create ttadmin user
* ttadmin db user (or just rely on ident..?) / database created * ttadmin db user (or just rely on ident..?) / database created
* copy `create_keyfile.py` from `scripts/` and put it in `/opt/bin/`. * copy `create_keyfile.py` from `scripts/` and put it in `/opt/bin/`.
* copy `rename_user.py` from `scripts/` and put it in `/tilde/bin/`. * `chmod o+x /opt/bin/create_keyfile.py``
* `chmod u+x /opt/bin/create_keyfile.py``
* add to sudoers: * add to sudoers:
``` ttadmin ALL=(ALL)NOPASSWD:/usr/sbin/adduser,/bin/mkdir,/opt/bin/create_keyfile.py
ttadmin ALL=(ALL)NOPASSWD:/usr/sbin/adduser,/bin/mkdir,/opt/bin/create_keyfile.py,/tilde/bin/rename_user.py
```
* have virtualenv with python 3.5+ ready, install tildetown-admin package into it * have virtualenv with python 3.5+ ready, install tildetown-admin package into it
* run django app as wsgi container through gunicorn as the ttadmin user with venv active * run django app as wsgi container through gunicorn as the ttadmin user with venv active

View File

@ -15,11 +15,12 @@ setup(
'License :: OSI Approved :: Affero GNU General Public License v3 (AGPLv3)', 'License :: OSI Approved :: Affero GNU General Public License v3 (AGPLv3)',
], ],
packages=['ttadmin'], packages=['ttadmin'],
install_requires = ['Django==1.11.29', install_requires = ['Django==1.10.2',
'sshpubkeys==2.2.0', 'sshpubkeys==2.2.0',
'psycopg2-binary==2.8.5', 'psycopg2-binary==2.7.4',
'requests==2.12.5',
'gunicorn==19.6.0', 'gunicorn==19.6.0',
'Mastodon.py==1.4.5', 'Mastodon.py==1.1.1',
'tweepy==3.7.0'], 'tweepy==3.5.0'],
include_package_data = True, include_package_data = True,
) )

View File

@ -4,15 +4,15 @@ from random import shuffle
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.forms import ChoiceField from django.forms import ChoiceField
CAPTCHA_CHOICES = [('number', 'zorp borp'), CAPTCHA_CHOICES = [('two', 'zorp borp'),
('hey', 'quop bop'), ('three', 'quop bop'),
('never', 'NO, I AM NOT A ROBOT'), ('four', 'NO, I AM NOT A ROBOT'),
('eleven', 'crackle zop'), ('five', 'crackle zop'),
('twelve', '*rusty screech*'), ('six', '*rusty screech*'),
('eighty', 'mother, give me legs')] ('seven', 'mother, give me legs')]
shuffle(CAPTCHA_CHOICES) shuffle(CAPTCHA_CHOICES)
CAPTCHA_CHOICES.insert(0, ('one', 'beep boop'),) CAPTCHA_CHOICES.insert(0, ('one', 'beep boop'),)
NOT_A_ROBOT = 'never' NOT_A_ROBOT = 'four'
def validate_captcha(captcha): def validate_captcha(captcha):
if captcha != NOT_A_ROBOT: if captcha != NOT_A_ROBOT:

View File

@ -1,26 +1,32 @@
import logging import logging
from smtplib import SMTP_SSL, SMTPException
from email.message import EmailMessage import requests
from django.conf import settings from django.conf import settings
logger = logging.getLogger() logger = logging.getLogger()
FROM='root@tilde.town'
def send_email(to, body, subject='a message from tilde.town'): def send_email(to, body, subject='a message from tilde.town', frum=FROM,):
"""Sends an email using external SMTP. Logs on failure.""" """Sends an email using mailgun. Logs on failure."""
em = EmailMessage() response = requests.post(
em['Subject'] = subject settings.MAILGUN_URL,
em['From'] = 'root@tilde.town' auth=('api', settings.MAILGUN_KEY),
em['To'] = to data={
em.set_content(body) 'from': frum,
try: 'to': to,
with SMTP_SSL(port=settings.SMTP_PORT, host=settings.SMTP_HOST) as smtp: 'subject': subject,
smtp.login('root@tilde.town', settings.SMTP_PASSWORD) 'text': body
smtp.send_message(em) }
smtp.quit() )
except SMTPException as e:
logger.error(f'failed to send email "{subject}" to {to}: {e}')
return False
return True success = response.status_code == 200
if not success:
logger.error('{}: failed to send email "{}" to {}'.format(
response.status_code,
subject,
to))
return success

View File

@ -20,7 +20,6 @@
</head> </head>
<body> <body>
<h1>tilde.town guestbook</h1> <h1>tilde.town guestbook</h1>
<p><em>don't try to post urls. it won't work.</em></p>
<marquee>~*~*~*~*say hello*~*~*~*~</marquee> <marquee>~*~*~*~*say hello*~*~*~*~</marquee>
<form class="tilde" action="{% url 'guestbook:guestbook' %}" method="post"> <form class="tilde" action="{% url 'guestbook:guestbook' %}" method="post">
{% csrf_token %} {% csrf_token %}

View File

@ -1,5 +1,3 @@
import re
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
@ -7,8 +5,6 @@ from django.views.generic.edit import FormView
from .forms import GuestbookForm from .forms import GuestbookForm
from .models import GuestbookMessage from .models import GuestbookMessage
SUSPICIOUS_RE = re.compile(r'https?://')
class GuestbookView(FormView): class GuestbookView(FormView):
form_class = GuestbookForm form_class = GuestbookForm
@ -21,7 +17,5 @@ class GuestbookView(FormView):
def form_valid(self, form): def form_valid(self, form):
del form.cleaned_data['captcha'] del form.cleaned_data['captcha']
if SUSPICIOUS_RE.search(form.cleaned_data['msg']) != None:
return redirect('guestbook:guestbook')
t = GuestbookMessage.objects.create(**form.cleaned_data) t = GuestbookMessage.objects.create(**form.cleaned_data)
return redirect('guestbook:guestbook') return redirect('guestbook:guestbook')

View File

@ -1,44 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.forms import ModelForm from .models import Ticket
from .models import Ticket, Note
class ImmutableNoteInline(admin.TabularInline):
model = Note
extra = 1
max_num = 0
fields = ('author', 'created', 'body')
readonly_fields = ('author', 'created', 'body')
can_delete = False
ordering = ('created',)
class NewNoteInline(admin.StackedInline):
model = Note
extra = 0
fields = ('body',)
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.none()
@admin.register(Ticket) @admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin): class TicketAdmin(admin.ModelAdmin):
inlines = [ImmutableNoteInline, NewNoteInline] list_display = ('issue_status', 'issue_type', 'name', 'email')
readonly_fields = ('submitted', 'issue_type') fields = ('name', 'email', 'issue_status', 'issue_type', 'issue_text')
list_display = ('submitted', 'issue_status', 'assigned', 'issue_type', 'name', 'email',)
list_filter = ('issue_status', 'issue_type', 'assigned')
fields = ('submitted', 'name', 'email', 'assigned', 'issue_status', 'issue_type', 'issue_text')
def save_related(self, request, form, formsets, change):
# THIS IS EXTREMELY BOOTLEG AND MAY BREAK IF MORE INLINES ARE ADDED TO THIS ADMIN.
for formset in formsets:
if len(formset.forms) == 1:
# It's probably the add new note form (i hope).
note_form = formset.forms[0]
note_form.instance.author = request.user
note_form.instance.save()
note_form.save(commit=False)
note_form.save_m2m()
return super().save_related(request, form, formsets, change)

View File

@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-01-19 18:28
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('help', '0003_auto_20171110_2323'),
]
operations = [
migrations.AddField(
model_name='ticket',
name='submitted',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-16 02:43
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0015_remove_townie_reviewed'),
('help', '0004_ticket_submitted'),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('body', models.TextField()),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.Townie')),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='help.Ticket')),
],
),
]

View File

@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-16 02:58
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('help', '0005_note'),
]
operations = [
migrations.AlterField(
model_name='note',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-18 22:21
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('help', '0006_auto_20190716_0258'),
]
operations = [
migrations.AddField(
model_name='ticket',
name='assigned',
field=models.ForeignKey(help_text='Assign this ticket to an admin or unassign it.', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-18 22:23
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('help', '0007_ticket_assigned'),
]
operations = [
migrations.AlterField(
model_name='ticket',
name='assigned',
field=models.ForeignKey(blank=True, help_text='Assign this ticket to an admin or unassign it.', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,5 +1,4 @@
from django.contrib.auth.models import User from django.db.models import Model, TextField, EmailField, CharField
from django.db.models import Model, TextField, EmailField, CharField, DateTimeField, ForeignKey
ISSUE_TYPE_CHOICES = ( ISSUE_TYPE_CHOICES = (
('logging_in', 'help logging in'), ('logging_in', 'help logging in'),
@ -19,7 +18,6 @@ ISSUE_STATUS_CHOICES = (
class Ticket(Model): class Ticket(Model):
submitted = DateTimeField(auto_now_add=True)
name = CharField(blank=False, null=False, max_length=100) name = CharField(blank=False, null=False, max_length=100)
email = EmailField(blank=False, null=False) email = EmailField(blank=False, null=False)
issue_type = CharField(choices=ISSUE_TYPE_CHOICES, issue_type = CharField(choices=ISSUE_TYPE_CHOICES,
@ -32,17 +30,6 @@ class Ticket(Model):
null=False, null=False,
max_length=50, max_length=50,
default=ISSUE_STATUS_CHOICES[0][0]) default=ISSUE_STATUS_CHOICES[0][0])
assigned = ForeignKey(User, blank=True, null=True, help_text="Assign this ticket to an admin or unassign it.")
def __str__(self): def __str__(self):
return '{} from {}'.format(self.issue_type, self.name) return '{} from {}'.format(self.issue_type, self.name)
class Note(Model):
created = DateTimeField(auto_now_add=True)
body = TextField(blank=False, null=False)
author = ForeignKey(User)
ticket = ForeignKey(Ticket)
def __str__(self):
return "admin note"

View File

@ -6,7 +6,7 @@ To run this For Real, you'll want to:
* set a different SECRET_KEY * set a different SECRET_KEY
* change the password for the database or delete the password and use ident * change the password for the database or delete the password and use ident
* change DEBUG to False * change DEBUG to False
* set smtp password * set mailgun api info
""" """
import os import os
@ -101,9 +101,8 @@ STATIC_URL = '/static/'
# Not used during local development, but used in staging+live environments # Not used during local development, but used in staging+live environments
STATIC_ROOT = 'static' STATIC_ROOT = 'static'
SMTP_PORT=465 MAILGUN_URL = "OVERWRITE THIS"
SMTP_HOST="smtp.zoho.com" MAILGUN_KEY = "OVERWRITE THIS"
SMTP_PASSWORD="OVERWRITE THIS"
# Mastodon credentials # Mastodon credentials
MASTO_CLIENT_ID = "OVERWRITE THIS" MASTO_CLIENT_ID = "OVERWRITE THIS"

View File

@ -2,8 +2,8 @@ from django.conf.urls import url, include
from django.contrib import admin from django.contrib import admin
urlpatterns = [ urlpatterns = [
# url(r'^help/', include('help.urls')), url(r'^help/', include('help.urls')),
url(r'^users/', include('users.urls')), url(r'^users/', include('users.urls')),
# url(r'^guestbook/', include('guestbook.urls')), url(r'^guestbook/', include('guestbook.urls')),
url(r'^admin/', admin.site.urls), url(r'^admin/', admin.site.urls),
] ]

View File

@ -9,27 +9,19 @@ class PubkeyInline(admin.TabularInline):
model = Pubkey model = Pubkey
extra = 1 extra = 1
def bulk_accept(madmin, req, qs): def bulk_review(madmin, req, qs):
for townie in qs: for townie in qs:
townie.state = Townie.ACCEPTED townie.reviewed = True
townie.save() townie.save()
post_users_to_social(qs) post_users_to_social(qs)
bulk_accept.short_description = 'mark selected townies as accepted' bulk_review.short_description = 'mark selected townies as reviewed'
def bulk_reject(madmin, req, qs):
for townie in qs:
townie.state = Townie.REJECTED
townie.save()
bulk_reject.short_description = 'mark selected townies as rejected'
@admin.register(Townie) @admin.register(Townie)
class TownieAdmin(admin.ModelAdmin): class TownieAdmin(admin.ModelAdmin):
inlines = [PubkeyInline] inlines = [PubkeyInline]
list_display = ('username', 'state', 'email') list_display = ('username', 'reviewed', 'email')
readonly_fields = ('reasons', 'plans', 'socials') ordering = ('reviewed',)
ordering = ('state',)
exclude = ('first_name', 'last_name', 'password', 'groups', 'user_permissions', 'last_login', 'is_staff', 'is_active', 'is_superuser') exclude = ('first_name', 'last_name', 'password', 'groups', 'user_permissions', 'last_login', 'is_staff', 'is_active', 'is_superuser')
actions = (bulk_accept, bulk_reject,) actions = (bulk_review,)
search_fields = ('username', 'email', 'displayname') search_fields = ('username', 'email', 'displayname')

View File

@ -12,9 +12,9 @@ submission_throttle = {}
throttle_submission = throttler(submission_throttle) throttle_submission = throttler(submission_throttle)
USERNAME_RE = re.compile(r'^[a-z][a-z0-9_]+$') USERNAME_RE = re.compile(r'[a-z][a-z0-9_]+')
USERNAME_MIN_LENGTH = 2 USERNAME_MIN_LENGTH = 3
DISPLAY_NAME_RE = re.compile(r"^[a-zA-Z0-9_\-']+$") DISPLAY_NAME_RE = re.compile(r"[a-zA-Z0-9_\-']+")
DISPLAY_MIN_LENGTH = 2 DISPLAY_MIN_LENGTH = 2
@ -23,7 +23,7 @@ def validate_username(username):
raise ValidationError('Username too short.') raise ValidationError('Username too short.')
if not USERNAME_RE.match(username): if not USERNAME_RE.match(username):
raise ValidationError('Username must be all lowercase, start with a letter, and only use the _ special character') raise ValidationError('Username must be all lowercase, start with a letter, and only use the _ special character')
duplicate = Townie.objects.filter(username=username).exclude(state=Townie.REJECTED).count() duplicate = Townie.objects.filter(username=username).count()
if duplicate > 0: if duplicate > 0:
raise ValidationError('Username already in use :(') raise ValidationError('Username already in use :(')
@ -34,7 +34,6 @@ def validate_displayname(display_name):
if not DISPLAY_NAME_RE.match(display_name): if not DISPLAY_NAME_RE.match(display_name):
raise ValidationError("Valid characters: a-z, A-Z, 0-9, -, _, and '.") raise ValidationError("Valid characters: a-z, A-Z, 0-9, -, _, and '.")
def validate_pubkey(pubkey): def validate_pubkey(pubkey):
# TODO see if I can get the type out # TODO see if I can get the type out
key = ssh.SSHKey(pubkey, strict_mode=False, skip_option_parsing=True) key = ssh.SSHKey(pubkey, strict_mode=False, skip_option_parsing=True)
@ -53,63 +52,32 @@ class TownieForm(Form):
validators=(validate_username,), validators=(validate_username,),
help_text='lowercase and no spaces. underscore ok', help_text='lowercase and no spaces. underscore ok',
label='username') label='username')
email = EmailField( email = EmailField(
help_text='only used to message you about your account and nothing else.', help_text='only used to message you about your account and nothing else.',
label='e-mail') label='e-mail')
displayname = CharField( displayname = CharField(
validators=(validate_displayname,), validators=(validate_displayname,),
help_text='100% optional. pseudonyms welcome.', help_text='100% optional. pseudonyms welcome.',
label='display name', label='display name',
required=False) required=False)
referral = CharField(
required=False,
label='did a townie refer you? put their handle here.',
help_text="this is optional and just helps us when reviewing your application.")
reasons = CharField( reasons = CharField(
widget=Textarea,
required=True,
label='what interests you about tilde.town?',
help_text="""
What about this intentional community intrigues you and makes you want to be a part of it?
""".strip())
plans = CharField(
widget=Textarea,
required=True,
label='what sort of things do you want to do on tilde.town?',
help_text="""
Do you want to socialize? Make something? Learn stuff?
""".strip())
socials = CharField(
widget=Textarea, widget=Textarea,
required=False, required=False,
label='where else are you online?', label='what interests you about tilde.town?',
help_text="""Optional, but if you're comfortable sharing with us some links to other online help_text='This is a totally optional place for you to tell us what excites you about getting a ~ account. This is mainly just so we can all feel warm fuzzies.')
spaces you're in (like twitter, mastodon, neocities, or whatever) we'd love to get to know
you when reviewing your application.
""".strip())
captcha = CaptchaField() captcha = CaptchaField()
pubkey = CharField( pubkey = CharField(
widget=Textarea, widget=Textarea,
validators=(validate_pubkey,), validators=(validate_pubkey,),
label='SSH public key', label='SSH public key',
help_text='if this is not a thing you are familiar with, that\'s okay! you can make one <a href="/users/keymachine">here</a> or read <a href="https://tilde.town/wiki/getting-started/ssh.html">our guide</a> to learn more.') help_text='if this is not a thing you are familiar with, that\'s okay! you can make one <a href="/users/keymachine">here</a> or read <a href="https://tilde.town/~wiki/ssh.html">our guide</a> to learn more.')
pubkey_type = ChoiceField( pubkey_type = ChoiceField(
choices=SSH_TYPE_CHOICES, choices=SSH_TYPE_CHOICES,
label='SSH public key type', label='SSH public key type',
help_text="unless you know what you're doing you can leave this be.") help_text="unless you know what you're doing you can leave this be.")
aup = BooleanField( aup = BooleanField(
label="i agree to the town's acceptable use policy", label='i super agree to our acceptable use policy',
help_text='please read our <a href="https://tilde.town/wiki/conduct.html">code of conduct</a> and click this box if you agree.') help_text='please read our <a href="https://tilde.town/~wiki/conduct.html">code of conduct</a> and click this box if you agree.')
def clean(self): def clean(self):
result = super().clean() result = super().clean()

View File

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-01-19 18:28
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0009_auto_20170114_0757'),
]
operations = [
migrations.AlterField(
model_name='pubkey',
name='key_type',
field=models.CharField(choices=[('ssh-rsa', 'ssh-rsa'), ('ssh-dss', 'ssh-dss'), ('ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp256')], max_length=50),
),
]

View File

@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-04-16 01:26
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0010_auto_20190119_1828'),
]
operations = [
migrations.AddField(
model_name='townie',
name='plans',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='townie',
name='referral',
field=models.CharField(max_length=100, null=True),
),
migrations.AddField(
model_name='townie',
name='socials',
field=models.TextField(blank=True, default=''),
),
]

View File

@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-10 16:22
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0011_auto_20190416_0126'),
]
operations = [
migrations.AddField(
model_name='townie',
name='notes',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='townie',
name='referral',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-16 01:48
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0012_auto_20190710_1622'),
]
operations = [
migrations.AddField(
model_name='townie',
name='state',
field=models.CharField(choices=[('3_rejected', 'Rejected'), ('2_accepted', 'Accepted'), ('0_unreviewed', 'Unreviewed'), ('4_permaban', 'Permanently Banned'), ('1_tempban', 'Temporarily Banned')], default='0_unreviewed', max_length=20),
),
migrations.AlterField(
model_name='townie',
name='notes',
field=models.TextField(blank=True, help_text='Use this field to share information about this user (reviewed or not) for other admins to see', null=True),
),
]

View File

@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-16 01:51
from __future__ import unicode_literals
from django.db import migrations
def set_state(apps, _):
Townie = apps.get_model('users', 'Townie')
for townie in Townie.objects.all():
if townie.reviewed:
townie.state = '2_accepted'
townie.save()
class Migration(migrations.Migration):
dependencies = [
('users', '0013_auto_20190716_0148'),
]
operations = [
migrations.RunPython(set_state)
]

View File

@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2019-07-16 02:10
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0014_auto_20190716_0151'),
]
operations = [
migrations.RemoveField(
model_name='townie',
name='reviewed',
),
]

View File

@ -4,7 +4,7 @@ from subprocess import run, CalledProcessError
from tempfile import TemporaryFile from tempfile import TemporaryFile
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import pre_save, post_save from django.db.models.signals import pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import TextField, BooleanField, CharField, ForeignKey from django.db.models import TextField, BooleanField, CharField, ForeignKey
@ -19,7 +19,6 @@ SSH_TYPE_CHOICES = (
('ssh-rsa', 'ssh-rsa',), ('ssh-rsa', 'ssh-rsa',),
('ssh-dss', 'ssh-dss',), ('ssh-dss', 'ssh-dss',),
('ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp256'), ('ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp256'),
('ssh-ed25519', 'ssh-ed25519'),
) )
DEFAULT_INDEX_PATH = '/etc/skel/public_html/index.html' DEFAULT_INDEX_PATH = '/etc/skel/public_html/index.html'
@ -33,7 +32,7 @@ else:
KEYFILE_HEADER = """########## GREETINGS! ########## KEYFILE_HEADER = """########## GREETINGS! ##########
# Hi! This file is automatically managed by tilde.town. You # Hi! This file is automatically managed by tilde.town. You
# seriously shouldn't change it. If you want to add more public keys that's # probably shouldn't change it. If you want to add more public keys that's
# totally fine: you can put them in ~/.ssh/authorized_keys""" # totally fine: you can put them in ~/.ssh/authorized_keys"""
@ -43,57 +42,25 @@ class Townie(User):
class Meta: class Meta:
verbose_name = 'Townie' verbose_name = 'Townie'
verbose_name_plural = 'Townies' verbose_name_plural = 'Townies'
# the actual values here have a leading int for sorting :(
UNREVIEWED = '0_unreviewed'
TEMPBAN = '1_tempban'
ACCEPTED = '2_accepted'
REJECTED = '3_rejected'
PERMABAN = '4_permaban'
STATE_CHOICES = (
(REJECTED, 'Rejected'),
(ACCEPTED, 'Accepted'),
(UNREVIEWED, 'Unreviewed'),
(PERMABAN, 'Permanently Banned'),
(TEMPBAN, 'Temporarily Banned'),
)
shell = CharField(max_length=50, default="/bin/bash") shell = CharField(max_length=50, default="/bin/bash")
state = CharField(max_length=20, choices=STATE_CHOICES, default=UNREVIEWED) reviewed = BooleanField(default=False)
reasons = TextField(blank=True, null=False, default='') reasons = TextField(blank=True, null=False, default='')
plans = TextField(blank=True, null=False, default='')
socials = TextField(blank=True, null=False, default='')
referral = CharField(max_length=100, null=True, blank=True)
displayname = CharField(max_length=100, blank=False, null=False) displayname = CharField(max_length=100, blank=False, null=False)
notes = TextField(blank=True, null=True,
help_text='Use this field to share information about this user (reviewed or not) for other admins to see')
@property
def accepted(self):
return self.ACCEPTED == self.state
@property
def unreviewed(self):
return self.UNREVIEWED == self.state
@property @property
def home(self): def home(self):
return os.path.join('/home', self.username) return os.path.join('/home', self.username)
def generate_gift(self): def send_welcome_email(self, admin_name='vilmibm'):
command = '/town/bin/generate_welcome_present.sh'
error = _guarded_run(['sudo', command, self.username])
if error:
logger.error(error)
return
def send_welcome_email(self):
welcome_tmpl = get_template('users/welcome_email.txt') welcome_tmpl = get_template('users/welcome_email.txt')
context = { context = {
'username': self.username, 'username': self.username,
'admin_name': 'vilmibm', 'admin_name': admin_name,
} }
text = welcome_tmpl.render(context) text = welcome_tmpl.render(context)
success = send_email(self.email, text, subject='tilde.town!') from_address = '{}@tilde.town'.format(admin_name)
success = send_email(self.email, text, subject='tilde.town!',
frum=from_address)
if not success: if not success:
Ticket.objects.create(name='system', Ticket.objects.create(name='system',
email='root@tilde.town', email='root@tilde.town',
@ -117,7 +84,7 @@ class Townie(User):
"""A VERY NOT IDEMPOTENT create function. Originally, I had ambitions """A VERY NOT IDEMPOTENT create function. Originally, I had ambitions
to have this be idempotent and able to incrementally update a user as to have this be idempotent and able to incrementally update a user as
needed, but decided that was overkill for now.""" needed, but decided that was overkill for now."""
assert(self.accepted) assert(self.reviewed)
dot_ssh_path = '/home/{}/.ssh'.format(self.username) dot_ssh_path = '/home/{}/.ssh'.format(self.username)
error = _guarded_run(['sudo', error = _guarded_run(['sudo',
@ -128,7 +95,7 @@ class Townie(User):
'--disabled-password', '--disabled-password',
self.username]) self.username])
if error: if error:
logger.error(error) logging.error(error)
return return
error = _guarded_run(['sudo', error = _guarded_run(['sudo',
@ -138,7 +105,7 @@ class Townie(User):
self.username]) self.username])
if error: if error:
logger.error(error) logging.error(error)
return return
# Create .ssh # Create .ssh
@ -147,7 +114,7 @@ class Townie(User):
'mkdir', 'mkdir',
dot_ssh_path]) dot_ssh_path])
if error: if error:
logger.error(error) logging.error(error)
return return
def write_authorized_keys(self): def write_authorized_keys(self):
@ -167,114 +134,54 @@ class Townie(User):
fp.seek(0) fp.seek(0)
error = _guarded_run(['sudo', error = _guarded_run(['sudo',
'--user={}'.format(self.username), '--user={}'.format(self.username),
'/town/src/tildetown-admin/scripts/create_keyfile.py', '/opt/bin/create_keyfile.py',
self.username], self.username],
stdin=fp) stdin=fp)
if error: if error:
logger.error(error) logging.error(error)
def generate_authorized_keys(self): def generate_authorized_keys(self):
"""returns a string suitable for writing out to an authorized_keys """returns a string suitable for writing out to an authorized_keys
file""" file"""
content = KEYFILE_HEADER content = KEYFILE_HEADER
for pubkey in self.pubkey_set.all(): for pubkey in self.pubkey_set.all():
prefix = pubkey.key.split(' ') if pubkey.key.startswith('ssh-'):
prefix = prefix[0] if len(prefix) > 0 else None
if prefix in [p[0] for p in SSH_TYPE_CHOICES]:
content += '\n{}'.format(pubkey.key) content += '\n{}'.format(pubkey.key)
else: else:
content += '\n{} {}'.format(pubkey.key_type, pubkey.key) content += '\n{} {}'.format(key.key_type, pubkey.key)
return content return content
def rename_on_disk(self, old_username):
"""Assuming that this instance has a new name set, renames this user on
disk with self.username."""
# TODO use systemd thing to end their session
error = _guarded_run([
'sudo',
'/town/src/tildetown-admin/scripts/rename_user.py',
old_username,
self.username])
if error:
logger.error(error)
return
logger.info('Renamed {} to {}'.format(old_username, self.username))
# send user an email
rename_tmpl = get_template('users/rename_email.txt')
context = {
'old_username': old_username,
'new_username': self.username
}
text = rename_tmpl.render(context)
success = send_email(self.email, text, subject='Your tilde.town user has been renamed!')
if not success:
Ticket.objects.create(name='system',
email='root@tilde.town',
issue_type='other',
issue_text='was not able to send rename email to {} ({})'.format(
self.username,
self.email))
class Pubkey(Model): class Pubkey(Model):
key_type = CharField(max_length=50, key_type = CharField(max_length=50,
blank=False, blank=False,
null=False, null=False,
choices=SSH_TYPE_CHOICES) choices=SSH_TYPE_CHOICES,
)
key = TextField(blank=False, null=False) key = TextField(blank=False, null=False)
townie = ForeignKey(Townie) townie = ForeignKey(Townie)
@receiver(post_save, sender=Pubkey)
def on_pubkey_post_save(sender, instance, **kwargs):
# Ensure we're checking the townie as it exists at the point of pubkey
# save. If a user is being reviewed, we'll write their key file in the
# townie pre save.
townie = Townie.objects.filter(username=instance.townie.username)
if not townie:
return
townie = townie[0]
if townie.accepted:
townie.write_authorized_keys()
@receiver(pre_save, sender=Townie) @receiver(pre_save, sender=Townie)
def on_townie_pre_save(sender, instance, **kwargs): def on_townie_pre_save(sender, instance, **kwargs):
if instance.id is None: existing = Townie.objects.filter(username=instance.username)
logger.info('Signup from {}'.format(instance.username)) if not existing:
# we're making a new Townie; this means someone just signed up. We
# don't care at all about their state on disk.
return return
existing = Townie.objects.get(id=instance.id) existing = existing[0]
# See if we need to create the user on disk. needs_creation = not existing.reviewed and instance.reviewed == True
if existing.unreviewed and instance.accepted: regen_keyfile = needs_creation or set(existing.pubkey_set.all()) != set(instance.pubkey_set.all())
logger.info('Creating user {} on disk.'.format(instance.username))
try:
instance.create_on_disk()
instance.write_authorized_keys()
except Exception as e:
logger.error('Failed syncing user {} to disk: {}'.format(instance.username, e))
else:
instance.send_welcome_email()
instance.generate_gift()
return if needs_creation:
else: instance.create_on_disk()
# This user state transition is currently undefined. In the future, we can check for things instance.send_welcome_email()
# like bans/unbans and then take the appropriate action.
return
# See if this user needs a rename on disk if regen_keyfile:
logger.info('checking for rename {} vs {}'.format( instance.write_authorized_keys()
existing.username, instance.username))
if existing.username != instance.username:
logger.info('username do not match, going to rename')
instance.rename_on_disk(existing.username)
def _guarded_run(cmd_args, **run_args): def _guarded_run(cmd_args, **run_args):
@ -293,3 +200,22 @@ def _guarded_run(cmd_args, **run_args):
issue_text='error while running {}: {}'.format( issue_text='error while running {}: {}'.format(
cmd_args, e)) cmd_args, e))
return e return e
# things to consider:
# * what happens when a user wants their name changed?
# * it looks like usermod -l and a mv of the home dir can change a user's username.
# * would hook this into the pre_save signal to note a username change
# * what happens when a user is marked as not reviewed?
# * does this signal user deletion? Or does literal Townie deletion signal
# "needs to be removed from disk"? I think it makes the most sense for the
# latter to imply full user deletion.
# * I honestly can't even think of a reason to revert a user to "not reviewed"
# and perhaps it's best to just not make that possible. for now, though, I
# think I can ignore it.
# * what happens when a user needs to be banned?
# * the Townie should be deleted via post_delete signal
# * what are things about a user that might change in django and require changes on disk?
# * username
# * displayname (only if i start using this?)
# * ssh key

View File

@ -104,7 +104,7 @@
For more information about where to save keys, how to For more information about where to save keys, how to
use them, and how to use terminals (on all platforms), use them, and how to use terminals (on all platforms),
check out the <a check out the <a
href="https://tilde.town/wiki/ssh.html">tilde.town ssh href="https://tilde.town/~wiki/ssh.html">tilde.town ssh
primer</a>. primer</a>.
</p> </p>
</td> </td>

View File

@ -1,13 +0,0 @@
hi!
you requested a new username on tilde.town. This process required logging you
out and killing any active processes you had running. sorry if this caused any
confusion or inconvenience.
old username: {{old_username}}
new username: {{new_username}}
you'll use this when ssh'ing into the town.
best,
~vilmibm

View File

@ -11,7 +11,6 @@
{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %} {% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}
{% if signups_enabled %}
<form class="tilde" action="{% url 'users:signup' %}" method="post"> <form class="tilde" action="{% url 'users:signup' %}" method="post">
{% csrf_token %} {% csrf_token %}
<table id="signup"> <table id="signup">
@ -41,11 +40,5 @@
<input type="submit" value="sign up <3" /> <input type="submit" value="sign up <3" />
</div> </div>
</form> </form>
{% else %}
<p>
Town signups are currently disabled, but will resume in the future. If you can't wait for an account, you
can check out some <a href="https://tildeverse.org/">similar servers</a>.
</p>
{% endif %}
</body> </body>
</html> </html>

View File

@ -12,22 +12,23 @@
<h1>thanks for signing up for <a href="https://tilde.town">tilde.town!</a>!</h1> <h1>thanks for signing up for <a href="https://tilde.town">tilde.town!</a>!</h1>
<p>you'll get an email when your account is live. a human reads over <p>you'll get an email when your account is live. a human reads over
each new account and manually activates it, so it might take 1-3 each new account and manually activates it, so it might take 1-3
days. if you think your account has been overlooked, file a days. if you think your account has been overlooked, tweet
<a href="https://cgi.tilde.town/help/tickets">ticket</a>. <a href="https://twitter.com/tildetown">@tildetown</a> or file a
<a href="https://tilde.town/helpdesk">ticket</a>.
</p> </p>
<table> <table>
<tr> <tr>
<td> <td>
<h2>in the meantime, check out some of our projects...</h2> <h2>in the meantime, check out some of our projects...</h2>
<ul> <ul>
<li><a href="https://tilde.town/wiki/">our wiki</a></li>
<li><a href="https://tilde.town/~bear/where.html">tilde map</a></li> <li><a href="https://tilde.town/~bear/where.html">tilde map</a></li>
<li><a href="https://github.com/tildetown/zine/raw/master/issue_1/zine.pdf">zine</a></li> <li><a href="https://github.com/tildetown/zine/raw/master/issue_1/zine.pdf">zine</a></li>
<li><a href="http://tilde.town/~wiki/">our wiki</a></li>
<li><a href="https://tilde.town/~karlen/">no one will ever read this but</a></li> <li><a href="https://tilde.town/~karlen/">no one will ever read this but</a></li>
<li><a href="https://tilde.town/~kc/blackout/">black out</a></li> <li><a href="https://tilde.town/~kc/blackout/">black out</a></li>
<li><a href="https://tilde.town/~subtransience/machinecode/index.html">the machine room</a></li> <li><a href="https://tilde.town/~subtransience/machinecode/index.html">the machine room</a></li>
</ul> </ul>
<h2>or a <a href="https://cgi.tilde.town/users/random">random page</a></h2> <h2>or a <a href="https://tilde.town/cgi/random">random page</a></h2>
</td> </td>
<td style="padding:1em"> <td style="padding:1em">
<a href="http://giphy.com/gifs/cyndipop-golden-girls-bea-arthur-ToMjGpK80QLT7KLWPLO"> <a href="http://giphy.com/gifs/cyndipop-golden-girls-bea-arthur-ToMjGpK80QLT7KLWPLO">

View File

@ -1,6 +1,6 @@
Welcome to tilde.town, ~{{username}}! Welcome to tilde.town, ~{{username}}!
Please take a moment to review our code of conduct: https://tilde.town/wiki/conduct.html Please take a moment to review our code of conduct: https://tilde.town/~wiki/conduct.html
and login with: and login with:

View File

@ -12,18 +12,10 @@ from django.views.generic.edit import FormView
from .forms import TownieForm from .forms import TownieForm
from .models import Townie, Pubkey from .models import Townie, Pubkey
SIGNUPS_ENABLED = True
class SignupView(FormView): class SignupView(FormView):
form_class = TownieForm form_class = TownieForm
template_name = 'users/signup.html' template_name = 'users/signup.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['signups_enabled'] = SIGNUPS_ENABLED
return ctx
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
del form.cleaned_data['captcha'] del form.cleaned_data['captcha']