diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aba4bf4a5..c2c6a79f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,77 @@ Update to Python 3 - 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. + +### Web + +Web/Django standard initiative (@strikaco) +- Features + - Adds a series of web-based forms and generic class-based views + - Accounts + - Register - Enhances registration; allows optional collection of email address + - Form - Adds a generic Django form for creating Accounts from the web + - Characters + - Create - Authenticated users can create new characters from the website (requires associated form) + - Detail - Authenticated and authorized users can view select details about characters + - List - Authenticated and authorized users can browse a list of all characters + - Manage - Authenticated users can edit or delete owned characters from the web + - Form - Adds a generic Django form for creating characters from the web + - Channels + - Detail - Authorized users can view channel logs from the web + - List - Authorized users can browse a list of all channels + - Help Entries + - Detail - Authorized users can view help entries from the web + - List - Authorized users can browse a list of all help entries from the web + - Navbar changes + - Characters - Link to character list + - Channels - Link to channel list + - Help - Link to help entry list + - Puppeting + - Users can puppet their own characters within the context of the website + - Dropdown + - Link to create characters + - Link to manage characters + - Link to quick-select puppets + - Link to password change workflow +- Functions + - Updates Bootstrap to v4 stable + - Enables use of Django Messages framework to communicate with users in browser + - Implements webclient/website `_shared_login` functionality as Django middleware + - 'account' and 'puppet' are added to all request contexts for authenticated users + - Adds unit tests for all web views +- Cosmetic + - Prettifies Django 'forgot password' workflow (requires SMTP to actually function) + - Prettifies Django 'change password' workflow +- Bugfixes + - Fixes bug on login page where error messages were not being displayed + +### Typeclasses + +- Add new methods on all typeclasses, useful specifically for object handling from the website/admin: + + `web_get_admin_url()`: Returns the path to the object detail page in the Admin backend. + + `web_get_create_url()`: Returns the path to the typeclass' creation page on the website, if implemented. + + `web_get_absolute_url()`: Returns the path to the object's detail page on the website, if implemented. + + `web_get_update_url()`: Returns the path to the object's update page on the website, if implemented. + + `web_get_delete_url()`: Returns the path to the object's delete page on the website, if implemented. +- All typeclasses have 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 (for Unicode environments) users cannot mimic existing usernames by replacing select characters with visually-similar Unicode chars. + + `validate_username`: Mechanism for validating a username based on predefined Django validators. + + `validate_password`: Mechanism for validating a password based on predefined Django validators. + + `set_password`: Apply password to account, using validation checks. + +### Utils + +- Added more unit tests. ### Utils @@ -20,6 +91,11 @@ Update to Python 3 ## 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) @@ -103,7 +179,6 @@ Update to Python 3 ### 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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..0d1917c4e4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing to Evennia + +Evennia utilizes GitHub for issue tracking and contributions: + + - Reporting Issues issues/bugs and making feature requests can be done [in the issue tracker](https://github.com/evennia/evennia/issues). + - Evennia's documentation is a [wiki](https://github.com/evennia/evennia/wiki) that everyone can contribute to. Further + instructions and details about contributing is found [here](https://github.com/evennia/evennia/wiki/Contributing). diff --git a/evennia/__init__.py b/evennia/__init__.py index c3f5355d56..50f106aefb 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -105,7 +105,7 @@ def _create_version(): "git rev-parse --short HEAD", shell=True, cwd=root, stderr=STDOUT).strip().decode() version = "%s (rev %s)" % (version, rev) - except (IOError, CalledProcessError): + except (IOError, CalledProcessError, OSError): # ignore if we cannot get to git pass return version diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2cb8064fef..1c81e97c9f 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -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, 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): """ @@ -189,6 +196,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): @lazy_property def sessions(self): return AccountSessionHandler(self) + + # Do not make this a lazy property; the web UI will not refresh it! + @property + def characters(self): + # Get playable characters list + objs = self.db._playable_characters + + # Rebuild the list if legacy code left null values after deletion + if None in objs: + objs = [x for x in self.db._playable_characters if x] + self.db._playable_characters = objs + + return objs # session-related methods @@ -359,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): """ @@ -416,9 +619,137 @@ 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 + if character not in account.characters: + 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. @@ -1067,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 diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 78ee87f37d..1d31e93e2c 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,11 +1,13 @@ +# -*- 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 @@ -58,6 +60,107 @@ class TestAccountSessionHandler(TestCase): "Check count method" self.assertEqual(self.handler.count(), len(self.handler.get())) +class TestDefaultGuest(EvenniaTest): + "Check DefaultGuest 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): + 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" + + 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(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(account.validate_password(better, account=self.account)[0]) + account.delete() + + def test_password_change(self): + "Check password setting and validation is working as expected" + 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, account.set_password, bad) + + # Try setting a better password (test for False; returns None on success) + self.assertFalse(account.set_password('Mxyzptlk')) + account.delete() class TestDefaultAccount(TestCase): "Check DefaultAccount class" @@ -66,36 +169,6 @@ class TestDefaultAccount(TestCase): self.s1 = MagicMock() self.s1.puppet = None self.s1.sessid = 0 - self.s1.data_outj - - def tearDown(self): - if hasattr(self, "account"): - self.account.delete() - - def test_password_validation(self): - "Check password validators deny bad passwords" - - self.account = create.create_account("TestAccount%s" % randint(0, 9), - 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]) - - "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]) - - def test_password_change(self): - "Check password setting and validation is working as expected" - self.account = create.create_account("TestAccount%s" % randint(0, 9), - 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) - - # Try setting a better password (test for False; returns None on success) - self.assertFalse(self.account.set_password('Mxyzptlk')) def test_puppet_object_no_object(self): "Check puppet_object method called with no object param" @@ -199,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) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 89b3520f0b..dd5731e1ed 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -163,8 +163,8 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): home=default_home, permissions=permissions) # only allow creator (and developers) to puppet this char - new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" % - (new_character.id, account.id)) + new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or perm(Admin)" % + (new_character.id, account.id, account.id)) account.db._playable_characters.append(new_character) if desc: new_character.db.desc = desc @@ -223,6 +223,12 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): match = match[0] account.ndb._char_to_delete = match + + # Return if caller has no permission to delete this + if not match.access(account, 'delete'): + self.msg("You do not have permission to delete this character.") + return + prompt = "|rThis will permanently destroy '%s'. This cannot be undone.|n Continue yes/[no]?" get_input(account, prompt % match.key, _callback) diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 08890dfcdc..c260abaa2e 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -17,7 +17,7 @@ PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY] # limit members for API inclusion __all__ = ("CmdBoot", "CmdBan", "CmdUnban", - "CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall") + "CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall", "CmdForce") class CmdBoot(COMMAND_DEFAULT_CLASS): @@ -513,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 = + + 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)) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 4b6ac6f782..17b19a1e53 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -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: @@ -1022,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 @@ -2854,7 +2861,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" @@ -2905,12 +2912,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))) @@ -2918,6 +2926,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 @@ -3034,7 +3046,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|rDeletion cancelled.|n") return try: - success = protlib.delete_db_prototype(caller, self.args) + success = protlib.delete_prototype(self.args) except protlib.PermissionError as err: caller.msg("|rError deleting:|R {}|n".format(err)) caller.msg("Deletion {}.".format( @@ -3084,7 +3096,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return # we have a prototype, check access prototype = prototypes[0] - if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'): + if not caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='spawn', default=True): caller.msg("You don't have access to use this prototype.") return diff --git a/evennia/commands/default/cmdset_character.py b/evennia/commands/default/cmdset_character.py index cfc8a30ca4..438996f536 100644 --- a/evennia/commands/default/cmdset_character.py +++ b/evennia/commands/default/cmdset_character.py @@ -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()) diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index d2011629c1..10064d06d0 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -66,7 +66,7 @@ class CmdHelp(Command): if type(self).help_more: usemore = True - if self.session.protocol_key in ("websocket", "ajax/comet"): + if self.session and self.session.protocol_key in ("websocket", "ajax/comet"): try: options = self.account.db._saved_webclient_options if options and options["helppopup"]: diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 853d15725e..3d66f98994 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -47,6 +47,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS): @reset to purge) and at_reload() hooks will be called. """ key = "@reload" + aliases = ['@restart'] locks = "cmd:perm(reload) or perm(Developer)" help_category = "System" @@ -710,6 +711,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()) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 73e66f7d28..a1a0f6833d 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -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): @@ -280,6 +283,32 @@ class TestAccount(CommandTest): def test_char_create(self): self.call(account.CmdCharCreate(), "Test1=Test char", "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account) + + def test_char_delete(self): + # Chardelete requires user input; this test is mainly to confirm + # whether permissions are being checked + + # Add char to account playable characters + self.account.db._playable_characters.append(self.char1) + + # Try deleting as Developer + self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account) + + # Downgrade permissions on account + self.account.permissions.add('Player') + self.account.permissions.remove('Developer') + + # Set lock on character object to prevent deletion + self.char1.locks.add('delete:none()') + + # Try deleting as Player + self.call(account.CmdCharDelete(), "Char", "You do not have permission to delete this character.", caller=self.account) + + # Set lock on character object to allow self-delete + self.char1.locks.add('delete:pid(%i)' % self.account.id) + + # Try deleting as Player again + self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account) def test_quell(self): self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account) @@ -315,6 +344,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 +381,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 +515,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): diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index d558de5587..a1f3c462d2 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -4,17 +4,11 @@ Commands that are available from the connect screen. import re import datetime from codecs import lookup as codecs_lookup -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 ") 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 or 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\" '." - else: - string += "\n\nYou can now log with the command 'connect %s '." - 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\" '." + else: + string += "\n\nYou can now log with the command 'connect %s '." + session.msg(string % (username, username)) + else: + session.msg("|R%s|n" % '\n'.join(errors)) class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS): @@ -395,6 +276,9 @@ Next you can connect to the game: |wconnect Anna c67jHL8p|n You can use the |wlook|n command if you want to see the connect screen again. """ + + if settings.STAFF_CONTACT_EMAIL: + string += 'For support, please contact: %s' % settings.STAFF_CONTACT_EMAIL self.caller.msg(string) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index cd7df8772e..f7ae94a910 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -2,10 +2,14 @@ Base typeclass for in-game Channels. """ +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.utils.text import slugify + 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 +224,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. @@ -578,3 +626,151 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): """ 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 'channel-create' would be referenced by this method. + + ex. + url(r'channels/create/', ChannelCreateView.as_view(), name='channel-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' % slugify(cls._meta.verbose_name)) + 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 'channel-detail' would be referenced by this method. + + ex. + url(r'channels/(?P[\w\d\-]+)/$', + ChannelDetailView.as_view(), name='channel-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' % slugify(self._meta.verbose_name), + kwargs={'slug': slugify(self.db_key)}) + 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 'channel-update' would be referenced by this method. + + ex. + url(r'channels/(?P[\w\d\-]+)/(?P[0-9]+)/change/$', + ChannelUpdateView.as_view(), name='channel-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' % slugify(self._meta.verbose_name), + kwargs={'slug': slugify(self.db_key)}) + 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 'channel-delete' would be referenced by this method. + + ex. + url(r'channels/(?P[\w\d\-]+)/(?P[0-9]+)/delete/$', + ChannelDeleteView.as_view(), name='channel-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' % slugify(self._meta.verbose_name), + kwargs={'slug': slugify(self.db_key)}) + except: + return '#' + + # Used by Django Sites/Admin + get_absolute_url = web_get_detail_url \ No newline at end of file diff --git a/evennia/comms/tests.py b/evennia/comms/tests.py new file mode 100644 index 0000000000..d38e34544b --- /dev/null +++ b/evennia/comms/tests.py @@ -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) \ No newline at end of file diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index fad1802237..c44937808f 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -1,7 +1,7 @@ # Contrib folder -This folder contains 'contributions': extra snippets of code that are +`evennia/contrib/` contains 'contributions': extra snippets of code that are potentially very useful for the game coder but which are considered too game-specific to be a part of the main Evennia game server. These modules are not used unless you explicitly import them. See each file @@ -17,7 +17,7 @@ things you want from here into your game folder and change them there. * Barter system (Griatch 2012) - A safe and effective barter-system for any game. Allows safe trading of any goods (including coin). -* Building menu (vincent-lg 2018) - An @edit command for modifying +* Building menu (vincent-lg 2018) - An `@edit` command for modifying objects using a generated menu. Customizable for different games. * CharGen (Griatch 2011) - A simple Character creator for OOC mode. Meant as a starting point for a more fleshed-out system. @@ -60,9 +60,6 @@ things you want from here into your game folder and change them there. * Tree Select (FlutterSprite 2017) - A simple system for creating a branching EvMenu with selection options sourced from a single multi-line string. -* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based - combat system with different levels of complexity, including versions with - equipment and magic as well as ranged combat. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. * UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax. @@ -75,7 +72,8 @@ things you want from here into your game folder and change them there. objects and events using Python from in-game. * Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant as a start to build from. Has attack/disengage and turn timeouts, - and includes optional expansions for equipment and combat movement. + and includes optional expansions for equipment and combat movement, magic + and ranged combat. * Tutorial examples (Griatch 2011, 2015) - A folder of basic example objects, commands and scripts. * Tutorial world (Griatch 2011, 2015) - A folder containing the diff --git a/evennia/contrib/tutorial_world/build.ev b/evennia/contrib/tutorial_world/build.ev index 1184865004..ce1bbe4bcf 100644 --- a/evennia/contrib/tutorial_world/build.ev +++ b/evennia/contrib/tutorial_world/build.ev @@ -744,7 +744,7 @@ hole the remains of the castle. There is also a standing archway offering passage to a path along the old |wsouth|nern inner wall. # -@detail portoculis;fall;fallen;grating = +@detail portcullis;fall;fallen;grating = This heavy iron grating used to block off the inner part of the gate house, now it has fallen to the ground together with the stone archway that once help it up. # @@ -786,7 +786,7 @@ archway The buildings make a half-circle along the main wall, here and there broken by falling stone and rubble. At one end (the |wnorth|nern) of this half-circle is the entrance to the castle, the ruined - gatehoue. |wEast|nwards from here is some sort of open courtyard. + gatehouse. |wEast|nwards from here is some sort of open courtyard. #------------------------------------------------------------ # @@ -808,7 +808,7 @@ archway Previously one could probably continue past the obelisk and eastward into the castle keep itself, but that way is now completely blocked by fallen rubble. To the |wwest|n is the gatehouse and entrance to - the castle, whereas |wsouth|nwards the collumns make way for a wide + the castle, whereas |wsouth|nwards the columns make way for a wide open courtyard. # @set here/tutorial_info = diff --git a/evennia/game_template/server/logs/README.md b/evennia/game_template/server/logs/README.md new file mode 100644 index 0000000000..35ad999cd5 --- /dev/null +++ b/evennia/game_template/server/logs/README.md @@ -0,0 +1,15 @@ +This directory contains Evennia's log files. The existence of this README.md file is also necessary +to correctly include the log directory in git (since log files are ignored by git and you can't +commit an empty directory). + +- `server.log` - log file from the game Server. +- `portal.log` - log file from Portal proxy (internet facing) + +Usually these logs are viewed together with `evennia -l`. They are also rotated every week so as not +to be too big. Older log names will have a name appended by `_month_date`. + +- `lockwarnings.log` - warnings from the lock system. +- `http_requests.log` - this will generally be empty unless turning on debugging inside the server. + +- `channel_.log` - these are channel logs for the in-game channels They are also used + by the `/history` flag in-game to get the latest message history. diff --git a/evennia/game_template/server/logs/server.log b/evennia/game_template/server/logs/server.log deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/evennia/help/models.py b/evennia/help/models.py index b19c551f25..92269994ad 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -11,7 +11,11 @@ game world, policy info, rules and similar. """ from builtins import object +from django.contrib.contenttypes.models import ContentType from django.db import models +from django.urls import reverse +from django.utils.text import slugify + from evennia.utils.idmapper.models import SharedMemoryModel from evennia.help.manager import HelpEntryManager from evennia.typeclasses.models import Tag, TagHandler, AliasHandler @@ -107,3 +111,158 @@ class HelpEntry(SharedMemoryModel): default - what to return if no lock of access_type was found """ return self.locks.check(accessing_obj, access_type=access_type, default=default) + + # + # 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' % slugify(cls._meta.verbose_name)) + 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[\w\d\-]+)/(?P[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' % slugify(self._meta.verbose_name), + kwargs={ + 'category': slugify(self.db_help_category), + 'topic': slugify(self.db_key)}) + except Exception as e: + print(e) + 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[\w\d\-]+)/(?P[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' % slugify(self._meta.verbose_name), + kwargs={ + 'category': slugify(self.db_help_category), + 'topic': slugify(self.db_key)}) + 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[\w\d\-]+)/(?P[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' % slugify(self._meta.verbose_name), + kwargs={ + 'category': slugify(self.db_help_category), + 'topic': slugify(self.db_key)}) + except: + return '#' + + # Used by Django Sites/Admin + get_absolute_url = web_get_detail_url diff --git a/evennia/locale/fr/LC_MESSAGES/django.mo b/evennia/locale/fr/LC_MESSAGES/django.mo index b2c14fb1b9..b16ad24fa0 100644 Binary files a/evennia/locale/fr/LC_MESSAGES/django.mo and b/evennia/locale/fr/LC_MESSAGES/django.mo differ diff --git a/evennia/locale/fr/LC_MESSAGES/django.po b/evennia/locale/fr/LC_MESSAGES/django.po index c1d4c534a9..28150e411d 100644 --- a/evennia/locale/fr/LC_MESSAGES/django.po +++ b/evennia/locale/fr/LC_MESSAGES/django.po @@ -1,253 +1,346 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-03-02 16:22-0500\n" -"PO-Revision-Date: 2016-03-04 11:51-0500\n" -"Language: fr\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -"Last-Translator: \n" -"Language-Team: \n" -"X-Generator: Poedit 1.7.6\n" - -#: commands/cmdhandler.py:485 -msgid "There were multiple matches." -msgstr "Il y a eu plusieurs correspondances." - -#: commands/cmdhandler.py:513 -#, python-format -msgid "Command '%s' is not available." -msgstr "Commande '%s' n'est pas disponible." - -#: commands/cmdhandler.py:518 -#, python-format -msgid " Maybe you meant %s?" -msgstr "Voulez-vous dire %s?" - -#: commands/cmdhandler.py:518 -msgid "or" -msgstr "ou" - -#: commands/cmdhandler.py:520 -msgid " Type \"help\" for help." -msgstr "Tapez \"help\" pour de l'aide." - -#: commands/cmdparser.py:183 -#, python-format -msgid "Could not find '%s'." -msgstr "Peut pas trouver '%s'." - -#: commands/cmdsethandler.py:130 -#, python-format -msgid "" -"\n" -"(Unsuccessfully tried '%s.' + '%s.%s')." -msgstr "" -"\n" -"(Essayé sans succès '%s.' + '%s.%s')." - -#: commands/cmdsethandler.py:151 -#, python-brace-format -msgid "" -"\n" -"Error loading cmdset {path}: \"{error}\"" -msgstr "" -"\n" -"Erreur de chargement de cmdset {path}: \"{error}\"" - -#: commands/cmdsethandler.py:155 -#, python-brace-format -msgid "" -"\n" -"Error in loading cmdset: No cmdset class '{classname}' in {path}." -msgstr "" -"\n" -"Erreur lors du chargement de cmdset: Pas de classe cmdset '{classname}' in " -"{path}." - -#: commands/cmdsethandler.py:159 -#, python-brace-format -msgid "" -"\n" -"SyntaxError encountered when loading cmdset '{path}': \"{error}\"." -msgstr "" -"\n" -"Erreur de syntaxe lors du chargement de cmdset '{path}': \"{error}\"." - -#: commands/cmdsethandler.py:163 -#, python-brace-format -msgid "" -"\n" -"Compile/Run error when loading cmdset '{path}': \"{error}\"." -msgstr "" -"\n" -"Erreur de compilation/exécution lors du chargement de cmdset '{path}': " -"\"{error}\"." - -#: commands/cmdsethandler.py:174 -msgid "" -"\n" -" (See log for details.)" -msgstr "" -"\n" -"(Voir registre pour plus de détails.)" - -#: commands/cmdsethandler.py:247 -#, python-brace-format -msgid "custom {mergetype} on cmdset '{cmdset}'" -msgstr "custom {mergetype} sur cmdset '{cmdset}'" - -#: commands/cmdsethandler.py:250 -#, python-brace-format -msgid " : {current}" -msgstr " : {current}" - -#: commands/cmdsethandler.py:258 -#, python-brace-format -msgid "" -" <{key} ({mergetype}, prio {prio}, {permstring})>:\n" -" {keylist}" -msgstr "" -" <{key} ({mergetype}, prio {prio}, {permstring})>:\n" -" {keylist}" - -#: commands/cmdsethandler.py:347 -msgid "Only CmdSets can be added to the cmdsethandler!" -msgstr "Seulement CmdSets peuvent être ajoutés au cmdsethandler!" - -#: comms/channelhandler.py:94 -msgid " (channel)" -msgstr " (channel)" - -#: locks/lockhandler.py:230 -#, python-format -msgid "Lock: lock-function '%s' is not available." -msgstr "Vérou: lock-function '%s' n'est pas disponible." - -#: locks/lockhandler.py:243 -#, python-format -msgid "Lock: definition '%s' has syntax errors." -msgstr "Vérou: définition '%s' a des erreurs de syntaxe." - -#: locks/lockhandler.py:247 -#, python-format -msgid "" -"LockHandler on %(obj)s: access type '%(access_type)s' changed from " -"'%(source)s' to '%(goal)s' " -msgstr "" -"Gestionnaire de vérrou sur %(obj)s: type d'accès '%(access_type)s' a changé " -"de '%(source)s' à '%(goal)s'" - -#: locks/lockhandler.py:304 -#, python-format -msgid "Lock: '%s' contains no colon (:)." -msgstr "Verrou: '%s' contient pas de deux points (:)." - -#: locks/lockhandler.py:308 -#, python-format -msgid "Lock: '%s' has no access_type (left-side of colon is empty)." -msgstr "" -"Verrou: '%s' n'a pas de access_type (côté gauche du deux point est vide)." - -#: locks/lockhandler.py:311 -#, python-format -msgid "Lock: '%s' has mismatched parentheses." -msgstr "Verrou: '%s' a des parenthèses dépareillées." - -#: locks/lockhandler.py:314 -#, python-format -msgid "Lock: '%s' has no valid lock functions." -msgstr "Verrou: '%s' n'a pas de fonctions verrou valides." - -#: objects/objects.py:528 -#, python-format -msgid "Couldn't perform move ('%s'). Contact an admin." -msgstr "Ne pouvait effectuer le coup ('%s'). Contactez un administrateur." - -#: objects/objects.py:538 -msgid "The destination doesn't exist." -msgstr "La destination n'existe pas." - -#: objects/objects.py:651 -#, python-format -msgid "Could not find default home '(#%d)'." -msgstr "Ne peut trouver la maison '(#%d)' par défaut." - -#: objects/objects.py:667 -msgid "Something went wrong! You are dumped into nowhere. Contact an admin." -msgstr "" -"Quelque chose a mal tourné! Vous êtes nulle part. Contactez un " -"administrateur." - -#: objects/objects.py:747 -#, python-format -msgid "Your character %s has been destroyed." -msgstr "Votre personnage %s a été détruit." - -#: players/players.py:356 -msgid "Player being deleted." -msgstr "Suppression de joueur." - -#: scripts/scripthandler.py:50 -#, python-format -msgid "" -"\n" -" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s" -msgstr "" -"\n" -" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s répète): %(desc)s" - -#: scripts/scripts.py:202 -#, python-format -msgid "" -"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'." -msgstr "" -"Scripte %(key)s(#%(dbid)s) de type '%(cname)s': at_repeat() erreur " -"'%(err)s'." - -#: server/initial_setup.py:29 -msgid "" -"\n" -"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if " -"you need\n" -"help, want to contribute, report issues or just join the community.\n" -"As Player #1 you can create a demo/tutorial area with |w@batchcommand " -"tutorial_world.build|n.\n" -" " -msgstr "" -"\n" -"Bienvenue dans ton nouveau jeu basé sur |wEvennia|n ! Visitez http://www." -"evennia.com si vous avez besoin\n" -"d'aide, si vous voulez contribuer, rapporter des problèmes ou faire partie " -"de la communauté.\n" -"En tant que Joueur #1 vous pouvez créer une zone de démo/tutoriel avec " -"|w@batchcommand tutorial_world.build|n.\n" -" " - -#: server/initial_setup.py:102 -msgid "This is User #1." -msgstr "Utilisateur #1." - -#: server/initial_setup.py:111 -msgid "Limbo" -msgstr "Limbes." - -#: server/sessionhandler.py:258 -msgid " ... Server restarted." -msgstr " ... Serveur redémarré." - -#: server/sessionhandler.py:408 -msgid "Logged in from elsewhere. Disconnecting." -msgstr "Connecté d'ailleurs. Déconnexion." - -#: server/sessionhandler.py:432 -msgid "Idle timeout exceeded, disconnecting." -msgstr "Délai d'inactivité dépassé, déconnexion." +# The French translation for the Evennia server. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the Evennia package. +# FIRST AUTHOR , YEAR. +# Maintained by: vincent-lg , 2018- +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-11-22 08:45+0100\n" +"PO-Revision-Date: 2016-03-04 11:51-0500\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 1.7.6\n" + +#: .\accounts\accounts.py:440 +#, fuzzy +msgid "Account being deleted." +msgstr "Suppression du compte." + +#: .\commands\cmdhandler.py:681 +msgid "There were multiple matches." +msgstr "Il y a plusieurs correspondances possibles." + +#: .\commands\cmdhandler.py:704 +#, python-format +msgid "Command '%s' is not available." +msgstr "La commande '%s' n'est pas disponible." + +#: .\commands\cmdhandler.py:709 +#, python-format +msgid " Maybe you meant %s?" +msgstr " Vouliez-vous dire %s ?" + +#: .\commands\cmdhandler.py:709 +msgid "or" +msgstr "ou" + +#: .\commands\cmdhandler.py:711 +msgid " Type \"help\" for help." +msgstr " Tapez \"help\" pour obtenir de l'aide." + +#: .\commands\cmdsethandler.py:89 +#, python-brace-format +msgid "" +"{traceback}\n" +"Error loading cmdset '{path}'\n" +"(Traceback was logged {timestamp})" +msgstr "" +"{traceback}\n" +"Une erreur s'est produite lors du chargement du cmdset '{path}'\n" +"(Référence de l'erreur : {timestamp})" + +#: .\commands\cmdsethandler.py:94 +#, fuzzy, python-brace-format +msgid "" +"Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n" +"(Traceback was logged {timestamp})" +msgstr "" +"\n" +"Une erreur s'est produite lors du chargement de cmdset : la classe cmdset '{classname}' est introuvable dans " +"{path}.\n" +"(Référence de l'erreur : {timestamp})" + +#: .\commands\cmdsethandler.py:98 +#, fuzzy, python-brace-format +msgid "" +"{traceback}\n" +"SyntaxError encountered when loading cmdset '{path}'.\n" +"(Traceback was logged {timestamp})" +msgstr "" +"\n" +"Erreur de syntaxe lors du chargement de cmdset '{path}' : \"{error}\".\n" +"(Référence de l'erreur : {timestamp})" + +#: .\commands\cmdsethandler.py:103 +#, fuzzy, python-brace-format +msgid "" +"{traceback}\n" +"Compile/Run error when loading cmdset '{path}'.\",\n" +"(Traceback was logged {timestamp})" +msgstr "" +"\n" +"Erreur de compilation/exécution lors du chargement de cmdset '{path}' : " +"\"{error}\".\n" +"(Référence de l'erreur : {timestamp})" + +#: .\commands\cmdsethandler.py:108 +#, python-brace-format +msgid "" +"\n" +"Error encountered for cmdset at path '{path}'.\n" +"Replacing with fallback '{fallback_path}'.\n" +msgstr "" +"\n" +"Une erreur a été rencontrée lors du chargement du cmdset '{path}'.\n" +"Le cmdset '{fallback_path}' est utilisé en remplacement.\n" + +#: .\commands\cmdsethandler.py:114 +#, python-brace-format +msgid "Fallback path '{fallback_path}' failed to generate a cmdset." +msgstr "Impossible de générer le cmdset de remplacement : '{fallback_path}'." + +#: .\commands\cmdsethandler.py:182 .\commands\cmdsethandler.py:192 +#, fuzzy, python-format +msgid "" +"\n" +"(Unsuccessfully tried '%s')." +msgstr "" +"\n" +"(Essayé sans succès '%s.' + '%s.%s')." + +#: .\commands\cmdsethandler.py:311 +#, python-brace-format +msgid "custom {mergetype} on cmdset '{cmdset}'" +msgstr "custom {mergetype} sur cmdset '{cmdset}'" + +#: .\commands\cmdsethandler.py:314 +#, python-brace-format +msgid " : {current}" +msgstr " : {current}" + +#: .\commands\cmdsethandler.py:322 +#, python-brace-format +msgid "" +" <{key} ({mergetype}, prio {prio}, {permstring})>:\n" +" {keylist}" +msgstr "" +" <{key} ({mergetype}, prio {prio}, {permstring})>:\n" +" {keylist}" + +#: .\commands\cmdsethandler.py:426 +msgid "Only CmdSets can be added to the cmdsethandler!" +msgstr "Seuls des CmdSets peuvent être ajoutés au cmdsethandler !" + +#: .\comms\channelhandler.py:100 +msgid "Say what?" +msgstr "Que voulez-vous dire ?" + +#: .\comms\channelhandler.py:105 +#, python-format +msgid "Channel '%s' not found." +msgstr "Le canal '%s' ne semble pas exister." + +#: .\comms\channelhandler.py:108 +#, python-format +msgid "You are not connected to channel '%s'." +msgstr "Vous n'êtes pas connecté au canal '%s'." + +#: .\comms\channelhandler.py:112 +#, python-format +msgid "You are not permitted to send to channel '%s'." +msgstr "Vous n'avez pas le droit de parler sur le canal '%s'." + +#: .\comms\channelhandler.py:155 +msgid " (channel)" +msgstr " (canal)" + +#: .\locks\lockhandler.py:236 +#, python-format +msgid "Lock: lock-function '%s' is not available." +msgstr "Verrou : lock-function '%s' n'est pas disponible." + +#: .\locks\lockhandler.py:249 +#, python-format +msgid "Lock: definition '%s' has syntax errors." +msgstr "Verrou : la définition '%s' a des erreurs de syntaxe." + +#: .\locks\lockhandler.py:253 +#, python-format +msgid "" +"LockHandler on %(obj)s: access type '%(access_type)s' changed from " +"'%(source)s' to '%(goal)s' " +msgstr "" +"Gestionnaire de verrous sur %(obj)s: type d'accès '%(access_type)s' a changé " +"de '%(source)s' à '%(goal)s'" + +#: .\locks\lockhandler.py:320 +#, fuzzy, python-brace-format +msgid "Lock: '{lockdef}' contains no colon (:)." +msgstr "Verrou : '%s' ne contient pas de deux points (:)." + +#: .\locks\lockhandler.py:328 +#, fuzzy, python-brace-format +msgid "Lock: '{lockdef}' has no access_type (left-side of colon is empty)." +msgstr "" +"Verrou : '%s' n'a pas de 'access_type' (il n'y a rien avant les deux points)." + +#: .\locks\lockhandler.py:336 +#, fuzzy, python-brace-format +msgid "Lock: '{lockdef}' has mismatched parentheses." +msgstr "Verrou : '%s' a des parenthèses déséquilibrées." + +#: .\locks\lockhandler.py:343 +#, fuzzy, python-brace-format +msgid "Lock: '{lockdef}' has no valid lock functions." +msgstr "Verrou : '%s' n'a pas de lock-function valide." + +#: .\objects\objects.py:729 +#, python-format +msgid "Couldn't perform move ('%s'). Contact an admin." +msgstr "Impossible de se déplacer vers ('%s'). Veuillez contacter un administrateur." + +#: .\objects\objects.py:739 +msgid "The destination doesn't exist." +msgstr "La destination est inconnue." + +#: .\objects\objects.py:830 +#, python-format +msgid "Could not find default home '(#%d)'." +msgstr "Impossible de trouver la salle de départ (default home) par défaut : '#%d'." + +#: .\objects\objects.py:846 +msgid "Something went wrong! You are dumped into nowhere. Contact an admin." +msgstr "" +"Quelque chose a mal tourné ! Vous vous trouvez au milieu de nulle part. " +"Veuillez contacter un administrateur." + +#: .\objects\objects.py:912 +#, python-format +msgid "Your character %s has been destroyed." +msgstr "Votre personnage %s a été détruit." + +#: .\scripts\scripthandler.py:53 +#, python-format +msgid "" +"\n" +" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s" +msgstr "" +"\n" +" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s répète) : %(desc)s" + +#: .\scripts\scripts.py:205 +#, python-format +msgid "" +"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'." +msgstr "" +"Le script %(key)s(#%(dbid)s) de type '%(cname)s' a rencontré une erreur " +"durant at_repeat() : '%(err)s'." + +#: .\server\initial_setup.py:28 +#, fuzzy +msgid "" +"\n" +"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if " +"you need\n" +"help, want to contribute, report issues or just join the community.\n" +"As Account #1 you can create a demo/tutorial area with |w@batchcommand " +"tutorial_world.build|n.\n" +" " +msgstr "" +"\n" +"Bienvenue dans votre nouveau jeu basé sur |wEvennia|n ! Visitez le site Web\n" +"http://www.evennia.com si vous avez besoin d'aide, pour contribuer au projet,\n" +"afin de rapporter des bugs ou faire partie de la communauté.\n" +"En tant que premier personnage (#1), vous pouvez créer une zone de\n" +"démo/tutoriel en entrant la commande |w@batchcommand tutorial_world.build|n.\n" +" " + +#: .\server\initial_setup.py:92 +msgid "This is User #1." +msgstr "C'est l'utilisateur #1." + +#: .\server\initial_setup.py:105 +msgid "Limbo" +msgstr "Limbes" + +#: .\server\server.py:139 +#, fuzzy +msgid "idle timeout exceeded" +msgstr "Délai d'inactivité dépassé, déconnexion." + +#: .\server\sessionhandler.py:386 +msgid " ... Server restarted." +msgstr " ... Serveur redémarré." + +#: .\server\sessionhandler.py:606 +msgid "Logged in from elsewhere. Disconnecting." +msgstr "Connexion d'une autre session. Déconnexion de celle-ci." + +#: .\server\sessionhandler.py:634 +msgid "Idle timeout exceeded, disconnecting." +msgstr "Délai d'inactivité dépassé, déconnexion." + +#: .\server\validators.py:50 +#, python-format +msgid "" +"%s From a terminal client, you can also use a phrase of multiple words if " +"you enclose the password in double quotes." +msgstr "" +"%s Depuis votre client, vous pouvez également préciser une phrase contenant " +"plusieurs mots séparés par un espace, dès lors que cette phrase est entourée de guillemets." + +#: .\utils\evmenu.py:192 +#, python-brace-format +msgid "" +"Menu node '{nodename}' is either not implemented or caused an error. Make " +"another choice." +msgstr "" +"Ce choix '{nodename}' n'est pas implémenté, ou bien a créé une erreur. " +"Faies un autre choix." + +#: .\utils\evmenu.py:194 +#, python-brace-format +msgid "Error in menu node '{nodename}'." +msgstr "Une erreur s'est produite dans le choix '{nodename}'." + +#: .\utils\evmenu.py:195 +msgid "No description." +msgstr "Description non renseignée." + +#: .\utils\evmenu.py:196 +msgid "Commands: , help, quit" +msgstr "Utilisez une des commandes : , help, quit" + +#: .\utils\evmenu.py:197 +msgid "Commands: , help" +msgstr "Utilisez une des commandes : , help" + +#: .\utils\evmenu.py:198 +msgid "Commands: help, quit" +msgstr "Utilisez une des commandes : help, quit" + +#: .\utils\evmenu.py:199 +msgid "Commands: help" +msgstr "Utilisez la commande : help" + +#: .\utils\evmenu.py:200 +msgid "Choose an option or try 'help'." +msgstr "Choisissez une option ou entrez la commande 'help'." + +#: .\utils\utils.py:1866 +#, python-format +msgid "Could not find '%s'." +msgstr "Impossible de trouver '%s'." + +#: .\utils\utils.py:1873 +#, python-format +msgid "More than one match for '%s' (please narrow target):\n" +msgstr "Plus d'une possibilité pour '%s' (veuillez préciser) :\n" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 81302a06f0..7ac0eba484 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -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 @@ -857,6 +863,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 @@ -912,8 +979,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): @@ -1820,6 +1891,91 @@ 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);delete:id({account_id}) or perm(Admin)" + + @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: + # Check to make sure account does not have too many chars + if len(account.characters) >= settings.MAX_NR_CHARACTERS: + errors.append("There are too many characters associated with this account.") + return obj, errors + + # 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 + if obj not in account.characters: + account.db._playable_characters.append(obj) + + # 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): """ @@ -1937,6 +2093,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): """ @@ -2015,6 +2237,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. @@ -2053,6 +2282,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 diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py new file mode 100644 index 0000000000..6bfe34248f --- /dev/null +++ b/evennia/objects/tests.py @@ -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()) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1aad01cc79..ccf7e53d4f 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -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 = [] diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 235a0d7628..478967722b 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -37,12 +37,15 @@ prototype key (this value must be possible to serialize in an Attribute). from ast import literal_eval from random import randint as base_randint, random as base_random, choice as base_choice +import re from evennia.utils import search from evennia.utils.utils import justify as base_justify, is_iter, to_str _PROTLIB = None +_RE_DBREF = re.compile(r"\#[0-9]+") + # default protfuncs @@ -325,3 +328,14 @@ def objlist(*args, **kwargs): """ return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)] + + +def dbref(*args, **kwargs): + """ + Usage $dbref(<#dbref>) + Returns one Object searched globally by #dbref. Error if #dbref is invalid. + """ + if not args or len(args) < 1 or _RE_DBREF.match(args[0]) is None: + raise ValueError('$dbref requires a valid #dbref argument.') + + return obj(args[0]) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 205f5f1846..efed01c4f0 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -5,7 +5,6 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ -import re import hashlib import time from ast import literal_eval @@ -33,8 +32,6 @@ _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" PROT_FUNCS = {} -_RE_DBREF = re.compile(r"(?') + mocked__obj_search.assert_not_called() + + with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search: + self.assertRaises(ValueError, protlib.protfunc_parser, "$dbref(Char)") + mocked__obj_search.assert_not_called() + self.assertEqual(protlib.value_to_obj( protlib.protfunc_parser("#6", session=self.session)), self.char1) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 8bff161cf5..e611ede0e2 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -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. diff --git a/evennia/scripts/tests.py b/evennia/scripts/tests.py index dbd36ae956..f5120d0da5 100644 --- a/evennia/scripts/tests.py +++ b/evennia/scripts/tests.py @@ -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" diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index be27c737e2..c05e999657 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -223,6 +223,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 diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 9e9cfc7cc0..083589f928 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -92,7 +92,10 @@ TWISTED_MIN = '18.0.0' DJANGO_MIN = '1.11' DJANGO_REC = '1.11' -sys.path[1] = EVENNIA_ROOT +try: + sys.path[1] = EVENNIA_ROOT +except IndexError: + sys.path.append(EVENNIA_ROOT) # ------------------------------------------------------------ # @@ -216,6 +219,19 @@ RECREATED_SETTINGS = \ their accounts with their old passwords. """ +ERROR_INITMISSING = \ + """ + ERROR: 'evennia --initmissing' must be called from the root of + your game directory, since it tries to create any missing files + in the server/ subfolder. + """ + +RECREATED_MISSING = \ + """ + (Re)created any missing directories or files. Evennia should + be ready to run now! + """ + ERROR_DATABASE = \ """ ERROR: Your database does not seem to be set up correctly. @@ -255,7 +271,7 @@ INFO_WINDOWS_BATFILE = \ twistd.bat to point to the actual location of the Twisted executable (usually called twistd.py) on your machine. - This procedure is only done once. Run evennia.py again when you + This procedure is only done once. Run `evennia` again when you are ready to start the server. """ @@ -319,7 +335,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 ---------------------------------------------------+ @@ -1207,7 +1223,7 @@ def evennia_version(): "git rev-parse --short HEAD", shell=True, cwd=EVENNIA_ROOT, stderr=STDOUT).strip().decode() version = "%s (rev %s)" % (version, rev) - except (IOError, CalledProcessError): + except (IOError, CalledProcessError, OSError): # move on if git is not answering pass return version @@ -1337,7 +1353,10 @@ def create_settings_file(init=True, secret_settings=False): else: print("Reset the settings file.") - default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py") + if secret_settings: + default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "secret_settings.py") + else: + default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py") shutil.copy(default_settings_path, settings_path) with open(settings_path, 'r') as f: @@ -1631,7 +1650,7 @@ def error_check_python_modules(): # # ------------------------------------------------------------ -def init_game_directory(path, check_db=True): +def init_game_directory(path, check_db=True, need_gamedir=True): """ Try to analyze the given path to find settings.py - this defines the game directory and also sets PYTHONPATH as well as the django @@ -1640,15 +1659,17 @@ def init_game_directory(path, check_db=True): Args: path (str): Path to new game directory, including its name. check_db (bool, optional): Check if the databae exists. + need_gamedir (bool, optional): set to False if Evennia doesn't require to be run in a valid game directory. """ # set the GAMEDIR path - set_gamedir(path) + if need_gamedir: + set_gamedir(path) # Add gamedir to python path sys.path.insert(0, GAMEDIR) - if TEST_MODE: + if TEST_MODE or not need_gamedir: if ENFORCED_SETTING: print(NOTE_TEST_CUSTOM.format(settings_dotpath=SETTINGS_DOTPATH)) os.environ['DJANGO_SETTINGS_MODULE'] = SETTINGS_DOTPATH @@ -1675,6 +1696,10 @@ def init_game_directory(path, check_db=True): if check_db: check_database() + # if we don't have to check the game directory, return right away + if not need_gamedir: + return + # set up the Evennia executables and log file locations global AMP_PORT, AMP_HOST, AMP_INTERFACE global SERVER_PY_FILE, PORTAL_PY_FILE @@ -1920,6 +1945,10 @@ def main(): '--initsettings', action='store_true', dest="initsettings", default=False, help="create a new, empty settings file as\n gamedir/server/conf/settings.py") + parser.add_argument( + '--initmissing', action='store_true', dest="initmissing", + default=False, + help="checks for missing secret_settings or server logs\n directory, and adds them if needed") parser.add_argument( '--profiler', action='store_true', dest='profiler', default=False, help="start given server component under the Python profiler") @@ -1993,6 +2022,21 @@ def main(): print(ERROR_INITSETTINGS) sys.exit() + if args.initmissing: + try: + log_path = os.path.join(SERVERDIR, "logs") + if not os.path.exists(log_path): + os.makedirs(log_path) + + settings_path = os.path.join(CONFDIR, "secret_settings.py") + if not os.path.exists(settings_path): + create_settings_file(init=False, secret_settings=True) + + print(RECREATED_MISSING) + except IOError: + print(ERROR_INITMISSING) + sys.exit() + if args.tail_log: # set up for tailing the log files global NO_REACTOR_STOP @@ -2061,6 +2105,10 @@ def main(): elif option != "noop": # pass-through to django manager check_db = False + need_gamedir = True + # some commands don't require the presence of a game directory to work + if option in ('makemessages', 'compilemessages'): + need_gamedir = False # handle special django commands if option in ('runserver', 'testserver'): @@ -2073,7 +2121,7 @@ def main(): global TEST_MODE TEST_MODE = True - init_game_directory(CURRENT_DIR, check_db=check_db) + init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir) # pass on to the manager args = [option] @@ -2089,6 +2137,11 @@ def main(): kwargs[arg.lstrip("--")] = value else: args.append(arg) + + # makemessages needs a special syntax to not conflict with the -l option + if len(args) > 1 and args[0] == "makemessages": + args.insert(1, "-l") + try: django.core.management.call_command(*args, **kwargs) except django.core.management.base.CommandError as exc: diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 0b8d0b2001..d9b30e4e8d 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -440,7 +440,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: diff --git a/evennia/server/portal/irc.py b/evennia/server/portal/irc.py index d74fbaa86e..2b616f2ce1 100644 --- a/evennia/server/portal/irc.py +++ b/evennia/server/portal/irc.py @@ -421,7 +421,7 @@ class IRCBotFactory(protocol.ReconnectingClientFactory): def clientConnectionLost(self, connector, reason): """ - Called when Client looses connection. + Called when Client loses connection. Args: connector (Connection): Represents the connection. diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 06c55683d4..239e139746 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -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() diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index ba41e2e96a..41fbf33750 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -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) diff --git a/evennia/server/portal/tests.py b/evennia/server/portal/tests.py index be400144c6..791e5172a4 100644 --- a/evennia/server/portal/tests.py +++ b/evennia/server/portal/tests.py @@ -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 diff --git a/evennia/server/profiling/memplot.py b/evennia/server/profiling/memplot.py index 5d031baaa3..1665a9d235 100644 --- a/evennia/server/profiling/memplot.py +++ b/evennia/server/profiling/memplot.py @@ -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. diff --git a/evennia/server/profiling/tests.py b/evennia/server/profiling/tests.py index b3e9fba8d5..cca4e0d99b 100644 --- a/evennia/server/profiling/tests.py +++ b/evennia/server/profiling/tests.py @@ -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() diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 9ff37b15d3..0a519a225f 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -284,6 +284,8 @@ class ServerSessionHandler(SessionHandler): super().__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): """ diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index 56c88c63f2..a1e6092844 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -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 - - \ No newline at end of file diff --git a/evennia/server/validators.py b/evennia/server/validators.py index b10f990a8a..faa1aa68c8 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -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 - ) \ No newline at end of file + "%s From a terminal client, you can also use a phrase of multiple words if " + "you enclose the password in double quotes." % self.policy + ) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index ddacd5e73e..85c725da08 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -13,6 +13,7 @@ always be sure of what you have changed and what is default behaviour. """ from builtins import range +from django.contrib.messages import constants as messages from django.urls import reverse_lazy import os @@ -116,7 +117,7 @@ AMP_INTERFACE = '127.0.0.1' EVENNIA_DIR = os.path.dirname(os.path.abspath(__file__)) # Path to the game directory (containing the server/conf/settings.py file) # This is dynamically created- there is generally no need to change this! -if sys.argv[1] == 'test' if len(sys.argv) > 1 else False: +if EVENNIA_DIR.lower() == os.getcwd().lower() or (sys.argv[1] == 'test' if len(sys.argv) > 1 else False): # unittesting mode GAME_DIR = os.getcwd() else: @@ -139,7 +140,7 @@ HTTP_LOG_FILE = os.path.join(LOG_DIR, 'http_requests.log') LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log') # Rotate log files when server and/or portal stops. This will keep log # file sizes down. Turn off to get ever growing log files and never -# loose log info. +# lose log info. CYCLE_LOGFILES = True # Number of lines to append to rotating channel logs when they rotate CHANNEL_LOG_NUM_TAIL_LINES = 20 @@ -222,7 +223,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 +412,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" @@ -666,6 +670,9 @@ DEBUG = False ADMINS = () # 'Your Name', 'your_email@domain.com'),) # These guys get broken link notifications when SEND_BROKEN_LINK_EMAILS is True. MANAGERS = ADMINS +# This is a public point of contact for players or the public to contact +# a staff member or administrator of the site. It is publicly posted. +STAFF_CONTACT_EMAIL = None # Absolute path to the directory that holds file uploads from web apps. # Example: "/home/media/media.lawrence.com" MEDIA_ROOT = os.path.join(GAME_DIR, "web", "media") @@ -752,6 +759,7 @@ TEMPLATES = [{ 'django.contrib.auth.context_processors.auth', 'django.template.context_processors.media', 'django.template.context_processors.debug', + 'django.contrib.messages.context_processors.messages', 'sekizai.context_processors.sekizai', 'evennia.web.utils.general_context.general_context'], # While true, show "pretty" error messages for template syntax errors. @@ -762,14 +770,15 @@ TEMPLATES = [{ # MiddleWare are semi-transparent extensions to Django's functionality. # see http://www.djangoproject.com/documentation/middleware/ for a more detailed # explanation. -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', # 1.4? 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.admindocs.middleware.XViewMiddleware', - 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',) + 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', + 'evennia.web.utils.middleware.SharedLoginMiddleware',) ###################################################################### # Evennia components @@ -786,6 +795,7 @@ INSTALLED_APPS = ( 'django.contrib.flatpages', 'django.contrib.sites', 'django.contrib.staticfiles', + 'django.contrib.messages', 'sekizai', 'evennia.utils.idmapper', 'evennia.server', @@ -811,9 +821,24 @@ 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' +# Messages and Bootstrap don't classify events the same way; this setting maps +# messages.error() to Bootstrap 'danger' classes. +MESSAGE_TAGS = { + messages.ERROR: 'danger', +} + ###################################################################### # Django extensions ###################################################################### @@ -821,7 +846,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. diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 4dad9aa038..86d83c2207 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -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,182 @@ 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' % slugify(cls._meta.verbose_name)) + 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[\w\d\-]+)/(?P[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' % slugify(self._meta.verbose_name), + kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: + return '#' + + def web_get_puppet_url(self): + """ + Returns the URI path for a View that allows users to puppet a specific + object. + + ex. Oscar (Character) = '/characters/oscar/1/puppet/' + + 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-puppet' would be referenced by this method. + + ex. + url(r'characters/(?P[\w\d\-]+)/(?P[0-9]+)/puppet/$', + CharPuppetView.as_view(), name='character-puppet') + + 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 puppet page, if defined. + + """ + try: + return reverse('%s-puppet' % slugify(self._meta.verbose_name), + 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[\w\d\-]+)/(?P[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' % slugify(self._meta.verbose_name), + 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[\w\d\-]+)/(?P[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' % slugify(self._meta.verbose_name), + kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: + return '#' + + # Used by Django Sites/Admin + get_absolute_url = web_get_detail_url diff --git a/evennia/utils/create.py b/evennia/utils/create.py index c2a8392905..8ae5d00298 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -229,6 +229,12 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, # at_first_save hook on the typeclass, where the _createdict # can be used. new_script.save() + + if not new_script.id: + # this happens in the case of having a repeating script with `repeats=1` and + # `start_delay=False` - the script will run once and immediately stop before save is over. + return None + return new_script diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py index 1fddd90520..c3cc0ac416 100644 --- a/evennia/utils/gametime.py +++ b/evennia/utils/gametime.py @@ -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. diff --git a/evennia/utils/idmapper/models.py b/evennia/utils/idmapper/models.py index 54ca44d572..ecd8f91e94 100644 --- a/evennia/utils/idmapper/models.py +++ b/evennia/utils/idmapper/models.py @@ -398,6 +398,11 @@ class SharedMemoryModel(with_metaclass(SharedMemoryModelBase, Model)): super().save(*args, **kwargs) callFromThread(_save_callback, self, *args, **kwargs) + if not self.pk: + # this can happen if some of the startup methods immediately + # delete the object (an example are Scripts that start and die immediately) + return + # update field-update hooks and eventual OOB watchers new = False if "update_fields" in kwargs and kwargs["update_fields"]: @@ -422,6 +427,7 @@ class SharedMemoryModel(with_metaclass(SharedMemoryModelBase, Model)): # fieldtracker = "_oob_at_%s_postsave" % fieldname # if hasattr(self, fieldtracker): # _GA(self, fieldtracker)(fieldname) + pass class WeakSharedMemoryModelBase(SharedMemoryModelBase): diff --git a/evennia/utils/tests/test_create_functions.py b/evennia/utils/tests/test_create_functions.py new file mode 100644 index 0000000000..2d5b1eeaf0 --- /dev/null +++ b/evennia/utils/tests/test_create_functions.py @@ -0,0 +1,79 @@ +""" +Tests of create functions + +""" + +from evennia.utils.test_resources import EvenniaTest +from evennia.scripts.scripts import DefaultScript +from evennia.utils import create + + +class TestCreateScript(EvenniaTest): + + def test_create_script(self): + class TestScriptA(DefaultScript): + def at_script_creation(self): + self.key = 'test_script' + self.interval = 10 + self.persistent = False + + script = create.create_script(TestScriptA, key='test_script') + assert script is not None + assert script.interval == 10 + assert script.key == 'test_script' + script.stop() + + def test_create_script_w_repeats_equal_1(self): + class TestScriptB(DefaultScript): + def at_script_creation(self): + self.key = 'test_script' + self.interval = 10 + self.repeats = 1 + self.persistent = False + + # script is already stopped (interval=1, start_delay=False) + script = create.create_script(TestScriptB, key='test_script') + assert script is None + + def test_create_script_w_repeats_equal_1_persisted(self): + class TestScriptB1(DefaultScript): + def at_script_creation(self): + self.key = 'test_script' + self.interval = 10 + self.repeats = 1 + self.persistent = True + + # script is already stopped (interval=1, start_delay=False) + script = create.create_script(TestScriptB1, key='test_script') + assert script is None + + def test_create_script_w_repeats_equal_2(self): + class TestScriptC(DefaultScript): + def at_script_creation(self): + self.key = 'test_script' + self.interval = 10 + self.repeats = 2 + self.persistent = False + + script = create.create_script(TestScriptC, key='test_script') + assert script is not None + assert script.interval == 10 + assert script.repeats == 2 + assert script.key == 'test_script' + script.stop() + + def test_create_script_w_repeats_equal_1_and_delayed(self): + class TestScriptD(DefaultScript): + def at_script_creation(self): + self.key = 'test_script' + self.interval = 10 + self.start_delay = True + self.repeats = 1 + self.persistent = False + + script = create.create_script(TestScriptD, key='test_script') + assert script is not None + assert script.interval == 10 + assert script.repeats == 1 + assert script.key == 'test_script' + script.stop() diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py index 44bc8b3cb3..8fd52eec2e 100644 --- a/evennia/web/utils/general_context.py +++ b/evennia/web/utils/general_context.py @@ -67,7 +67,17 @@ def general_context(request): Returns common Evennia-related context stuff, which is automatically added to context of all views. """ + account = None + if request.user.is_authenticated(): account = request.user + + puppet = None + if account and request.session.get('puppet'): + pk = int(request.session.get('puppet')) + puppet = next((x for x in account.characters if x.pk == pk), None) + return { + 'account': account, + 'puppet': puppet, 'game_name': GAME_NAME, 'game_slogan': GAME_SLOGAN, 'evennia_userapps': ACCOUNT_RELATED, diff --git a/evennia/web/utils/middleware.py b/evennia/web/utils/middleware.py new file mode 100644 index 0000000000..16b7ee2091 --- /dev/null +++ b/evennia/web/utils/middleware.py @@ -0,0 +1,61 @@ +from django.contrib.auth import authenticate, login +from evennia.accounts.models import AccountDB +from evennia.utils import logger + +class SharedLoginMiddleware(object): + """ + Handle the shared login between website and webclient. + + """ + def __init__(self, get_response): + # One-time configuration and initialization. + self.get_response = get_response + + def __call__(self, request): + # Code to be executed for each request before + # the view (and later middleware) are called. + + # Synchronize credentials between webclient and website + # Must be performed *before* rendering the view (issue #1723) + self.make_shared_login(request) + + # Process view + response = self.get_response(request) + + # Code to be executed for each request/response after + # the view is called. + + # Return processed view + return response + + @classmethod + def make_shared_login(cls, request): + csession = request.session + account = request.user + website_uid = csession.get("website_authenticated_uid", None) + webclient_uid = csession.get("webclient_authenticated_uid", None) + + if not csession.session_key: + # this is necessary to build the sessid key + csession.save() + + if account.is_authenticated(): + # Logged into website + if not website_uid: + # fresh website login (just from login page) + csession["website_authenticated_uid"] = account.id + if webclient_uid is None: + # auto-login web client + csession["webclient_authenticated_uid"] = account.id + + elif webclient_uid: + # Not logged into website, but logged into webclient + if not website_uid: + csession["website_authenticated_uid"] = account.id + account = AccountDB.objects.get(id=webclient_uid) + try: + # calls our custom authenticate, in web/utils/backend.py + authenticate(autologin=account) + login(request, account) + except AttributeError: + logger.log_trace() \ No newline at end of file diff --git a/evennia/web/utils/tests.py b/evennia/web/utils/tests.py index e2b28c3510..093b858f4d 100644 --- a/evennia/web/utils/tests.py +++ b/evennia/web/utils/tests.py @@ -1,10 +1,8 @@ -from mock import Mock, patch - -from django.test import TestCase - +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory, TestCase +from mock import MagicMock, patch from . import general_context - class TestGeneralContext(TestCase): maxDiff = None @@ -15,8 +13,18 @@ class TestGeneralContext(TestCase): @patch('evennia.web.utils.general_context.WEBSOCKET_PORT', "websocket_client_port_testvalue") @patch('evennia.web.utils.general_context.WEBSOCKET_URL', "websocket_client_url_testvalue") def test_general_context(self): - request = Mock() - self.assertEqual(general_context.general_context(request), { + request = RequestFactory().get('/') + request.user = AnonymousUser() + request.session = { + 'account': None, + 'puppet': None, + } + + response = general_context.general_context(request) + + self.assertEqual(response, { + 'account': None, + 'puppet': None, 'game_name': "test_name", 'game_slogan': "test_game_slogan", 'evennia_userapps': ['Accounts'], diff --git a/evennia/web/webclient/static/webclient/js/evennia.js b/evennia/web/webclient/static/webclient/js/evennia.js index 501a04021d..ddc6b792e4 100644 --- a/evennia/web/webclient/static/webclient/js/evennia.js +++ b/evennia/web/webclient/static/webclient/js/evennia.js @@ -102,7 +102,7 @@ An "emitter" object must have a function return; } this.connection.connect(); - log('Evenna reconnecting.') + log('Evennia reconnecting.') }, // Returns true if the connection is open. diff --git a/evennia/web/webclient/static/webclient/js/plugins/history.js b/evennia/web/webclient/static/webclient/js/plugins/history.js index c33dbcabf9..60b1a2b163 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/history.js +++ b/evennia/web/webclient/static/webclient/js/plugins/history.js @@ -69,8 +69,12 @@ let history_plugin = (function () { } if (history_entry !== null) { - // Doing a history navigation; replace the text in the input. - inputfield.val(history_entry); + // 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; diff --git a/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js b/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js index e3d2ea8a7a..a20a620f52 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js +++ b/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js @@ -30,32 +30,31 @@ plugin_handler.add('hotbuttons', (function () { // Add Buttons var addButtonsUI = function () { var buttons = $( [ - '
', - '
', - '
', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - '
', - '
', - '
', - ].join("\n") ); + '
', + '
', + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + '
', + '
', + ].join("\n") ); // Add buttons in front of the existing #inputform - buttons.insertBefore('#inputform'); - $('#inputform').addClass('split split-vertical'); + $('#input').prev().replaceWith(buttons); - Split(['#buttons','#inputform'], { + Split(['#main','#buttons','#input'], { + sizes: [85,5,10], direction: 'vertical', - sizes: [50,50], gutterSize: 4, - minSize: 150, + minSize: [150,20,50], }); } diff --git a/evennia/web/webclient/static/webclient/js/plugins/options.js b/evennia/web/webclient/static/webclient/js/plugins/options.js index 114cd6cf0e..d3b6d13158 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/options.js +++ b/evennia/web/webclient/static/webclient/js/plugins/options.js @@ -77,10 +77,12 @@ let options_plugin = (function () { if (code === 27) { // Escape key if ($('#helpdialog').is(':visible')) { plugins['popups'].closePopup("#helpdialog"); - } else { - plugins['popups'].closePopup("#optionsdialog"); + return true; + } + if ($('#optionsdialog').is(':visible')) { + plugins['popups'].closePopup("#optionsdialog"); + return true; } - return true; } return false; } @@ -129,6 +131,21 @@ let options_plugin = (function () { plugins['popups'].closePopup("#helpdialog"); } + // + // Make sure to close any dialogs on connection lost + var onText = function (args, kwargs) { + // is helppopup set? and if so, does this Text have type 'help'? + if ('helppopup' in options && options['helppopup'] ) { + if (kwargs && ('type' in kwargs) && (kwargs['type'] == 'help') ) { + $('#helpdialogcontent').append('
'+ args + '
'); + plugins['popups'].togglePopup("#helpdialog"); + return true; + } + } + + return false; + } + // // Register and init plugin var init = function () { @@ -155,6 +172,7 @@ let options_plugin = (function () { onGotOptions: onGotOptions, onPrompt: onPrompt, onConnectionClose: onConnectionClose, + onText: onText, } })() plugin_handler.add('options', options_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js index 4667065856..284d74b912 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js @@ -183,12 +183,12 @@ let splithandler_plugin = (function () { var dialog = $("#splitdialogcontent"); dialog.empty(); - var selection = ''; for ( var pane in split_panes ) { - selection = selection + ''; + selection = selection + ''; } - selection = "Pane to split: " + selection + " "; - dialog.append(selection); + selection = "Pane to split: " + selection + " "; + dialog.append(selection); dialog.append('top/bottom '); dialog.append('side-by-side
'); @@ -203,7 +203,7 @@ let splithandler_plugin = (function () { dialog.append('replace '); dialog.append('append
'); - dialog.append('
Split
'); + dialog.append('
Split
'); $("#splitclose").bind("click", onSplitDialogClose); @@ -251,21 +251,21 @@ let splithandler_plugin = (function () { var dialog = $("#panedialogcontent"); dialog.empty(); - var selection = ''; for ( var pane in split_panes ) { - selection = selection + ''; + selection = selection + ''; } - selection = "Assign to pane: " + selection + "
"; - dialog.append(selection); + selection = "Assign to pane: " + selection + "
"; + dialog.append(selection); - var multiple = ''; for ( var type in known_types ) { - multiple = multiple + ''; + multiple = multiple + ''; } - multiple = "Content types: " + multiple + "
"; - dialog.append(multiple); + multiple = "Content types: " + multiple + "
"; + dialog.append(multiple); - dialog.append('
Assign
'); + dialog.append('
Assign
'); $("#paneclose").bind("click", onPaneControlDialogClose); @@ -276,9 +276,9 @@ let splithandler_plugin = (function () { // Close "Pane Controls" dialog var onPaneControlDialogClose = function () { var pane = $("select[name=assign-pane]").val(); - var types = $("select[name=assign-type]").val(); + var types = $("select[name=assign-type]").val(); - // var types = new Array; + // var types = new Array; // $('#splitdialogcontent input[type=checkbox]:checked').each(function() { // types.push( $(this).attr('value') ); // }); @@ -287,24 +287,24 @@ let splithandler_plugin = (function () { plugins['popups'].closePopup("#panedialog"); } + // // helper function sending text to a pane var txtToPane = function (panekey, txt) { - var pane = split_panes[panekey]; - var text_div = $('#' + panekey + '-sub'); + var pane = split_panes[panekey]; + var text_div = $('#' + panekey + '-sub'); - if ( pane['update_method'] == 'replace' ) { - text_div.html(txt) - } else if ( pane['update_method'] == 'append' ) { - text_div.append(txt); - var scrollHeight = text_div.parent().prop("scrollHeight"); - text_div.parent().animate({ scrollTop: scrollHeight }, 0); - } else { // line feed - text_div.append("
" + txt + "
"); - var scrollHeight = text_div.parent().prop("scrollHeight"); - text_div.parent().animate({ scrollTop: scrollHeight }, 0); - } - + if ( pane['update_method'] == 'replace' ) { + text_div.html(txt) + } else if ( pane['update_method'] == 'append' ) { + text_div.append(txt); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); + } else { // line feed + text_div.append("
" + txt + "
"); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); + } } @@ -316,53 +316,76 @@ let splithandler_plugin = (function () { // // Accept plugin onText events var onText = function (args, kwargs) { - - // If the message is not itself tagged, we'll assume it - // should go into any panes with 'all' or 'rest' set + // If the message is not itself tagged, we'll assume it + // should go into any panes with 'all' or 'rest' set var msgtype = "rest"; if ( kwargs && 'type' in kwargs ) { - msgtype = kwargs['type']; + msgtype = kwargs['type']; if ( ! known_types.includes(msgtype) ) { // this is a new output type that can be mapped to panes console.log('detected new output type: ' + msgtype) known_types.push(msgtype); } - } - var target_panes = []; - var rest_panes = []; - - for (var key in split_panes) { - var pane = split_panes[key]; - // is this message type mapped to this pane (or does the pane has an 'all' type)? - if (pane['types'].length > 0) { - if (pane['types'].includes(msgtype) || pane['types'].includes('all')) { - target_panes.push(key); - } else if (pane['types'].includes('rest')) { - // store rest-panes in case we have no explicit to send to - rest_panes.push(key); - } - } else { - // unassigned panes are assumed to be rest-panes too - rest_panes.push(key); - } - } - var ntargets = target_panes.length; - var nrests = rest_panes.length; - if (ntargets > 0) { - // we have explicit target panes to send to - for (var i=0; i 0) { - // no targets, send remainder to rest-panes/unassigned - for (var i=0; i 0) { + if (pane['types'].includes(msgtype) || pane['types'].includes('all')) { + target_panes.push(key); + } else if (pane['types'].includes('rest')) { + // store rest-panes in case we have no explicit to send to + rest_panes.push(key); + } + } else { + // unassigned panes are assumed to be rest-panes too + rest_panes.push(key); + } + } + var ntargets = target_panes.length; + var nrests = rest_panes.length; + if (ntargets > 0) { + // we have explicit target panes to send to + for (var i=0; i 0) { + // no targets, send remainder to rest-panes/unassigned + for (var i=0; i - + @@ -73,10 +73,10 @@ JQuery available. + - {% endblock %} diff --git a/evennia/web/webclient/views.py b/evennia/web/webclient/views.py index b8a83f8264..3674e4b779 100644 --- a/evennia/web/webclient/views.py +++ b/evennia/web/webclient/views.py @@ -5,6 +5,8 @@ page and serve it eventual static content. """ +from django.conf import settings +from django.http import Http404 from django.shortcuts import render from django.contrib.auth import login, authenticate @@ -12,51 +14,16 @@ from evennia.accounts.models import AccountDB from evennia.utils import logger -def _shared_login(request): - """ - Handle the shared login between website and webclient. - - """ - csession = request.session - account = request.user - # these can have 3 values: - # None - previously unused (auto-login) - # False - actively logged out (don't auto-login) - # - logged in User/Account id - website_uid = csession.get("website_authenticated_uid", None) - webclient_uid = csession.get("webclient_authenticated_uid", None) - - # check if user has authenticated to website - if not csession.session_key: - # this is necessary to build the sessid key - csession.save() - - if webclient_uid: - # The webclient has previously registered a login to this browser_session - if not account.is_authenticated() and not website_uid: - try: - account = AccountDB.objects.get(id=webclient_uid) - except AccountDB.DoesNotExist: - # this can happen e.g. for guest accounts or deletions - csession["website_authenticated_uid"] = False - csession["webclient_authenticated_uid"] = False - return - try: - # calls our custom authenticate in web/utils/backends.py - account = authenticate(autologin=account) - login(request, account) - csession["website_authenticated_uid"] = webclient_uid - except AttributeError: - logger.log_trace() - - def webclient(request): """ Webclient page template loading. """ - # handle webclient-website shared login - _shared_login(request) + # auto-login is now handled by evennia.web.utils.middleware + + # check if webclient should be enabled + if not settings.WEBCLIENT_ENABLED: + raise Http404 # make sure to store the browser session's hash so the webclient can get to it! pagevars = {'browser_sessid': request.session.session_key} diff --git a/evennia/web/website/forms.py b/evennia/web/website/forms.py new file mode 100644 index 0000000000..66c5224914 --- /dev/null +++ b/evennia/web/website/forms.py @@ -0,0 +1,157 @@ +from django import forms +from django.conf import settings +from django.contrib.auth.forms import UserCreationForm, UsernameField +from django.forms import ModelForm +from django.utils.html import escape +from evennia.utils import class_from_module + +class EvenniaForm(forms.Form): + """ + This is a stock Django form, but modified so that all values provided + through it are escaped (sanitized). Validation is performed by the fields + you define in the form. + + This has little to do with Evennia itself and is more general web security- + related. + + https://www.owasp.org/index.php/Input_Validation_Cheat_Sheet#Goals_of_Input_Validation + + """ + def clean(self): + """ + Django hook. Performed on form submission. + + Returns: + cleaned (dict): Dictionary of key:value pairs submitted on the form. + + """ + # Call parent function + cleaned = super(EvenniaForm, self).clean() + + # Escape all values provided by user + cleaned = {k:escape(v) for k,v in cleaned.items()} + return cleaned + +class AccountForm(UserCreationForm): + """ + This is a generic Django form tailored to the Account model. + + In this incarnation it does not allow getting/setting of attributes, only + core User model fields (username, email, password). + + """ + class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + # The model/typeclass this form creates + model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + + # The fields to display on the form, in the given order + fields = ("username", "email") + + # Any overrides of field classes + field_classes = {'username': UsernameField} + + # Username is collected as part of the core UserCreationForm, so we just need + # to add a field to (optionally) capture email. + email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False) + +class ObjectForm(EvenniaForm, ModelForm): + """ + This is a Django form for generic Evennia Objects that allows modification + of attributes when called from a descendent of ObjectUpdate or ObjectCreate + views. + + It defines no fields by default; you have to do that by extending this class + and defining what fields you want to be recorded. See the CharacterForm for + a simple example of how to do this. + + """ + class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + # The model/typeclass this form creates + model = class_from_module(settings.BASE_OBJECT_TYPECLASS) + + # The fields to display on the form, in the given order + fields = ("db_key",) + + # This lets us rename ugly db-specific keys to something more human + labels = { + 'db_key': 'Name', + } + +class CharacterForm(ObjectForm): + """ + This is a Django form for Evennia Character objects. + + Since Evennia characters only have one attribute by default, this form only + defines a field for that single attribute. The names of fields you define should + correspond to their names as stored in the dbhandler; you can display + 'prettier' versions of the fieldname on the form using the 'label' kwarg. + + The basic field types are CharFields and IntegerFields, which let you enter + text and numbers respectively. IntegerFields have some neat validation tricks + they can do, like mandating values fall within a certain range. + + For example, a complete "age" field (which stores its value to + `character.db.age` might look like: + + age = forms.IntegerField( + label="Your Age", + min_value=18, max_value=9000, + help_text="Years since your birth.") + + Default input fields are generic single-line text boxes. You can control what + sort of input field users will see by specifying a "widget." An example of + this is used for the 'desc' field to show a Textarea box instead of a Textbox. + + For help in building out your form, please see: + https://docs.djangoproject.com/en/1.11/topics/forms/#building-a-form-in-django + + For more information on fields and their capabilities, see: + https://docs.djangoproject.com/en/1.11/ref/forms/fields/ + + For more on widgets, see: + https://docs.djangoproject.com/en/1.11/ref/forms/widgets/ + + """ + class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + # Get the correct object model + model = class_from_module(settings.BASE_CHARACTER_TYPECLASS) + + # Allow entry of the 'key' field + fields = ("db_key",) + + # Rename 'key' to something more intelligible + labels = { + 'db_key': 'Name', + } + + # Fields pertaining to configurable attributes on the Character object. + desc = forms.CharField(label='Description', max_length=2048, required=False, + widget=forms.Textarea(attrs={'rows': 3}), + help_text="A brief description of your character.") + +class CharacterUpdateForm(CharacterForm): + """ + This is a Django form for updating Evennia Character objects. + + By default it is the same as the CharacterForm, but if there are circumstances + in which you don't want to let players edit all the same attributes they had + access to during creation, you can redefine this form with those fields you do + wish to allow. + + """ + pass \ No newline at end of file diff --git a/evennia/web/website/templates/website/_menu.html b/evennia/web/website/templates/website/_menu.html index 8430d0f805..888a63bf3f 100644 --- a/evennia/web/website/templates/website/_menu.html +++ b/evennia/web/website/templates/website/_menu.html @@ -23,35 +23,67 @@ folder and edit it to add/remove links to the menu.