Merge branch 'develop' into modernize

This commit is contained in:
Johnny 2018-10-24 01:05:01 +00:00
commit 2d27a54da9
46 changed files with 1810 additions and 431 deletions

View file

@ -1,7 +1,51 @@
# Changelog
## Evennia 0.9 (2018-2019)
### Commands
- Removed default `@delaccount` command, incorporating as `@account/delete` instead. Added confirmation
question.
- Add new `@force` command to have another object perform a command.
- Add the Portal uptime to the `@time` command.
- Make the `@link` command first make a local search before a global search.
### Typeclasses
- Add new methods on all typeclasses, useful specifically for viewing the object in the web/admin:
+ `web_get_admin_url()`: Returns a path that, if followed, will display the object in the Admin backend.
+ `web_get_create_url()`: Returns a path for a view allowing the creation of new instances of this object.
+ `web_get_absolute_url()`: Django construct; returns a path that should display the object in a DetailView.
+ `web_get_update_url()`: Returns a path that should display the object in an UpdateView.
+ `web_get_delete_url()`: Returns a path that should display the object in a DeleteView.
- All typeclasses has new helper class method `create`, which encompasses useful functionality
that used to be embedded for example in the respective `@create` or `@connect` commands.
- DefaultAccount now has new class methods implementing many things that used to be in unloggedin
commands (these can now be customized on the class instead):
+ `is_banned()`: Checks if a given username or IP is banned.
+ `get_username_validators`: Return list of validators for username validation (see
`settings.AUTH_USERNAME_VALIDATORS`)
+ `authenticate`: Method to check given username/password.
+ `normalize_username`: Normalizes names so you can't fake names with similar-looking Unicode
chars.
+ `validate_username`: Mechanism for validating a username.
+ `validate_password`: Mechanism for validating a password.
+ `set_password`: Apply password to account, using validation checks.
### Utils
- Added more unit tests.
## Evennia 0.8 (2018)
### Requirements
- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
- Add `inflect` dependency for automatic pluralization of object names.
### Server/Portal
- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program)
@ -14,7 +58,7 @@
to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will
return Server to normal daemon operation.
- For validating passwords, use safe Django password-validation backend instead of custom Evennia one.
- Alias `evennia restart` to mean the same as `evennia reload`.
- Alias `evennia restart` to mean the same as `evennia reload`.
### Prototype changes
@ -85,7 +129,6 @@
### General
- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
- Start structuring the `CHANGELOG` to list features in more detail.
- Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch.
- Inflection and grouping of multiple objects in default room (an box, three boxes)

View file

@ -7,7 +7,7 @@
# Usage:
# cd to a folder where you want your game data to be (or where it already is).
#
# docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia
# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia
#
# (If your OS does not support $PWD, replace it with the full path to your current
# folder).
@ -15,6 +15,14 @@
# You will end up in a shell where the `evennia` command is available. From here you
# can install and run the game normally. Use Ctrl-D to exit the evennia docker container.
#
# You can also start evennia directly by passing arguments to the folder:
#
# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia evennia start -l
#
# This will start Evennia running as the core process of the container. Note that you *must* use -l
# or one of the foreground modes (like evennia ipstart) since otherwise the container will immediately
# die since no foreground process keeps it up.
#
# The evennia/evennia base image is found on DockerHub and can also be used
# as a base for creating your own custom containerized Evennia game. For more
# info, see https://github.com/evennia/evennia/wiki/Running%20Evennia%20in%20Docker .
@ -58,7 +66,7 @@ WORKDIR /usr/src/game
ENV PS1 "evennia|docker \w $ "
# startup a shell when we start the container
ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh"
ENTRYPOINT ["/usr/src/evennia/bin/unix/evennia-docker-start.sh"]
# expose the telnet, webserver and websocket client ports
EXPOSE 4000 4001 4005

16
bin/unix/evennia-docker-start.sh Normal file → Executable file
View file

