Compare commits

..

No commits in common. "master" and "feature/enhanced-ticket-view" have entirely different histories.

26 changed files with 87 additions and 435 deletions

View File

@ -10,7 +10,7 @@ _Being an adminstrative tool written in Django for <https://tilde.town>_.
* [x] Helpdesk
* [x] User account management
* [ ] Start/stop services
* [ ] Cost reporting from DO
* [ ] Cost reporting from AWS
* [ ] Status monitoring
## Requirements

View File

@ -9,4 +9,4 @@ 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
gunicorn -b0.0.0.0:$PORT ttadmin.wsgi

View File

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

View File

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

View File

@ -1,26 +1,35 @@
import logging
from smtplib import SMTP_SSL, SMTPException
from email.message import EmailMessage
import requests
from django.conf import settings
logger = logging.getLogger()
ADMIN_NAME='vilmibm'
EXTERNAL_FROM='root@tilde.town'
REPLY_TO='tildetown@protonmail.ch'
def send_email(to, body, subject='a message from tilde.town'):
"""Sends an email using external SMTP. Logs on failure."""
em = EmailMessage()
em['Subject'] = subject
em['From'] = 'root@tilde.town'
em['To'] = to
em.set_content(body)
try:
with SMTP_SSL(port=settings.SMTP_PORT, host=settings.SMTP_HOST) as smtp:
smtp.login('root@tilde.town', settings.SMTP_PASSWORD)
smtp.send_message(em)
smtp.quit()
except SMTPException as e:
logger.error(f'failed to send email "{subject}" to {to}: {e}')
return False
"""Sends an email using mailgun. Logs on failure."""
response = requests.post(
settings.MAILGUN_URL,
auth=('api', settings.MAILGUN_KEY),
data={
'from': EXTERNAL_FROM,
'h:Reply-To': REPLY_TO,
'to': to,
'subject': subject,
'text': body
}
)
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>
<body>
<h1>tilde.town guestbook</h1>
<p><em>don't try to post urls. it won't work.</em></p>
<marquee>~*~*~*~*say hello*~*~*~*~</marquee>
<form class="tilde" action="{% url 'guestbook:guestbook' %}" method="post">
{% csrf_token %}

View File

@ -1,5 +1,3 @@
import re
from django.shortcuts import redirect
from django.views.generic import TemplateView
from django.views.generic.edit import FormView
@ -7,8 +5,6 @@ from django.views.generic.edit import FormView
from .forms import GuestbookForm
from .models import GuestbookMessage
SUSPICIOUS_RE = re.compile(r'https?://')
class GuestbookView(FormView):
form_class = GuestbookForm
@ -21,7 +17,5 @@ class GuestbookView(FormView):
def form_valid(self, form):
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)
return redirect('guestbook:guestbook')

View File

