Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # Не больше 3 слов через подчеркивание в именах!!!
- # Не используем DBRef
- # В качестве идентификаторов используем ObjectId либо UUID, если хотим чтобы
- # идентификатор нельзя было узнать с помощью перебора (последние 3 байта
- # инкрементируются в ObjectId).
- import binascii
- import bson
- import hashlib
- import string
- import os
- import pymongo
- import uuid
- import random
- from datetime import datetime, timedelta
- from typing import Union
- # TODO: пофиксить регулярки
- # 1-32 символов. Первый буква, остальные буквы, числа, которы могут отделяться
- # друг от друга одним подчеркиванием.
- # USERNAME_RE = r'^(?!.{33})[a-zA-Z](?:_?[a-zA-Z0-9]+)*$'
- # Email должен быть не длинее 256 символов, состоять из двух частей,
- # разделенных "@", сегменты имени хоста разделены точкой.
- EMAIL_RE = r'^(?!.{257})[^\s@]+@(?:[^\s@.]+\.)+[^\s@.]+$'
- # Заглушка ебаная
- HOSTNAME_RE = r'^(?!.{257})[-a-zA-Z0-9.]+$'
- # Формально, длина URL не ограничена, но браузеры имеют ограничения по длине
- # URL. Не рекомендуется использовать URL длиной более 2048 символов, так как
- # Microsoft Internet Explorer имеет именно такое ограничение[8].
- # Валидность URL нас мало волнует. Главное чтобы не прошли URL'ы вида
- # "javascript:" и "data:".
- SIMPLE_URL_RE = r'^(?!.{2049})https?://\S+$'
- USER_ROLES = ['admin', 'agent', 'customer']
- TAG_RE = r'^\S{1,32}$'
- TICKET_PRIORITIES = ['critical', 'high', 'normal', 'low']
- TICKET_STATUSES = ['open', 'pending', 'resolved', 'close']
- # Время жизни токена
- EXPIRES_IN = timedelta(hours=2)
- # Идет в utils.py
- def normalize_email(email: str) -> str:
- # Нужно ли добавить обработку ошибок?
- user, hostname = email.split('@')
- return '{}@{}'.format(user, hostname.lower())
- def normalize_hostname(hostname: str) -> str:
- # >>> 'САЙТ.COM'.encode('idna')
- # b'xn--80aswg.COM'
- return hostname.lower().encode('idna').decode()
- def encode_password(
- password: str,
- salt: bytes,
- iterations: int=100000
- ) -> bytes:
- dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations)
- return binascii.hexlify(dk)
- def random_string(
- length: int,
- chars: str=string.ascii_letters + string.digits
- ) -> str:
- return ''.join(random.choice(chars) for i in range(length))
- def create_shared_collections(db: pymongo.database.Database) -> None:
- collections = db.collection_names()
- if 'users' not in collections:
- db.create_collection('users')
- # Подробнее про валидацию схемы можно прочитать здесь:
- # https://www.mongodb.com/blog/post/mongodb-36-json-schema-validation-expressive-query-syntax
- # Есть какой-то стандарт:
- # http://json-schema.org/latest/json-schema-validation.html
- rv = db.command({
- 'collMod': 'users',
- 'validator': {
- # Должно существовать одно из двух полей
- # '$or': [
- # {'username': {'$exists': True}},
- # {'email': {'$exists': True}}
- # ],
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': ['email', 'password_hash', 'salt'],
- 'additionalProperties': False,
- 'properties': {
- # Если _id не указать, то при insert'е бросит исключение
- '_id': {
- 'bsonType': 'objectId'
- },
- # 'username': {
- # 'bsonType': 'string',
- # 'pattern': USERNAME_RE
- # },
- # Перед сохранением должен нормализоваться (доменная часть должна
- # быть переведена в нижний регистр)!
- 'email': {
- 'bsonType': 'string',
- 'pattern': EMAIL_RE
- },
- 'password_hash': {
- 'bsonType': 'binData',
- 'maxLength': 256
- },
- 'salt': {
- 'bsonType': 'binData',
- 'maxLength': 256
- },
- # Человек может не иметь фамилии
- 'name': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- # Эти поля пока не нужны
- 'about_me': {
- 'bsonType': 'string',
- 'maxLength': 1000
- },
- 'url': {
- 'bsonType': 'string',
- 'pattern': SIMPLE_URL_RE
- },
- 'company': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- 'location': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- # Эти данные пользователь не должен напрмяую редактировать. Фото
- # должно загружаться по url'у /upload_photo, а там уже данные
- # коллекции будут обновляться.
- 'photo': {
- 'bsonType': 'object',
- 'required': ['original_id', 'thumbnail_id'],
- 'additionalProperties': False,
- 'properties': {
- 'original_id': {
- 'bsonType': 'objectId'
- },
- 'thumbnail_id': {
- 'bsonType': 'objectId'
- }
- }
- },
- 'registered': {
- 'bsonType': 'date'
- },
- 'deleted': {
- 'bsonType': 'bool'
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- # >>> pymongo.ASCENDING
- # 1
- # >>> pymongo.DESCENDING
- # -1
- # sparse=True
- # Sparse indexes only contain entries for documents that have the indexed
- # field, even if the index field contains a null value. The index skips
- # over any document that is missing the indexed field. The index is
- # “sparse” because it does not include all documents of a collection. By
- # contrast, non-sparse indexes contain all documents in a collection,
- # storing null values for those documents that do not contain the indexed
- # field.
- db.users.create_index([('email', 1)], unique=True)
- db.users.create_index([('name', 1)])
- db.users.create_index([('company', 1)])
- db.users.create_index([('registered', 1)])
- db.users.create_index([('deleted', 1)])
- # 1 тенант = 1 домен = 1 обслуживаемый ящик
- # Добавить имя, ссылку на логотип и пр.
- if 'tenants' not in collections:
- db.create_collection('tenants')
- rv = db.command({
- 'collMod': 'tenants',
- 'validator': {
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': [
- 'owner_id',
- 'hostname',
- 'imap',
- 'smtp'
- ],
- 'additionalProperties': False,
- 'properties': {
- '_id': {
- 'bsonType': 'objectId'
- },
- 'owner_id': {
- 'bsonType': 'objectId'
- },
- # Перед вставкой переводим в нижний регистр.
- 'hostname': {
- 'bsonType': 'string',
- 'pattern': HOSTNAME_RE
- },
- 'imap': {
- 'bsonType': 'object',
- 'required': ['server', 'username', 'password'],
- 'additionalProperties': False,
- 'properties': {
- 'server': {
- 'bsonType': 'string',
- 'pattern': HOSTNAME_RE
- },
- 'username': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- 'password': {
- 'bsonType': 'string',
- 'maxLength': 1024
- },
- 'port': {
- 'bsonType': 'int',
- 'minimum': 0,
- 'maximum': 65535
- },
- 'ssl': {
- 'bsonType': 'bool'
- }
- }
- },
- 'smtp': {
- 'bsonType': 'object',
- 'required': ['server', 'username', 'password'],
- 'additionalProperties': False,
- 'properties': {
- 'server': {
- 'bsonType': 'string',
- 'pattern': HOSTNAME_RE
- },
- 'username': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- 'password': {
- 'bsonType': 'string',
- 'maxLength': 1024
- },
- 'port': {
- 'bsonType': 'int',
- 'minimum': 0,
- 'maximum': 65535
- },
- 'ssl': {
- 'bsonType': 'bool'
- }
- }
- },
- # uid последнего входящего письма
- 'last_message_uid': {
- 'bsonType': 'long'
- },
- # Время следующей проверки входящих
- 'next_fetch_date': {
- 'bsonType': 'date'
- },
- # Время следующей рассылки писем
- 'next_send_date': {
- 'bsonType': 'date'
- },
- 'created': {
- 'bsonType': 'date'
- },
- # Может быть отключен
- 'enabled': {
- 'bsonType': 'bool'
- },
- 'deleted': {
- 'bsonType': 'bool'
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- db.tenants.create_index([('owner_id', 1)])
- db.tenants.create_index([('hostname', 1)], unique=True)
- db.tenants.create_index([('enabled', 1)])
- db.tenants.create_index([('next_fetch_date', 1)])
- db.tenants.create_index([('next_send_date', 1)])
- db.tenants.create_index([('deleted', 1)])
- return True
- def create_tenant_collections(db: pymongo.database.Database) -> None:
- collections = db.collection_names()
- if 'users' not in collections:
- db.create_collection('users')
- rv = db.command({
- 'collMod': 'users',
- 'validator': {
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': ['email', 'password_hash', 'salt', 'role'],
- 'additionalProperties': False,
- 'properties': {
- '_id': {
- 'bsonType': 'objectId'
- },
- 'email': {
- 'bsonType': 'string',
- 'pattern': EMAIL_RE
- },
- 'password_hash': {
- 'bsonType': 'binData',
- 'maxLength': 256
- },
- 'salt': {
- 'bsonType': 'binData',
- 'maxLength': 256
- },
- 'name': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- # Эти поля пока не нужны
- 'about_me': {
- 'bsonType': 'string',
- 'maxLength': 1000
- },
- 'url': {
- 'bsonType': 'string',
- 'pattern': SIMPLE_URL_RE
- },
- 'company': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- 'location': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- 'photo': {
- 'bsonType': 'object',
- 'required': ['original_id', 'thumbnail_id'],
- 'additionalProperties': False,
- # В коллекции photos хранится
- 'properties': {
- 'original_id': {
- 'bsonType': 'objectId'
- },
- 'thumbnail_id': {
- 'bsonType': 'objectId'
- }
- }
- },
- 'registered': {
- 'bsonType': 'date'
- },
- 'role': {
- 'enum': USER_ROLES
- },
- 'deleted': {
- 'bsonType': 'bool'
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- db.users.create_index([('email', 1)], unique=True)
- db.users.create_index([('registered', 1)])
- db.users.create_index([('role', 1)])
- db.users.create_index([('deleted', 1)])
- if 'access_tokens' not in collections:
- db.create_collection('access_tokens')
- rv = db.command({
- 'collMod': 'access_tokens',
- 'validator': {
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': ['_id', 'user_id', 'expiry'],
- 'additionalProperties': False,
- 'properties': {
- # >>> db.test.insert({'_id': uuid.uuid4()})
- # UUID('c2adba25-3459-4762-a001-78df28a54a6c')
- # >>> db.test.find_one()
- # {'_id': UUID('c2adba25-3459-4762-a001-78df28a54a6c')}
- '_id': {},
- 'user_id': {
- 'bsonType': 'objectId'
- },
- 'expiry': {
- 'bsonType': 'date'
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- # Это если потребуется убить все токены опр юзера
- db.access_tokens.create_index([('user_id', 1)])
- db.access_tokens.create_index([('expiry', 1)])
- if 'tags' not in collections:
- db.create_collection('tags')
- rv = db.command({
- 'collMod': 'tags',
- 'validator': {
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': ['name'],
- 'additionalProperties': False,
- 'properties': {
- '_id': {
- 'bsonType': 'objectId'
- },
- 'name': {
- 'bsonType': 'string',
- 'pattern': TAG_RE
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- db.tags.create_index([('name', 1)], unique=True)
- if 'tag_count' not in collections:
- db.create_collection('tag_count')
- rv = db.command({
- 'collMod': 'tag_count',
- 'validator': {
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': ['tag_id', 'count'],
- 'additionalProperties': False,
- 'properties': {
- '_id': {
- 'bsonType': 'objectId'
- },
- 'tag_id': {
- 'bsonType': 'objectId'
- },
- 'count': {
- 'bsonType': 'long'
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- db.tag_count.create_index([('tag_id', 1)], unique=True)
- db.tag_count.create_index([('count', 1)])
- if 'tickets' not in collections:
- db.create_collection('tickets')
- rv = db.command({
- 'collMod': 'tickets',
- 'validator': {
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': [
- 'subject',
- 'priority',
- 'status',
- 'created'
- ],
- 'additionalProperties': False,
- 'properties': {
- '_id': {
- 'bsonType': 'objectId'
- },
- 'subject': {
- 'bsonType': 'string',
- 'maxLength': 256
- },
- 'agent_comment': {
- 'bsonType': 'string',
- 'maxLength': 1024
- },
- 'priority': {
- 'enum': TICKET_PRIORITIES
- },
- 'status': {
- 'enum': TICKET_STATUSES
- },
- 'tag_ids': {
- 'bsonType': 'array',
- 'uniqueItems': True,
- 'maxItems': 5,
- 'items': {
- 'bsonType': 'objectId'
- }
- },
- 'created': {
- 'bsonType': 'object',
- 'required': ['user_id', 'date'],
- 'additionalProperties': False,
- 'properties': {
- 'user_id': {
- 'bsonType': 'objectId'
- },
- 'date': {
- 'bsonType': 'date'
- }
- }
- },
- 'edited': {
- 'bsonType': 'object',
- 'required': ['user_id', 'date'],
- 'additionalProperties': False,
- 'properties': {
- 'user_id': {
- 'bsonType': 'objectId'
- },
- 'date': {
- 'bsonType': 'date'
- }
- }
- },
- 'replied': {
- 'bsonType': 'object',
- 'required': ['user_id', 'date'],
- 'additionalProperties': False,
- 'properties': {
- 'user_id': {
- 'bsonType': 'objectId'
- },
- 'date': {
- 'bsonType': 'date'
- }
- }
- },
- 'assigned_user_id': {
- 'bsonType': 'objectId'
- },
- 'solutions': {
- 'bsonType': 'array',
- 'uniqueItems': True,
- 'items': {
- 'bsonType': 'string'
- }
- },
- 'deleted': {
- 'bsonType': 'bool'
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- # https://docs.mongodb.com/manual/core/index-text/
- db.tickets.create_index([('subject', 'text')])
- db.tickets.create_index([('status', 1)])
- db.tickets.create_index([('priority', 1)])
- db.tickets.create_index([('tag_ids', 1)])
- db.tickets.create_index([('deleted', 1)])
- if not 'messages' in collections:
- db.create_collection('messages')
- rv = db.command({
- 'collMod': 'messages',
- 'validator': {
- '$jsonSchema': {
- 'bsonType': 'object',
- 'required': [
- 'ticket_id',
- 'created',
- 'content'
- ],
- 'additionalProperties': False,
- 'properties': {
- '_id': {},
- 'ticket_id': {},
- 'content': {
- 'bsonType': 'string',
- 'maxLength': 8192
- },
- 'attachments': {
- 'bsonType': 'array',
- 'items': {
- 'bsonType': 'object',
- 'required': ['id', 'filename'],
- 'additionalProperties': False,
- 'properties': {
- 'id': {},
- # Для изображений генерируем миниатюру
- 'thumbnail_id': {},
- 'filename': {
- 'bsonType': 'string',
- 'maxLength': 256
- }
- }
- }
- },
- 'created': {
- 'bsonType': 'object',
- 'required': ['user_id', 'date'],
- 'additionalProperties': False,
- 'properties': {
- 'user_id': {
- 'bsonType': 'objectId'
- },
- 'date': {
- 'bsonType': 'date'
- }
- }
- },
- 'edited': {
- 'bsonType': 'object',
- 'required': ['user_id', 'date'],
- 'additionalProperties': False,
- 'properties': {
- 'user_id': {
- 'bsonType': 'objectId'
- },
- 'date': {
- 'bsonType': 'date'
- }
- }
- },
- 'deleted': {
- 'bsonType': 'bool'
- }
- }
- }
- }
- })
- db.messages.create_index([('ticket_id', 1)])
- db.messages.create_index([('content', 'text')])
- db.messages.create_index([('deleted', 1)])
- if 'email_notifications' not in collections:
- db.create_collection('email_notifications')
- rv = db.command({
- 'collMod': 'email_notifications',
- 'validator': {
- '$jsonSchema': {
- 'required': ['type'],
- 'properties': {
- 'type': {
- 'bsonType': 'string'
- },
- 'done': {
- 'bsonType': 'bool'
- }
- }
- }
- }
- })
- assert rv == {'ok': 1}
- # Примеры:
- # { 'type': 'send_password', 'user_id': <ObjectId>, password: '<password>' }
- # { 'type': 'ticket_reply', 'message_id': <ObjectId> }
- db.email_notifications.create_index([('done', 1)])
- class EmailNotificationManager:
- def __init__(self, db: pymongo.database.Database):
- self.collection = db.email_notifications
- def create(self, notification) -> bson.ObjectId:
- return self.collection.insert_one(notification).inserted_id
- class AccessTokenManager:
- def __init__(self, db: pymongo.database.Database):
- self.collection = db.access_tokens
- def find_by_id(self, id: Union[str, uuid.UUID]) -> Union[None, dict]:
- if not isinstance(id, uuid.UUID):
- id = uuid.UUID(id)
- return self.collection.find({
- '_id': id,
- # Токен должен быть не истекшим
- 'expiry': {
- '$gt': datetime.utcnow()
- }
- })
- def create(self, data: dict) -> uuid.UUID:
- return self.collection.insert_one(data).inserted_id
- def refresh_expiry(self, access_token: dict) -> bool:
- assert access_token['expiry'] > datetime.utcnow()
- return self.collection.update({
- '_id': access_token['_id']
- }, {
- 'expiry': access_token['expiry'] + EXPIRES_IN
- }).modified_count > 0
- class UserManager:
- def __init__(self, db: pymongo.database.Database):
- self.collection = db.users
- def find(self):
- raise NotImplemented
- def find_by_id(
- self,
- id: Union[bson.ObjectId, str],
- filter: dict=None
- ) -> Union[None, dict]:
- if not isinstance(id, bson.ObjectId):
- id = bson.ObjectId(id)
- return self.collection.find_one({
- '_id': id,
- 'deleted': {
- '$ne': True
- }
- }, filter)
- def find_by_email(
- self,
- email: str,
- filter: dict=None
- ) -> Union[None, dict]:
- return self.collection.find_one({
- 'email': normalize_email(email),
- 'deleted': {
- '$ne': True
- }
- }, filter)
- def create(self, data: dict, send_password: bool=False) -> bson.ObjectId:
- data = dict(data)
- data.pop('_id', 0)
- data['email'] = normalize_email(data['email'])
- password = data.pop('password')
- salt = self._generate_salt()
- data.update({
- # Пароли не хранятся в открытом виде
- 'password_hash': self._encode_password(password, salt),
- 'salt': salt,
- 'registered': datetime.utcnow()
- })
- # print(data)
- rv = self.collection.insert_one(data)
- user_id = rv.inserted_id
- if send_password:
- EmailNotificationManager(self.collection.database).create({
- 'type': 'send_password',
- 'user_id': user_id,
- 'password': password
- })
- return user_id
- def update(self, id: Union[bson.ObjectId, str], data: dict) -> bool:
- if not isinstance(id, bson.ObjectId):
- id = bson.ObjectId(id)
- data = dict(data)
- data.pop('_id', 0)
- data.pop('password_hash', 0)
- data.pop('salt', 0)
- missing = object()
- password = data.pop('password', missing)
- if password is not missing:
- # Генерируем новую соль чтобы не тащить ее из базы
- salt = self._generate_salt()
- data.update({
- 'password_hash': self._encode_password(password, salt),
- 'salt': salt
- })
- rv = self.collection.update_one({'_id': id}, {'$set': data})
- return rv.modified_count > 0
- def remove(self, id: Union[bson.ObjectId, str]) -> bool:
- if not isinstance(id, bson.ObjectId):
- id = bson.ObjectId(id)
- rv = self.collection.update_one({'_id': id}, {'$set': {'delete': True}})
- return rv.modified_count > 0
- # Какой HTTP-метод для restore? PATCH???
- def restore(self, id: Union[bson.ObjectId, str]) -> bool:
- if not isinstance(id, bson.ObjectId):
- id = bson.ObjectId(id)
- rv = self.collection.update_one({'_id': id}, {'$unset': {'delete': ''}})
- return rv.modified_count > 0
- def authenticate(self, email: str, password: str) -> Union[None, dict]:
- user = self.find_by_email(email)
- if user is None:
- return None
- if user['password_hash'] != self._encode_password(password, user['salt']):
- return None
- access_token_manager = AccessTokenManager(self.collection.database)
- access_token = {
- '_id': uuid.uuid4(),
- 'user_id': user['_id'],
- 'expiry': datetime.utcnow() + EXPIRES_IN
- }
- access_token_id = access_token_manager.create(access_token)
- assert access_token_id == access_token['_id']
- return access_token
- def _generate_salt(self) -> bytes:
- return os.urandom(42)
- def _encode_password(self, password: str, salt: bytes) -> bytes:
- return encode_password(password, salt)
- class TagCounManager:
- def __init__(self, db: pymongo.database.Database):
- self.db = db
- self.collection = db.tag_count
- class TagManager:
- def __init__(self, db: pymongo.database.Database):
- self.db = db
- self.collection = db.tags
- # Нужна ли эта функция?
- def find_by_id(self, id: Union[bson.ObjectId, str]) -> Union[None, dict]:
- return self.collection.find_one({'_id': id})
- def find_by_name(self, tag: str) -> Union[None, dict]:
- return self.collection.find_one({'name': tag})
- def create(self, tag: str) -> bson.ObjectId:
- return self.collection.insert_one({'name': tag}).inserted_id
- class TicketManager:
- def __init__(self, db: pymongo.database.Database):
- self.db = db
- self.collection = db.tickets
- def create(self, data: dict) -> bson.ObjectId:
- data = dict(data)
- data.pop('_id', 0)
- return self.collection.insert_one(data).inserted_id
- client = pymongo.MongoClient()
- for db_name in client.database_names():
- if db_name not in ('admin', 'config', 'local'):
- client.drop_database(db_name)
- shared_db = client.shared
- create_shared_collections(shared_db)
- tenant_db = client.test_tenant
- create_tenant_collections(tenant_db)
- user_manager = UserManager(tenant_db)
- i = user_manager.create({
- 'email': 'sergey.m@Upsafe.Com',
- 'password': '123456',
- 'role': 'agent'
- }, send_password=True)
- print(i)
- user = user_manager.find_by_id(i)
- print(user)
- access_token = user_manager.authenticate('sergey.m@UPSAFE.COM', '123456')
- print(access_token)
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement