@@ -0,0 +1,62 @@ | |||
# Byte-compiled / optimized / DLL files | |||
__pycache__/ | |||
*.py[cod] | |||
# C extensions | |||
*.so | |||
# Distribution / packaging | |||
.Python | |||
env/ | |||
build/ | |||
develop-eggs/ | |||
dist/ | |||
downloads/ | |||
eggs/ | |||
.eggs/ | |||
lib/ | |||
lib64/ | |||
parts/ | |||
sdist/ | |||
var/ | |||
*.egg-info/ | |||
.installed.cfg | |||
*.egg | |||
# PyInstaller | |||
# Usually these files are written by a python script from a template | |||
# before PyInstaller builds the exe, so as to inject date/other infos into it. | |||
*.manifest | |||
*.spec | |||
# Installer logs | |||
pip-log.txt | |||
pip-delete-this-directory.txt | |||
# Unit test / coverage reports | |||
htmlcov/ | |||
.tox/ | |||
.coverage | |||
.coverage.* | |||
.cache | |||
nosetests.xml | |||
coverage.xml | |||
*,cover | |||
# Translations | |||
*.mo | |||
*.pot | |||
# Django stuff: | |||
*.log | |||
# Sphinx documentation | |||
docs/_build/ | |||
# PyBuilder | |||
target/ | |||
# Local .ini | |||
local.ini | |||
local.*.ini | |||
*.local.ini |
@@ -0,0 +1,22 @@ | |||
The MIT License (MIT) | |||
Copyright (c) 2015 Alice | |||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||
of this software and associated documentation files (the "Software"), to deal | |||
in the Software without restriction, including without limitation the rights | |||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all | |||
copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
SOFTWARE. | |||
@@ -0,0 +1,106 @@ | |||
PizzaVolus | |||
========== | |||
PizzaVolus is a web interface to manage virtual mail domains, boxes and aliases | |||
for postfix and dovecot. | |||
It can be used to let people register an email address on open domains, and | |||
owners of addresses to set or not a redirect and change | |||
their own password. | |||
A public instance is hosted by CCrypto : https://mail.ccrypto.org/ | |||
## Installation | |||
Copy a .ini file and edit it. **Only PostgreSQL databases are supported for now.** | |||
```ini | |||
sqlalchemy.url = postgresql+psycopg2://pizza:C1tad3l@127.0.0.1/pizza | |||
``` | |||
Then create the database structure with `pv-initdb local.ini` | |||
and start a dev server with `pserve local.ini` | |||
The default admin user is Admin:root, users can be added/modified with pv-user. | |||
## Postfix & Dovecot | |||
For Debian using postfix's default "Internet Site" configuration | |||
*Optional*: Create a limited access PostgreSQL role and copy an .ini file to use it: | |||
```sql | |||
CREATE ROLE pizza_ro WITH PASSWORD '...'; | |||
GRANT SELECT ON addresses, domains TO pizza_ro; | |||
``` | |||
pv-config will make files with SQL details and queries for each server: | |||
```sh | |||
pv-config local.ini postfix_domains > /etc/postfix/pg_domains.cf | |||
pv-config local.ini postfix_aliases > /etc/postfix/pg_aliases.cf | |||
pv-config local.ini postfix_mailboxes > /etc/postfix/pg_mailboxes.cf | |||
pv-config local.ini dovecot > /etc/dovecot/dovecot-sql.conf.ext | |||
``` | |||
You also need an user for virtual mail accounts: | |||
```sh | |||
groupadd vmail --gid 500 | |||
useradd vmail --uid 500 --gid 500 --create-home | |||
``` | |||
Make postfix use that, add in main.cf: | |||
``` | |||
virtual_mailbox_domains = pgsql:/etc/postfix/pg_domains.cf | |||
virtual_alias_maps = pgsql:/etc/postfix/pg_aliases.cf | |||
virtual_mailbox_maps = pgsql:/etc/postfix/pg_mailboxes.cf | |||
virtual_transport = dovecot | |||
virtual_mailbox_base = /home/vmail/ | |||
virtual_gid_maps = static:500 | |||
virtual_uid_maps = static:500 | |||
smtpd_sasl_type = dovecot | |||
smtpd_sasl_path = private/auth | |||
smtpd_sasl_auth_enable = yes | |||
``` | |||
Make sure your virtual domains are **not** listed in `mydestination`. | |||
And in master.cf: | |||
``` | |||
dovecot unix - n n - - pipe | |||
flags=DRhu user=vmail:vmail argv=/usr/lib/dovecot/deliver -f ${sender} -d ${user}@${nexthop} | |||
``` | |||
For dovecot, enable SQL auth in /etc/dovecot/conf.d/auth-sql.conf.ext: | |||
``` | |||
passdb { | |||
driver = sql | |||
args = /etc/dovecot/dovecot-sql.conf.ext | |||
} | |||
userdb { | |||
driver = static | |||
args = uid=vmail gid=vmail home=/home/vmail/%u | |||
} | |||
``` | |||
/etc/dovecot/conf.d/10-auth.conf, uncomment: | |||
``` | |||
!include auth-sql.conf.ext | |||
``` | |||
/etc/dovecot/conf.d/10-master.conf, find and uncomment this block for SASL auth: | |||
``` | |||
# Postfix smtp-auth | |||
unix_listener /var/spool/postfix/private/auth { | |||
mode = 0666 | |||
} | |||
``` | |||
## CSV Import | |||
A previous database can be imported as CSV with pv-importcsv: | |||
- Local part | |||
- crypt()'d password | |||
- Redirect, if any | |||
- Domain name |
@@ -0,0 +1,63 @@ | |||
from __future__ import with_statement | |||
from alembic import context | |||
from sqlalchemy import engine_from_config, pool | |||
from logging.config import fileConfig | |||
from pizzavolus.models import Base | |||
# this is the Alembic Config object, which provides | |||
# access to the values within the .ini file in use. | |||
config = context.config | |||
# Interpret the config file for Python logging. | |||
# This line sets up loggers basically. | |||
fileConfig(config.config_file_name) | |||
target_metadata = Base.metadata | |||
def run_migrations_offline(): | |||
"""Run migrations in 'offline' mode. | |||
This configures the context with just a URL | |||
and not an Engine, though an Engine is acceptable | |||
here as well. By skipping the Engine creation | |||
we don't even need a DBAPI to be available. | |||
Calls to context.execute() here emit the given string to the | |||
script output. | |||
""" | |||
url = config.get_main_option('sqlalchemy.url', 'app:main') | |||
context.configure( | |||
url=url, target_metadata=target_metadata, literal_binds=True) | |||
with context.begin_transaction(): | |||
context.run_migrations() | |||
def run_migrations_online(): | |||
"""Run migrations in 'online' mode. | |||
In this scenario we need to create an Engine | |||
and associate a connection with the context. | |||
""" | |||
connectable = engine_from_config( | |||
config.get_section('app:main'), | |||
prefix='sqlalchemy.', | |||
poolclass=pool.NullPool) | |||
with connectable.connect() as connection: | |||
context.configure( | |||
connection=connection, | |||
target_metadata=target_metadata | |||
) | |||
with context.begin_transaction(): | |||
context.run_migrations() | |||
if context.is_offline_mode(): | |||
run_migrations_offline() | |||
else: | |||
run_migrations_online() |
@@ -0,0 +1,24 @@ | |||
"""${message} | |||
Revision ID: ${up_revision} | |||
Revises: ${down_revision | comma,n} | |||
Create Date: ${create_date} | |||
""" | |||
# revision identifiers, used by Alembic. | |||
revision = ${repr(up_revision)} | |||
down_revision = ${repr(down_revision)} | |||
branch_labels = ${repr(branch_labels)} | |||
depends_on = ${repr(depends_on)} | |||
from alembic import op | |||
import sqlalchemy as sa | |||
${imports if imports else ""} | |||
def upgrade(): | |||
${upgrades if upgrades else "pass"} | |||
def downgrade(): | |||
${downgrades if downgrades else "pass"} |
@@ -0,0 +1,24 @@ | |||
"""Initial | |||
Revision ID: 2584e227ce1 | |||
Revises: | |||
Create Date: 2015-09-16 02:03:26.097459 | |||
""" | |||
# revision identifiers, used by Alembic. | |||
revision = '2584e227ce1' | |||
down_revision = None | |||
branch_labels = None | |||
depends_on = None | |||
from alembic import op | |||
import sqlalchemy as sa | |||
def upgrade(): | |||
pass | |||
def downgrade(): | |||
pass |
@@ -0,0 +1,102 @@ | |||
### | |||
# app configuration | |||
# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html | |||
### | |||
[app:main] | |||
use = egg:pizzavolus | |||
pyramid.reload_templates = true | |||
pyramid.debug_authorization = false | |||
pyramid.debug_notfound = false | |||
pyramid.debug_routematch = false | |||
pyramid.includes = pyramid_debugtoolbar | |||
sqlalchemy.url = sqlite:///%(here)s/pizzavolus.sqlite | |||
# By default, the toolbar only appears for clients from IP addresses | |||
# '127.0.0.1' and '::1'. | |||
# debugtoolbar.hosts = 127.0.0.1 ::1 | |||
# Site name | |||
#app.sitename = PizzaVolus | |||
# Footer text, next to github link | |||
#app.footer_text = | |||
# Default local part regexp | |||
#app.lname_pattern = [a-zA-Z0-9_.-]{3,64} | |||
# Log IP/user-agent for users/addresses | |||
#app.log_user_auth = false | |||
#app.log_address_auth = false | |||
# Log entries displayed on users/addresses pages | |||
#app.user_log_limit = 10 | |||
#app.address_log_limit = 5 | |||
# Recaptcha site key and secret key, to use recaptcha on open domains | |||
#app.recaptcha_site = | |||
#app.recaptcha_secret = | |||
# Required access level to see logs. | |||
# "address" < "user" < other values (disable log access) | |||
#app.address_log_access = user | |||
#app.user_log_access = user | |||
# URL to use for the webmail link. Not shown if empty. | |||
#app.webmail_link = /rc2/ | |||
session.type = file | |||
session.data_dir = %(here)s/.tmp/session_data | |||
session.lock_dir = %(here)s/.tmp/session_lock | |||
session.key = session | |||
[server:main] | |||
use = egg:waitress#main | |||
host = 0.0.0.0 | |||
port = 6543 | |||
[alembic] | |||
script_location = ./alembic | |||
[loggers] | |||
keys = root, pizzavolus, sqlalchemy | |||
[handlers] | |||
keys = console | |||
[formatters] | |||
keys = generic | |||
[logger_root] | |||
level = INFO | |||
handlers = console | |||
[logger_pizzavolus] | |||
level = DEBUG | |||
handlers = | |||
qualname = pizzavolus | |||
[logger_sqlalchemy] | |||
level = INFO | |||
handlers = | |||
qualname = sqlalchemy.engine | |||
# "level = INFO" logs SQL queries. | |||
# "level = DEBUG" logs SQL queries and results. | |||
# "level = WARN" logs neither. (Recommended for production systems.) | |||
[logger_alembic] | |||
level = INFO | |||
handlers = | |||
qualname = alembic | |||
[handler_console] | |||
class = StreamHandler | |||
args = (sys.stderr,) | |||
level = NOTSET | |||
formatter = generic | |||
[formatter_generic] | |||
format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s |
@@ -0,0 +1,186 @@ | |||
from sqlalchemy import engine_from_config | |||
from pyramid.config import Configurator | |||
from pyramid_beaker import session_factory_from_settings | |||
from pyramid.settings import asbool | |||
from datetime import datetime | |||
from sqlalchemy.orm.exc import NoResultFound | |||
import user_agents | |||
from .models import DBSession, Base, Address, User | |||
from . import views | |||
class AuthenticationPolicy(object): | |||
def authenticated_userid(self, request): | |||
return request.user | |||
def unauthenticated_userid(self, request): | |||
return None | |||
def effective_principals(self, request): | |||
if not request.user or not request.user.enabled: | |||
return ['guest'] | |||
return ['auth'] + request.user.get_permissions() | |||
class AuthorizationPolicy(object): | |||
def permits(self, context, principals, permission): | |||
return permission in principals | |||
class NoEscape(str): | |||
def __html__(self): | |||
return str(self) | |||
def __str__(self): | |||
s = super().__str__() | |||
return s.replace(' ', ' ') | |||
def __repr__(self): | |||
return str(self) | |||
@classmethod | |||
def decorator(cls, fn): | |||
def dfn(*args, **kwargs): | |||
return cls(fn(*args, **kwargs)) | |||
return dfn | |||
@NoEscape.decorator | |||
def date_fmt(d, fmt='%Y-%m-%d %H:%M:%S'): | |||
""" Format datetime """ | |||
if not d: | |||
return '' | |||
if not isinstance(d, datetime): | |||
raise ValueError() | |||
return d.strftime(fmt) | |||
def get_user(request): | |||
try: | |||
type = request.session['auth_type'] | |||
id = request.session['auth_id'] | |||
if type == 'User': | |||
return User.query(id=id).one() | |||
elif type == 'Address': | |||
return Address.query(id=id).one() | |||
except NoResultFound: | |||
pass | |||
except KeyError: | |||
pass | |||
def get_ua(request): | |||
return user_agents.parse(request.user_agent) | |||
class Messages(object): | |||
""" Simple class that manages session flash messages """ | |||
def __init__(self, request): | |||
self.request = request | |||
self.session = request.session | |||
def add(self, level, text): | |||
self.session.flash((level, text)) | |||
def error(self, text): | |||
return self.add('error', text) | |||
def info(self, text): | |||
return self.add('info', text) | |||
class AppConfig: | |||
def __init__(self, settings, prefix): | |||
self.load(settings, prefix) | |||
def load(self, settings, prefix): | |||
d = {key[len(prefix):]: value | |||
for key, value in settings.items() | |||
if key.startswith(prefix)} | |||
self.sitename = d.get('sitename', 'PizzaVolus') | |||
footer_file = d.get('footer_file') | |||
if footer_file: | |||
with open(footer_file) as f: | |||
self.footer_text = f.read() | |||
else: | |||
self.footer_text = d.get('footer_text') | |||
self.log_user_auth = asbool(d.get('log_user_auth', True)) | |||
self.log_address_auth = asbool(d.get('log_address_auth', False)) | |||
self.user_log_limit = int(d.get('user_log_limit', 10)) | |||
self.address_log_limit = int(d.get('address_log_limit', 5)) | |||
default_recaptcha_api = 'https://www.google.com/recaptcha/api/siteverify' | |||
self.recaptcha_site = d.get('recaptcha_site') | |||
self.recaptcha_secret = d.get('recaptcha_secret') | |||
self.recaptcha_api = d.get('recaptcha_api', default_recaptcha_api) | |||
self.use_recaptcha = self.recaptcha_secret and self.recaptcha_site | |||
# Required level to see logs. | |||
# "address" < "user" < other values (disable log access) | |||
self.address_log_access = d.get('address_log_access', 'user') | |||
self.user_log_access = d.get('user_log_access', 'user') | |||
# Default local name regexp | |||
self.lname_pattern = d.get('lname_pattern', '^[a-zA-Z0-9_.-]{3,64}$') | |||
self.webmail_link = d.get('webmail_link') | |||
def setup_routes(config): | |||
config.add_static_view('static', 'static', cache_max_age=3600) | |||
a = config.add_route | |||
a('root', '/') | |||
a('auth', '/auth') | |||
a('logout', '/logout') | |||
a('user', '/user') | |||
a('address', '/address') | |||
a('reset', '/reset/{token}') | |||
a('admin_root', '/admin') | |||
a('admin_domain', '/admin/{domain_name}') | |||
a('admin_address', '/admin/{domain_name}/{local_part}') | |||
def main(global_config, **settings): | |||
""" This function returns a Pyramid WSGI application. | |||
""" | |||
engine = engine_from_config(settings, 'sqlalchemy.') | |||
DBSession.configure(bind=engine) | |||
Base.metadata.bind = engine | |||
settings.setdefault('mako.directories', 'pizzavolus:templates/') | |||
settings.setdefault('mako.imports', ''' | |||
from pizzavolus import date_fmt | |||
from datetime import datetime | |||
''') | |||
appconfig = AppConfig(settings, 'app.') | |||
config = Configurator(settings=settings) | |||
config.set_authentication_policy(AuthenticationPolicy()) | |||
config.set_authorization_policy(AuthorizationPolicy()) | |||
config.set_session_factory(session_factory_from_settings(settings)) | |||
config.include('pyramid_mako') | |||
config.include('pyramid_beaker') | |||
config.include('pyramid_tm') | |||
config.add_request_method(lambda r: appconfig, 'appconfig', property=True) | |||
config.add_request_method(get_user, 'user', reify=True, property=True) | |||
config.add_request_method(get_ua, 'parsed_user_agent', reify=True, property=True) | |||
config.add_request_method(Messages, 'messages', reify=True, property=True) | |||
setup_routes(config) | |||
config.scan(views) | |||
return config.make_wsgi_app() |
@@ -0,0 +1,304 @@ | |||
import crypt | |||
import random | |||
from datetime import datetime | |||
from sqlalchemy import func | |||
from sqlalchemy import Column, ForeignKey, UniqueConstraint, CheckConstraint, Index | |||
from sqlalchemy import Integer, BigInteger, DateTime, Boolean, String | |||
from sqlalchemy.orm import scoped_session, sessionmaker, relationship, backref | |||
from sqlalchemy.orm.exc import NoResultFound | |||
from sqlalchemy.ext.declarative import declarative_base | |||
from sqlalchemy.ext.hybrid import hybrid_property | |||
from sqlalchemy.dialects.postgresql import JSONB | |||
from zope.sqlalchemy import ZopeTransactionExtension | |||
ext = ZopeTransactionExtension(keep_session=False) | |||
DBSession = scoped_session(sessionmaker(extension=ext)) | |||
prng = random.SystemRandom() | |||
def random_token(length): | |||
charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" | |||
return ''.join(prng.choice(charset) for _ in range(length)) | |||
class Base(object): | |||
@classmethod | |||
def query(cls, **kwargs): | |||
return DBSession.query(cls).filter_by(**kwargs) | |||
@classmethod | |||
def count(cls, **kwargs): | |||
return DBSession.query(func.count(cls.id)).filter_by(**kwargs) | |||
Base = declarative_base(cls=Base) | |||
class AuthBase(object): | |||
""" Base class for User and Address, who can log in and has a password """ | |||
password = Column(String, nullable=True) | |||
user = False | |||
address = False | |||
def set_password(self, clear): | |||
self.password = crypt.crypt(clear, crypt.mksalt()) | |||
def check_password(self, clear): | |||
if not self.password: | |||
return False | |||
return crypt.crypt(clear, self.password) == self.password | |||
def get_permissions(self): | |||
raise NotImplementedError() | |||
class User(Base, AuthBase): | |||
__tablename__ = 'users' | |||
id = Column(Integer, primary_key=True) | |||
username = Column(String(32), nullable=False, unique=True, index=True) | |||
enabled = Column(Boolean, nullable=False, default=True) | |||
user = True | |||
log_entries = relationship('Log', backref='user', lazy='dynamic') | |||
def __init__(self, *args, **kwargs): | |||
super().__init__(*args, **kwargs) | |||
def get_permissions(self): | |||
return ['admin'] | |||
def __repr__(self): | |||
return "User(%r)" % self.username | |||
class Address(Base, AuthBase): | |||
__tablename__ = 'addresses' | |||
__table_args__ = ( | |||
UniqueConstraint('local_part', 'domain_id', name='addresses_addr_unique'), | |||
Index('addresses_addr_index', 'domain_id', 'local_part'), | |||
) | |||
id = Column(Integer, primary_key=True) | |||
domain_id = Column(ForeignKey('domains.id'), index=True) | |||
local_part = Column(String(255), index=True) | |||
redirect = Column(String, nullable=True, index=True) | |||
enabled = Column(Boolean, nullable=False, default=True) | |||
quota_mbytes = Column(Integer, nullable=True) | |||
add_date = Column(DateTime, nullable=False, default=datetime.now) | |||
expire_date = Column(DateTime, nullable=True) | |||
last_change = Column(DateTime, nullable=True) | |||
address = True | |||
log_entries = relationship('Log', backref='address', lazy='dynamic') | |||
pwresettokens = relationship('PwResetToken', backref='address') | |||
def get_permissions(self): | |||
return ['address'] | |||
@hybrid_property | |||
def address(self): | |||
return self.local_part + '@' + self.domain.name | |||
@address.expression | |||
def address(cls): | |||
return cls.local_part + '@' + Domain.name | |||
def __repr__(self): | |||
if self.domain: | |||
return "Address(%r)" % self.address | |||
else: | |||
return "Address(local_part=%r)" % self.local_part | |||
class Log(Base): | |||
""" Even log | |||
user_id/address_id is the target, who will be able see the log entry. | |||
Events are made of a type and data (JSON). | |||
""" | |||
__tablename__ = 'log' | |||
__table_args__ = ( | |||
CheckConstraint('user_id IS NULL OR address_id IS NULL'), | |||
) | |||
id = Column(BigInteger, primary_key=True) | |||
date = Column(DateTime, default=datetime.now, nullable=False) | |||
user_id = Column(ForeignKey('users.id'), nullable=True) | |||
address_id = Column(ForeignKey('addresses.id'), nullable=True) | |||
type = Column(String, nullable=False) | |||
data = Column(JSONB, nullable=True) | |||
def __init__(self, type, target=None, data=None): | |||
assert isinstance(type, str) | |||
self.type = type | |||
self.target = target | |||
# Ignore by_user if it's the same as the target | |||
by_user = data.get('by_user') | |||
if by_user and isinstance(target, User) and by_user == target.id: | |||
del data['by_user'] | |||
# We don't want to end up with useless str/repr in logs; | |||
# ORM objects should be explicitly converted before calling log() | |||
for k, v in data.items(): | |||
if isinstance(v, Base): | |||
raise ValueError() | |||
self.data = data | |||
def get_text(self): | |||
""" Returns a human-readable text representation of type and data | |||
""" | |||
def lookup(model, field, id_format='#{}', format='#{0.id}'): | |||
try: | |||
id = self.data[field] | |||
item = model.query(id=id).one() | |||
return format.format(item) | |||
except KeyError: | |||
return '' | |||
except NoResultFound: | |||
return id_format.format(id) | |||
def user_lookup(field): | |||
return lookup(User, field, ' by #{}', ' by {0.username} (#{0.id})') | |||
if self.type == 'auth': | |||
remote_addr = self.data.get('remote_addr') | |||
user_agent = self.data.get('user_agent') | |||
return 'Auth from %s (%s)' % (remote_addr, user_agent) | |||
if self.type == 'set_password': | |||
by_user = user_lookup('by_user') | |||
return 'Password changed' + by_user | |||
if self.type == 'set_redirect': | |||
value = self.data.get('to') | |||
by_user = user_lookup('by_user') | |||
if value: | |||
return 'Redirection changed to {}'.format(value) + by_user | |||
else: | |||
return 'Redirection disabled' + by_user | |||
if self.type == 'use_reset_token': | |||
token = self.data.get('token') | |||
return 'Used password reset token #{}'.format(token) | |||
if self.type == 'add_domain': | |||
domain = lookup(Domain, 'domain', '#{}', '{0.name} (#{0.id})') | |||
by_user = user_lookup('by_user') | |||
return 'Domain {} created'.format(domain) + by_user | |||
if self.type == 'add_address': | |||
addr = lookup(Address, 'address', '#{}', '{0.address} (#{0.id})') | |||
by_user = user_lookup('by_user') | |||
return 'Address {} created'.format(addr) + by_user | |||
if self.type == 'add_user': | |||
user = lookup(User, 'user', '#{}', '{0.username} (#{0.id})') | |||
return 'User {} created'.format(user) | |||
if self.type == 'disable': | |||
return 'User disabled' | |||
if self.type == 'enable': | |||
return 'User enabled' | |||
if self.type == 'register': | |||
addr = lookup(Address, 'address', '#{}', '{0.address} (#{0.id})') | |||
return 'Address {} registered'.format(addr) | |||
return '%s %r' % (self.type, self.data) | |||
@property | |||
def target(self): | |||
return self.user or self.address | |||
@target.setter | |||
def target(self, target): | |||
if target is None: | |||
pass | |||
elif isinstance(target, User): | |||
self.user_id = target.id | |||
elif isinstance(target, Address): | |||
self.address_id = target.id | |||
else: | |||
raise ValueError('Log target should be User or Address') | |||
def __str__(self): | |||
return self.get_text() | |||
def __repr__(self): | |||
return "Log(%r, target=%r, data=%r)" % (self.type, self.target, self.data) | |||
class Domain(Base): | |||
__tablename__ = 'domains' | |||
id = Column(Integer, primary_key=True) | |||
name = Column(String, index=True, unique=True) | |||
catchall = Column(String, nullable=True) | |||
register_open = Column(Boolean, nullable=False, default=False) | |||
register_regexp = Column(String, nullable=True) | |||
default_quota = Column(Integer, nullable=True) | |||
add_date = Column(DateTime, nullable=False, default=datetime.now) | |||
addresses = relationship('Address', lazy='dynamic', | |||
backref=backref('domain', lazy='joined')) | |||
def __repr__(self): | |||
return "Domain(%r)" % self.name | |||
class PwResetToken(Base): | |||
__tablename__ = 'pwresettokens' | |||
id = Column(Integer, primary_key=True) | |||
address_id = Column(ForeignKey('addresses.id'), nullable=False, index=True) | |||
token = Column(String(32), index=True, nullable=False) | |||
create_date = Column(DateTime, default=datetime.now, nullable=False) | |||
def __init__(self, **kwargs): | |||
kwargs.setdefault('token', random_token(32)) | |||
super().__init__(**kwargs) | |||
def __repr__(self): | |||
return "PwResetToken(%r)" % self.token | |||
def log(type, target=None, **data): | |||
""" Save a Log entry """ | |||
l = Log(type=type, target=target, data=data) | |||
DBSession.add(l) | |||
return l | |||
def get_user_log(request, user=None): | |||
user = user or request.user | |||
access = request.appconfig.user_log_access | |||
if request.user.user and access not in ('address', 'user'): | |||
return None | |||
return Log.query(user_id=user.id) \ | |||
.order_by(Log.id.desc()) \ | |||
.limit(request.appconfig.user_log_limit) \ | |||
.all() | |||
def get_address_log(request, address=None): | |||
address = address or request.user | |||
access = request.appconfig.address_log_access | |||
if request.user.address and access != 'address': | |||
return None | |||
if request.user.user and access not in ('address', 'user'): | |||
return None | |||
return Log.query(address_id=address.id) \ | |||
.order_by(Log.id.desc()) \ | |||
.limit(request.appconfig.address_log_limit) \ | |||
.all() | |||
@@ -0,0 +1 @@ | |||
# package |
@@ -0,0 +1,125 @@ | |||
""" | |||
Mail server configuration generator | |||
""" | |||
import sys | |||
import re | |||
from argparse import ArgumentParser | |||
from sqlalchemy import engine_from_config | |||
from pyramid.paster import get_appsettings, setup_logging | |||
from pizzavolus.models import Address, Domain | |||
def format_sql(query, **kwargs): | |||
query = re.sub('[ \n]+', ' ', query) | |||
query = query.format(addresses_t=Address.__tablename__, | |||
domains_t=Domain.__tablename__, | |||
**kwargs) | |||
return query | |||
dovecot_query = format_sql(""" | |||
SELECT '%u' AS userid, password | |||
FROM {addresses_t} AS at, {domains_t} AS dt | |||
WHERE dt.id=at.domain_id | |||
AND at.local_part='%n' AND dt.name='%d' | |||
AND at.password IS NOT NULL | |||
AND at.enabled | |||
""") | |||
postfix_domains_query = format_sql(""" | |||
SELECT '%s' AS domain | |||
FROM {domains_t} AS dt | |||
WHERE dt.name='%s' | |||
""") | |||
postfix_aliases_query = format_sql(""" | |||
SELECT COALESCE(( | |||
SELECT at.redirect | |||
FROM {addresses_t} AS at, {domains_t} AS dt | |||
WHERE dt.id=at.domain_id AND dt.name='%d' | |||
AND at.local_part='%u' AND enabled AND redirect IS NOT NULL | |||
), ( | |||
SELECT dt.catchall | |||
FROM {domains_t} AS dt | |||
WHERE dt.name='%d' AND dt.catchall IS NOT NULL | |||
AND NOT EXISTS ( | |||
SELECT id | |||
FROM {addresses_t} AS at | |||
WHERE at.local_part='%u' AND at.domain_id=dt.id | |||
) | |||
)) as redirect | |||
""") | |||
postfix_mailboxes_query = format_sql(""" | |||
SELECT '%d/%u' | |||
FROM {addresses_t} AS at, {domains_t} AS dt | |||
WHERE dt.id=at.domain_id | |||
AND dt.name='%d' AND at.local_part='%u' | |||
AND at.enabled | |||
AND at.redirect IS NULL | |||
""") | |||
def gen_dovecot(url): | |||
print("driver = pgsql") | |||
print("default_pass_scheme = SHA512-CRYPT") | |||
connect = "host={url.host} dbname={url.database} user={url.username}" | |||
if url.password: | |||
connect += " password={url.password}" | |||
print("connect = " + connect.format(url=url)) | |||
print("password_query = " + dovecot_query) | |||
def postfix_common(url): | |||
print('hosts = ' + url.host) | |||
print('dbname = ' + url.database) | |||
print('user = ' + url.username) | |||
if url.password: | |||
print('password = ' + url.password) | |||
def gen_postfix_domains(url): | |||
postfix_common(url) | |||
print("query = " + postfix_domains_query) | |||
def gen_postfix_aliases(url): | |||
postfix_common(url) | |||
print("query = " + postfix_aliases_query) | |||
def gen_postfix_mailboxes(url): | |||
postfix_common(url) | |||
print("query = " + postfix_mailboxes_query) | |||
def main(argv=sys.argv): | |||
servers = dict( | |||
postfix_domains=gen_postfix_domains, | |||
postfix_aliases=gen_postfix_aliases, | |||
postfix_mailboxes=gen_postfix_mailboxes, | |||
dovecot=gen_dovecot, | |||
) | |||
parser = ArgumentParser(description=__doc__) | |||
parser.add_argument('config') | |||
parser.add_argument('server', type=str, action='store', | |||
choices=servers.keys()) | |||
args = parser.parse_args() | |||
config_uri = args.config | |||
setup_logging(config_uri) | |||
settings = get_appsettings(config_uri) | |||
engine = engine_from_config(settings, 'sqlalchemy.') | |||
servers[args.server](engine.url) | |||
@@ -0,0 +1,112 @@ | |||
#!/usr/bin/env python3 | |||
""" | |||
Import CSV files (local_part, password, redirect, domain_name). | |||
""" | |||
import os | |||
import sys | |||
import csv | |||
from argparse import ArgumentParser | |||
from sqlalchemy import engine_from_config | |||
from sqlalchemy.orm.exc import NoResultFound | |||
from pyramid.paster import get_appsettings, setup_logging | |||
import transaction | |||
from pizzavolus.models import DBSession, Address, Domain | |||
def usage(argv, out=sys.stdout): | |||
cmd = os.path.basename(argv[0]) | |||
out.write('usage: %s <config_uri> <csv_file>\n' | |||
'(example: "%s development.ini")\n' % (cmd, cmd)) | |||
sys.exit(1) | |||
def main(argv=sys.argv): | |||
parser = ArgumentParser(description=__doc__) | |||
parser.add_argument('config') | |||
parser.add_argument('csvfile', type=str, action='store', | |||
help="CSV file") | |||
parser.add_argument('-q', dest='quiet', action='store_true', | |||
help="Dont print every address") | |||
commit = parser.add_mutually_exclusive_group() | |||
commit.add_argument('-n', dest='commit_never', action='store_true', | |||
help="Dont commit changes and dont ask") | |||
commit.add_argument('-y', dest='commit_always', action='store_true', | |||
help="Commit changes without asking") | |||
args = parser.parse_args() | |||
config_uri = args.config | |||
setup_logging(config_uri) | |||
settings = get_appsettings(config_uri) | |||
engine = engine_from_config(settings, 'sqlalchemy.') | |||
DBSession.configure(bind=engine) | |||
transaction.begin() | |||
known_domains = {} | |||
counter = 0 | |||
with open(args.csvfile, 'r') as csvfile: | |||
reader = csv.reader(csvfile) | |||
for row in reader: | |||
local, password, redirect, dname = row | |||
if dname in known_domains: | |||
domain = known_domains[dname] | |||
else: | |||
try: | |||
domain = Domain.query(name=dname).one() | |||
except NoResultFound: | |||
domain = Domain(name=dname) | |||
DBSession.add(domain) | |||
known_domains[domain.name] = domain | |||
if not local: | |||
if not redirect: | |||
print('Invalid address: catch-all without redirect') | |||
return 1 | |||
domain.catchall = redirect | |||
print('*@%s -> %s' % (domain.name, domain.catchall)) | |||
continue | |||
address = Address() | |||
address.local_part = local | |||
address.password = password or None | |||
address.redirect = redirect or None | |||
address.domain = domain | |||
address.domain_id = domain.id | |||
DBSession.add(address) | |||
if not args.quiet: | |||
print(address.address, end='') | |||
if address.password: | |||
print(' [...%s]' % address.password[-8:], end='') | |||
if address.redirect: | |||
print(' -> %s' % address.redirect, end='') | |||
print() | |||
counter += 1 | |||
if args.commit_always: | |||
transaction.commit() | |||
print('saved.') | |||
elif args.commit_never: | |||
transaction.abort() | |||
print('aborted.') | |||
else: | |||
r = input('Commit %d addresses? ' % counter) | |||
if r.strip().lower() == 'y': | |||
transaction.commit() | |||
print('saved.') | |||
else: | |||
transaction.abort() | |||
print('aborted.') | |||
@@ -0,0 +1,42 @@ | |||
import os | |||
import sys | |||
from sqlalchemy import engine_from_config | |||
from pyramid.paster import get_appsettings, setup_logging | |||
import transaction | |||
from alembic.config import Config | |||
from alembic import command | |||
from pizzavolus.models import DBSession, Base, User | |||
def usage(argv, out=sys.stdout): | |||
cmd = os.path.basename(argv[0]) | |||
out.write('usage: %s <config_uri>\n' | |||
'(example: "%s development.ini")\n' % (cmd, cmd)) | |||
sys.exit(1) | |||
def initialize_db(): | |||
Base.metadata.create_all(DBSession.bind.engine) | |||
with transaction.manager: | |||
admin = User(username='Admin') | |||
admin.set_password('root') | |||
DBSession.add(admin) | |||
DBSession.flush() | |||
def main(argv=sys.argv): | |||
if len(argv) != 2: | |||
usage(argv) | |||
config_uri = argv[1] | |||
setup_logging(config_uri) | |||
settings = get_appsettings(config_uri) | |||
engine = engine_from_config(settings, 'sqlalchemy.') | |||
DBSession.configure(bind=engine) | |||
initialize_db() | |||
alembic = Config(config_uri) | |||
command.stamp(alembic, 'head') | |||
@@ -0,0 +1,89 @@ | |||
""" | |||
App log query tool. | |||
Log entries can be filtered by address or user, and/or by type name. | |||
They are sorted by date, oldest entries first without -r. | |||
""" | |||
import sys | |||
from argparse import ArgumentParser | |||
from sqlalchemy import engine_from_config | |||
from pyramid.paster import get_appsettings, setup_logging | |||
from pizzavolus.models import DBSession, Log, User, Address, Domain | |||
def main(argv=sys.argv): | |||
parser = ArgumentParser(description=__doc__) | |||
parser.add_argument('config') | |||
parser.add_argument('-u', '--userid', type=int, action='store', | |||
help='Filter by user ID') | |||
parser.add_argument('-a', '--addressid', type=int, action='store', | |||
help='Filter by address ID') | |||
parser.add_argument('-U', '--user', type=str, action='store', | |||
help='Filter by username') | |||
parser.add_argument('-A', '--address', type=str, action='store', | |||
help='Filter by address') | |||
parser.add_argument('-s', '--site', action='store_true', | |||
help='Get only site entries (no user or address)') | |||
parser.add_argument('-t', '--type', action='store', | |||
help='Filter by entry type') | |||
parser.add_argument('-r', '--reverse', action='store_true', | |||
help='Latest entries first') | |||
parser.add_argument('-n', '--lines', type=int, action='store', | |||
help='Limit number of entries') | |||
args = parser.parse_args() | |||
config_uri = args.config | |||
setup_logging(config_uri) | |||
settings = get_appsettings(config_uri) | |||
engine = engine_from_config(settings, 'sqlalchemy.') | |||
DBSession.configure(bind=engine) | |||
q = DBSession.query(Log) | |||
filters = ('userid', 'addressid', 'user', 'address', 'site') | |||
if sum(1 for f in filters if getattr(args, f)) > 1: | |||
print('Only use one of ' + ', '.join('--'+f for f in filters)) | |||
return 1 | |||
if args.userid: | |||
q = q.filter(Log.user_id == args.userid) | |||
elif args.addressid: | |||
q = q.filter(Log.address_id == args.addressid) | |||
elif args.user: | |||
q = q.filter(User.username == args.user) | |||
q = q.filter(Log.user_id == User.id) | |||
elif args.address: | |||
q = q.filter(Address.address == args.address) | |||
q = q.filter(Log.address_id == Address.id) | |||
q = q.filter(Address.domain_id == Domain.id) | |||
elif args.site: | |||
q = q.filter(Log.user_id == None) | |||
q = q.filter(Log.address_id == None) | |||
if args.type: | |||
q = q.filter(Log.type == args.type) | |||
if args.reverse: | |||
q = q.order_by(Log.date) | |||
else: | |||
q = q.order_by(Log.date.desc()) | |||
if args.lines: | |||
q = q.limit(args.lines) | |||
for entry in reversed(q.all()): | |||
date = entry.date.strftime('%Y-%m-%d %H:%M:%S') | |||
if entry.user_id: | |||
id = 'user#{}'.format(entry.user_id) | |||
elif entry.address_id: | |||
id = 'addr#{}'.format(entry.address_id) | |||
else: | |||
id = 'site' | |||
print('{date} {id}: {text}' | |||
.format(date=date, id=id, text=entry.get_text())) | |||
@@ -0,0 +1,87 @@ | |||
""" | |||
User management. | |||
""" | |||
import sys | |||
from argparse import ArgumentParser | |||
import getpass | |||
from sqlalchemy import engine_from_config | |||
from pyramid.paster import get_appsettings, setup_logging | |||
import transaction | |||
from pizzavolus.models import DBSession, User, log | |||
def main(argv=sys.argv): | |||
parser = ArgumentParser(description=__doc__) | |||
parser.add_argument('config') | |||
parser.add_argument('username', type=str, action='store', | |||
help="Username") | |||
command = parser.add_mutually_exclusive_group() | |||
command.add_argument('-P', '--set-password', action='store_true', | |||
help="Change user's password (prompt)") | |||
command.add_argument('-L', '--lock', action='store_true', | |||
help="Lock account") | |||
command.add_argument('-U', '--unlock', action='store_true', | |||
help="Unlock account") | |||
command.add_argument('-I', '--info', action='store_true', | |||
help="Account details (default)") | |||
command.add_argument('-A', '--add', action='store_true', | |||
help="Add account") | |||
args = parser.parse_args() | |||
config_uri = args.config | |||
setup_logging(config_uri) | |||
settings = get_appsettings(config_uri) | |||
engine = engine_from_config(settings, 'sqlalchemy.') | |||
DBSession.configure(bind=engine) | |||
with transaction.manager: | |||
if args.add: | |||
u = User(username=args.username) | |||
p = getpass.getpass() | |||
if not p: | |||
print("No password supplied, user not created.") | |||
return 1 | |||
u.set_password(p) | |||
DBSession.add(u) | |||
DBSession.flush() | |||
log('add_user', user=u.id) | |||
else: | |||
user = User.query(username=args.username).first() | |||
if not user: | |||
print("User not found.") | |||
return 1 | |||
if args.set_password: | |||
p = getpass.getpass() | |||
if not p: | |||
print("No password supplied, user not created.") | |||
return 1 | |||
user.set_password(p) | |||
log('set_password', target=user) | |||
print("Password changed") | |||
elif args.lock: | |||
user.enabled = False | |||
log('disable', target=user) | |||
print("Account locked") | |||
elif args.unlock: | |||
user.enabled = True | |||
log('enable', target=user) | |||
print("Account unlocked") | |||
else: | |||
print("Account #%d" % user.id) | |||
print("Username: %s" % user.username) | |||
print("Status: %s" % 'enabled' if user.enabled else 'disabled') | |||
@@ -0,0 +1,239 @@ | |||
* { | |||
box-sizing: border-box; | |||
} | |||
body { | |||
background: #111; | |||
font-family: "Open Sans", "DejaVu Sans", Helvetica, Arial, sans-serif; | |||
} | |||
.align-left { text-align: left; } | |||
.align-right { text-align: right; } | |||
.pure-g > div { | |||
margin: 0 auto; | |||
margin-bottom: 3em; | |||
} | |||
a { | |||
color: #bbb; | |||
} | |||
a:visited { | |||
color: #999; | |||
} | |||
body { | |||
color: #ccc; | |||
} | |||
#header { | |||
margin: 0 4em 0 4em; | |||
} | |||
#header h1 { | |||
font-size: 2.5em; | |||
display: inline-block; | |||
} | |||
#header h1 a { | |||
text-transform: uppercase; | |||
color: #32A1F0; | |||
font-weight: 300; | |||
text-decoration: none; | |||
} | |||
#header p { | |||
display: inline-block; | |||
float: right; | |||
text-align: right; | |||
border-bottom: 2px solid #2A79A2; | |||
} | |||
#header p a { | |||
font-weight: 300; | |||
font-size: 1.3em; | |||
text-transform: uppercase; | |||
text-decoration: none; | |||
padding: 0 .5em 0 .5em; | |||
color: #4CC2FF; | |||
color: #2092E3; | |||
} | |||
#header p a:hover { | |||
border-bottom: 2px solid #3da4da; | |||
} | |||
.pure-form input[type=submit], | |||
.pure-button { | |||
background: #0F3047; | |||
border: 1px solid #133E5C; | |||
color: #ddd; | |||
} | |||
.pure-form input[type=text], | |||
.pure-form input[type=password], | |||
.pure-form input[type=email], | |||
.pure-form textarea, | |||
.pure-form select { | |||
background: #111; | |||
border: 1px solid #2a79a2; | |||
box-shadow: none; | |||
height: 2.5em; | |||
margin: 1em; | |||
color: #fff; | |||
} | |||
.pure-form input[type=text], | |||
.pure-form input[type=password], | |||
.pure-form input[type=email], | |||
.pure-form textarea { | |||
} | |||
.pure-form input[type=text]:focus, | |||
.pure-form input[type=password]:focus, | |||
.pure-form input[type=email]:focus, | |||
.pure-form textarea:focus { | |||
border: 1px solid #3DA4DA; | |||
} | |||
.pure-form label { | |||
color: #ddd; | |||
margin-top: 0.50em; | |||
} | |||
.pure-form select { | |||
} | |||
.input-like-thing { | |||
margin: 1em; | |||
padding: 0.5em 0.6em; | |||
display: inline-block; | |||
border: 1px solid transparent; | |||
text-align: left; | |||
color: #999; | |||
} | |||
.pure-form input[type=checkbox] { | |||
margin: 1em; | |||
padding: 0.5em 0.6em; | |||
border: 1px solid #2a79a2; | |||
width: 1em; | |||
height: 1em; | |||
} | |||
.pure-form input.half-left, | |||
.pure-form select.half-left { | |||
margin-right: 0; | |||
border-top-right-radius: 0; | |||
border-bottom-right-radius: 0; | |||
} | |||
.pure-form input.half-right, | |||
.pure-form select.half-right { | |||
margin-left: 0; | |||
border-top-left-radius: 0; | |||
border-bottom-left-radius: 0; | |||
border-left: 0; | |||
} | |||
.status-enabled { color: #00B104; } | |||
.status-disabled { color: #B00051; } | |||
.pure-form legend, | |||
h3 { | |||
margin: 0 0 1em 0; | |||
border-bottom: 1px solid #2092e3; | |||
color: #2092e3; | |||
font-size: 1.2em; | |||
font-weight: 300; | |||
text-transform: uppercase; | |||
padding: 0.3em 0px; | |||
} | |||
h2 { | |||
text-align: center; | |||
color: #ddd; | |||
font-weight: 300; | |||
font-size: 2em; | |||
letter-spacing: 0.08em; | |||
} | |||
.message { | |||
margin: 0; | |||
padding: 0.25em; | |||
text-align: center; | |||
} | |||
.message--info { | |||
background: #004371; | |||
color: #eee; | |||
} | |||
.message--error { | |||
background: #710000; | |||
color: #ccc; | |||
} | |||
#footer { | |||
background: #111; | |||
padding: 2em 0.5em 1em 0.5em; | |||
margin-top: 6em; | |||
text-align: center; | |||
} | |||
#footer p, | |||
#footer a { | |||
color: #999; | |||
} | |||
#footer a.impact { | |||
margin: 0.3em; | |||
display: inline-block; | |||
text-transform: uppercase; | |||
font-size: 1.7em; | |||
font-weight: 300; | |||
color: #a00000; | |||
text-decoration: none; | |||
border: 1px solid #a00000; | |||
} | |||
table { | |||
width: 100%; | |||
} | |||
table td { | |||
padding: 0.5em; | |||
text-align: center; | |||
} | |||
table thead tr td { | |||
color: #2092e3; | |||
} | |||
table tr:nth-child(even) { background: #181818; } | |||
table tr:nth-child(odd) { background: #1f1f1f; } | |||
table thead tr:nth-child(odd) { background: transparent; } | |||
div.g-recaptcha div div { | |||
margin: 1em auto; | |||
} | |||
#page { | |||
text-align: center; | |||
} | |||
@media (min-width: 48em) { | |||
.content { | |||
padding-right: 2em; | |||
} | |||
} | |||
@media (max-width: 48em) { | |||
form.pure-form-stacked input.pure-input-1 { | |||
margin-left: 0; | |||
} | |||
} | |||
@@ -0,0 +1,112 @@ | |||
<%! title = 'Address' %> | |||
<%inherit file="layout.mako" /> | |||
% if admin: | |||
<h2> | |||
<a href="/admin/${address.domain.name}">${address.domain.name}</a> | |||
> | |||
${address.address} | |||
</h2> | |||
% else: | |||
<h2>Address: ${address.address}</h2> | |||
% endif | |||
<div id="page" class="pure-g"> | |||
<div class="pure-u-1 pure-u-md-1-2"> | |||
<form class="pure-form pure-form-aligned" action="" method="post"> | |||
<fieldset> | |||
<legend>Change Password</legend> | |||
<div class="pure-control-group"> | |||
<label for="password">New Password</label> | |||
<input type="password" id="password" name="password" required | |||
class="pure-input-1-2" /> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="password2">Repeat</label> | |||
<input type="password" id="password2" name="password2" required | |||
class="pure-input-1-2" /> | |||
</div> | |||
<div class="pure-controls"> | |||
<input type="hidden" name="update" value="password" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Change Password" /> | |||
</div> | |||
</fieldset> | |||
</form> | |||
% if admin: | |||
<form class="pure-form pure-form-aligned" action="" method="post"> | |||
<div class="pure-controls"> | |||
<input type="hidden" name="update" value="new_reset_token" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Create Reset Token" /> | |||
</div> | |||
</form> | |||
% endif | |||
</div> | |||
<div class="pure-u-1 pure-u-md-1-2"> | |||
<form id="redirect-form" class="pure-form pure-form-aligned" action="" method="post"> | |||
<fieldset> | |||
<legend>Redirection</legend> | |||
<% redirect_enabled = 'enabled' if address.redirect else 'disabled' %> | |||
<div class="pure-control-group"> | |||
<label for="redirect">Status</label> | |||
<p class="input-like-thing pure-input-1-2"> | |||
<span class="status-${redirect_enabled}">⬤</span> | |||
${redirect_enabled.title()} | |||
</p> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="redirect">Destination</label> | |||
<input type="text" id="redirect" name="redirect" pattern="(.+@.+)?" | |||
value="${address.redirect or ''}" class="pure-input-1-2" /> | |||
</div> | |||
<div class="pure-controls"> | |||
<input type="hidden" name="update" value="redirect" /> | |||
% if address.redirect: | |||
<button class="pure-button pure-button-primary" id="redirect-disable"> | |||
Disable | |||
</button> | |||
% endif | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Set Redirection" /> | |||
</div> | |||
</fieldset> | |||
</form> | |||
</div> | |||
% if log: | |||
<div class="pure-u-1"> | |||
<h3>Log</h3> | |||
<table> | |||
<thead> | |||
<tr><td>date</td> <td>event</td></tr> | |||
</thead> | |||
% for entry in log: | |||
<tr> | |||
<td>${date_fmt(entry.date)}</td> | |||
<td class="align-left">${entry.get_text()} | |||
</tr> | |||
% endfor | |||
</table> | |||
</div> | |||
% endif | |||
</div> | |||
<script type="text/javascript"> | |||
(function() { | |||
form = document.getElementById("redirect-form"); | |||
dest = document.getElementById("redirect"); | |||
button = document.getElementById("redirect-disable"); | |||
button.onclick = function() { | |||
dest.value = ""; | |||
form.submit(); | |||
}; | |||
})(); | |||
</script> |
@@ -0,0 +1,40 @@ | |||
<%! title = 'Domain' %> | |||
<%inherit file="layout.mako" /> | |||
<h2>Admin</h2> | |||
<div id="page" class="pure-g"> | |||
<div class="pure-u-1"> | |||
<h3>Add Domain</h3> | |||
<form class="pure-form pure-form-aligned pure-u-lg-1-2" action="" method="post"> | |||
<fieldset> | |||
<div class="pure-control-group"> | |||
<label for="name">Domain Name</label> | |||
<input type="text" id="name" name="name" required | |||
class="pure-input-1-2" /> | |||
</div> | |||
<div class="pure-controls"> | |||
<input type="hidden" name="update" value="new_domain" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Add Domain" /> | |||
</div> | |||
</fieldset> | |||
</form> | |||
</div> | |||
<div class="pure-u-1"> | |||
<h3>Domains (${len(domains)})</h3> | |||
<table> | |||
<thead> | |||
<tr><td>name</td> <td>open</td> <td>addresses</td> </tr> | |||
</thead> | |||
% for domain in domains: | |||
<tr> | |||
<td><a href="/admin/${domain.name}">${domain.name}</a></td> | |||
<td>${domain.register_open or ''}</td> | |||
<td>${domain.addresses.count()}</td> | |||
</tr> | |||
% endfor | |||
</table> | |||
</div> | |||
</div> |
@@ -0,0 +1,25 @@ | |||
<%! title = 'Auth' %> | |||
<%inherit file="layout.mako" /> | |||
<div id="page" class="pure-g"> | |||
<div class="pure-u-1"> | |||
<h3>Log in</h3> | |||
<form class="pure-form pure-form-stacked pure-u-md-1-2" action="?" method="post"> | |||
<fieldset> | |||
<label for="username">Username / E-Mail</label> | |||
<input type="text" id="username" name="username" required | |||
value="${username or ''}" class="pure-input-1" | |||
pattern=".{2,}(@[a-zA-Z0-9_.-]{2,})?" | |||
placeholder="Username" autofocus/> | |||
<label for="password">Password</label> | |||
<input type="password" id="password" name="password" required class="pure-input-1" | |||
value="${password or ''}" placeholder="Password" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Log in" /> | |||
</fieldset> | |||
</form> | |||
</div> | |||
</div><!-- div#page --> | |||
@@ -0,0 +1,107 @@ | |||
<%! title = 'Domain' %> | |||
<%inherit file="layout.mako" /> | |||
<h2>Domain: ${domain.name}</h2> | |||
<div id="page" class="pure-g"> | |||
<div class="pure-u-1 pure-u-md-1-2"> | |||
<form class="pure-form pure-form-aligned" action="" method="post"> | |||
<fieldset> | |||
<legend>Add Address</legend> | |||
<div class="pure-control-group"> | |||
<label for="local_part">Local Name</label> | |||
<input type="text" id="local_part" name="local_part" required | |||
class="pure-input-1-2" | |||
placeholder="[local name]@${domain.name}" /> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="password">Password</label> | |||
<input type="password" id="password" name="password" | |||
class="pure-input-1-2" placeholder="Keep empty to get a reset link" /> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="redirect">Redirect?</label> | |||
<input type="text" id="redirect" name="redirect" pattern="(.+@.+)?" | |||
class="pure-input-1-2" /> | |||
</div> | |||
<div class="pure-controls"> | |||
<input type="hidden" name="update" value="new_addr" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Add Address" /> | |||
</div> | |||
</fieldset> | |||
</form> | |||
</div> | |||
<div class="pure-u-1 pure-u-md-1-2"> | |||
<form class="pure-form pure-form-aligned" action="" method="post" id="settings_form"> | |||
<fieldset> | |||
<legend>Settings</legend> | |||
<label for="reg_open" class="pure-checkbox"> | |||
<input type="checkbox" id="reg_open" name="reg_open" | |||
${'checked' if domain.register_open else ''} /> | |||
Open registrations | |||
</label> | |||
<div class="pure-control-group"> | |||
<label for="reg_regexp">Local part regex</label> | |||
<input type="text" id="reg_regexp" name="reg_regexp" | |||
class="pure-input-1-2" value="${domain.register_regexp or ''}" | |||
placeholder="Keep empty to use the default RE" /> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="catchall">Catch all?</label> | |||
<input type="text" id="catchall" name="catchall" pattern="(.+@.+,?)*" | |||
class="pure-input-1-2" value="${domain.catchall or ''}" /> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="default_quota">Default quota (MB)</label> | |||
<input type="text" id="default_quota" name="default_quota" pattern="[0-9]+" | |||
class="pure-input-1-2" value="${domain.default_quota or 0}" /> | |||
</div> | |||
<div class="pure-controls"> | |||
<input type="hidden" name="update" value="settings" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Update" /> | |||
</div> | |||
<script> | |||
(function(){ | |||
var form = document.getElementById("settings_form"); | |||
var ro = form.elements.namedItem("reg_open"); | |||
var rr = form.elements.namedItem("reg_regex"); | |||
function update() { | |||
rr.parentElement.style.display = ro.checked ? "block" : "none"; | |||
} | |||
ro.onchange = update; | |||
update(); | |||
})(); | |||
</script> | |||
</fieldset> | |||
</form> | |||
</div> | |||
<div class="pure-u-1"> | |||
<h3>Addresses (${len(addresses)})</h3> | |||
<table> | |||
<thead> | |||
<tr><td>name</td> <td>redirect</td></tr> | |||
</thead> | |||
% for address in addresses: | |||
<tr> | |||
<td class="align-right"> | |||
<a href="/admin/${address.domain.name}/${address.local_part}"> | |||
${address.address} | |||
</a> | |||
</td> | |||
<td>${address.redirect or ''}</td> | |||
</tr> | |||
% endfor | |||
</table> | |||
</div> | |||
</div> |
@@ -0,0 +1,76 @@ | |||
<% | |||
sitename = request.appconfig.sitename | |||
footer_text = request.appconfig.footer_text | |||
if title: | |||
page_title = title + ' - ' + sitename | |||
elif hasattr(self.attr, 'title') and self.attr.title: | |||
page_title = self.attr.title + ' - ' + sitename | |||
else: | |||
page_title = sitename | |||
webmail = request.appconfig.webmail_link | |||
%> | |||
<%def name="csrf_input()"> | |||
<input type="hidden" name="csrf_token" | |||
value="${request.session.get_csrf_token()}" /> | |||
</%def> | |||
<!DOCTYPE html> | |||
<html xmlns="http://www.w3.org/1999/xhtml" lang="${request.locale_name}"> | |||
<head> | |||
<title>${page_title}</title> | |||
<meta charset="UTF-8" /> | |||
<%block name="headers"></%block> | |||
<link rel="stylesheet" href="/static/pure-min.css"> | |||
<link rel="stylesheet" href="/static/grids-responsive-min.css" media="screen" /> | |||
<link rel="stylesheet" href="/static/style.css" media="screen" /> | |||
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,300,600&subset=latin,greek' rel='stylesheet' type='text/css' /> | |||
</head> | |||
<body> | |||
<div id="header"> | |||
<h1><a href="/">${sitename}</a></h1> | |||
<p> | |||
% if request.user: | |||
% if webmail: | |||
<a href="${webmail}">Webmail</a> | |||
% endif | |||
% if request.user.user: | |||
<a href="/admin">Admin</a> | |||
<a href="/user">Account</a> | |||
% else: | |||
<a href="/address">Address</a> | |||
% endif | |||
<a href="/logout">Logout</a> | |||
% else: | |||
% if webmail: | |||
<a href="${webmail}">Webmail</a> | |||
% endif | |||
<a href="/auth">Log in</a> | |||
% endif | |||
</p> | |||
<div style="clear: both"></div> | |||
</div> | |||
<div class="messages"> | |||
% for packed in request.session.pop_flash(): | |||
<% t, m = packed if len(packed) == 2 else ('info', packed) %> | |||
<p class="message message--${t}">${m}</p> | |||
% endfor | |||
</div> | |||
${next.body()} | |||
<div id="footer"> | |||
<p> | |||
<a href="https://git.ccrypto.org/CCrypto/PizzaVolus">PizzaVolus on CCrypto Git</a> | |||
% if footer_text: | |||
- ${footer_text | n} | |||
% endif | |||
</p> | |||
</div> | |||
</body> | |||
</html> | |||
@@ -0,0 +1,25 @@ | |||
<%! title = 'Password Reset' %> | |||
<%inherit file="layout.mako" /> | |||
<h2>${address.address}</h2> | |||
<div id="page" class="pure-g"> | |||
<div class="pure-u-1"> | |||
<h3>Password Reset</h3> | |||
<form class="pure-form pure-form-stacked pure-u-md-1-2" action="?" method="post"> | |||
<fieldset> | |||
<label for="password">Password</label> | |||
<input type="password" id="password" name="password" required | |||
class="pure-input-1" autofocus /> | |||
<label for="password2">Repeat</label> | |||
<input type="password" id="password2" name="password2" required | |||
class="pure-input-1" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Set password" /> | |||
</fieldset> | |||
</form> | |||
</div> | |||
</div> |
@@ -0,0 +1,104 @@ | |||
<%inherit file="layout.mako" /> | |||
<div id="page" class="pure-g"> | |||
<div class="pure-u-1"> | |||
<h3>Register an Address</h3> | |||
<form class="pure-form pure-form-aligned pure-u-lg-1-2" action="" method="post"> | |||
<fieldset> | |||
% if domains_reg: | |||
<script src="https://www.google.com/recaptcha/api.js" async defer></script> | |||
<div class="pure-control-group"> | |||
<label for="local_part">Name</label> | |||
<input type="text" id="local_part" name="local_part" required | |||
pattern="${lname_pattern}" | |||
class="pure-input-1-4 half-left" /><!-- | |||
--><select class="pure-input-1-4 half-right" name="domain" id="domain"> | |||
% for d in domains_reg: | |||
<option value="${d.id}" data-pattern="${d.register_regexp}">@${d.name}</option> | |||
% endfor | |||
</select> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="password">Password</label> | |||
<input type="password" id="password" name="password" required | |||
class="pure-input-1-2" /> | |||
</div> | |||
<div class="pure-control-group"> | |||
<label for="password2">Repeat</label> | |||
<input type="password" id="password2" name="password2" required | |||
class="pure-input-1-2" /> | |||
</div> | |||
% if recaptcha_site: | |||
<div class="g-recaptcha" | |||
data-theme="dark" | |||
data-sitekey="${recaptcha_site}"></div> | |||
<noscript> | |||
<div style="width: 302px; height: 422px;"> | |||
<div style="width: 302px; height: 422px; position: relative;"> | |||
<div style="width: 302px; height: 422px;"> | |||
<iframe src="https://www.google.com/recaptcha/api/fallback?k=${recaptcha_site}" | |||
frameborder="0" scrolling="no" | |||
style="width: 302px; height:422px; border-style: none;"> | |||
</iframe> | |||
</div> | |||
<div style="width: 300px; height: 60px; border-style: none; | |||
bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px; | |||
background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;"> | |||
<textarea id="g-recaptcha-response" name="g-recaptcha-response" | |||
class="g-recaptcha-response" | |||
style="width: 250px; height: 40px; border: 1px solid #c1c1c1; | |||
margin: 10px 25px; padding: 0px; resize: none;" > | |||
</textarea> | |||
</div> | |||
</div> | |||
</div> | |||
</noscript> | |||
% endif | |||
<div class="pure-controls"> | |||
<input type="hidden" name="update" value="new_addr" /> | |||
<input type="submit" class="pure-button pure-button-primary" | |||
value="Add Address" /> | |||
</div> | |||
% else: | |||
<p>No domain open for registrations for now.</p> | |||
% endif | |||
</fieldset> | |||
</form> | |||
</div> | |||
</div><!-- div#page --> | |||
<script> | |||
(function(){ | |||
var local_part = document.getElementById("local_part"); | |||
var domain_select = document.getElementById("domain"); | |||
var pw1 = document.getElementById("password"); | |||
var pw2 = document.getElementById("password2"); | |||
function update() { | |||
var item = domain_select.options[domain_select.selectedIndex]; | |||
var pattern = item.getAttribute("data-pattern"); | |||
if (pattern != null && pattern != "") { | |||
local_part.setAttribute("pattern", pattern); | |||
} else { | |||
local_part.setAttribute("pattern", "${lname_pattern}"); | |||
} | |||
} | |||
function onpwupdate() { | |||
if (pw2.value != pw1.value) { | |||
pw2.setCustomValidity("Passwords does not match"); | |||
} else { | |||
pw2.setCustomValidity(""); | |||
} | |||
} | |||
domain_select.onchange = update; | |||
update(); | |||
pw1.onkeyup = onpwupdate; | |||
pw2.onkeyup = onpwupdate; | |||
onpwupdate(); | |||
})(); | |||
</script> | |||
@@ -0,0 +1,52 @@ | |||