mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-07-22 11:59:33 +00:00
email: Implement view for setting up domains
This commit is contained in:
parent
502cfa4953
commit
a234407b97
@ -1,7 +1,32 @@
|
||||
"""The domain audit resource"""
|
||||
"""Configure email domains"""
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from plinth.errors import ActionError
|
||||
from plinth.actions import superuser_run
|
||||
|
||||
from . import models
|
||||
from plinth.modules.email_server import postconf
|
||||
|
||||
EXIT_VALIDATION = 40
|
||||
|
||||
managed_keys = ['_mailname', 'mydomain', 'myhostname', 'mydestination']
|
||||
|
||||
|
||||
class ClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def get():
|
||||
@ -12,3 +37,167 @@ def get():
|
||||
def repair():
|
||||
# Stub
|
||||
raise RuntimeError()
|
||||
|
||||
|
||||
def get_domain_config():
|
||||
fields = []
|
||||
|
||||
# Special keys
|
||||
mailname = SimpleNamespace(key='_mailname', name='/etc/mailname')
|
||||
with open('/etc/mailname', 'r') as fd:
|
||||
mailname.value = fd.readline().strip()
|
||||
fields.append(mailname)
|
||||
|
||||
# Postconf keys
|
||||
postconf_keys = [k for k in managed_keys if not k.startswith('_')]
|
||||
result_dict = postconf.get_many(postconf_keys)
|
||||
for key, value in result_dict.items():
|
||||
field = SimpleNamespace(key=key, value=value, name='$' + key)
|
||||
fields.append(field)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def set_keys(raw):
|
||||
# Serialize the keys we know
|
||||
config_dict = {k: v for (k, v) in raw.items() if k in managed_keys}
|
||||
if not config_dict:
|
||||
raise ClientError('To update a key, specify a new value')
|
||||
|
||||
ipc = b'%s\n' % json.dumps(config_dict).encode('utf8')
|
||||
if len(ipc) > 4096:
|
||||
raise ClientError('POST data exceeds max line length')
|
||||
|
||||
try:
|
||||
superuser_run('email_server', ['-i', 'domain', 'set_keys'], input=ipc)
|
||||
except ActionError as e:
|
||||
stdout = e.args[1]
|
||||
if not stdout.startswith('ClientError:'):
|
||||
raise RuntimeError('Action script failure') from e
|
||||
else:
|
||||
raise ValidationError(stdout) from e
|
||||
|
||||
|
||||
def action_set_keys():
|
||||
try:
|
||||
_action_set_keys()
|
||||
except ClientError as e:
|
||||
print('ClientError:', end=' ')
|
||||
print(e.args[0])
|
||||
sys.exit(EXIT_VALIDATION)
|
||||
|
||||
|
||||
def _action_set_keys():
|
||||
line = _stdin_readline()
|
||||
if not line.startswith('{') or not line.endswith('}\n'):
|
||||
raise ClientError('Bad stdin data')
|
||||
|
||||
clean_dict = {}
|
||||
# Input validation
|
||||
for key, value in json.loads(line).items():
|
||||
if key not in managed_keys:
|
||||
raise ClientError('Key not allowed: %r' % key)
|
||||
if not isinstance(value, str):
|
||||
raise ClientError('Bad value type from key: %r' % key)
|
||||
clean_function = globals()['clean_' + key.lstrip('_')]
|
||||
clean_dict[key] = clean_function(value)
|
||||
|
||||
# Apply changes (postconf)
|
||||
postconf_dict = dict(filter(lambda kv: not kv[0].startswith('_'),
|
||||
clean_dict.items()))
|
||||
postconf.set_many(postconf_dict)
|
||||
|
||||
# Apply changes (special)
|
||||
for key, value in clean_dict.items():
|
||||
if key.startswith('_'):
|
||||
set_function = globals()['su_set' + key]
|
||||
set_function(value)
|
||||
|
||||
|
||||
def clean_mailname(mailname):
|
||||
mailname = mailname.lower().strip()
|
||||
if not re.match('^[a-z0-9-\\.]+$', mailname):
|
||||
raise ClientError('Invalid character in host/domain/mail name')
|
||||
# XXX: need more verification
|
||||
return mailname
|
||||
|
||||
|
||||
def clean_mydomain(raw):
|
||||
return clean_mailname(raw)
|
||||
|
||||
|
||||
def clean_myhostname(raw):
|
||||
return clean_mailname(raw)
|
||||
|
||||
|
||||
def clean_mydestination(raw):
|
||||
ascii_code = (ord(c) for c in raw)
|
||||
valid = all(32 <= a <= 126 for a in ascii_code)
|
||||
if not valid:
|
||||
raise ClientError('Bad input for $mydestination')
|
||||
else:
|
||||
return raw
|
||||
|
||||
|
||||
def su_set_mailname(cleaned):
|
||||
with _atomically_rewrite('/etc/mailname', 'x') as fd:
|
||||
fd.write(cleaned)
|
||||
fd.write('\n')
|
||||
|
||||
|
||||
def _stdin_readline():
|
||||
membuf = io.BytesIO()
|
||||
bytes_saved = 0
|
||||
fd = sys.stdin.buffer
|
||||
time_started = time.monotonic()
|
||||
|
||||
# Reading stdin with timeout
|
||||
# https://stackoverflow.com/a/21429655
|
||||
os.set_blocking(fd.fileno(), False)
|
||||
|
||||
while bytes_saved < 4096:
|
||||
rlist, wlist, xlist = select.select([fd], [], [], 1.0)
|
||||
if fd in rlist:
|
||||
data = os.read(fd.fileno(), 4096)
|
||||
membuf.write(data)
|
||||
bytes_saved += len(data)
|
||||
if len(data) == 0 or b'\n' in data: # end of file or line
|
||||
break
|
||||
if time.monotonic() - time_started > 5:
|
||||
raise TimeoutError()
|
||||
|
||||
# Read a line
|
||||
membuf.seek(0)
|
||||
line = membuf.readline()
|
||||
if not line.endswith(b'\n'):
|
||||
raise ClientError('Line was too long')
|
||||
|
||||
try:
|
||||
return line.decode('utf8')
|
||||
except ValueError as e:
|
||||
raise ClientError('UTF-8 decode failed') from e
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _atomically_rewrite(filepath, mode):
|
||||
successful = False
|
||||
tmp = '%s.%s.plinth-tmp' % (filepath, uuid.uuid4().hex)
|
||||
fd = open(tmp, mode)
|
||||
|
||||
try:
|
||||
# Let client write to a temporary file
|
||||
yield fd
|
||||
successful = True
|
||||
finally:
|
||||
fd.close()
|
||||
|
||||
try:
|
||||
if successful:
|
||||
# Invoke rename(2) to atomically replace the original
|
||||
os.rename(tmp, filepath)
|
||||
finally:
|
||||
# Delete temp file
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@ -13,15 +13,3 @@ class EmailServerForm(forms.Form):
|
||||
class AliasCreationForm(forms.Form):
|
||||
email_name = forms.CharField(label=_('New alias (without @domain)'),
|
||||
max_length=50)
|
||||
|
||||
|
||||
class MailnameForm(forms.Form):
|
||||
mailname = forms.CharField(label=_('New value'), max_length=256)
|
||||
|
||||
|
||||
class MydomainForm(forms.Form):
|
||||
mydomain = forms.CharField(label=_('New value'), max_length=256)
|
||||
|
||||
|
||||
class MydestinationForm(forms.Form):
|
||||
mydestination = forms.CharField(label=_('New value'), max_length=4000)
|
||||
|
||||
@ -7,40 +7,29 @@
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
<div class="form-group">
|
||||
<strong>/etc/mailname</strong>
|
||||
<span> = {{ mailname }}</span>
|
||||
<form action="{{ request.path }}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ mailname_form|bootstrap }}
|
||||
<input type="hidden" name="form" value="MailnameForm">
|
||||
<input class="btn btn-primary" type="submit" name="btn_mailname"
|
||||
value="{% trans 'Change' %}">
|
||||
</form>
|
||||
</div>
|
||||
<form action="{{ request.path }}" method="post">
|
||||
|
||||
<div class="form-group">
|
||||
<strong>$mydomain</strong>
|
||||
<span> = {{ mydomain }}</span>
|
||||
<form action="{{ request.path }}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ mydomain_form|bootstrap }}
|
||||
<input type="hidden" name="form" value="MydomainForm">
|
||||
<input class="btn btn-primary" type="submit" name="btn_mydomain"
|
||||
value="{% trans 'Change' %}">
|
||||
</form>
|
||||
</div>
|
||||
{% for data in fields %}
|
||||
<div class="form-group">
|
||||
<div class="form-text">
|
||||
<strong>{{ data.name }}</strong> = {{ data.value }}
|
||||
</div> <!-- end form-text -->
|
||||
<div class="row">
|
||||
<label for="text_{{ data.key }}" class="col-sm-2 col-form-label">
|
||||
{% trans "New value" %}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="text_{{ data.key }}"
|
||||
name="{{ data.key }}" value="{{ data.new_value }}">
|
||||
</div>
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- end form-group -->
|
||||
{% endfor %}
|
||||
|
||||
{% csrf_token %}
|
||||
<input class="btn btn-primary" type="submit" name="btn_update"
|
||||
value="{% trans 'Update' %}">
|
||||
</form>
|
||||
|
||||
<div class="form-group">
|
||||
<strong>$mydestination</strong>
|
||||
<span> = {{ mydestination }}</span>
|
||||
<form action="{{ request.path }}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ mydestination_form|bootstrap }}
|
||||
<input type="hidden" name="form" value="MydestinationForm">
|
||||
<input class="btn btn-primary" type="submit" name="btn_mydestination"
|
||||
value="{% trans 'Change' %}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -69,6 +69,11 @@ class TabMixin(View):
|
||||
context['error'] = validation_error
|
||||
return self.render_to_response(context, status=status)
|
||||
|
||||
def render_exception(self, exception, status=500):
|
||||
context = self.get_context_data()
|
||||
context['error'] = [str(exception)]
|
||||
return self.render_to_response(context, status=status)
|
||||
|
||||
def find_button(self, post):
|
||||
key_filter = (k for k in post.keys() if k.startswith('btn_'))
|
||||
lst = list(itertools.islice(key_filter, 2))
|
||||
@ -109,9 +114,7 @@ class MyMailView(TabMixin, TemplateView):
|
||||
except ValidationError as validation_error:
|
||||
return self.render_validation_error(validation_error)
|
||||
except RuntimeError as runtime_error:
|
||||
context = self.get_context_data()
|
||||
context['error'] = [str(runtime_error)]
|
||||
return self.render_to_response(context, status=500)
|
||||
return self.render_exception(runtime_error)
|
||||
|
||||
def _post(self, request):
|
||||
if 'btn_mkhome' not in request.POST:
|
||||
@ -197,8 +200,10 @@ class AliasView(TabMixin, TemplateView):
|
||||
def post(self, request):
|
||||
try:
|
||||
return self._post(request)
|
||||
except ValidationError as e:
|
||||
return self.render_validation_error(e)
|
||||
except ValidationError as validation_error:
|
||||
return self.render_validation_error(validation_error)
|
||||
except Exception as exception:
|
||||
return self.render_exception(exception)
|
||||
|
||||
def _post(self, request):
|
||||
form = self.find_form(request.POST)
|
||||
@ -241,17 +246,30 @@ class TLSView(TabMixin, TemplateView):
|
||||
|
||||
class DomainView(TabMixin, TemplateView):
|
||||
template_name = 'domains.html'
|
||||
form_classes = (forms.MailnameForm, forms.MydomainForm,
|
||||
forms.MydestinationForm)
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['mailname'] = 'placeholder'
|
||||
context['mydomain'] = 'placeholder.exmaple.com'
|
||||
context['mydestination'] = '$mydomain, placeholder.example'
|
||||
|
||||
context['mailname_form'] = forms.MailnameForm()
|
||||
context['mydomain_form'] = forms.MydomainForm()
|
||||
context['mydestination_form'] = forms.MydestinationForm()
|
||||
|
||||
fields = audit.domain.get_domain_config()
|
||||
# If having post data, display the posted values
|
||||
for field in fields:
|
||||
field.new_value = self.request.POST.get(field.key, '')
|
||||
context['fields'] = fields
|
||||
return context
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
return self._post(request)
|
||||
except ValidationError as validation_error:
|
||||
return self.render_validation_error(validation_error)
|
||||
except RuntimeError as runtime_error:
|
||||
return self.render_exception(runtime_error)
|
||||
|
||||
def _post(self, request):
|
||||
changed = {}
|
||||
# Skip blank fields
|
||||
for key, value in request.POST.items():
|
||||
value = value.strip()
|
||||
if value:
|
||||
changed[key] = value
|
||||
audit.domain.set_keys(changed)
|
||||
return self.render_to_response(self.get_context_data())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user