@ -1,44 +1,9 @@
from django.contrib import admin
from django.forms import ModelForm
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()
from .models import Ticket
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
inlines = [ImmutableNoteInline, NewNoteInline]
readonly_fields = ('submitted', 'issue_type')
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)
readonly_fields = ('submitted',)
list_display = ('submitted', 'issue_status', 'issue_type', 'name', 'email')
list_filter = ('issue_status', 'issue_type')
fields = ('submitted', 'name', 'email', 'issue_status', 'issue_type', 'issue_text')

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, DateTimeField, ForeignKey
from django.db.models import Model, TextField, EmailField, CharField, DateTimeField
ISSUE_TYPE_CHOICES = (
('logging_in', 'help logging in'),
@ -32,17 +31,6 @@ class Ticket(Model):
null=False,
max_length=50,
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):
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
* change the password for the database or delete the password and use ident
* change DEBUG to False
* set smtp password
* set mailgun api info
"""
import os
@ -101,9 +101,8 @@ STATIC_URL = '/static/'
# Not used during local development, but used in staging+live environments
STATIC_ROOT = 'static'
SMTP_PORT=465
SMTP_HOST="smtp.zoho.com"
SMTP_PASSWORD="OVERWRITE THIS"
MAILGUN_URL = "OVERWRITE THIS"
MAILGUN_KEY = "OVERWRITE THIS"
# Mastodon credentials
MASTO_CLIENT_ID = "OVERWRITE THIS"

View File

@ -2,8 +2,8 @@ from django.conf.urls import url, include
from django.contrib import admin
urlpatterns = [
# url(r'^help/', include('help.urls')),
url(r'^help/', include('help.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),
]

View File

@ -9,27 +9,19 @@ class PubkeyInline(admin.TabularInline):
model = Pubkey
extra = 1
def bulk_accept(madmin, req, qs):
def bulk_review(madmin, req, qs):
for townie in qs:
townie.state = Townie.ACCEPTED
townie.reviewed = True
townie.save()
post_users_to_social(qs)
bulk_accept.short_description = 'mark selected townies as accepted'
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'
bulk_review.short_description = 'mark selected townies as reviewed'
@admin.register(Townie)
class TownieAdmin(admin.ModelAdmin):
inlines = [PubkeyInline]
list_display = ('username', 'state', 'email')
readonly_fields = ('reasons', 'plans', 'socials')
ordering = ('state',)
list_display = ('username', 'reviewed', 'email')
ordering = ('reviewed',)
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')

View File

@ -12,9 +12,9 @@ submission_throttle = {}
throttle_submission = throttler(submission_throttle)
USERNAME_RE = re.compile(r'^[a-z][a-z0-9_]+$')
USERNAME_MIN_LENGTH = 2
DISPLAY_NAME_RE = re.compile(r"^[a-zA-Z0-9_\-']+$")
USERNAME_RE = re.compile(r'[a-z][a-z0-9_]+')
USERNAME_MIN_LENGTH = 3
DISPLAY_NAME_RE = re.compile(r"[a-zA-Z0-9_\-']+")
DISPLAY_MIN_LENGTH = 2
@ -23,7 +23,7 @@ def validate_username(username):
raise ValidationError('Username too short.')
if not USERNAME_RE.match(username):
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:
raise ValidationError('Username already in use :(')
@ -53,62 +53,31 @@ class TownieForm(Form):
validators=(validate_username,),
help_text='lowercase and no spaces. underscore ok',
label='username')
email = EmailField(
help_text='only used to message you about your account and nothing else.',
label='e-mail')
displayname = CharField(
validators=(validate_displayname,),
help_text='100% optional. pseudonyms welcome.',
label='display name',
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(
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,
required=False,
label='where else are you online?',
help_text="""Optional, but if you're comfortable sharing with us some links to other online
spaces you're in (like twitter, mastodon, neocities, or whatever) we'd love to get to know
you when reviewing your application.
""".strip())
label='what interests you about tilde.town?',
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.')
captcha = CaptchaField()
pubkey = CharField(
widget=Textarea,
validators=(validate_pubkey,),
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.')
pubkey_type = ChoiceField(
choices=SSH_TYPE_CHOICES,
label='SSH public key type',
help_text="unless you know what you're doing you can leave this be.")
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.')
def clean(self):

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

@ -10,7 +10,7 @@ from django.contrib.auth.models import User
from django.db.models import TextField, BooleanField, CharField, ForeignKey
from django.template.loader import get_template
from common.mailing import send_email
from common.mailing import send_email, ADMIN_NAME
from help.models import Ticket
logger = logging.getLogger()
@ -19,7 +19,6 @@ SSH_TYPE_CHOICES = (
('ssh-rsa', 'ssh-rsa',),
('ssh-dss', 'ssh-dss',),
('ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp256'),
('ssh-ed25519', 'ssh-ed25519'),
)
DEFAULT_INDEX_PATH = '/etc/skel/public_html/index.html'
@ -43,54 +42,20 @@ class Townie(User):
class Meta:
verbose_name = 'Townie'
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")
state = CharField(max_length=20, choices=STATE_CHOICES, default=UNREVIEWED)
reviewed = BooleanField(default=False)
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)
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
def home(self):
return os.path.join('/home', self.username)
def generate_gift(self):
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')
context = {
'username': self.username,
'admin_name': 'vilmibm',
'admin_name': ADMIN_NAME,
}
text = welcome_tmpl.render(context)
success = send_email(self.email, text, subject='tilde.town!')
@ -117,7 +82,7 @@ class Townie(User):
"""A VERY NOT IDEMPOTENT create function. Originally, I had ambitions
to have this be idempotent and able to incrementally update a user as
needed, but decided that was overkill for now."""
assert(self.accepted)
assert(self.reviewed)
dot_ssh_path = '/home/{}/.ssh'.format(self.username)
error = _guarded_run(['sudo',
@ -239,7 +204,7 @@ def on_pubkey_post_save(sender, instance, **kwargs):
townie = townie[0]
if townie.accepted:
if townie.reviewed:
townie.write_authorized_keys()
@ -251,22 +216,12 @@ def on_townie_pre_save(sender, instance, **kwargs):
existing = Townie.objects.get(id=instance.id)
# See if we need to create the user on disk.
if existing.unreviewed and instance.accepted:
# See if we need to create this user on disk.
if not existing.reviewed and instance.reviewed is True:
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
else:
# This user state transition is currently undefined. In the future, we can check for things
# like bans/unbans and then take the appropriate action.
instance.write_authorized_keys()
return
# See if this user needs a rename on disk
@ -293,3 +248,15 @@ def _guarded_run(cmd_args, **run_args):
issue_text='error while running {}: {}'.format(
cmd_args, e))
return e
# things to consider:
# * 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

View File

@ -11,7 +11,6 @@
{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}
{% if signups_enabled %}
<form class="tilde" action="{% url 'users:signup' %}" method="post">
{% csrf_token %}
<table id="signup">
@ -41,11 +40,5 @@
<input type="submit" value="sign up <3" />
</div>
</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>
</html>

View File

@ -27,7 +27,7 @@
<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>
</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 style="padding:1em">
<a href="http://giphy.com/gifs/cyndipop-golden-girls-bea-arthur-ToMjGpK80QLT7KLWPLO">

View File

@ -12,18 +12,10 @@ from django.views.generic.edit import FormView
from .forms import TownieForm
from .models import Townie, Pubkey
SIGNUPS_ENABLED = True
class SignupView(FormView):
form_class = TownieForm
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
def form_valid(self, form):
del form.cleaned_data['captcha']