@ -1,10 +1,18 @@
#! /bin/bash
#! /bin/sh
# called by the Dockerfile to start the server in docker mode
# remove leftover .pid files (such as from when dropping the container)
rm /usr/src/game/server/*.pid >& /dev/null || true
# start evennia server; log to server.log but also output to stdout so it can
# be viewed with docker-compose logs
exec 3>&1; evennia start -l
PS1="evennia|docker \w $ "
cmd="$@"
output="Docker starting with argument '$cmd' ..."
if test -z $cmd; then
cmd="bash"
output="No argument given, starting shell ..."
fi
echo $output
exec 3>&1; $cmd

View file

@ -10,19 +10,22 @@ character object, so you should customize that
instead for most things).
"""
import re
import time
from django.conf import settings
from django.contrib.auth import password_validation
from django.core.exceptions import ValidationError
from django.contrib.auth import authenticate, password_validation
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.utils import timezone
from django.utils.module_loading import import_string
from evennia.typeclasses.models import TypeclassBase
from evennia.accounts.manager import AccountManager
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.comms.models import ChannelDB
from evennia.commands import cmdhandler
from evennia.utils import logger
from evennia.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.utils import class_from_module, create, logger
from evennia.utils.utils import (lazy_property, to_str,
make_iter, to_unicode, is_iter,
variable_from_module)
@ -32,6 +35,7 @@ from evennia.commands.cmdsethandler import CmdSetHandler
from django.utils.translation import ugettext as _
from future.utils import with_metaclass
from random import getrandbits
__all__ = ("DefaultAccount",)
@ -43,6 +47,9 @@ _MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS
_CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
_CONNECT_CHANNEL = None
# Create throttles for too many account-creations and login attempts
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
class AccountSessionHandler(object):
"""
@ -372,6 +379,189 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
puppet = property(__get_single_puppet)
# utility methods
@classmethod
def is_banned(cls, **kwargs):
"""
Checks if a given username or IP is banned.
Kwargs:
ip (str, optional): IP address.
username (str, optional): Username.
Returns:
is_banned (bool): Whether either is banned or not.
"""
ip = kwargs.get('ip', '').strip()
username = kwargs.get('username', '').lower().strip()
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == username for tup in bans if username) or
any(tup[2].match(ip) for tup in bans if ip and tup[2])):
return True
return False
@classmethod
def get_username_validators(cls, validator_config=getattr(settings, 'AUTH_USERNAME_VALIDATORS', [])):
"""
Retrieves and instantiates validators for usernames.
Args:
validator_config (list): List of dicts comprising the battery of
validators to apply to a username.
Returns:
validators (list): List of instantiated Validator objects.
"""
objs = []
for validator in validator_config:
try:
klass = import_string(validator['NAME'])
except ImportError:
msg = "The module in NAME could not be imported: %s. Check your AUTH_USERNAME_VALIDATORS setting."
raise ImproperlyConfigured(msg % validator['NAME'])
objs.append(klass(**validator.get('OPTIONS', {})))
return objs
@classmethod
def authenticate(cls, username, password, ip='', **kwargs):
"""
Checks the given username/password against the database to see if the
credentials are valid.
Note that this simply checks credentials and returns a valid reference
to the user-- it does not log them in!
To finish the job:
After calling this from a Command, associate the account with a Session:
- session.sessionhandler.login(session, account)
...or after calling this from a View, associate it with an HttpRequest:
- django.contrib.auth.login(account, request)
Args:
username (str): Username of account
password (str): Password of account
ip (str, optional): IP address of client
Kwargs:
session (Session, optional): Session requesting authentication
Returns:
account (DefaultAccount, None): Account whose credentials were
provided if not banned.
errors (list): Error messages of any failures.
"""
errors = []
if ip: ip = str(ip)
# See if authentication is currently being throttled
if ip and LOGIN_THROTTLE.check(ip):
errors.append('Too many login failures; please try again in a few minutes.')
# With throttle active, do not log continued hits-- it is a
# waste of storage and can be abused to make your logs harder to
# read and/or fill up your disk.
return None, errors
# Check IP and/or name bans
banned = cls.is_banned(username=username, ip=ip)
if banned:
# this is a banned IP or name!
errors.append("|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x")
logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip))
LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.')
return None, errors
# Authenticate and get Account object
account = authenticate(username=username, password=password)
if not account:
# User-facing message
errors.append('Username and/or password is incorrect.')
# Log auth failures while throttle is inactive
logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip))
# Update throttle
if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures.')
# Try to call post-failure hook
session = kwargs.get('session', None)
if session:
account = AccountDB.objects.get_account_from_name(username)
if account:
account.at_failed_login(session)
return None, errors
# Account successfully authenticated
logger.log_sec('Authentication Success: %s (IP: %s).' % (account, ip))
return account, errors
@classmethod
def normalize_username(cls, username):
"""
Django: Applies NFKC Unicode normalization to usernames so that visually
identical characters with different Unicode code points are considered
identical.
(This deals with the Turkish "i" problem and similar
annoyances. Only relevant if you go out of your way to allow Unicode
usernames though-- Evennia accepts ASCII by default.)
In this case we're simply piggybacking on this feature to apply
additional normalization per Evennia's standards.
"""
username = super(DefaultAccount, cls).normalize_username(username)
# strip excessive spaces in accountname
username = re.sub(r"\s+", " ", username).strip()
return username
@classmethod
def validate_username(cls, username):
"""
Checks the given username against the username validator associated with
Account objects, and also checks the database to make sure it is unique.
Args:
username (str): Username to validate
Returns:
valid (bool): Whether or not the password passed validation
errors (list): Error messages of any failures
"""
valid = []
errors = []
# Make sure we're at least using the default validator
validators = cls.get_username_validators()
if not validators:
validators = [cls.username_validator]
# Try username against all enabled validators
for validator in validators:
try:
valid.append(not validator(username))
except ValidationError as e:
valid.append(False)
errors.extend(e.messages)
# Disqualify if any check failed
if False in valid:
valid = False
else: valid = True
return valid, errors
@classmethod
def validate_password(cls, password, account=None):
"""
@ -429,9 +619,136 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
if error: raise error
super(DefaultAccount, self).set_password(password)
logger.log_info("Password succesfully changed for %s." % self)
logger.log_sec("Password successfully changed for %s." % self)
self.at_password_change()
@classmethod
def create(cls, *args, **kwargs):
"""
Creates an Account (or Account/Character pair for MULTISESSION_MODE<2)
with default (or overridden) permissions and having joined them to the
appropriate default channels.
Kwargs:
username (str): Username of Account owner
password (str): Password of Account owner
email (str, optional): Email address of Account owner
ip (str, optional): IP address of requesting connection
guest (bool, optional): Whether or not this is to be a Guest account
permissions (str, optional): Default permissions for the Account
typeclass (str, optional): Typeclass to use for new Account
character_typeclass (str, optional): Typeclass to use for new char
when applicable.
Returns:
account (Account): Account if successfully created; None if not
errors (list): List of error messages in string form
"""
account = None
errors = []
username = kwargs.get('username')
password = kwargs.get('password')
email = kwargs.get('email', '').strip()
guest = kwargs.get('guest', False)
permissions = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT)
typeclass = kwargs.get('typeclass', settings.BASE_ACCOUNT_TYPECLASS)
ip = kwargs.get('ip', '')
if ip and CREATION_THROTTLE.check(ip):
errors.append("You are creating too many accounts. Please log into an existing account.")
return None, errors
# Normalize username
username = cls.normalize_username(username)
# Validate username
if not guest:
valid, errs = cls.validate_username(username)
if not valid:
# this echoes the restrictions made by django's auth
# module (except not allowing spaces, for convenience of
# logging in).
errors.extend(errs)
return None, errors
# Validate password
# Have to create a dummy Account object to check username similarity
valid, errs = cls.validate_password(password, account=cls(username=username))
if not valid:
errors.extend(errs)
return None, errors
# Check IP and/or name bans
banned = cls.is_banned(username=username, ip=ip)
if banned:
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
errors.append(string)
return None, errors
# everything's ok. Create the new account account.
try:
try:
account = create.create_account(username, email, password, permissions=permissions, typeclass=typeclass)
logger.log_sec('Account Created: %s (IP: %s).' % (account, ip))
except Exception as e:
errors.append("There was an error creating the Account. If this problem persists, contact an admin.")
logger.log_trace()
return None, errors
# This needs to be set so the engine knows this account is
# logging in for the first time. (so it knows to call the right
# hooks during login later)
account.db.FIRST_LOGIN = True
# Record IP address of creation, if available
if ip: account.db.creator_ip = ip
# join the new account to the public channel
pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"])
if not pchannel or not pchannel.connect(account):
string = "New account '%s' could not connect to public channel!" % account.key
errors.append(string)
logger.log_err(string)
if account and settings.MULTISESSION_MODE < 2:
# Load the appropriate Character class
character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_TYPECLASS)
character_home = kwargs.get('home')
Character = class_from_module(character_typeclass)
# Create the character
character, errs = Character.create(
account.key, account, ip=ip, typeclass=character_typeclass,
permissions=permissions, home=character_home
)
errors.extend(errs)
if character:
# Update playable character list
account.db._playable_characters.append(character)
# We need to set this to have @ic auto-connect to this character
account.db._last_puppet = character
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
# Update the throttle to indicate a new account was created from this IP
if ip and not guest: CREATION_THROTTLE.update(ip, 'Too many accounts being created.')
return account, errors
def delete(self, *args, **kwargs):
"""
Deletes the account permanently.
@ -1014,7 +1331,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
if target and not is_iter(target):
# single target - just show it
return target.return_appearance(self)
if hasattr(target, "return_appearance"):
return target.return_appearance(self)
else:
return "{} has no in-game appearance.".format(target)
else:
# list of targets - make list to disconnect from db
characters = list(tar for tar in target if tar) if target else []
@ -1078,6 +1398,78 @@ class DefaultGuest(DefaultAccount):
their characters are deleted after disconnection.
"""
@classmethod
def create(cls, **kwargs):
"""
Forwards request to cls.authenticate(); returns a DefaultGuest object
if one is available for use.
"""
return cls.authenticate(**kwargs)
@classmethod
def authenticate(cls, **kwargs):
"""
Gets or creates a Guest account object.
Kwargs:
ip (str, optional): IP address of requestor; used for ban checking,
throttling and logging
Returns:
account (Object): Guest account object, if available
errors (list): List of error messages accrued during this request.
"""
errors = []
account = None
username = None
ip = kwargs.get('ip', '').strip()
# check if guests are enabled.
if not settings.GUEST_ENABLED:
errors.append('Guest accounts are not enabled on this server.')
return None, errors
try:
# Find an available guest name.
for name in settings.GUEST_LIST:
if not AccountDB.objects.filter(username__iexact=name).count():
username = name
break
if not username:
errors.append("All guest accounts are in use. Please try again later.")
if ip: LOGIN_THROTTLE.update(ip, 'Too many requests for Guest access.')
return None, errors
else:
# build a new account with the found guest username
password = "%016x" % getrandbits(64)
home = settings.GUEST_HOME
permissions = settings.PERMISSION_GUEST_DEFAULT
typeclass = settings.BASE_GUEST_TYPECLASS
# Call parent class creator
account, errs = super(DefaultGuest, cls).create(
guest=True,
username=username,
password=password,
permissions=permissions,
typeclass=typeclass,
home=home,
ip=ip,
)
errors.extend(errs)
return account, errors
except Exception as e:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
return None, errors
return account, errors
def at_post_login(self, session=None, **kwargs):
"""
In theory, guests only have one character regardless of which

View file

@ -1,10 +1,13 @@
from mock import Mock
# -*- coding: utf-8 -*-
from mock import Mock, MagicMock
from random import randint
from unittest import TestCase
from django.test import override_settings
from evennia.accounts.accounts import AccountSessionHandler
from evennia.accounts.accounts import DefaultAccount
from evennia.server.session import Session
from evennia.accounts.accounts import DefaultAccount, DefaultGuest
from evennia.utils.test_resources import EvenniaTest
from evennia.utils import create
from django.conf import settings
@ -14,9 +17,15 @@ class TestAccountSessionHandler(TestCase):
"Check AccountSessionHandler class"
def setUp(self):
self.account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
self.account = create.create_account(
"TestAccount%s" % randint(0, 999999), email="test@test.com",
password="testpassword", typeclass=DefaultAccount)
self.handler = AccountSessionHandler(self.account)
def tearDown(self):
if hasattr(self, 'account'):
self.account.delete()
def test_get(self):
"Check get method"
self.assertEqual(self.handler.get(), [])
@ -24,24 +33,24 @@ class TestAccountSessionHandler(TestCase):
import evennia.server.sessionhandler
s1 = Session()
s1 = MagicMock()
s1.logged_in = True
s1.uid = self.account.uid
evennia.server.sessionhandler.SESSIONS[s1.uid] = s1
s2 = Session()
s2 = MagicMock()
s2.logged_in = True
s2.uid = self.account.uid + 1
evennia.server.sessionhandler.SESSIONS[s2.uid] = s2
s3 = Session()
s3 = MagicMock()
s3.logged_in = False
s3.uid = self.account.uid + 2
evennia.server.sessionhandler.SESSIONS[s3.uid] = s3
self.assertEqual(self.handler.get(), [s1])
self.assertEqual(self.handler.get(self.account.uid), [s1])
self.assertEqual(self.handler.get(self.account.uid + 1), [])
self.assertEqual([s.uid for s in self.handler.get()], [s1.uid])
self.assertEqual([s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid])
self.assertEqual([s.uid for s in self.handler.get(self.account.uid + 1)], [])
def test_all(self):
"Check all method"
@ -51,40 +60,115 @@ class TestAccountSessionHandler(TestCase):
"Check count method"
self.assertEqual(self.handler.count(), len(self.handler.get()))
class TestDefaultGuest(EvenniaTest):
"Check DefaultGuest class"
class TestDefaultAccount(TestCase):
"Check DefaultAccount class"
ip = '212.216.134.22'
def test_authenticate(self):
# Guest account should not be permitted
account, errors = DefaultGuest.authenticate(ip=self.ip)
self.assertFalse(account, 'Guest account was created despite being disabled.')
settings.GUEST_ENABLED = True
settings.GUEST_LIST = ['bruce_wayne']
# Create a guest account
account, errors = DefaultGuest.authenticate(ip=self.ip)
self.assertTrue(account, 'Guest account should have been created.')
# Create a second guest account
account, errors = DefaultGuest.authenticate(ip=self.ip)
self.assertFalse(account, 'Two guest accounts were created with a single entry on the guest list!')
settings.GUEST_ENABLED = False
class TestDefaultAccountAuth(EvenniaTest):
def setUp(self):
self.s1 = Session()
self.s1.puppet = None
self.s1.sessid = 0
super(TestDefaultAccountAuth, self).setUp()
self.password = "testpassword"
self.account.delete()
self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password=self.password, typeclass=DefaultAccount)
def test_authentication(self):
"Confirm Account authentication method is authenticating/denying users."
# Valid credentials
obj, errors = DefaultAccount.authenticate(self.account.name, self.password)
self.assertTrue(obj, 'Account did not authenticate given valid credentials.')
# Invalid credentials
obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy')
self.assertFalse(obj, 'Account authenticated using invalid credentials.')
def test_create(self):
"Confirm Account creation is working as expected."
# Create a normal account
account, errors = DefaultAccount.create(username='ziggy', password='stardust11')
self.assertTrue(account, 'New account should have been created.')
# Try creating a duplicate account
account2, errors = DefaultAccount.create(username='Ziggy', password='starman11')
self.assertFalse(account2, 'Duplicate account name should not have been allowed.')
account.delete()
def test_throttle(self):
"Confirm throttle activates on too many failures."
for x in xrange(20):
obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy', ip='12.24.36.48')
self.assertFalse(obj, 'Authentication was provided a bogus password; this should NOT have returned an account!')
self.assertTrue('too many login failures' in errors[-1].lower(), 'Failed logins should have been throttled.')
def test_username_validation(self):
"Check username validators deny relevant usernames"
# Should not accept Unicode by default, lest users pick names like this
result, error = DefaultAccount.validate_username('¯\_(ツ)_/¯')
self.assertFalse(result, "Validator allowed kanji in username.")
# Should not allow duplicate username
result, error = DefaultAccount.validate_username(self.account.name)
self.assertFalse(result, "Duplicate username should not have passed validation.")
# Should not allow username too short
result, error = DefaultAccount.validate_username('xx')
self.assertFalse(result, "2-character username passed validation.")
def test_password_validation(self):
"Check password validators deny bad passwords"
self.account = create.create_account("TestAccount%s" % randint(0, 9),
account = create.create_account("TestAccount%s" % randint(100000, 999999),
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'):
self.assertFalse(self.account.validate_password(bad, account=self.account)[0])
self.assertFalse(account.validate_password(bad, account=self.account)[0])
"Check validators allow sufficiently complex passwords"
for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"):
self.assertTrue(self.account.validate_password(better, account=self.account)[0])
self.account.delete()
self.assertTrue(account.validate_password(better, account=self.account)[0])
account.delete()
def test_password_change(self):
"Check password setting and validation is working as expected"
self.account = create.create_account("TestAccount%s" % randint(0, 9),
account = create.create_account("TestAccount%s" % randint(100000, 999999),
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
from django.core.exceptions import ValidationError
# Try setting some bad passwords
for bad in ('', '#', 'TestAccount', 'password'):
self.assertRaises(ValidationError, self.account.set_password, bad)
self.assertRaises(ValidationError, account.set_password, bad)
# Try setting a better password (test for False; returns None on success)
self.assertFalse(self.account.set_password('Mxyzptlk'))
self.assertFalse(account.set_password('Mxyzptlk'))
account.delete()
class TestDefaultAccount(TestCase):
"Check DefaultAccount class"
def setUp(self):
self.s1 = MagicMock()
self.s1.puppet = None
self.s1.sessid = 0
def test_puppet_object_no_object(self):
"Check puppet_object method called with no object param"
@ -109,7 +193,9 @@ class TestDefaultAccount(TestCase):
import evennia.server.sessionhandler
account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
account = create.create_account(
"TestAccount%s" % randint(0, 999999), email="test@test.com",
password="testpassword", typeclass=DefaultAccount)
self.s1.uid = account.uid
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
@ -131,10 +217,7 @@ class TestDefaultAccount(TestCase):
self.s1.uid = account.uid
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
self.s1.puppet = None
self.s1.logged_in = True
self.s1.data_out = Mock(return_value=None)
self.s1.data_out = MagicMock()
obj = Mock()
obj.access = Mock(return_value=False)
@ -143,6 +226,7 @@ class TestDefaultAccount(TestCase):
self.assertTrue(self.s1.data_out.call_args[1]['text'].startswith("You don't have permission to puppet"))
self.assertIsNone(obj.at_post_puppet.call_args)
@override_settings(MULTISESSION_MODE=0)
def test_puppet_object_joining_other_session(self):
"Check puppet_object method called, joining other session"
@ -154,15 +238,16 @@ class TestDefaultAccount(TestCase):
self.s1.puppet = None
self.s1.logged_in = True
self.s1.data_out = Mock(return_value=None)
self.s1.data_out = MagicMock()
obj = Mock()
obj.access = Mock(return_value=True)
obj.account = account
obj.sessions.all = MagicMock(return_value=[self.s1])
account.puppet_object(self.s1, obj)
# works because django.conf.settings.MULTISESSION_MODE is not in (1, 3)
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions."))
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.|n"))
self.assertTrue(obj.at_post_puppet.call_args[1] == {})
def test_puppet_object_already_puppeted(self):
@ -171,6 +256,7 @@ class TestDefaultAccount(TestCase):
import evennia.server.sessionhandler
account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
self.account = account
self.s1.uid = account.uid
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
@ -186,3 +272,20 @@ class TestDefaultAccount(TestCase):
account.puppet_object(self.s1, obj)
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account."))
self.assertIsNone(obj.at_post_puppet.call_args)
class TestAccountPuppetDeletion(EvenniaTest):
@override_settings(MULTISESSION_MODE=2)
def test_puppet_deletion(self):
# Check for existing chars
self.assertFalse(self.account.db._playable_characters, 'Account should not have any chars by default.')
# Add char1 to account's playable characters
self.account.db._playable_characters.append(self.char1)
self.assertTrue(self.account.db._playable_characters, 'Char was not added to account.')
# See what happens when we delete char1.
self.char1.delete()
# Playable char list should be empty.
self.assertFalse(self.account.db._playable_characters, 'Playable character list is not empty! %s' % self.account.db._playable_characters)

View file

@ -23,7 +23,7 @@ from builtins import range
import time
from django.conf import settings
from evennia.server.sessionhandler import SESSIONS
from evennia.utils import utils, create, search, evtable
from evennia.utils import utils, create, logger, search, evtable
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -171,6 +171,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
new_character.db.desc = "This is a character."
self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character."
% (new_character.key, new_character.key))
logger.log_sec('Character Created: %s (Caller: %s, IP: %s).' % (new_character, account, self.session.address))
class CmdCharDelete(COMMAND_DEFAULT_CLASS):
@ -214,6 +215,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS):
caller.db._playable_characters = [pc for pc in caller.db._playable_characters if pc != delobj]
delobj.delete()
self.msg("Character '%s' was permanently deleted." % key)
logger.log_sec('Character Deleted: %s (Caller: %s, IP: %s).' % (key, account, self.session.address))
else:
self.msg("Deletion was aborted.")
del caller.ndb._char_to_delete
@ -279,8 +281,10 @@ class CmdIC(COMMAND_DEFAULT_CLASS):
try:
account.puppet_object(session, new_character)
account.db._last_puppet = new_character
logger.log_sec('Puppet Success: (Caller: %s, Target: %s, IP: %s).' % (account, new_character, self.session.address))
except RuntimeError as exc:
self.msg("|rYou cannot become |C%s|n: %s" % (new_character.name, exc))
logger.log_sec('Puppet Failed: %s (Caller: %s, Target: %s, IP: %s).' % (exc, account, new_character, self.session.address))
# note that this is inheriting from MuxAccountLookCommand,
@ -641,6 +645,7 @@ class CmdPassword(COMMAND_DEFAULT_CLASS):
account.set_password(newpass)
account.save()
self.msg("Password changed.")
logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, account, self.session.address))
class CmdQuit(COMMAND_DEFAULT_CLASS):

View file

@ -9,15 +9,15 @@ import re
from django.conf import settings
from evennia.server.sessionhandler import SESSIONS
from evennia.server.models import ServerConfig
from evennia.utils import evtable, search, class_from_module
from evennia.utils import evtable, logger, search, class_from_module
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY]
# limit members for API inclusion
__all__ = ("CmdBoot", "CmdBan", "CmdUnban", "CmdDelAccount",
"CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall")
__all__ = ("CmdBoot", "CmdBan", "CmdUnban",
"CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall", "CmdForce")
class CmdBoot(COMMAND_DEFAULT_CLASS):
@ -96,6 +96,9 @@ class CmdBoot(COMMAND_DEFAULT_CLASS):
session.msg(feedback)
session.account.disconnect_session_from_account(session)
if pobj and boot_list:
logger.log_sec('Booted: %s (Reason: %s, Caller: %s, IP: %s).' % (pobj, reason, caller, self.session.address))
# regex matching IP addresses with wildcards, eg. 233.122.4.*
IPREGEX = re.compile(r"[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}")
@ -130,7 +133,7 @@ class CmdBan(COMMAND_DEFAULT_CLASS):
reason to be able to later remember why the ban was put in place.
It is often preferable to ban an account from the server than to
delete an account with @delaccount. If banned by name, that account
delete an account with @accounts/delete. If banned by name, that account
account can no longer be logged into.
IP (Internet Protocol) address banning allows blocking all access
@ -203,6 +206,7 @@ class CmdBan(COMMAND_DEFAULT_CLASS):
banlist.append(bantup)
ServerConfig.objects.conf('server_bans', banlist)
self.caller.msg("%s-Ban |w%s|n was added." % (typ, ban))
logger.log_sec('Banned %s: %s (Caller: %s, IP: %s).' % (typ, ban.strip(), self.caller, self.session.address))
class CmdUnban(COMMAND_DEFAULT_CLASS):
@ -246,79 +250,10 @@ class CmdUnban(COMMAND_DEFAULT_CLASS):
ban = banlist[num - 1]
del banlist[num - 1]
ServerConfig.objects.conf('server_bans', banlist)
value = " ".join([s for s in ban[:2]])
self.caller.msg("Cleared ban %s: %s" %
(num, " ".join([s for s in ban[:2]])))
class CmdDelAccount(COMMAND_DEFAULT_CLASS):
"""
delete an account from the server
Usage:
@delaccount[/switch] <name> [: reason]
Switch:
delobj - also delete the account's currently
assigned in-game object.
Completely deletes a user from the server database,
making their nick and e-mail again available.
"""
key = "@delaccount"
switch_options = ("delobj",)
locks = "cmd:perm(delaccount) or perm(Developer)"
help_category = "Admin"
def func(self):
"""Implements the command."""
caller = self.caller
args = self.args
if hasattr(caller, 'account'):
caller = caller.account
if not args:
self.msg("Usage: @delaccount <account/user name or #id> [: reason]")
return
reason = ""
if ':' in args:
args, reason = [arg.strip() for arg in args.split(':', 1)]
# We use account_search since we want to be sure to find also accounts
# that lack characters.
accounts = search.account_search(args)
if not accounts:
self.msg('Could not find an account by that name.')
return
if len(accounts) > 1:
string = "There were multiple matches:\n"
string += "\n".join(" %s %s" % (account.id, account.key) for account in accounts)
self.msg(string)
return
# one single match
account = accounts.first()
if not account.access(caller, 'delete'):
string = "You don't have the permissions to delete that account."
self.msg(string)
return
uname = account.username
# boot the account then delete
self.msg("Informing and disconnecting account ...")
string = "\nYour account '%s' is being *permanently* deleted.\n" % uname
if reason:
string += " Reason given:\n '%s'" % reason
account.msg(string)
account.delete()
self.msg("Account %s was successfully deleted." % uname)
(num, value))
logger.log_sec('Unbanned: %s (Caller: %s, IP: %s).' % (value.strip(), self.caller, self.session.address))
class CmdEmit(COMMAND_DEFAULT_CLASS):
@ -428,9 +363,9 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS):
account = caller.search_account(self.lhs)
if not account:
return
newpass = self.rhs
# Validate password
validated, error = account.validate_password(newpass)
if not validated:
@ -438,13 +373,14 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS):
string = "\n".join(errors)
caller.msg(string)
return
account.set_password(newpass)
account.save()
self.msg("%s - new password set to '%s'." % (account.name, newpass))
if account.character != caller:
account.msg("%s has changed your password to '%s'." % (caller.name,
newpass))
logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, caller, self.session.address))
class CmdPerm(COMMAND_DEFAULT_CLASS):
@ -526,6 +462,7 @@ class CmdPerm(COMMAND_DEFAULT_CLASS):
else:
caller_result.append("\nPermission %s removed from %s (if they existed)." % (perm, obj.name))
target_result.append("\n%s revokes the permission(s) %s from you." % (caller.name, perm))
logger.log_sec('Permissions Deleted: %s, %s (Caller: %s, IP: %s).' % (perm, obj, caller, self.session.address))
else:
# add a new permission
permissions = obj.permissions.all()
@ -547,6 +484,8 @@ class CmdPerm(COMMAND_DEFAULT_CLASS):
caller_result.append("\nPermission '%s' given to %s (%s)." % (perm, obj.name, plystring))
target_result.append("\n%s gives you (%s, %s) the permission '%s'."
% (caller.name, obj.name, plystring, perm))
logger.log_sec('Permissions Added: %s, %s (Caller: %s, IP: %s).' % (obj, perm, caller, self.session.address))
caller.msg("".join(caller_result).strip())
if target_result:
obj.msg("".join(target_result).strip())
@ -574,3 +513,33 @@ class CmdWall(COMMAND_DEFAULT_CLASS):
message = "%s shouts \"%s\"" % (self.caller.name, self.args)
self.msg("Announcing to all connected sessions ...")
SESSIONS.announce_all(message)
class CmdForce(COMMAND_DEFAULT_CLASS):
"""
forces an object to execute a command
Usage:
@force <object>=<command string>
Example:
@force bob=get stick
"""
key = "@force"
locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building"
perm_used = "edit"
def func(self):
"""Implements the force command"""
if not self.lhs or not self.rhs:
self.caller.msg("You must provide a target and a command string to execute.")
return
targ = self.caller.search(self.lhs)
if not targ:
return
if not targ.access(self.caller, self.perm_used):
self.caller.msg("You don't have permission to force them to execute commands.")
return
targ.execute_cmd(self.rhs)
self.caller.msg("You have forced %s to: %s" % (targ, self.rhs))

View file

@ -10,7 +10,7 @@ from evennia.objects.models import ObjectDB
from evennia.locks.lockhandler import LockException
from evennia.commands.cmdhandler import get_and_merge_cmdsets
from evennia.utils import create, utils, search
from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses
from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses, variable_from_module
from evennia.utils.eveditor import EvEditor
from evennia.utils.evmore import EvMore
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
@ -612,12 +612,12 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
self.edit_handler()
return
if self.rhs:
if '=' in self.args:
# We have an =
obj = caller.search(self.lhs)
if not obj:
return
desc = self.rhs
desc = self.rhs or ''
else:
obj = caller.location or self.msg("|rYou can't describe oblivion.|n")
if not obj:
@ -737,12 +737,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS):
confirm += ", ".join(["#{}".format(obj.id) for obj in objs])
confirm += " [yes]/no?" if self.default_confirm == 'yes' else " yes/[no]"
answer = ""
while answer.strip().lower() not in ("y", "yes", "n", "no"):
answer = yield(confirm)
answer = self.default_confirm if answer == '' else answer
answer = yield(confirm)
answer = self.default_confirm if answer == '' else answer
if answer.strip().lower() in ("n", "no"):
caller.msg("Cancelled: no object was destroyed.")
caller.msg("Canceled: no object was destroyed.")
delete = False
if delete:
@ -1023,10 +1022,17 @@ class CmdLink(COMMAND_DEFAULT_CLASS):
object_name = self.lhs
# get object
obj = caller.search(object_name, global_search=True)
if not obj:
return
# try to search locally first
results = caller.search(object_name, quiet=True)
if len(results) > 1: # local results was a multimatch. Inform them to be more specific
_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1))
return _AT_SEARCH_RESULT(results, caller, query=object_name)
elif len(results) == 1: # A unique local match
obj = results[0]
else: # No matches. Search globally
obj = caller.search(object_name, global_search=True)
if not obj:
return
if self.rhs:
# this means a target name was given
@ -2856,7 +2862,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
key = "@spawn"
aliases = ["olc"]
switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update")
switch_options = ("noloc", "search", "list", "show", "examine", "save", "delete", "menu", "olc", "update", "edit")
locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building"
@ -2889,7 +2895,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
"use the 'exec' prototype key.")
return None
try:
protlib.validate_prototype(prototype)
# we homogenize first, to be more lenient
protlib.validate_prototype(protlib.homogenize_prototype(prototype))
except RuntimeError as err:
self.caller.msg(str(err))
return
@ -2906,12 +2913,13 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
caller = self.caller
if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches:
if self.cmdstring == "olc" or 'menu' in self.switches \
or 'olc' in self.switches or 'edit' in self.switches:
# OLC menu mode
prototype = None
if self.lhs:
key = self.lhs
prototype = spawner.search_prototype(key=key, return_meta=True)
prototype = protlib.search_prototype(key=key)
if len(prototype) > 1:
caller.msg("More than one match for {}:\n{}".format(
key, "\n".join(proto.get('prototype_key', '') for proto in prototype)))
@ -2919,6 +2927,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
elif prototype:
# one match
prototype = prototype[0]
else:
# no match
caller.msg("No prototype '{}' was found.".format(key))
return
olc_menus.start_olc(caller, session=self.session, prototype=prototype)
return

View file

@ -55,7 +55,6 @@ class AccountCmdSet(CmdSet):
self.add(system.CmdPy())
# Admin commands
self.add(admin.CmdDelAccount())
self.add(admin.CmdNewPassword())
# Comm commands

View file

@ -57,6 +57,7 @@ class CharacterCmdSet(CmdSet):
self.add(admin.CmdEmit())
self.add(admin.CmdPerm())
self.add(admin.CmdWall())
self.add(admin.CmdForce())
# Building and world manipulation
self.add(building.CmdTeleport())

View file

@ -7,6 +7,8 @@ make sure to homogenize self.caller to always be the account object
for easy handling.
"""
import hashlib
import time
from past.builtins import cmp
from django.conf import settings
from evennia.comms.models import ChannelDB, Msg
@ -14,7 +16,7 @@ from evennia.accounts.models import AccountDB
from evennia.accounts import bots
from evennia.comms.channelhandler import CHANNELHANDLER
from evennia.locks.lockhandler import LockException
from evennia.utils import create, utils, evtable
from evennia.utils import create, logger, utils, evtable
from evennia.utils.utils import make_iter, class_from_module
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -368,6 +370,7 @@ class CmdCdestroy(COMMAND_DEFAULT_CLASS):
channel.delete()
CHANNELHANDLER.update()
self.msg("Channel '%s' was destroyed." % channel_key)
logger.log_sec('Channel Deleted: %s (Caller: %s, IP: %s).' % (channel_key, caller, self.session.address))
class CmdCBoot(COMMAND_DEFAULT_CLASS):
@ -433,6 +436,8 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS):
# disconnect account
channel.disconnect(account)
CHANNELHANDLER.update()
logger.log_sec('Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s).' % (
account, channel, reason, self.caller, self.session.address))
class CmdCemit(COMMAND_DEFAULT_CLASS):
@ -918,8 +923,9 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS):
self.msg("Account '%s' already exists and is not a bot." % botname)
return
else:
password = hashlib.md5(str(time.time())).hexdigest()[:11]
try:
bot = create.create_account(botname, None, None, typeclass=botclass)
bot = create.create_account(botname, None, password, typeclass=botclass)
except Exception as err:
self.msg("|rError, could not create the bot:|n '%s'." % err)
return

View file

@ -18,7 +18,7 @@ from evennia.server.sessionhandler import SESSIONS
from evennia.scripts.models import ScriptDB
from evennia.objects.models import ObjectDB
from evennia.accounts.models import AccountDB
from evennia.utils import logger, utils, gametime, create
from evennia.utils import logger, utils, gametime, create, search
from evennia.utils.eveditor import EvEditor
from evennia.utils.evtable import EvTable
from evennia.utils.utils import crop, class_from_module
@ -460,17 +460,22 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
class CmdAccounts(COMMAND_DEFAULT_CLASS):
"""
list all registered accounts
Manage registered accounts
Usage:
@accounts [nr]
@accounts/delete <name or #id> [: reason]
Lists statistics about the Accounts registered with the game.
Switches:
delete - delete an account from the server
By default, lists statistics about the Accounts registered with the game.
It will list the <nr> amount of latest registered accounts
If not given, <nr> defaults to 10.
"""
key = "@accounts"
aliases = ["@listaccounts"]
aliases = ["@account", "@listaccounts"]
switch_options = ("delete", )
locks = "cmd:perm(listaccounts) or perm(Admin)"
help_category = "System"
@ -478,6 +483,56 @@ class CmdAccounts(COMMAND_DEFAULT_CLASS):
"""List the accounts"""
caller = self.caller
args = self.args
if "delete" in self.switches:
account = getattr(caller, "account")
if not account or not account.check_permstring("Developer"):
caller.msg("You are not allowed to delete accounts.")
return
if not args:
caller.msg("Usage: @accounts/delete <name or #id> [: reason]")
return
reason = ""
if ":" in args:
args, reason = [arg.strip() for arg in args.split(":", 1)]
# We use account_search since we want to be sure to find also accounts
# that lack characters.
accounts = search.account_search(args)
if not accounts:
self.msg("Could not find an account by that name.")
return
if len(accounts) > 1:
string = "There were multiple matches:\n"
string += "\n".join(" %s %s" % (account.id, account.key) for account in accounts)
self.msg(string)
return
account = accounts.first()
if not account.access(caller, "delete"):
self.msg("You don't have the permissions to delete that account.")
return
username = account.username
# ask for confirmation
confirm = ("It is often better to block access to an account rather than to delete it. "
"|yAre you sure you want to permanently delete "
"account '|n{}|y'|n yes/[no]?".format(username))
answer = yield(confirm)
if answer.lower() not in ('y', 'yes'):
caller.msg("Canceled deletion.")
return
# Boot the account then delete it.
self.msg("Informing and disconnecting account ...")
string = "\nYour account '%s' is being *permanently* deleted.\n" % username
if reason:
string += " Reason given:\n '%s'" % reason
account.msg(string)
logger.log_sec("Account Deleted: %s (Reason: %s, Caller: %s, IP: %s)." % (account, reason, caller, self.session.address))
account.delete()
self.msg("Account %s was successfully deleted." % username)
return
# No switches, default to displaying a list of accounts.
if self.args and self.args.isdigit():
nlim = int(self.args)
else:
@ -655,6 +710,7 @@ class CmdTime(COMMAND_DEFAULT_CLASS):
"""Show server time data in a table."""
table1 = EvTable("|wServer time", "", align="l", width=78)
table1.add_row("Current uptime", utils.time_format(gametime.uptime(), 3))
table1.add_row("Portal uptime", utils.time_format(gametime.portal_uptime(), 3))
table1.add_row("Total runtime", utils.time_format(gametime.runtime(), 2))
table1.add_row("First start", datetime.datetime.fromtimestamp(gametime.server_epoch()))
table1.add_row("Current time", datetime.datetime.now())

View file

@ -243,6 +243,9 @@ class TestAdmin(CommandTest):
def test_ban(self):
self.call(admin.CmdBan(), "Char", "Name-Ban char was added.")
def test_force(self):
self.call(admin.CmdForce(), "Char2=say test", 'Char2(#7) says, "test"|You have forced Char2 to: say test')
class TestAccount(CommandTest):
@ -315,6 +318,24 @@ class TestBuilding(CommandTest):
def test_desc(self):
self.call(building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2(#5).")
def test_empty_desc(self):
"""
empty desc sets desc as ''
"""
o2d = self.obj2.db.desc
r1d = self.room1.db.desc
self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2(#5).")
assert self.obj2.db.desc == '' and self.obj2.db.desc != o2d
assert self.room1.db.desc == r1d
def test_desc_default_to_room(self):
"""no rhs changes room's desc"""
o2d = self.obj2.db.desc
r1d = self.room1.db.desc
self.call(building.CmdDesc(), "Obj2", "The description was set on Room(#1).")
assert self.obj2.db.desc == o2d
assert self.room1.db.desc == 'Obj2' and self.room1.db.desc != r1d
def test_wipe(self):
confirm = building.CmdDestroy.confirm
building.CmdDestroy.confirm = False
@ -334,6 +355,14 @@ class TestBuilding(CommandTest):
self.call(building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2")
self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).")
self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.")
self.char1.location = self.room2
self.call(building.CmdOpen(), "TestExit2=Room", "Created new Exit 'TestExit2' from Room2 to Room.")
# ensure it matches locally first
self.call(building.CmdLink(), "TestExit=Room2", "Link created TestExit2 -> Room2 (one way).")
# ensure can still match globally when not a local name
self.call(building.CmdLink(), "TestExit1=Room2", "Note: TestExit1(#8) did not have a destination set before. "
"Make sure you linked the right thing.\n"
"Link created TestExit1 -> Room2 (one way).")
def test_set_home(self):
self.call(building.CmdSetHome(), "Obj = Room2", "Obj's home location was changed from Room")
@ -460,6 +489,61 @@ class TestBuilding(CommandTest):
# Test listing commands
self.call(building.CmdSpawn(), "/list", "Key ")
# @spawn/edit (missing prototype)
# brings up olc menu
msg = self.call(
building.CmdSpawn(),
'/edit')
assert 'Prototype wizard' in msg
# @spawn/edit with valid prototype
# brings up olc menu loaded with prototype
msg = self.call(
building.CmdSpawn(),
'/edit testball')
assert 'Prototype wizard' in msg
assert hasattr(self.char1.ndb._menutree, "olc_prototype")
assert dict == type(self.char1.ndb._menutree.olc_prototype) \
and 'prototype_key' in self.char1.ndb._menutree.olc_prototype \
and 'key' in self.char1.ndb._menutree.olc_prototype \
and 'testball' == self.char1.ndb._menutree.olc_prototype['prototype_key'] \
and 'Ball' == self.char1.ndb._menutree.olc_prototype['key']
assert 'Ball' in msg and 'testball' in msg
# @spawn/edit with valid prototype (synomym)
msg = self.call(
building.CmdSpawn(),
'/edit BALL')
assert 'Prototype wizard' in msg
assert 'Ball' in msg and 'testball' in msg
# @spawn/edit with invalid prototype
msg = self.call(
building.CmdSpawn(),
'/edit NO_EXISTS',
"No prototype 'NO_EXISTS' was found.")
# @spawn/examine (missing prototype)
# lists all prototypes that exist
msg = self.call(
building.CmdSpawn(),
'/examine')
assert 'testball' in msg and 'testprot' in msg
# @spawn/examine with valid prototype
# prints the prototype
msg = self.call(
building.CmdSpawn(),
'/examine BALL')
assert 'Ball' in msg and 'testball' in msg
# @spawn/examine with invalid prototype
# shows error
self.call(
building.CmdSpawn(),
'/examine NO_EXISTS',
"No prototype 'NO_EXISTS' was found.")
class TestComms(CommandTest):

View file

@ -2,19 +2,13 @@
Commands that are available from the connect screen.
"""
import re
import time
import datetime
from random import getrandbits
from django.conf import settings
from django.contrib.auth import authenticate
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.comms.models import ChannelDB
from evennia.server.sessionhandler import SESSIONS
from evennia.utils import create, logger, utils, gametime
from evennia.utils import class_from_module, create, logger, utils, gametime
from evennia.commands.cmdhandler import CMD_LOGINSTART
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -26,11 +20,6 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
MULTISESSION_MODE = settings.MULTISESSION_MODE
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
# Create throttles for too many connections, account-creations and login attempts
CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
def create_guest_account(session):
"""
@ -44,49 +33,20 @@ def create_guest_account(session):
the boolean is whether guest accounts are enabled at all.
the Account which was created from an available guest name.
"""
# check if guests are enabled.
if not settings.GUEST_ENABLED:
return False, None
enabled = settings.GUEST_ENABLED
address = session.address
# Check IP bans.
bans = ServerConfig.objects.conf("server_bans")
if bans and any(tup[2].match(session.address) for tup in bans if tup[2]):
# this is a banned IP!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
session.msg(string)
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
return True, None
# Get account class
Guest = class_from_module(settings.BASE_GUEST_TYPECLASS)
try:
# Find an available guest name.
accountname = None
for name in settings.GUEST_LIST:
if not AccountDB.objects.filter(username__iexact=accountname).count():
accountname = name
break
if not accountname:
session.msg("All guest accounts are in use. Please try again later.")
return True, None
else:
# build a new account with the found guest accountname
password = "%016x" % getrandbits(64)
home = ObjectDB.objects.get_id(settings.GUEST_HOME)
permissions = settings.PERMISSION_GUEST_DEFAULT
typeclass = settings.BASE_CHARACTER_TYPECLASS
ptypeclass = settings.BASE_GUEST_TYPECLASS
new_account = _create_account(session, accountname, password, permissions, ptypeclass)
if new_account:
_create_character(session, new_account, typeclass, home, permissions)
return True, new_account
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
session.msg("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
raise
# Get an available guest account
# authenticate() handles its own throttling
account, errors = Guest.authenticate(ip=address)
if account:
return enabled, account
else:
session.msg("|R%s|n" % '\n'.join(errors))
return enabled, None
def create_normal_account(session, name, password):
@ -101,38 +61,17 @@ def create_normal_account(session, name, password):
Returns:
account (Account): the account which was created from the name and password.
"""
# check for too many login errors too quick.
address = session.address
if isinstance(address, tuple):
address = address[0]
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
if LOGIN_THROTTLE.check(address):
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return None
address = session.address
# Match account name and check password
account = authenticate(username=name, password=password)
# authenticate() handles all its own throttling
account, errors = Account.authenticate(username=name, password=password, ip=address, session=session)
if not account:
# No accountname or password match
session.msg("Incorrect login information given.")
# this just updates the throttle
LOGIN_THROTTLE.update(address)
# calls account hook for a failed login if possible.
account = AccountDB.objects.get_account_from_name(name)
if account:
account.at_failed_login(session)
return None
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == account.name.lower() for tup in bans) or
any(tup[2].match(session.address) for tup in bans if tup[2])):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
session.msg(string)
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
session.msg("|R%s|n" % '\n'.join(errors))
return None
return account
@ -164,15 +103,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
there is no object yet before the account has logged in)
"""
session = self.caller
# check for too many login errors too quick.
address = session.address
if isinstance(address, tuple):
address = address[0]
if CONNECTION_THROTTLE.check(address):
# timeout is 5 minutes.
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return
args = self.args
# extract double quote parts
@ -180,23 +111,33 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
if len(parts) == 1:
# this was (hopefully) due to no double quotes being found, or a guest login
parts = parts[0].split(None, 1)
# Guest login
if len(parts) == 1 and parts[0].lower() == "guest":
enabled, new_account = create_guest_account(session)
if new_account:
session.sessionhandler.login(session, new_account)
if enabled:
# Get Guest typeclass
Guest = class_from_module(settings.BASE_GUEST_TYPECLASS)
account, errors = Guest.authenticate(ip=address)
if account:
session.sessionhandler.login(session, account)
return
else:
session.msg("|R%s|n" % '\n'.join(errors))
return
if len(parts) != 2:
session.msg("\n\r Usage (without <>): connect <name> <password>")
return
CONNECTION_THROTTLE.update(address)
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
name, password = parts
account = create_normal_account(session, name, password)
account, errors = Account.authenticate(username=name, password=password, ip=address, session=session)
if account:
session.sessionhandler.login(session, account)
else:
session.msg("|R%s|n" % '\n'.join(errors))
class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
@ -222,14 +163,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
session = self.caller
args = self.args.strip()
# Rate-limit account creation.
address = session.address
if isinstance(address, tuple):
address = address[0]
if CREATION_THROTTLE.check(address):
session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n")
return
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# extract double quoted parts
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
@ -241,77 +178,21 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
"\nIf <name> or <password> contains spaces, enclose it in double quotes."
session.msg(string)
return
accountname, password = parts
# sanity checks
if not re.findall(r"^[\w. @+\-']+$", accountname) or not (0 < len(accountname) <= 30):
# this echoes the restrictions made by django's auth
# module (except not allowing spaces, for convenience of
# logging in).
string = "\n\r Accountname can max be 30 characters or fewer. Letters, spaces, digits and @/./+/-/_/' only."
session.msg(string)
return
# strip excessive spaces in accountname
accountname = re.sub(r"\s+", " ", accountname).strip()
if AccountDB.objects.filter(username__iexact=accountname):
# account already exists (we also ignore capitalization here)
session.msg("Sorry, there is already an account with the name '%s'." % accountname)
return
# Reserve accountnames found in GUEST_LIST
if settings.GUEST_LIST and accountname.lower() in (guest.lower() for guest in settings.GUEST_LIST):
string = "\n\r That name is reserved. Please choose another Accountname."
session.msg(string)
return
# Validate password
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# Have to create a dummy Account object to check username similarity
valid, error = Account.validate_password(password, account=Account(username=accountname))
if error:
errors = [e for suberror in error.messages for e in error.messages]
string = "\n".join(errors)
session.msg(string)
return
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == accountname.lower() for tup in bans) or
any(tup[2].match(session.address) for tup in bans if tup[2])):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
session.msg(string)
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
return
username, password = parts
# everything's ok. Create the new account account.
try:
permissions = settings.PERMISSION_ACCOUNT_DEFAULT
typeclass = settings.BASE_CHARACTER_TYPECLASS
new_account = _create_account(session, accountname, password, permissions)
if new_account:
if MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
_create_character(session, new_account, typeclass, default_home, permissions)
# Update the throttle to indicate a new account was created from this IP
CREATION_THROTTLE.update(address)
# tell the caller everything went well.
string = "A new account '%s' was created. Welcome!"
if " " in accountname:
string += "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
else:
string += "\n\nYou can now log with the command 'connect %s <your password>'."
session.msg(string % (accountname, accountname))
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
session.msg("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
account, errors = Account.create(username=username, password=password, ip=address, session=session)
if account:
# tell the caller everything went well.
string = "A new account '%s' was created. Welcome!"
if " " in username:
string += "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
else:
string += "\n\nYou can now log with the command 'connect %s <your password>'."
session.msg(string % (username, username))
else:
session.msg("|R%s|n" % '\n'.join(errors))
class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS):

View file

@ -5,7 +5,7 @@ Base typeclass for in-game Channels.
from evennia.typeclasses.models import TypeclassBase
from evennia.comms.models import TempMsg, ChannelDB
from evennia.comms.managers import ChannelManager
from evennia.utils import logger
from evennia.utils import create, logger
from evennia.utils.utils import make_iter
from future.utils import with_metaclass
_CHANNEL_HANDLER = None
@ -220,6 +220,50 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
return self.locks.check(accessing_obj, access_type=access_type,
default=default, no_superuser_bypass=no_superuser_bypass)
@classmethod
def create(cls, key, account=None, *args, **kwargs):
"""
Creates a basic Channel with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_channel() function.
Args:
key (str): This must be unique.
account (Account): Account to attribute this object to.
Kwargs:
aliases (list of str): List of alternative (likely shorter) keynames.
description (str): A description of the channel, for use in listings.
locks (str): Lockstring.
keep_log (bool): Log channel throughput.
typeclass (str or class): The typeclass of the Channel (not
often used).
ip (str): IP address of creator (for object auditing).
Returns:
channel (Channel): A newly created Channel.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
ip = kwargs.pop('ip', '')
try:
kwargs['desc'] = kwargs.pop('description', '')
obj = create.create_channel(key, *args, **kwargs)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
except Exception as exc:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(exc)
return obj, errors
def delete(self):
"""
Deletes channel while also cleaning up channelhandler.

13
evennia/comms/tests.py Normal file
View file

@ -0,0 +1,13 @@
from evennia.utils.test_resources import EvenniaTest
from evennia import DefaultChannel
class ObjectCreationTest(EvenniaTest):
def test_channel_create(self):
description = "A place to talk about coffee."
obj, errors = DefaultChannel.create('coffeetalk', description=description)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)

View file

@ -475,14 +475,14 @@ class CmdShiftRoot(Command):
root_pos["blue"] -= 1
self.caller.msg("The root with blue flowers gets in the way and is pushed to the left.")
else:
self.caller.msg("You cannot move the root in that direction.")
self.caller.msg("The root hangs straight down - you can only move it left or right.")
elif color == "blue":
if direction == "left":
root_pos[color] = max(-1, root_pos[color] - 1)
self.caller.msg("You shift the root with small blue flowers to the left.")
if root_pos[color] != 0 and root_pos[color] == root_pos["red"]:
root_pos["red"] += 1
self.caller.msg("The reddish root is to big to fit as well, so that one falls away to the left.")
self.caller.msg("The reddish root is too big to fit as well, so that one falls away to the left.")
elif direction == "right":
root_pos[color] = min(1, root_pos[color] + 1)
self.caller.msg("You shove the root adorned with small blue flowers to the right.")
@ -490,7 +490,7 @@ class CmdShiftRoot(Command):
root_pos["red"] -= 1
self.caller.msg("The thick reddish root gets in the way and is pushed back to the left.")
else:
self.caller.msg("You cannot move the root in that direction.")
self.caller.msg("The root hangs straight down - you can only move it left or right.")
# now the horizontal roots (yellow/green). They can be moved up/down
elif color == "yellow":
@ -507,7 +507,7 @@ class CmdShiftRoot(Command):
root_pos["green"] -= 1
self.caller.msg("The weedy green root is shifted upwards to make room.")
else:
self.caller.msg("You cannot move the root in that direction.")
self.caller.msg("The root hangs across the wall - you can only move it up or down.")
elif color == "green":
if direction == "up":
root_pos[color] = max(-1, root_pos[color] - 1)
@ -522,7 +522,7 @@ class CmdShiftRoot(Command):
root_pos["yellow"] -= 1
self.caller.msg("The root with yellow flowers gets in the way and is pushed upwards.")
else:
self.caller.msg("You cannot move the root in that direction.")
self.caller.msg("The root hangs across the wall - you can only move it up or down.")
# we have moved the root. Store new position
self.obj.db.root_pos = root_pos

View file

@ -747,9 +747,16 @@ class CmdLookDark(Command):
"""
caller = self.caller
if random.random() < 0.75:
# count how many searches we've done
nr_searches = caller.ndb.dark_searches
if nr_searches is None:
nr_searches = 0
caller.ndb.dark_searches = nr_searches
if nr_searches < 4 and random.random() < 0.90:
# we don't find anything
caller.msg(random.choice(DARK_MESSAGES))
caller.ndb.dark_searches += 1
else:
# we could have found something!
if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)):
@ -791,7 +798,8 @@ class CmdDarkNoMatch(Command):
def func(self):
"""Implements the command."""
self.caller.msg("Until you find some light, there's not much you can do. Try feeling around.")
self.caller.msg("Until you find some light, there's not much you can do. "
"Try feeling around, maybe you'll find something helpful!")
class DarkCmdSet(CmdSet):
@ -814,7 +822,9 @@ class DarkCmdSet(CmdSet):
self.add(CmdLookDark())
self.add(CmdDarkHelp())
self.add(CmdDarkNoMatch())
self.add(default_cmds.CmdSay)
self.add(default_cmds.CmdSay())
self.add(default_cmds.CmdQuit())
self.add(default_cmds.CmdHome())
class DarkRoom(TutorialRoom):

View file

@ -21,6 +21,7 @@ from evennia.scripts.scripthandler import ScriptHandler
from evennia.commands import cmdset, command
from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.commands import cmdhandler
from evennia.utils import create
from evennia.utils import search
from evennia.utils import logger
from evennia.utils import ansi
@ -191,6 +192,10 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
without `obj.save()` having to be called explicitly.
"""
# lockstring of newly created objects, for easy overloading.
# Will be formatted with the appropriate attributes.
lockstring = "control:id({account_id}) or perm(Admin);delete:id({account_id}) or perm(Admin)"
objects = ObjectManager()
# on-object properties
@ -214,7 +219,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
@property
def is_connected(self):
# we get an error for objects subscribed to channels without this
if self.account: # seems sane to pass on the account
if self.account: # seems sane to pass on the account
return self.account.is_connected
else:
return False
@ -425,7 +430,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# only allow exact matching if searching the entire database
# or unique #dbrefs
exact = True
elif candidates is None:
else:
# TODO: write code...if candidates is None:
# no custom candidates given - get them automatically
if location:
# location(s) were given
@ -858,6 +864,67 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
obj.msg(_(string))
obj.move_to(home)
@classmethod
def create(cls, key, account=None, **kwargs):
"""
Creates a basic object with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_object() function.
Args:
key (str): Name of the new object.
account (Account): Account to attribute this object to.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
object (Object): A newly created object of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get a supplied description, if any
description = kwargs.pop('description', '')
# Create a sane lockstring if one wasn't supplied
lockstring = kwargs.get('locks')
if account and not lockstring:
lockstring = cls.lockstring.format(account_id=account.id)
kwargs['locks'] = lockstring
# Create object
try:
obj = create.create_object(**kwargs)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
# Set description if there is none, or update it if provided
if description or not obj.db.desc:
desc = description if description else "You see nothing special."
obj.db.desc = desc
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def copy(self, new_key=None):
"""
Makes an identical copy of this object, identical except for a
@ -913,8 +980,12 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# no need to disconnect, Account just jumps to OOC mode.
# sever the connection (important!)
if self.account:
# Remove the object from playable characters list
if self in self.account.db._playable_characters:
self.account.db._playable_characters = [x for x in self.account.db._playable_characters if x != self]
for session in self.sessions.all():
self.account.unpuppet_object(session)
self.account = None
for script in _ScriptDB.objects.get_all_scripts_on_obj(self):
@ -1821,6 +1892,83 @@ class DefaultCharacter(DefaultObject):
a character avatar controlled by an account.
"""
# lockstring of newly created rooms, for easy overloading.
# Will be formatted with the appropriate attributes.
lockstring = "puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer)"
@classmethod
def create(cls, key, account, **kwargs):
"""
Creates a basic Character with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_character() function.
Args:
key (str): Name of the new Character.
account (obj): Account to associate this Character with. Required as
an argument, but one can fake it out by supplying None-- it will
change the default lockset and skip creator attribution.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
character (Object): A newly created Character of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get home for character
kwargs['home'] = ObjectDB.objects.get_id(kwargs.get('home', settings.DEFAULT_HOME))
# Get permissions
kwargs['permissions'] = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT)
# Get description if provided
description = kwargs.pop('description', '')
# Get locks if provided
locks = kwargs.pop('locks', '')
try:
# Create the Character
obj = create.create_object(**kwargs)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
# Add locks
if not locks and account:
# Allow only the character itself and the creator account to puppet this character (and Developers).
locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': account.id})
elif not locks and not account:
locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': -1})
obj.locks.add(locks)
# If no description is set, set a default description
if description or not obj.db.desc:
obj.db.desc = description if description else "This is a character."
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def basetype_setup(self):
"""
@ -1938,6 +2086,72 @@ class DefaultRoom(DefaultObject):
This is the base room object. It's just like any Object except its
location is always `None`.
"""
# lockstring of newly created rooms, for easy overloading.
# Will be formatted with the {id} of the creating object.
lockstring = "control:id({id}) or perm(Admin); " \
"delete:id({id}) or perm(Admin); " \
"edit:id({id}) or perm(Admin)"
@classmethod
def create(cls, key, account, **kwargs):
"""
Creates a basic Room with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_object() function.
Args:
key (str): Name of the new Room.
account (obj): Account to associate this Room with.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
room (Object): A newly created Room of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get who to send errors to
kwargs['report_to'] = kwargs.pop('report_to', account)
# Get description, if provided
description = kwargs.pop('description', '')
try:
# Create the Room
obj = create.create_object(**kwargs)
# Set appropriate locks
lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id))
obj.locks.add(lockstring)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
# If no description is set, set a default description
if description or not obj.db.desc:
obj.db.desc = description if description else "This is a room."
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def basetype_setup(self):
"""
@ -2016,6 +2230,13 @@ class DefaultExit(DefaultObject):
exit_command = ExitCommand
priority = 101
# lockstring of newly created exits, for easy overloading.
# Will be formatted with the {id} of the creating object.
lockstring = "control:id({id}) or perm(Admin); " \
"delete:id({id}) or perm(Admin); " \
"edit:id({id}) or perm(Admin)"
# Helper classes and methods to implement the Exit. These need not
# be overloaded unless one want to change the foundation for how
# Exits work. See the end of the class for hook methods to overload.
@ -2054,6 +2275,73 @@ class DefaultExit(DefaultObject):
# Command hooks
@classmethod
def create(cls, key, account, source, dest, **kwargs):
"""
Creates a basic Exit with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_object() function.
Args:
key (str): Name of the new Exit, as it should appear from the
source room.
account (obj): Account to associate this Exit with.
source (Room): The room to create this exit in.
dest (Room): The room to which this exit should go.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
exit (Object): A newly created Room of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get who to send errors to
kwargs['report_to'] = kwargs.pop('report_to', account)
# Set to/from rooms
kwargs['location'] = source
kwargs['destination'] = dest
description = kwargs.pop('description', '')
try:
# Create the Exit
obj = create.create_object(**kwargs)
# Set appropriate locks
lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id))
obj.locks.add(lockstring)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
# If no description is set, set a default description
if description or not obj.db.desc:
obj.db.desc = description if description else "This is an exit."
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def basetype_setup(self):
"""
Setup exit-security

47
evennia/objects/tests.py Normal file
View file

@ -0,0 +1,47 @@
from evennia.utils.test_resources import EvenniaTest
from evennia import DefaultObject, DefaultCharacter, DefaultRoom, DefaultExit
class DefaultObjectTest(EvenniaTest):
ip = '212.216.139.14'
def test_object_create(self):
description = 'A home for a grouch.'
obj, errors = DefaultObject.create('trashcan', self.account, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_character_create(self):
description = 'A furry green monster, reeking of garbage.'
obj, errors = DefaultCharacter.create('oscar', self.account, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_room_create(self):
description = 'A dimly-lit alley behind the local Chinese restaurant.'
obj, errors = DefaultRoom.create('alley', self.account, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_exit_create(self):
description = 'The steaming depths of the dumpster, ripe with refuse in various states of decomposition.'
obj, errors = DefaultExit.create('in', self.account, self.room1, self.room2, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_urls(self):
"Make sure objects are returning URLs"
self.assertTrue(self.char1.get_absolute_url())
self.assertTrue('admin' in self.char1.web_get_admin_url())
self.assertTrue(self.room1.get_absolute_url())
self.assertTrue('admin' in self.room1.web_get_admin_url())

View file

@ -562,6 +562,7 @@ def node_index(caller):
text = """
|c --- Prototype wizard --- |n
%s
A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype
can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to
@ -599,6 +600,17 @@ def node_index(caller):
{pfuncs}
""".format(pfuncs=_format_protfuncs())
# If a prototype is being edited, show its key and
# prototype_key under the title
loaded_prototype = ''
if 'prototype_key' in prototype \
or 'key' in prototype:
loaded_prototype = ' --- Editing: |y{}({})|n --- '.format(
prototype.get('key', ''),
prototype.get('prototype_key', '')
)
text = text % (loaded_prototype)
text = (text, helptxt)
options = []

View file

@ -6,6 +6,8 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos
"""
import re
import hashlib
import time
from ast import literal_eval
from django.conf import settings
from evennia.scripts.scripts import DefaultScript
@ -13,7 +15,7 @@ from evennia.objects.models import ObjectDB
from evennia.utils.create import create_script
from evennia.utils.utils import (
all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module,
get_all_typeclasses, to_str, dbref, justify)
get_all_typeclasses, to_str, dbref, justify, class_from_module)
from evennia.locks.lockhandler import validate_lockstring, check_lockstring
from evennia.utils import logger
from evennia.utils import inlinefuncs, dbserialize
@ -47,8 +49,8 @@ class ValidationError(RuntimeError):
def homogenize_prototype(prototype, custom_keys=None):
"""
Homogenize the more free-form prototype (where undefined keys are non-category attributes)
into the stricter form using `attrs` required by the system.
Homogenize the more free-form prototype supported pre Evennia 0.7 into the stricter form.
Args:
prototype (dict): Prototype.
@ -56,18 +58,45 @@ def homogenize_prototype(prototype, custom_keys=None):
the default reserved keys.
Returns:
homogenized (dict): Prototype where all non-identified keys grouped as attributes.
homogenized (dict): Prototype where all non-identified keys grouped as attributes and other
homogenizations like adding missing prototype_keys and setting a default typeclass.
"""
reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ())
attrs = list(prototype.get('attrs', [])) # break reference
tags = make_iter(prototype.get('tags', []))
homogenized_tags = []
homogenized = {}
for key, val in prototype.items():
if key in reserved:
homogenized[key] = val
if key == 'tags':
for tag in tags:
if not is_iter(tag):
homogenized_tags.append((tag, None, None))
else:
homogenized_tags.append(tag)
else:
homogenized[key] = val
else:
# unassigned keys -> attrs
attrs.append((key, val, None, ''))
if attrs:
homogenized['attrs'] = attrs
if homogenized_tags:
homogenized['tags'] = homogenized_tags
# add required missing parts that had defaults before
if "prototype_key" not in prototype:
# assign a random hash as key
homogenized["prototype_key"] = "prototype-{}".format(
hashlib.md5(str(time.time())).hexdigest()[:7])
if "typeclass" not in prototype and "prototype_parent" not in prototype:
homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS
return homogenized
@ -76,9 +105,12 @@ def homogenize_prototype(prototype, custom_keys=None):
for mod in settings.PROTOTYPE_MODULES:
# to remove a default prototype, override it with an empty dict.
# internally we store as (key, desc, locks, tags, prototype_dict)
prots = [(prototype_key.lower(), homogenize_prototype(prot))
for prototype_key, prot in all_from_module(mod).items()
if prot and isinstance(prot, dict)]
prots = []
for variable_name, prot in all_from_module(mod).items():
if isinstance(prot, dict):
if "prototype_key" not in prot:
prot['prototype_key'] = variable_name.lower()
prots.append((prot['prototype_key'], homogenize_prototype(prot)))
# assign module path to each prototype_key for easy reference
_MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots})
# make sure the prototype contains all meta info
@ -432,11 +464,13 @@ def validate_prototype(prototype, protkey=None, protparents=None,
_flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks "
"a typeclass or a prototype_parent.".format(protkey))
if (strict and typeclass and typeclass not
in get_all_typeclasses("evennia.objects.models.ObjectDB")):
_flags['errors'].append(
"Prototype {} is based on typeclass {}, which could not be imported!".format(
protkey, typeclass))
if strict and typeclass:
try:
class_from_module(typeclass)
except ImportError as err:
_flags['errors'].append(
"{}: Prototype {} is based on typeclass {}, which could not be imported!".format(
err, protkey, typeclass))
# recursively traverese prototype_parent chain

View file

@ -259,11 +259,11 @@ def prototype_from_object(obj):
if aliases:
prot['aliases'] = aliases
tags = [(tag.db_key, tag.db_category, tag.db_data)
for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag]
for tag in obj.tags.all(return_objs=True)]
if tags:
prot['tags'] = tags
attrs = [(attr.key, attr.value, attr.category, ';'.join(attr.locks.all()))
for attr in obj.attributes.get(return_obj=True, return_list=True) if attr]
for attr in obj.attributes.all()]
if attrs:
prot['attrs'] = attrs
@ -659,6 +659,10 @@ def spawn(*prototypes, **kwargs):
# get available protparents
protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()}
if not kwargs.get("only_validate"):
# homogenization to be more lenient about prototype format when entering the prototype manually
prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes]
# overload module's protparents with specifically given protparents
# we allow prototype_key to be the key of the protparent dict, to allow for module-level
# prototype imports. We need to insert prototype_key in this case
@ -711,8 +715,8 @@ def spawn(*prototypes, **kwargs):
val = prot.pop("tags", [])
tags = []
for (tag, category, data) in tags:
tags.append((init_spawn_value(val, str), category, data))
for (tag, category, data) in val:
tags.append((init_spawn_value(tag, str), category, data))
prototype_key = prototype.get('prototype_key', None)
if prototype_key:
@ -730,7 +734,7 @@ def spawn(*prototypes, **kwargs):
val = make_iter(prot.pop("attrs", []))
attributes = []
for (attrname, value, category, locks) in val:
attributes.append((attrname, init_spawn_value(val), category, locks))
attributes.append((attrname, init_spawn_value(value), category, locks))
simple_attributes = []
for key, value in ((key, value) for key, value in prot.items()

View file

@ -134,6 +134,7 @@ class TestUtils(EvenniaTest):
'prototype_key': Something,
'prototype_locks': 'spawn:all();edit:all()',
'prototype_tags': [],
'tags': [(u'footag', u'foocategory', None)],
'typeclass': 'evennia.objects.objects.DefaultObject'})
self.assertEqual(old_prot,
@ -182,6 +183,7 @@ class TestUtils(EvenniaTest):
'typeclass': ('evennia.objects.objects.DefaultObject',
'evennia.objects.objects.DefaultObject', 'KEEP'),
'aliases': {'foo': ('foo', None, 'REMOVE')},
'tags': {u'footag': ((u'footag', u'foocategory', None), None, 'REMOVE')},
'prototype_desc': ('Built from Obj',
'New version of prototype', 'UPDATE'),
'permissions': {"Builder": (None, 'Builder', 'ADD')}
@ -200,6 +202,7 @@ class TestUtils(EvenniaTest):
'prototype_key': 'UPDATE',
'prototype_locks': 'KEEP',
'prototype_tags': 'KEEP',
'tags': 'REMOVE',
'typeclass': 'KEEP'}
)
@ -384,8 +387,9 @@ class TestPrototypeStorage(EvenniaTest):
prot3 = protlib.create_prototype(**self.prot3)
# partial match
self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3])
self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3])
with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}):
self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3])
self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3])
self.assertTrue(str(unicode(protlib.list_prototypes(self.char1))))

View file

@ -12,7 +12,7 @@ from django.utils.translation import ugettext as _
from evennia.typeclasses.models import TypeclassBase
from evennia.scripts.models import ScriptDB
from evennia.scripts.manager import ScriptManager
from evennia.utils import logger
from evennia.utils import create, logger
from future.utils import with_metaclass
__all__ = ["DefaultScript", "DoNothing", "Store"]
@ -324,6 +324,32 @@ class DefaultScript(ScriptBase):
"""
@classmethod
def create(cls, key, **kwargs):
"""
Provides a passthrough interface to the utils.create_script() function.
Args:
key (str): Name of the new object.
Returns:
object (Object): A newly created object of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
kwargs['key'] = key
try:
obj = create.create_script(**kwargs)
except Exception as e:
errors.append("The script '%s' encountered errors and could not be created." % key)
logger.log_err(e)
return obj, errors
def at_script_creation(self):
"""
Only called once, when script is first created.

View file

@ -1,10 +1,20 @@
# this is an optimized version only available in later Django versions
from unittest import TestCase
from evennia import DefaultScript
from evennia.scripts.models import ScriptDB, ObjectDoesNotExist
from evennia.utils.create import create_script
from evennia.utils.test_resources import EvenniaTest
from evennia.scripts.scripts import DoNothing
class TestScript(EvenniaTest):
def test_create(self):
"Check the script can be created via the convenience method."
obj, errors = DefaultScript.create('useless-machine')
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
class TestScriptDB(TestCase):
"Check the singleton/static ScriptDB object works correctly"

View file

@ -221,6 +221,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
server_restart_mode = kwargs.get("server_restart_mode", "shutdown")
self.factory.server.run_init_hooks(server_restart_mode)
server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
server_sessionhandler.portal_start_time = kwargs.get("portal_start_time")
elif operation == amp.SRELOAD: # server reload
# shut down in reload mode

View file

@ -325,7 +325,7 @@ MENU = \
| 7) Kill Server only (send kill signal to process) |
| 8) Kill Portal + Server |
+--- Information -----------------------------------------------+
| 9) Tail log files (quickly see errors) |
| 9) Tail log files (quickly see errors - Ctrl-C to exit) |
| 10) Status |
| 11) Port info |
+--- Testing ---------------------------------------------------+

View file

@ -428,7 +428,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
self.send_AdminPortal2Server(amp.DUMMYSESSION,
amp.PSYNC,
server_restart_mode=server_restart_mode,
sessiondata=sessdata)
sessiondata=sessdata,
portal_start_time=self.factory.portal.start_time)
self.factory.portal.sessions.at_server_connection()
if self.factory.server_connection:

View file

@ -11,6 +11,7 @@ from builtins import object
import sys
import os
import time
from os.path import dirname, abspath
from twisted.application import internet, service
@ -114,6 +115,8 @@ class Portal(object):
self.server_restart_mode = "shutdown"
self.server_info_dict = {}
self.start_time = time.time()
# in non-interactive portal mode, this gets overwritten by
# cmdline sent by the evennia launcher
self.server_twistd_cmd = self._get_backup_server_twistd_cmd()

View file

@ -84,7 +84,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
from evennia.utils.utils import delay
# timeout the handshakes in case the client doesn't reply at all
delay(2, callback=self.handshake_done, timeout=True)
self._handshake_delay = delay(2, callback=self.handshake_done, timeout=True)
# TCP/IP keepalive watches for dead links
self.transport.setTcpKeepAlive(1)

View file

@ -8,9 +8,24 @@ try:
except ImportError:
import unittest
from mock import Mock
import string
from evennia.server.portal import irc
from twisted.conch.telnet import IAC, WILL, DONT, SB, SE, NAWS, DO
from twisted.test import proto_helpers
from twisted.trial.unittest import TestCase as TwistedTestCase
from .telnet import TelnetServerFactory, TelnetProtocol
from .portal import PORTAL_SESSIONS
from .suppress_ga import SUPPRESS_GA
from .naws import DEFAULT_HEIGHT, DEFAULT_WIDTH
from .ttype import TTYPE, IS
from .mccp import MCCP
from .mssp import MSSP
from .mxp import MXP
from .telnet_oob import MSDP, MSDP_VAL, MSDP_VAR
class TestIRC(TestCase):
@ -73,3 +88,64 @@ class TestIRC(TestCase):
s = r'|wthis|Xis|gis|Ma|C|complex|*string'
self.assertEqual(irc.parse_irc_to_ansi(irc.parse_ansi_to_irc(s)), s)
class TestTelnet(TwistedTestCase):
def setUp(self):
super(TestTelnet, self).setUp()
factory = TelnetServerFactory()
factory.protocol = TelnetProtocol
factory.sessionhandler = PORTAL_SESSIONS
factory.sessionhandler.portal = Mock()
self.proto = factory.buildProtocol(("localhost", 0))
self.transport = proto_helpers.StringTransport()
self.addCleanup(factory.sessionhandler.disconnect_all)
def test_mudlet_ttype(self):
self.transport.client = ["localhost"]
self.transport.setTcpKeepAlive = Mock()
d = self.proto.makeConnection(self.transport)
# test suppress_ga
self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"])
self.proto.dataReceived(IAC + DONT + SUPPRESS_GA)
self.assertFalse(self.proto.protocol_flags["NOGOAHEAD"])
self.assertEqual(self.proto.handshakes, 7)
# test naws
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'], {0: DEFAULT_WIDTH})
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'], {0: DEFAULT_HEIGHT})
self.proto.dataReceived(IAC + WILL + NAWS)
self.proto.dataReceived([IAC, SB, NAWS, '', 'x', '', 'd', IAC, SE])
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'][0], 120)
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'][0], 100)
self.assertEqual(self.proto.handshakes, 6)
# test ttype
self.assertTrue(self.proto.protocol_flags["FORCEDENDLINE"])
self.assertFalse(self.proto.protocol_flags["TTYPE"])
self.assertTrue(self.proto.protocol_flags["ANSI"])
self.proto.dataReceived(IAC + WILL + TTYPE)
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MUDLET", IAC, SE])
self.assertTrue(self.proto.protocol_flags["XTERM256"])
self.assertEqual(self.proto.protocol_flags["CLIENTNAME"], "MUDLET")
self.proto.dataReceived([IAC, SB, TTYPE, IS, "XTERM", IAC, SE])
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MTTS 137", IAC, SE])
self.assertEqual(self.proto.handshakes, 5)
# test mccp
self.proto.dataReceived(IAC + DONT + MCCP)
self.assertFalse(self.proto.protocol_flags['MCCP'])
self.assertEqual(self.proto.handshakes, 4)
# test mssp
self.proto.dataReceived(IAC + DONT + MSSP)
self.assertEqual(self.proto.handshakes, 3)
# test oob
self.proto.dataReceived(IAC + DO + MSDP)
self.proto.dataReceived([IAC, SB, MSDP, MSDP_VAR, "LIST", MSDP_VAL, "COMMANDS", IAC, SE])
self.assertTrue(self.proto.protocol_flags['OOB'])
self.assertEqual(self.proto.handshakes, 2)
# test mxp
self.proto.dataReceived(IAC + DONT + MXP)
self.assertFalse(self.proto.protocol_flags['MXP'])
self.assertEqual(self.proto.handshakes, 1)
# clean up to prevent Unclean reactor
self.proto.nop_keep_alive.stop()
self.proto._handshake_delay.cancel()
return d

View file

@ -13,14 +13,14 @@ import time
# TODO!
#sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
#os.environ['DJANGO_SETTINGS_MODULE'] = 'game.settings'
import ev
from evennia.utils.idmapper import base as _idmapper
import evennia
from evennia.utils.idmapper import models as _idmapper
LOGFILE = "logs/memoryusage.log"
INTERVAL = 30 # log every 30 seconds
class Memplot(ev.Script):
class Memplot(evennia.DefaultScript):
"""
Describes a memory plotting action.

View file

@ -1,7 +1,8 @@
from django.test import TestCase
from mock import Mock
from mock import Mock, patch, mock_open
from .dummyrunner_settings import (c_creates_button, c_creates_obj, c_digs, c_examines, c_help, c_idles, c_login,
c_login_nodig, c_logout, c_looks, c_moves, c_moves_n, c_moves_s, c_socialize)
import memplot
class TestDummyrunnerSettings(TestCase):
@ -91,3 +92,21 @@ class TestDummyrunnerSettings(TestCase):
def test_c_move_s(self):
self.assertEqual(c_moves_s(self.client), "south")
class TestMemPlot(TestCase):
@patch.object(memplot, "_idmapper")
@patch.object(memplot, "os")
@patch.object(memplot, "open", new_callable=mock_open, create=True)
@patch.object(memplot, "time")
def test_memplot(self, mock_time, mocked_open, mocked_os, mocked_idmapper):
from evennia.utils.create import create_script
mocked_idmapper.cache_size.return_value = (9, 5000)
mock_time.time = Mock(return_value=6000.0)
script = create_script(memplot.Memplot)
script.db.starttime = 0.0
mocked_os.popen.read.return_value = 5000.0
script.at_repeat()
handle = mocked_open()
handle.write.assert_called_with('100.0, 0.001, 0.001, 9\n')
script.stop()

View file

@ -407,7 +407,7 @@ class ServerSession(Session):
else:
self.data_out(**kwargs)
def execute_cmd(self, raw_string, **kwargs):
def execute_cmd(self, raw_string, session=None, **kwargs):
"""
Do something as this object. This method is normally never
called directly, instead incoming command instructions are
@ -417,6 +417,9 @@ class ServerSession(Session):
Args:
raw_string (string): Raw command input
session (Session): This is here to make API consistent with
Account/Object.execute_cmd. If given, data is passed to
that Session, otherwise use self.
Kwargs:
Other keyword arguments will be added to the found command
object instace as variables before it executes. This is
@ -426,7 +429,7 @@ class ServerSession(Session):
"""
# inject instruction into input stream
kwargs["text"] = ((raw_string,), {})
self.sessionhandler.data_in(self, **kwargs)
self.sessionhandler.data_in(session or self, **kwargs)
def __eq__(self, other):
"""Handle session comparisons"""

View file

@ -280,6 +280,8 @@ class ServerSessionHandler(SessionHandler):
super(ServerSessionHandler, self).__init__(*args, **kwargs)
self.server = None # set at server initialization
self.server_data = {"servername": _SERVERNAME}
# will be set on psync
self.portal_start_time = 0.0
def _run_cmd_login(self, session):
"""

View file

@ -1,25 +1,27 @@
from collections import defaultdict, deque
from evennia.utils import logger
import time
class Throttle(object):
"""
Keeps a running count of failed actions per IP address.
Keeps a running count of failed actions per IP address.
Available methods indicate whether or not the number of failures exceeds a
particular threshold.
This version of the throttle is usable by both the terminal server as well
as the web server, imposes limits on memory consumption by using deques
with length limits instead of open-ended lists, and removes sparse keys when
no recent failures have been recorded.
"""
error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.'
def __init__(self, **kwargs):
"""
Allows setting of throttle parameters.
Kwargs:
limit (int): Max number of failures before imposing limiter
timeout (int): number of timeout seconds after
@ -31,55 +33,67 @@ class Throttle(object):
self.storage = defaultdict(deque)
self.cache_size = self.limit = kwargs.get('limit', 5)
self.timeout = kwargs.get('timeout', 5 * 60)
def get(self, ip=None):
"""
Convenience function that returns the storage table, or part of.
Args:
ip (str, optional): IP address of requestor
Returns:
storage (dict): When no IP is provided, returns a dict of all
current IPs being tracked and the timestamps of their recent
storage (dict): When no IP is provided, returns a dict of all
current IPs being tracked and the timestamps of their recent
failures.
timestamps (deque): When an IP is provided, returns a deque of
timestamps (deque): When an IP is provided, returns a deque of
timestamps of recent failures only for that IP.
"""
if ip: return self.storage.get(ip, deque(maxlen=self.cache_size))
else: return self.storage
def update(self, ip):
def update(self, ip, failmsg='Exceeded threshold.'):
"""
Store the time of the latest failure/
Store the time of the latest failure.
Args:
ip (str): IP address of requestor
failmsg (str, optional): Message to display in logs upon activation
of throttle.
Returns:
None
"""
# Get current status
previously_throttled = self.check(ip)
# Enforce length limits
if not self.storage[ip].maxlen:
self.storage[ip] = deque(maxlen=self.cache_size)
self.storage[ip].append(time.time())
# See if this update caused a change in status
currently_throttled = self.check(ip)
# If this makes it engage, log a single activation event
if (not previously_throttled and currently_throttled):
logger.log_sec('Throttle Activated: %s (IP: %s, %i hits in %i seconds.)' % (failmsg, ip, self.limit, self.timeout))
def check(self, ip):
"""
This will check the session's address against the
storage dictionary to check they haven't spammed too many
storage dictionary to check they haven't spammed too many
fails recently.
Args:
ip (str): IP address of requestor
Returns:
throttled (bool): True if throttling is active,
False otherwise.
"""
now = time.time()
ip = str(ip)
@ -97,5 +111,3 @@ class Throttle(object):
return False
else:
return False

View file

@ -1,34 +1,73 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from evennia.accounts.models import AccountDB
import re
class EvenniaUsernameAvailabilityValidator:
"""
Checks to make sure a given username is not taken or otherwise reserved.
"""
def __call__(self, username):
"""
Validates a username to make sure it is not in use or reserved.
Args:
username (str): Username to validate
Returns:
None (None): None if password successfully validated,
raises ValidationError otherwise.
"""
# Check guest list
if (settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.GUEST_LIST)):
raise ValidationError(
_('Sorry, that username is reserved.'),
code='evennia_username_reserved',
)
# Check database
exists = AccountDB.objects.filter(username__iexact=username).exists()
if exists:
raise ValidationError(
_('Sorry, that username is already taken.'),
code='evennia_username_taken',
)
class EvenniaPasswordValidator:
def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."):
def __init__(self, regex=r"^[\w. @+\-',]+$",
policy="Password should contain a mix of letters, "
"spaces, digits and @/./+/-/_/'/, only."):
"""
Constructs a standard Django password validator.
Args:
regex (str): Regex pattern of valid characters to allow.
policy (str): Brief explanation of what the defined regex permits.
"""
self.regex = regex
self.policy = policy
def validate(self, password, user=None):
"""
Validates a password string to make sure it meets predefined Evennia
acceptable character policy.
Args:
password (str): Password to validate
user (None): Unused argument but required by Django
Returns:
None (None): None if password successfully validated,
raises ValidationError otherwise.
"""
# Check complexity
if not re.findall(self.regex, password):
@ -41,11 +80,12 @@ class EvenniaPasswordValidator:
"""
Returns a user-facing explanation of the password policy defined
by this validator.
Returns:
text (str): Explanation of password policy.
"""
return _(
"%s From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." % self.policy
)
"%s From a terminal client, you can also use a phrase of multiple words if "
"you enclose the password in double quotes." % self.policy
)

View file

@ -222,7 +222,8 @@ COMMAND_RATE_WARNING = "You entered commands too fast. Wait a moment and try aga
# 0 or less.
MAX_CHAR_LIMIT = 6000
# The warning to echo back to users if they enter a very large string
MAX_CHAR_LIMIT_WARNING = "You entered a string that was too long. Please break it up into multiple parts."
MAX_CHAR_LIMIT_WARNING = ("You entered a string that was too long. "
"Please break it up into multiple parts.")
# If this is true, errors and tracebacks from the engine will be
# echoed as text in-game as well as to the log. This can speed up
# debugging. OBS: Showing full tracebacks to regular users could be a
@ -410,12 +411,14 @@ CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet"
CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet"
# Location to search for cmdsets if full path not given
CMDSET_PATHS = ["commands", "evennia", "contribs"]
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your default cmdsets,
# you will also need to copy CMDSET_FALLBACKS after your change in your settings file for it to detect the change.
CMDSET_FALLBACKS = {CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet',
CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet',
CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet',
CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'}
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your
# default cmdsets, you will also need to copy CMDSET_FALLBACKS after your change in your
# settings file for it to detect the change.
CMDSET_FALLBACKS = {
CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet',
CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet',
CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet',
CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'}
# Parent class for all default commands. Changing this class will
# modify all default commands, so do so carefully.
COMMAND_DEFAULT_CLASS = "evennia.commands.default.muxcommand.MuxCommand"
@ -813,6 +816,15 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
{'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}]
# Username validation plugins
AUTH_USERNAME_VALIDATORS = [
{'NAME': 'django.contrib.auth.validators.ASCIIUsernameValidator'},
{'NAME': 'django.core.validators.MinLengthValidator',
'OPTIONS': {'limit_value': 3}},
{'NAME': 'django.core.validators.MaxLengthValidator',
'OPTIONS': {'limit_value': 30}},
{'NAME': 'evennia.server.validators.EvenniaUsernameAvailabilityValidator'}]
# Use a custom test runner that just tests Evennia-specific apps.
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
@ -823,7 +835,7 @@ TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
# Django extesions are useful third-party tools that are not
# always included in the default django distro.
try:
import django_extensions
import django_extensions # noqa
INSTALLED_APPS = INSTALLED_APPS + ('django_extensions',)
except ImportError:
# Django extensions are not installed in all distros.

View file

@ -668,7 +668,7 @@ class AttributeHandler(object):
def all(self, accessing_obj=None, default_access=True):
"""
Return all Attribute objects on this object.
Return all Attribute objects on this object, regardless of category.
Args:
accessing_obj (object, optional): Check the `attrread`

View file

@ -31,9 +31,12 @@ from django.db.models import signals
from django.db.models.base import ModelBase
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from django.urls import reverse
from django.utils.encoding import smart_str
from django.utils.text import slugify
from evennia.typeclasses.attributes import Attribute, AttributeHandler, NAttributeHandler
from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler
@ -733,3 +736,150 @@ class TypedObject(SharedMemoryModel):
"""
pass
#
# Web/Django methods
#
def web_get_admin_url(self):
"""
Returns the URI path for the Django Admin page for this object.
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
Returns:
path (str): URI path to Django Admin page for object.
"""
content_type = ContentType.objects.get_for_model(self.__class__)
return reverse("admin:%s_%s_change" % (content_type.app_label,
content_type.model), args=(self.id,))
@classmethod
def web_get_create_url(cls):
"""
Returns the URI path for a View that allows users to create new
instances of this object.
ex. Chargen = '/characters/create/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-create' would be referenced by this method.
ex.
url(r'characters/create/', ChargenView.as_view(), name='character-create')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can create new objects is the
developer's responsibility.
Returns:
path (str): URI path to object creation page, if defined.
"""
try:
return reverse('%s-create' % cls._meta.verbose_name.lower())
except:
return '#'
def web_get_detail_url(self):
"""
Returns the URI path for a View that allows users to view details for
this object.
ex. Oscar (Character) = '/characters/oscar/1/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-detail' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$',
CharDetailView.as_view(), name='character-detail')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can view this object is the developer's
responsibility.
Returns:
path (str): URI path to object detail page, if defined.
"""
try:
return reverse('%s-detail' % self._meta.verbose_name.lower(),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
def web_get_update_url(self):
"""
Returns the URI path for a View that allows users to update this
object.
ex. Oscar (Character) = '/characters/oscar/1/change/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-update' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
CharUpdateView.as_view(), name='character-update')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can modify objects is the developer's
responsibility.
Returns:
path (str): URI path to object update page, if defined.
"""
try:
return reverse('%s-update' % self._meta.verbose_name.lower(),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
def web_get_delete_url(self):
"""
Returns the URI path for a View that allows users to delete this object.
ex. Oscar (Character) = '/characters/oscar/1/delete/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-detail' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
CharDeleteView.as_view(), name='character-delete')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can delete this object is the developer's
responsibility.
Returns:
path (str): URI path to object deletion page, if defined.
"""
try:
return reverse('%s-delete' % self._meta.verbose_name.lower(),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
# Used by Django Sites/Admin
get_absolute_url = web_get_detail_url

View file

@ -345,13 +345,14 @@ class TagHandler(object):
self._catcache = {}
self._cache_complete = False
def all(self, return_key_and_category=False):
def all(self, return_key_and_category=False, return_objs=False):
"""
Get all tags in this handler, regardless of category.
Args:
return_key_and_category (bool, optional): Return a list of
tuples `[(key, category), ...]`.
return_objs (bool, optional): Return tag objects.
Returns:
tags (list): A list of tag keys `[tagkey, tagkey, ...]` or
@ -365,6 +366,8 @@ class TagHandler(object):
if return_key_and_category:
# return tuple (key, category)
return [(to_str(tag.db_key), to_str(tag.db_category)) for tag in tags]
elif return_objs:
return tags
else:
return [to_str(tag.db_key) for tag in tags]

View file

@ -107,6 +107,17 @@ def uptime():
return time.time() - SERVER_START_TIME
def portal_uptime():
"""
Get the current uptime of the portal.
Returns:
time (float): The uptime of the portal.
"""
from evennia.server.sessionhandler import SESSIONS
return time.time() - SESSIONS.portal_start_time
def game_epoch():
"""
Get the game epoch.

View file

@ -8,6 +8,7 @@ let defaultin_plugin = (function () {
//
// handle the default <enter> key triggering onSend()
var onKeydown = function (event) {
$("#inputfield").focus();
if ( (event.which === 13) && (!event.shiftKey) ) { // Enter Key without shift
var inputfield = $("#inputfield");
var outtext = inputfield.val();

View file

@ -43,14 +43,6 @@ let history_plugin = (function () {
history_pos = 0;
}
//
// Go to the last history line
var end = function () {
// move to the end of the history stack
history_pos = 0;
return history[history.length -1];
}
//
// Add input to the scratch line
var scratch = function (input) {
@ -69,27 +61,20 @@ let history_plugin = (function () {
var history_entry = null;
var inputfield = $("#inputfield");
if (inputfield[0].selectionStart == inputfield.val().length) {
// Only process up/down arrow if cursor is at the end of the line.
if (code === 38) { // Arrow up
history_entry = back();
}
else if (code === 40) { // Arrow down
history_entry = fwd();
}
if (code === 38) { // Arrow up
history_entry = back();
}
else if (code === 40) { // Arrow down
history_entry = fwd();
}
if (history_entry !== null) {
// Doing a history navigation; replace the text in the input.
inputfield.val(history_entry);
}
else {
// Save the current contents of the input to the history scratch area.
setTimeout(function () {
// Need to wait until after the key-up to capture the value.
scratch(inputfield.val());
end();
}, 0);
// Performing a history navigation
// replace the text in the input and move the cursor to the end of the new value
inputfield.val('');
inputfield.blur().focus().val(history_entry);
event.preventDefault();
return true;
}
return false;
@ -99,6 +84,7 @@ let history_plugin = (function () {
// Listen for onSend lines to add to history
var onSend = function (line) {
add(line);
return null; // we are not returning an altered input line
}
//

View file

@ -5,7 +5,7 @@ pypiwin32
django > 1.11, < 2.0
twisted >= 18.0.0, < 19.0.0
pillow == 2.9.0
pillow == 5.2.0
pytz
future >= 0.15.2
django-sekizai