mirror of
https://github.com/DeclanHoare/shiftgears.git
synced 2025-01-22 08:11:52 -05:00
Initial Release
This commit is contained in:
commit
7000fce72f
25 changed files with 1376 additions and 0 deletions
18
README
Normal file
18
README
Normal file
|
@ -0,0 +1,18 @@
|
|||
Shift Gears is a new server for ShiftOS 1.0.
|
||||
|
||||
Currently focusing on the available source and binary of 1.0 Beta 2.5.2
|
||||
but ultimately it would be nice to support as many client versions as
|
||||
possible.
|
||||
|
||||
To run it you need a config.json file that looks something like
|
||||
|
||||
```
|
||||
{
|
||||
"dbaddr": "mysql://root:password@localhost/shiftos"
|
||||
}
|
||||
```
|
||||
|
||||
dependencies: sqlalchemy, twisted, flask, netfleece
|
||||
You need a WSGI Web server to run unite.wsgi for the Unite server.
|
||||
You can just execute server.py to run the MUD server.
|
||||
|
99
auth.py
Normal file
99
auth.py
Normal file
|
@ -0,0 +1,99 @@
|
|||
|
||||
from datetime import datetime, timedelta
|
||||
import io
|
||||
import secrets
|
||||
import socket
|
||||
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from database import AuthSession
|
||||
from mapping import map_from, map_to
|
||||
from messagehandler import handler
|
||||
from myconfig import config, mappings
|
||||
from netclass.jsonconverter import from_json
|
||||
|
||||
class InvalidTokenError(Exception):
|
||||
pass
|
||||
|
||||
class Auth:
|
||||
def __init__(self, dbsession, addr):
|
||||
self.dbsession = dbsession
|
||||
self.addr = addr
|
||||
|
||||
def session(self, token):
|
||||
try:
|
||||
session = self.dbsession.query(AuthSession).filter_by(Token = token).one()
|
||||
except NoResultFound:
|
||||
raise InvalidTokenError()
|
||||
session.LastUsed = datetime.utcnow()
|
||||
session.LastIP = self.addr
|
||||
return session
|
||||
|
||||
def create_session(self, user, app_name, app_description, version):
|
||||
session = AuthSession()
|
||||
session.Token = secrets.token_urlsafe()
|
||||
session.User = user
|
||||
session.AppName = app_name
|
||||
session.AppDesc = app_description
|
||||
session.Version = version
|
||||
|
||||
session.Created = session.LastUsed = datetime.utcnow()
|
||||
session.LastIP = self.addr
|
||||
self.dbsession.add(session)
|
||||
return session
|
||||
|
||||
|
||||
@handler("mud_token_login")
|
||||
def mud_token_login(connection, contents):
|
||||
if connection.authsession is not None:
|
||||
connection.error("You're already logged in and tried to log in again.")
|
||||
return
|
||||
try:
|
||||
connection.authsession = connection.auth.session(contents)
|
||||
user = connection.authsession.User
|
||||
save = user.Save
|
||||
if save is None:
|
||||
connection.send_message("mud_login_denied")
|
||||
else:
|
||||
data = {"Upgrades": {u.Name: u.Installed for u in save.Upgrades},
|
||||
"CurrentLegions": [], #NYI
|
||||
"UniteAuthToken": connection.authsession.Token,
|
||||
"StoriesExperienced": [s.Name for s in save.StoriesExperienced],
|
||||
"Users": [map_to(u, mappings["ClientSave"]) for u in save.Users]}
|
||||
data.update(map_to(user, mappings["UserSave"]))
|
||||
data.update(map_to(save, mappings["Save"]))
|
||||
connection.send_message("mud_savefile", data)
|
||||
except InvalidTokenError:
|
||||
connection.send_message("mud_login_denied")
|
||||
|
||||
@handler("mud_save", dict)
|
||||
def mud_save(connection, contents):
|
||||
user = connection.authsession.User
|
||||
save = user.Save
|
||||
if save is None:
|
||||
save = Save()
|
||||
user.Save = save
|
||||
map_from(user, mappings["UserSave"], contents)
|
||||
map_from(save, mappings["Save"], contents)
|
||||
connection.dbsession.query(Upgrade).filter_by(Save = save).delete()
|
||||
if contents["Upgrades"] is not None:
|
||||
for k, v in contents["Upgrades"].items():
|
||||
connection.dbsession.add(Upgrade(Name = k, Installed = v, Save = save))
|
||||
connection.dbsession.query(StoryExperienced).filter_by(Save = save).delete()
|
||||
if contents["StoriesExperienced"] is not None:
|
||||
for v in contents["StoriesExperienced"]:
|
||||
connection.dbsession.add(StoryExperienced(Name = v, Save = save))
|
||||
if contents["Users"] is not None:
|
||||
users_new = {u["Username"]: u for u in contents["Users"]}
|
||||
for usr in connection.dbsession.query(ClientSave).filter_by(Save = save):
|
||||
if usr.Username in users_new:
|
||||
map_from(usr, mappings["ClientSave"], users_new[usr.Username])
|
||||
del users_new[usr.Username]
|
||||
else:
|
||||
connection.dbsession.delete(usr)
|
||||
for data in users_new.values():
|
||||
usr = ClientSave(Save = save)
|
||||
map_from(usr, mappings["ClientSave"], data)
|
||||
connection.dbsession.add(usr)
|
||||
|
||||
|
32
create_tables.py
Normal file
32
create_tables.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import database
|
||||
database.Base.metadata.create_all(database.engine)
|
||||
session = database.DbSession()
|
||||
|
||||
systems = [("DevX", "mud", ["sys", "DevX"]),
|
||||
("hacker101", "undisclosed", ["hacker101"]),
|
||||
("victortran", "theos", ["victortran"])]
|
||||
|
||||
# create the system account
|
||||
|
||||
for i, (displayname, sysname, users) in enumerate(systems):
|
||||
|
||||
user = database.User()
|
||||
user.dontvalidate = True
|
||||
user.ID = "00000000-0000-0000-0000-%012d" % i
|
||||
user.Email = f"{sysname}@system.invalid"
|
||||
user.DisplayName = displayname
|
||||
user.SysName = sysname
|
||||
session.add(user)
|
||||
save = database.Save()
|
||||
save.User = user
|
||||
save.IsMUDAdmin = True
|
||||
session.add(save)
|
||||
print(repr(user.Save))
|
||||
for username in users:
|
||||
clientsave = database.ClientSave()
|
||||
clientsave.Username = username
|
||||
clientsave.Save = save
|
||||
session.add(clientsave)
|
||||
|
||||
session.commit()
|
||||
session.close()
|
21
create_test_account.py
Normal file
21
create_test_account.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
|
||||
uniq = str(uuid.uuid4())[:8].upper()
|
||||
|
||||
displayname = f"u{uniq}"
|
||||
sysname = f"s{uniq}"
|
||||
email = f"{uniq}@getshiftos.ml"
|
||||
password = "P@ssw0rd"
|
||||
|
||||
auth = "Basic " + base64.b64encode(f"{email}:{password}".encode()).decode()
|
||||
|
||||
token = requests.get("http://getshiftos.ml/Auth/Register",
|
||||
params = {"appname": "ShiftGears", "appdesc": "ShiftGears testing software", "version": "45", "displayname": displayname, "sysname": sysname},
|
||||
headers = {"Authentication": auth}).text.strip()
|
||||
|
||||
print(token)
|
206
database.py
Normal file
206
database.py
Normal file
|
@ -0,0 +1,206 @@
|
|||
# SQL Tables
|
||||
|
||||
# This shits a mess but hey it's python...
|
||||
|
||||
import contextlib
|
||||
import enum
|
||||
import os
|
||||
|
||||
from sqlalchemy import Boolean, Column, create_engine, DateTime, ForeignKey, Integer, MetaData, String, Table, Text, Unicode, UnicodeText
|
||||
from sqlalchemy.dialects.mysql import BIGINT, DOUBLE
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import backref, relationship, RelationshipProperty, sessionmaker, validates
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from mapping import Direction
|
||||
from myconfig import config, mappings
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
engine = create_engine(config["dbaddr"])
|
||||
DbSession = sessionmaker(bind = engine)
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
def validated(col, validator = None, strip = False, min_length = None, min_value = None, max_value = None):
|
||||
|
||||
def validate_string(row, key, val, encoding = None):
|
||||
if not isinstance(val, str):
|
||||
raise ValidationError(f"{col.name} must be a string")
|
||||
if encoding is not None:
|
||||
try:
|
||||
val.encode(encoding)
|
||||
except UnicodeEncodeError: # this exc is also used for ASCII!
|
||||
raise ValidationError(f"{col.name} must be a valid {encoding} string")
|
||||
|
||||
if strip:
|
||||
val = val.strip()
|
||||
if min_length is not None and len(val) < min_length:
|
||||
raise ValidationError(f"{col.name} must be at least {min_length} characters long")
|
||||
if col.type.length is not None and len(val) > col.type.length:
|
||||
raise ValidationError(f"{col.name} cannot be more than {col.type.length} characters long")
|
||||
return val
|
||||
|
||||
def validate_number(row, key, val, min, max, t):
|
||||
if not isinstance(val, t):
|
||||
raise ValidationError(f"{val} must be {t.__name__}")
|
||||
if min is not None and val < min:
|
||||
raise ValidationError(f"{col.name} cannot be less than {min}")
|
||||
if max is not None and val > max:
|
||||
raise ValidationError(f"{col.name} cannot be greater than {max}")
|
||||
return val
|
||||
|
||||
@validates(col.name)
|
||||
def validate(row, key, val):
|
||||
if hasattr(row, "dontvalidate"):
|
||||
return val
|
||||
if validator is not None:
|
||||
val = validator(row, key, val)
|
||||
if val is None:
|
||||
if col.nullable:
|
||||
return None
|
||||
else:
|
||||
raise ValidationError(f"{col.name} cannot be null")
|
||||
|
||||
min = min_value
|
||||
max = max_value
|
||||
|
||||
if isinstance(col.type, String) or isinstance(col.type, Text):
|
||||
val = validate_string(row, key, val, "ASCII")
|
||||
elif isinstance(col.type, Unicode) or isinstance(col.type, UnicodeText):
|
||||
val = validate_string(row, key, val, "UTF-8")
|
||||
|
||||
elif isinstance(col.type, Boolean) and not isinstance(val, bool):
|
||||
raise ValidationError(f"{col.name} must be a boolean")
|
||||
|
||||
|
||||
elif isinstance(col.type, Integer):
|
||||
if min is None:
|
||||
min = -(1 << 31)
|
||||
if max is None:
|
||||
max = (1 << 31) - 1
|
||||
val = validate_number(row, key, val, min, max, int)
|
||||
elif isinstance(col.type, BIGINT):
|
||||
if min is None:
|
||||
min = 0 if col.type.unsigned else -(1 << 63)
|
||||
if max is None:
|
||||
max = 1 << (63 if col.type.unsigned else 64)
|
||||
val = validate_number(row, key, val, min, max, int)
|
||||
elif isinstance(col.type, DOUBLE):
|
||||
val = validate_number(row, key, val, min, max, float)
|
||||
return val
|
||||
return col, validate
|
||||
|
||||
def validate_email(row, key, val):
|
||||
if val.count("@") == 1 and ".." not in val.split("@")[-1]:
|
||||
return val
|
||||
else:
|
||||
raise ValidationError("Invalid email")
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "User"
|
||||
|
||||
# This is a GUID
|
||||
ID = Column(String(36), nullable = False, primary_key = True)
|
||||
|
||||
Email, ValidateEmail = validated(Column("Email", Unicode(254), nullable = False, unique = True),
|
||||
validate_email, strip = True)
|
||||
Password = Column(String(60))
|
||||
|
||||
DisplayName, ValidateDisplayName = validated(
|
||||
Column("DisplayName", Unicode(255), unique = True, nullable = False),
|
||||
strip = True, min_length = 1)
|
||||
|
||||
SysName, ValidateSysName = validated(
|
||||
Column("SysName", Unicode(255), unique = True, nullable = False),
|
||||
strip = True, min_length = 5)
|
||||
|
||||
FullName, ValidateFullName = validated(
|
||||
Column("FullName", Unicode(255), nullable = False, default = ""),
|
||||
strip = True)
|
||||
|
||||
Codepoints, ValidateCodepoints = validated(Column("Codepoints", BIGINT(unsigned = True), nullable = False, default = 0))
|
||||
PongLevel, ValidatePongLevel = validated(Column("PongLevel", Integer), min_value = 1, max_value = 42)
|
||||
PongCP, ValidatePongCP = validated(Column("PongCP", BIGINT(unsigned = True)))
|
||||
|
||||
Sessions = relationship("AuthSession", back_populates = "User")
|
||||
|
||||
class AuthSession(Base):
|
||||
__tablename__ = "AuthSession"
|
||||
Token = Column(String(43), primary_key = True)
|
||||
UserID = Column(String(36), ForeignKey("User.ID"))
|
||||
User = relationship("User", back_populates = "Sessions")
|
||||
AppName, ValidateAppName = validated(Column("AppName", Unicode(255), nullable = False), strip = True)
|
||||
AppDesc, ValidateAppDesc = validated(Column("AppDesc", Unicode(255), nullable = False), strip = True)
|
||||
Version, ValidateVersion = validated(Column("Version", Unicode(255), nullable = False), strip = True)
|
||||
Created = Column(DateTime)
|
||||
LastUsed = Column(DateTime)
|
||||
LastIP = Column(String(39), nullable = False)
|
||||
|
||||
# Save is out to a separate table cause the player can delete it without
|
||||
# deleting their whole account
|
||||
class Save(Base):
|
||||
__tablename__ = "Save"
|
||||
|
||||
ID = Column(Integer, primary_key = True)
|
||||
|
||||
UserID = Column(String(36), ForeignKey("User.ID"))
|
||||
User = relationship("User", backref = backref("Save", uselist = False))
|
||||
|
||||
MusicVolume, ValidateMusicVolume = validated(Column("MusicVolume", Integer, nullable = False, default = 0))
|
||||
SfxVolume, ValidateSfxVolume = validated(Column("SfxVolume", Integer, nullable = False, default = 0))
|
||||
|
||||
Upgrades = relationship("Upgrade", backref = "Save")
|
||||
|
||||
# I think if the StoryPosition is changed back to 0 after the Oobe then ShiftOS will softlock.
|
||||
# Keep an eye on this...
|
||||
StoryPosition, ValidateStoryPosition = validated(Column("StoryPosition", Integer, nullable = False, default = 0))
|
||||
|
||||
Language, ValidateLanguage = validated(Column("Language", Unicode(255)))
|
||||
MyShop, ValidateMyShop = validated(Column("MyShop", Unicode(255)))
|
||||
|
||||
MajorVersion, ValidateMajorVersion = validated(Column("MajorVersion", Integer, nullable = False, default = 0))
|
||||
MinorVersion, ValidateMinorVersion = validated(Column("MinorVersion", Integer, nullable = False, default = 0))
|
||||
Revision, ValidateRevision = validated(Column("Revision", Integer, nullable = False, default = 0))
|
||||
|
||||
IsPatreon = Column(Boolean, nullable = False, default = False)
|
||||
Class, ValidateClass = validated(Column("Class", Integer, nullable = False, default = 0),
|
||||
min_value = 0, max_value = 8)
|
||||
RawReputation, ValidateRawReputation = validated(Column("RawReputation", DOUBLE, nullable = False, default = 0))
|
||||
|
||||
Password, ValidatePassword = validated(Column("Password", Unicode(255)))
|
||||
PasswordHashed, ValidatePasswordHashed = validated(Column("PasswordHashed", Boolean, nullable = False, default = False))
|
||||
|
||||
ShiftnetSubscription, ValidateShiftnetSubscription = validated(Column("ShiftnetSubscription", Integer, nullable = False, default = False),
|
||||
min_value = 0, max_value = 3)
|
||||
|
||||
IsMUDAdmin = Column(Boolean, nullable = False, default = False)
|
||||
|
||||
LastMonthPaid, ValidateLastMonthPaid = validated(Column("LastMonthPaid", Integer, nullable = False, default = 0))
|
||||
|
||||
StoriesExperienced = relationship("StoryExperienced", backref = "Save")
|
||||
Users = relationship("ClientSave", backref = "Save")
|
||||
|
||||
class Upgrade(Base):
|
||||
__tablename__ = "Upgrade"
|
||||
ID = Column(Integer, primary_key = True)
|
||||
SaveID = Column(Integer, ForeignKey("Save.ID"))
|
||||
Name, ValidateName = validated(Column("Name", Unicode(255)))
|
||||
Installed, ValidateInstalled = validated(Column("Installed", Boolean, nullable = False))
|
||||
|
||||
class StoryExperienced(Base):
|
||||
__tablename__ = "StoryExperienced"
|
||||
ID = Column(Integer, primary_key = True)
|
||||
SaveID = Column(Integer, ForeignKey("Save.ID"))
|
||||
Name, ValidateName = validated(Column("Name", Unicode(255)))
|
||||
Name = Column(Unicode(255))
|
||||
|
||||
class ClientSave(Base):
|
||||
__tablename__ = "ClientSave"
|
||||
ID = Column(Integer, primary_key = True)
|
||||
SaveID = Column(Integer, ForeignKey("Save.ID"))
|
||||
Username, ValidateUsername = validated(Column("Username", Unicode(255)))
|
||||
Password, ValidatePassword = validated(Column("Password", Unicode(255)))
|
||||
Permissions, ValidatePermissions = validated(Column("Permissions", Integer, nullable = False, default = 0), min_value = 0, max_value = 3)
|
||||
|
52
mapping.py
Normal file
52
mapping.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
import enum
|
||||
import os
|
||||
|
||||
# A mapping defines how to convert part of a row to a dictionary for
|
||||
# an API response, and how to insert data from a received dictionary
|
||||
# back into the row. In other words it allows you to quickly select
|
||||
# and rename a lot of fields. This module deals with parsing them.
|
||||
# They are used in database and the path is chosen in myconfig.
|
||||
|
||||
class Direction(enum.Flag):
|
||||
TO = enum.auto()
|
||||
FROM = enum.auto()
|
||||
|
||||
Direction.BOTH = Direction.TO|Direction.FROM
|
||||
|
||||
_direction_symbols = [("<", Direction.TO), (">", Direction.FROM)]
|
||||
|
||||
def load_mappings(path):
|
||||
mappings = {}
|
||||
for fn in os.listdir(path):
|
||||
with open(os.path.join(path, fn)) as f:
|
||||
mapping = []
|
||||
for l in f:
|
||||
l = l.strip()
|
||||
if l == "" or l.startswith("#"):
|
||||
continue
|
||||
cols = l.split()
|
||||
dictname = cols.pop(0)
|
||||
if cols:
|
||||
direction = Direction(0)
|
||||
dirstring = cols.pop(0)
|
||||
for sym, val in _direction_symbols:
|
||||
if sym in dirstring:
|
||||
direction |= val
|
||||
else:
|
||||
direction = Direction.BOTH
|
||||
if cols:
|
||||
tablename = cols.pop(0)
|
||||
else:
|
||||
tablename = dictname
|
||||
mapping.append((dictname, direction, tablename))
|
||||
mappings[fn] = mapping
|
||||
return mappings
|
||||
|
||||
def map_to(row, mapping):
|
||||
return {dictname: getattr(row, tablename) for dictname, direction, tablename in mapping if direction & direction.TO}
|
||||
|
||||
def map_from(row, mapping, data):
|
||||
for dictname, direction, tablename in mapping:
|
||||
if direction & direction.FROM:
|
||||
setattr(row, tablename, data[dictname])
|
||||
|
3
mappings/ClientSave
Normal file
3
mappings/ClientSave
Normal file
|
@ -0,0 +1,3 @@
|
|||
Username
|
||||
Password
|
||||
Permissions
|
3
mappings/PongHighscore
Normal file
3
mappings/PongHighscore
Normal file
|
@ -0,0 +1,3 @@
|
|||
UserId < ID
|
||||
Level < PongLevel
|
||||
CodepointsCashout < PongCP
|
17
mappings/Save
Normal file
17
mappings/Save
Normal file
|
@ -0,0 +1,17 @@
|
|||
MusicVolume
|
||||
SfxVolume
|
||||
StoryPosition
|
||||
Language
|
||||
MyShop
|
||||
MajorVersion
|
||||
MinorVersion
|
||||
Revision
|
||||
IsPatreon <
|
||||
Class
|
||||
RawReputation
|
||||
Password
|
||||
PasswordHashed
|
||||
ShiftnetSubscription
|
||||
ID < UserID
|
||||
IsMUDAdmin <
|
||||
LastMonthPaid
|
3
mappings/UserSave
Normal file
3
mappings/UserSave
Normal file
|
@ -0,0 +1,3 @@
|
|||
Username <> Email
|
||||
Codepoints <
|
||||
SystemName <> SysName
|
30
messagehandler.py
Normal file
30
messagehandler.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
|
||||
from netclass.jsonconverter import from_json, to_json
|
||||
from servermessage import ServerMessage
|
||||
|
||||
handlers = {}
|
||||
forwardhandlers = {}
|
||||
|
||||
def handler(name, t = None, dct = handlers):
|
||||
def decorator(fun):
|
||||
dct[name] = (fun if t is None
|
||||
else lambda conn, c, *a: fun(conn, from_json(t, c), *a))
|
||||
return fun
|
||||
return decorator
|
||||
|
||||
def forwardhandler(name, t = None):
|
||||
return handler(name, t, dct = forwardhandlers)
|
||||
|
||||
# "Forward" messages are meant to be sent to other clients, and the
|
||||
# original server did this without validating them at all. This
|
||||
# gives clients all the same power over each other as the server has
|
||||
# over them, all the way up to arbitrary code execution...not a good
|
||||
# idea. Instead forwards are treated as just a different kind of
|
||||
# message for the server to handle. Forward-handlers get the GUID
|
||||
# which in this case is a target, unlike normal handlers, where it is
|
||||
# discarded because it is on the source connection.
|
||||
@handler("mud_forward", ServerMessage)
|
||||
def mud_forward(connection, contents):
|
||||
if contents.Name in forwardhandlers:
|
||||
return forwardhandlers[contents.Name](connection, contents.Contents, contents.GUID)
|
||||
|
87
mud.py
Normal file
87
mud.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from twisted.internet.protocol import Factory
|
||||
|
||||
from auth import Auth
|
||||
from database import DbSession
|
||||
from netclass import netclass_root, jsonconverter
|
||||
from servermessage import ServerMessage, ServerMessageStream
|
||||
|
||||
import pong
|
||||
import save
|
||||
from messagehandler import handlers
|
||||
|
||||
class MudConnection(ServerMessageStream):
|
||||
def __init__(self, factory, addr):
|
||||
self.factory = factory
|
||||
self.addr = addr
|
||||
self.authsession = None
|
||||
super().__init__()
|
||||
def connectionMade(self):
|
||||
self.dbsession = DbSession()
|
||||
|
||||
self.pong = pong.PongState(self)
|
||||
|
||||
self.auth = Auth(self.dbsession, self.addr.host)
|
||||
print(f"{self.addr.host} connected.")
|
||||
self.send_message("Welcome", str(uuid.uuid4()))
|
||||
|
||||
# If the server shuts down, all the clients that were left open
|
||||
# will reconnect as soon as it comes back on. But, they don't
|
||||
# bother to re-authenticate on their own, so the server has to
|
||||
# prompt them to save to get the copy of the auth token that is
|
||||
# in the save. When the client joins on its own startup,
|
||||
# and authenticates anyway, hopefully this won't matter...
|
||||
#self.run_command("sos.save")
|
||||
# but it is commented out cause the lua script doesn't work
|
||||
def connectionLost(self, reason):
|
||||
print(f"{self.addr.host} disconnected.")
|
||||
self.closing = True
|
||||
self.pong.leave()
|
||||
self.dbsession.close()
|
||||
def serverMessageReceived(self, message):
|
||||
if message.Name not in ["pong_mp_setballpos", "pong_mp_setopponenty"]:
|
||||
print(f"{self.addr.host}: {message.Name}({repr(message.Contents)})")
|
||||
if message.Name in handlers:
|
||||
try:
|
||||
handlers[message.Name](self, message.Contents)
|
||||
self.dbsession.commit()
|
||||
except:
|
||||
self.dbsession.rollback()
|
||||
self.error(traceback.format_exc())
|
||||
traceback.print_exc()
|
||||
else:
|
||||
self.error(f"Unimplemented message {message.Name}. Thanks for using Shift Gears!")
|
||||
|
||||
def send_message(self, name, contents = None, guid = None):
|
||||
if contents is not None and not isinstance(contents, str):
|
||||
contents = jsonconverter.to_json(contents)
|
||||
guid = str(guid)
|
||||
self.sendServerMessage(ServerMessage(name, contents, guid))
|
||||
|
||||
def error(self, message):
|
||||
# The content of the Error message is deserialised at the other
|
||||
# end as an Exception, but only the Message member is read.
|
||||
self.send_message("Error", {"ClassName":"System.Exception","Message":message,"Data":None,"InnerException":None,"HelpURL":None,"StackTraceString":None,"RemoteStackTraceString":None,"RemoteStackIndex":0,"ExceptionMethod":None,"HResult":-2146233088,"Source":None})
|
||||
|
||||
# executes Lua code on the client...its that easy
|
||||
def run(self, script):
|
||||
self.send_message("run", {"script": script})
|
||||
|
||||
# executes a ShiftOS command on the client
|
||||
def run_command(self, cmd):
|
||||
# We don't use trm_invokecommand because it expects the prompt
|
||||
# to be sent to it before the command and it's not always
|
||||
# feasible to figure out what the prompt is.
|
||||
self.run(f"sos.runCommand({repr(cmd)})")
|
||||
|
||||
def infobox(self, msg, title = "MUD"):
|
||||
self.invoke_command("infobox.show" + jsonconverter.to_json({"title": title, "msg": msg}))
|
||||
|
||||
class MudConnectionFactory(Factory):
|
||||
def __init__(self):
|
||||
self.pong = pong.PongMatchmaking()
|
||||
def buildProtocol(self, addr):
|
||||
return MudConnection(self, addr)
|
10
myconfig.py
Normal file
10
myconfig.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
import os
|
||||
import json
|
||||
|
||||
import mapping
|
||||
|
||||
os.chdir(os.path.dirname(os.path.realpath(__file__)))
|
||||
with open("config.json") as f:
|
||||
config = json.load(f)
|
||||
|
||||
mappings = mapping.load_mappings("mappings")
|
2
netclass/__init__.py
Normal file
2
netclass/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
from .netclass import netclass, netclass_root
|
128
netclass/binaryformatter.py
Normal file
128
netclass/binaryformatter.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
# Copyright 2020 Declan Hoare
|
||||
|
||||
# This should really be split into two layers:
|
||||
# - binaryformatter.py deals with transforming Python objects into
|
||||
# NRBF meta-dictionaries and vice versa.
|
||||
# - nrbf.py reads and writes the binary structure.
|
||||
# To do this, netfleece should ideally be patched, because as-is, it
|
||||
# changes the meta-dictionaries somewhat from how they are in the file:
|
||||
# in particular, after reading *AndTypes records it will consume the
|
||||
# following records and move them to a 'Values' member on the lead
|
||||
# record.
|
||||
# Anyway, I think the interface of this module is fine, so it will do
|
||||
# as a black box for now.
|
||||
|
||||
import struct
|
||||
|
||||
import netfleece
|
||||
from netfleece.netfleece import RecordTypeEnum
|
||||
|
||||
from .netclass import netclass_root, classes
|
||||
|
||||
def deserialise(stream):
|
||||
def extract_value(meta): #Recover the underlying structure from NRBF meta-dictionary
|
||||
if "Value" in meta:
|
||||
return meta["Value"]
|
||||
elif "Values" in meta:
|
||||
return classes[meta["ClassInfo"]["Name"]].from_dict(
|
||||
{n.split("<")[1].split(">")[0]: extract_value(v) # They look like this: <Name>k__BackingField for some reason
|
||||
for n, v in zip(meta["ClassInfo"]["MemberNames"], meta["Values"])})
|
||||
elif meta["RecordTypeEnum"] == "ObjectNull":
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"Unknown value format: {meta}")
|
||||
dnb = netfleece.DNBinary(stream, expand = True)
|
||||
dnb.parse()
|
||||
meta = dnb.backfill()
|
||||
return extract_value(meta)
|
||||
|
||||
# Serialisation is limited, mostly only supporting the features needed
|
||||
# for ShiftOS
|
||||
_nrbf_header = b"\0\x01\0\0\0\xFF\xFF\xFF\xFF\x01\0\0\0\0\0\0\0"
|
||||
_nrbf_footer = b"\x0B"
|
||||
|
||||
s32 = struct.Struct("<i")
|
||||
|
||||
def serialise(stream, obj):
|
||||
|
||||
assemblies = [None]
|
||||
last_id = 0
|
||||
|
||||
def write_byte(val):
|
||||
stream.write(bytes([val]))
|
||||
def write_s32(val):
|
||||
stream.write(s32.pack(val))
|
||||
def write_string(s):
|
||||
data = s.encode("utf-8")
|
||||
n = len(data)
|
||||
if n > 0x7FFFFFFF:
|
||||
raise ValueError(f"String is too long ({n} bytes)")
|
||||
while n > 0x7F:
|
||||
write_byte((n & 0x7F) | 0x80)
|
||||
n >>= 7
|
||||
write_byte(n)
|
||||
stream.write(data)
|
||||
|
||||
def next_id():
|
||||
nonlocal last_id
|
||||
last_id += 1
|
||||
return last_id
|
||||
|
||||
def write_record_type(typ):
|
||||
write_byte(typ.value)
|
||||
|
||||
def write_library(name):
|
||||
library_id = len(assemblies)
|
||||
write_record_type(RecordTypeEnum.BinaryLibrary)
|
||||
write_s32(library_id)
|
||||
write_string(name)
|
||||
assemblies.append(name)
|
||||
return library_id
|
||||
|
||||
def library(name):
|
||||
return assemblies.index(name)
|
||||
|
||||
def write_object_string(object_id, val):
|
||||
write_record_type(RecordTypeEnum.BinaryObjectString)
|
||||
write_s32(object_id)
|
||||
write_string(val)
|
||||
|
||||
def write_object_null():
|
||||
write_record_type(RecordTypeEnum.ObjectNull)
|
||||
|
||||
def write_class_with_members(object_id, val):
|
||||
library_id = library(val._assembly)
|
||||
write_record_type(RecordTypeEnum.ClassWithMembers)
|
||||
write_s32(object_id)
|
||||
write_string(val._name)
|
||||
write_s32(len(val._members))
|
||||
for typ, name in val._members:
|
||||
write_string(f"<{name}>k__BackingField")
|
||||
write_s32(library_id)
|
||||
|
||||
def walk_library(it):
|
||||
if isinstance(it, netclass_root):
|
||||
if it._assembly not in assemblies:
|
||||
write_library(it._assembly)
|
||||
for typ, name in it._members:
|
||||
walk_library(it._contents[name])
|
||||
|
||||
def walk(it):
|
||||
object_id = next_id()
|
||||
if isinstance(it, netclass_root):
|
||||
write_class_with_members(object_id, it)
|
||||
for typ, name in it._members:
|
||||
walk(it._contents[name])
|
||||
elif isinstance(it, str):
|
||||
write_object_string(object_id, it)
|
||||
elif it is None:
|
||||
write_object_null()
|
||||
else:
|
||||
raise TypeError("Type not supported!")
|
||||
return object_id
|
||||
|
||||
stream.write(_nrbf_header)
|
||||
walk_library(obj)
|
||||
walk(obj)
|
||||
stream.write(_nrbf_footer)
|
||||
|
26
netclass/jsonconverter.py
Normal file
26
netclass/jsonconverter.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
|
||||
import decimal
|
||||
import json
|
||||
|
||||
from .netclass import netclass_root
|
||||
|
||||
class _encoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, netclass_root):
|
||||
return obj._contents
|
||||
elif isinstance(obj, decimal.Decimal):
|
||||
return float(obj)
|
||||
else:
|
||||
return super().default(obj)
|
||||
|
||||
def from_json(t, j):
|
||||
print(repr(j))
|
||||
val = json.loads(j)
|
||||
if issubclass(t, netclass_root):
|
||||
val = t.from_dict(val)
|
||||
if not isinstance(val, t):
|
||||
raise ValueError(f"the JSON value was of type {type(val)}, not {t}")
|
||||
return val
|
||||
|
||||
def to_json(obj):
|
||||
return json.dumps(obj, cls=_encoder)
|
53
netclass/netclass.py
Normal file
53
netclass/netclass.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
|
||||
# The netclasses derive this so they can be identified.
|
||||
class netclass_root:
|
||||
pass
|
||||
|
||||
classes = {}
|
||||
|
||||
def netclass(name, assembly, members):
|
||||
member_names = [n for _, n in members]
|
||||
class proxy(netclass_root):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if args == ():
|
||||
super().__setattr__("_contents", kwargs)
|
||||
else:
|
||||
super().__setattr__("_contents", dict(zip(member_names, args)))
|
||||
self._validate()
|
||||
|
||||
def _validate(self):
|
||||
if set(member_names) != set(self._contents.keys()):
|
||||
raise TypeError("The instance does not have the correct members")
|
||||
for typ, name in members:
|
||||
val = self._contents[name]
|
||||
if not isinstance(val, typ):
|
||||
raise TypeError(f"{name} is {val}, must be {typ}")
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d):
|
||||
cleaned = {}
|
||||
for typ, name in members:
|
||||
val = d[name]
|
||||
if isinstance(val, dict) and issubclass(typ, netclass_root):
|
||||
val = typ.from_dict(val)
|
||||
cleaned[name] = val
|
||||
return proxy(**cleaned)
|
||||
|
||||
def __getattr__(self, member):
|
||||
try:
|
||||
return self._contents[member]
|
||||
except KeyError:
|
||||
raise AttributeError(f"No such member {member}")
|
||||
|
||||
def __setattr__(self, member, value):
|
||||
if member in self._members:
|
||||
self._contents[member] = value
|
||||
else:
|
||||
raise AttributeError(f"No such member {member}")
|
||||
|
||||
proxy._name = name
|
||||
proxy._assembly = assembly
|
||||
proxy._members = members
|
||||
proxy.__name__ = name.split(".")[-1]
|
||||
classes[name] = proxy
|
||||
return proxy
|
23
netobject.py
Normal file
23
netobject.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
|
||||
import io
|
||||
|
||||
from netclass import netclass, binaryformatter
|
||||
from payload import PayloadStream
|
||||
|
||||
NetObject = netclass("NetSockets.NetObject",
|
||||
"NetSockets, Version=1.1.0.0, Culture=neutral, PublicKeyToken=null",
|
||||
[(str, "Name"), (object, "Object")])
|
||||
|
||||
class NetObjectStream(PayloadStream):
|
||||
def payloadReceived(self, payload):
|
||||
with io.BytesIO(payload) as buf:
|
||||
obj = binaryformatter.deserialise(buf)
|
||||
if not isinstance(obj, NetObject):
|
||||
raise TypeError(f"An object was received on the NetObjectStream of type {type(obj)}")
|
||||
self.netObjectReceived(obj)
|
||||
def sendNetObject(self, obj):
|
||||
if not isinstance(obj, NetObject):
|
||||
raise TypeError(f"The NetObjectStream can only send NetObject, not {type(obj)}")
|
||||
with io.BytesIO() as buf:
|
||||
binaryformatter.serialise(buf, obj)
|
||||
self.sendPayload(buf.getvalue())
|
30
payload.py
Normal file
30
payload.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
|
||||
import struct
|
||||
|
||||
from twisted.internet.protocol import Protocol
|
||||
|
||||
s32 = struct.Struct("<i")
|
||||
|
||||
class PayloadStream(Protocol):
|
||||
"""'Payloads' are length-prefixed binary blobs used in NetSockets."""
|
||||
def __init__(self):
|
||||
self.__length = None
|
||||
self.__buffer = b""
|
||||
def dataReceived(self, data):
|
||||
self.__buffer += data
|
||||
while True:
|
||||
if self.__length is None and len(self.__buffer) >= 4:
|
||||
self.__length = s32.unpack(self.__buffer[:4])[0]
|
||||
self.__buffer = self.__buffer[4:]
|
||||
if self.__length < 0:
|
||||
raise ValueError(f"Invalid (negative) payload length {self.__length}")
|
||||
if self.__length is not None and len(self.__buffer) >= self.__length:
|
||||
payload = self.__buffer[:self.__length]
|
||||
self.__buffer = self.__buffer[self.__length:]
|
||||
self.__length = None
|
||||
self.payloadReceived(payload)
|
||||
continue
|
||||
break
|
||||
def sendPayload(self, data):
|
||||
self.transport.write(s32.pack(len(data)) + data)
|
||||
|
216
pong.py
Normal file
216
pong.py
Normal file
|
@ -0,0 +1,216 @@
|
|||
# The original server didn't even know about the pong multiplayer
|
||||
# protocol, it just relayed "Forward" messages between clients and let
|
||||
# them sort it all out. In practice this worked but if anybody with
|
||||
# a sort of twisted Joker mind ever read the ShiftOS source then this
|
||||
# could have led to mischief and malice.
|
||||
# This implementation mediates the protocol to make sure messages are
|
||||
# only going where they should. That makes it more complicated but
|
||||
# on the plus side it's hopefully also more reliable even when
|
||||
# everyone's playing by the rules.
|
||||
|
||||
import datetime
|
||||
|
||||
from messagehandler import forwardhandler, handler
|
||||
|
||||
class PongMatchmaking:
|
||||
def __init__(self):
|
||||
self.players = {}
|
||||
|
||||
self.current_heartbeat = 0
|
||||
def join(self, newcomer):
|
||||
for opponent in self.players.values():
|
||||
opponent.connection.send_message("pong_handshake_matchmake", newcomer.name)
|
||||
newcomer.connection.send_message("pong_handshake_matchmake", opponent.name)
|
||||
self.players[newcomer.name] = newcomer
|
||||
def leave(self, player):
|
||||
try:
|
||||
del self.players[player.name]
|
||||
except KeyError:
|
||||
return
|
||||
for opponent in self.players.values():
|
||||
opponent.connection.send_message("pong_handshake_left", player.name)
|
||||
def heartbeat(self):
|
||||
for player in list(self.players.values()): # copy to be able to delete
|
||||
if self.current_heartbeat - player.last_heartbeat >= 3:
|
||||
player.connection.infobox("Your connection to Pong has timed out.", "Timed out")
|
||||
player.leave()
|
||||
else:
|
||||
player.send_heartbeat()
|
||||
self.current_heartbeat += 1
|
||||
def handshake(self, leader, follower_name):
|
||||
try:
|
||||
follower = self.players[follower_name]
|
||||
except KeyError:
|
||||
return False
|
||||
if follower.opponent is not None:
|
||||
return False
|
||||
|
||||
leader.opponent = follower
|
||||
leader.is_leader = True
|
||||
follower.opponent = leader
|
||||
follower.is_leader = False
|
||||
follower.handshake_complete = False
|
||||
|
||||
# If the opponent GUID is null-or-whitespace then the client
|
||||
# won't send pong_mp_left. Other than that, the value doesn't
|
||||
# matter.
|
||||
follower.connection.send_message("pong_handshake_chosen", "_")
|
||||
return True
|
||||
|
||||
class PongPlayer:
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
|
||||
# We have to store this, because it's used as the key sent to
|
||||
# the clients to identify each other but it can change at any
|
||||
# time.
|
||||
self.name = self.connection.authsession.User.DisplayName
|
||||
|
||||
# The heartbeat 'freezes' while the server is waiting for the
|
||||
# client to finish the handshake. This means that a
|
||||
# mischievous client can't leave someone else hanging by
|
||||
# responding to the heartbeats and deliberately ignoring the
|
||||
# handshake. As a bonus it clears up the connection a little.
|
||||
self.frozen = False
|
||||
self.heartbeat()
|
||||
|
||||
self.y = 0
|
||||
|
||||
self.connected = True
|
||||
self.connection.factory.pong.join(self)
|
||||
|
||||
self.opponent = None
|
||||
def leave(self):
|
||||
self.connected = False
|
||||
self.connection.factory.pong.leave(self)
|
||||
if self.opponent is not None:
|
||||
self.opponent.opponent = None
|
||||
|
||||
# In the special situation that the follower leaves before
|
||||
# the handshake is complete, the leader is still fit to
|
||||
# choose another follower.
|
||||
if not self.is_leader and not self.handshake_complete:
|
||||
self.opponent.connection.infobox(f"{self.name} is no longer available.", "Matchmaking failed")
|
||||
|
||||
# in all other circumstances, including leader leaving
|
||||
# part-way through handshake, the other player's state has
|
||||
# changed and can't be recovered, so Pong has to quit.
|
||||
else:
|
||||
self.opponent.connection.send_message("pong_mp_left")
|
||||
|
||||
def send_heartbeat(self):
|
||||
if not self.frozen:
|
||||
self.connection.send_message("pong_handshake_resendid")
|
||||
def heartbeat(self):
|
||||
if not self.frozen:
|
||||
self.last_heartbeat = self.connection.factory.pong.current_heartbeat
|
||||
def freeze(self):
|
||||
self.frozen = True
|
||||
def unfreeze(self):
|
||||
self.frozen = False
|
||||
self.heartbeat()
|
||||
|
||||
def complete_handshake(self):
|
||||
if self.is_leader:
|
||||
raise ValueError("The handshake can only be completed by a follower")
|
||||
|
||||
self.handshake_complete = True
|
||||
self.opponent.connection.send_message("pong_handshake_complete", "_")
|
||||
|
||||
|
||||
class PongState:
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
self.player = None
|
||||
def busy(self):
|
||||
return self.player is not None and self.player.connected
|
||||
def join(self):
|
||||
if self.busy():
|
||||
self.connection.infobox("You can't play multiple simultaneous games of online Pong...nobody is that good")
|
||||
return
|
||||
self.player = PongPlayer(self.connection)
|
||||
def leave(self):
|
||||
if self.busy():
|
||||
self.player.leave()
|
||||
def heartbeat(self):
|
||||
if self.busy():
|
||||
self.player.heartbeat()
|
||||
|
||||
# Note that this is a normal handler. This is sent as a normal
|
||||
# message only once at the start of matchmaking; after that, it's resent
|
||||
# as a forward.
|
||||
@handler("pong_handshake_matchmake")
|
||||
def pong_handshake_matchmake(connection, contents):
|
||||
connection.pong.join()
|
||||
|
||||
# We send out a pong_handshake_resendid to every matchmaking player
|
||||
# every 5 seconds as a heartbeat, and a working client will respond with
|
||||
# a pong_handshake_matchmake immediately.
|
||||
@forwardhandler("pong_handshake_matchmake")
|
||||
def pong_handshake_matchmake_forward(connection, contents, name):
|
||||
connection.pong.heartbeat()
|
||||
|
||||
@forwardhandler("pong_handshake_chosen")
|
||||
def pong_handshake_chosen(connection, contents, name):
|
||||
if not connection.factory.pong.handshake(connection.pong.player, name):
|
||||
connection.infobox(f"{name} is no longer available.", "Matchmaking failed")
|
||||
|
||||
@forwardhandler("pong_handshake_complete")
|
||||
def pong_handshake_complete(connection, contents, name):
|
||||
connection.pong.player.complete_handshake()
|
||||
|
||||
@forwardhandler("pong_handshake_left")
|
||||
def pong_handshake_left(connection, contents, name):
|
||||
connection.factory.pong.leave(connection.pong.player)
|
||||
|
||||
@forwardhandler("pong_mp_setopponenty", int)
|
||||
def pong_mp_setopponenty(connection, y, name):
|
||||
try:
|
||||
assert -2147483648 <= y <= 2147483647
|
||||
except:
|
||||
connection.error("Y co-ordinate out of range")
|
||||
|
||||
connection.pong.player.y = y
|
||||
|
||||
@forwardhandler("pong_mp_setballpos", str)
|
||||
def pong_mp_setballpos(connection, point, name):
|
||||
if not connection.pong.player.is_leader:
|
||||
connection.error("That message can only be used by the leader")
|
||||
return
|
||||
|
||||
try:
|
||||
x, y = point.split(",")
|
||||
x = int(x)
|
||||
y = int(y)
|
||||
assert -2147483648 <= x <= 2147483647
|
||||
assert -2147483648 <= y <= 2147483647
|
||||
except:
|
||||
connection.error("Invalid Point") # har har
|
||||
|
||||
connection.pong.player.opponent.connection.send_message("pong_mp_setballpos", f'"{x},{y}"')
|
||||
connection.pong.player.opponent.connection.send_message("pong_mp_setopponenty", connection.pong.player.y)
|
||||
connection.pong.player.connection.send_message("pong_mp_setopponenty", connection.pong.player.opponent.y)
|
||||
|
||||
@forwardhandler("pong_mp_left")
|
||||
def pong_mp_left(connection, contents, name):
|
||||
connection.pong.leave()
|
||||
|
||||
@forwardhandler("pong_mp_youwin")
|
||||
def pong_mp_youwin(connection, contents, name):
|
||||
connection.pong.player.opponent.connection.send_message("pong_mp_youwin")
|
||||
|
||||
@forwardhandler("pong_mp_youlose")
|
||||
def pong_mp_youlose(connection, contents, name):
|
||||
connection.pong.player.opponent.connection.send_message("pong_mp_youlose")
|
||||
|
||||
@forwardhandler("pong_mp_cashedout")
|
||||
def pong_mp_cashedout(connection, contents, name):
|
||||
# The client is already out of here immediately after sending the
|
||||
# message.
|
||||
opponent = connection.pong.player.opponent
|
||||
if opponent is not None:
|
||||
opponent.connection.send_message("pong_mp_cashedout")
|
||||
opponent.opponent = None
|
||||
connection.pong.player.opponent = None
|
||||
|
||||
|
75
save.py
Normal file
75
save.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
# mud save/load
|
||||
|
||||
from auth import Auth, InvalidTokenError
|
||||
from database import ClientSave, Save, StoryExperienced, Upgrade
|
||||
from mapping import map_from, map_to
|
||||
from messagehandler import handler
|
||||
from myconfig import mappings
|
||||
from netclass.jsonconverter import from_json
|
||||
|
||||
@handler("mud_token_login")
|
||||
def mud_token_login(connection, contents):
|
||||
try:
|
||||
connection.authsession = connection.auth.session(contents)
|
||||
user = connection.authsession.User
|
||||
save = user.Save
|
||||
if save is None:
|
||||
connection.send_message("mud_login_denied")
|
||||
else:
|
||||
data = {"Upgrades": {u.Name: u.Installed for u in save.Upgrades},
|
||||
"CurrentLegions": [], #NYI
|
||||
"UniteAuthToken": connection.authsession.Token,
|
||||
"StoriesExperienced": [s.Name for s in save.StoriesExperienced],
|
||||
"Users": [map_to(u, mappings["ClientSave"]) for u in save.Users]}
|
||||
data.update(map_to(user, mappings["UserSave"]))
|
||||
data.update(map_to(save, mappings["Save"]))
|
||||
connection.send_message("mud_savefile", data)
|
||||
except InvalidTokenError:
|
||||
connection.send_message("mud_login_denied")
|
||||
|
||||
@handler("mud_save", dict)
|
||||
def mud_save(connection, contents):
|
||||
|
||||
# No token means the save isn't ready yet
|
||||
if contents["UniteAuthToken"] is None or contents["UniteAuthToken"].strip() == "":
|
||||
return
|
||||
|
||||
# If the server shuts down, all the clients that were left open
|
||||
# will reconnect as soon as it comes back on. But, they don't
|
||||
# bother to re-authenticate on their own, so the server has to
|
||||
# prompt them to save to get the copy of the auth token that is
|
||||
# in the save.
|
||||
if connection.authsession is None:
|
||||
try:
|
||||
connection.authsession = connection.auth.session(contents["UniteAuthToken"])
|
||||
except InvalidTokenError:
|
||||
connection.error("Your token is incorrect and you could not be re-authenticated with the server")
|
||||
return
|
||||
|
||||
user = connection.authsession.User
|
||||
save = user.Save
|
||||
if save is None:
|
||||
save = Save()
|
||||
user.Save = save
|
||||
map_from(user, mappings["UserSave"], contents)
|
||||
map_from(save, mappings["Save"], contents)
|
||||
connection.dbsession.query(Upgrade).filter_by(Save = save).delete()
|
||||
if contents["Upgrades"] is not None:
|
||||
for k, v in contents["Upgrades"].items():
|
||||
connection.dbsession.add(Upgrade(Name = k, Installed = v, Save = save))
|
||||
connection.dbsession.query(StoryExperienced).filter_by(Save = save).delete()
|
||||
if contents["StoriesExperienced"] is not None:
|
||||
for v in contents["StoriesExperienced"]:
|
||||
connection.dbsession.add(StoryExperienced(Name = v, Save = save))
|
||||
if contents["Users"] is not None:
|
||||
users_new = {u["Username"]: u for u in contents["Users"]}
|
||||
for usr in connection.dbsession.query(ClientSave).filter_by(Save = save):
|
||||
if usr.Username in users_new:
|
||||
map_from(usr, mappings["ClientSave"], users_new[usr.Username])
|
||||
del users_new[usr.Username]
|
||||
else:
|
||||
connection.dbsession.delete(usr)
|
||||
for data in users_new.values():
|
||||
usr = ClientSave(Save = save)
|
||||
map_from(usr, mappings["ClientSave"], data)
|
||||
connection.dbsession.add(usr)
|
11
server.py
Normal file
11
server.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
from twisted.internet.endpoints import TCP4ServerEndpoint
|
||||
from twisted.internet import reactor
|
||||
|
||||
from mud import MudConnectionFactory
|
||||
|
||||
endpoint = TCP4ServerEndpoint(reactor, 13370)
|
||||
endpoint.listen(MudConnectionFactory())
|
||||
reactor.run()
|
||||
|
||||
|
24
servermessage.py
Normal file
24
servermessage.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
|
||||
from netclass import netclass
|
||||
from netobject import NetObject, NetObjectStream
|
||||
|
||||
ServerMessage = netclass("ShiftOS.Objects.ServerMessage",
|
||||
"ShiftOS.Objects, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
|
||||
[(str, "Name"), (str, "Contents"), (str, "GUID")])
|
||||
|
||||
class ServerMessageStream(NetObjectStream):
|
||||
def netObjectReceived(self, obj):
|
||||
if not isinstance(obj.Object, ServerMessage):
|
||||
raise TypeError(f"An object was received on the ServerMessageStream of type {type(obj.Object)}")
|
||||
|
||||
self.serverMessageReceived(obj.Object)
|
||||
|
||||
def sendServerMessage(self, message):
|
||||
if not isinstance(message, ServerMessage):
|
||||
raise TypeError(f"The ServerMessageStream can only send ServerMessage, not {type(obj)}")
|
||||
|
||||
# Although the real ShiftOS fills in the Name field on the
|
||||
# NetObject, it does not ever read it, so it's not really
|
||||
# part of the protocol.
|
||||
self.sendNetObject(NetObject(None, message))
|
||||
|
203
unite.py
Normal file
203
unite.py
Normal file
|
@ -0,0 +1,203 @@
|
|||
import base64
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
import bcrypt
|
||||
import flask
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from auth import Auth, InvalidTokenError
|
||||
from database import User, DbSession, ValidationError
|
||||
import mapping
|
||||
from myconfig import mappings
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
|
||||
def cb_decorator(fn):
|
||||
return lambda *args, **kwargs: lambda cb: fn(*args, **kwargs, cb = cb)
|
||||
|
||||
@cb_decorator
|
||||
def db_route(path, cb):
|
||||
@app.route(path, endpoint = path)
|
||||
def db_handler(*args, **kwargs):
|
||||
dbsession = DbSession()
|
||||
try:
|
||||
ret = cb(*args, **kwargs, dbsession = dbsession)
|
||||
except (ValidationError, NoResultFound):
|
||||
traceback.print_exc()
|
||||
return flask.Response(status = 400)
|
||||
try:
|
||||
dbsession.commit()
|
||||
except IntegrityError as ex:
|
||||
# 1062 is the SQL error code for duplicate entry.
|
||||
# That's a user input error, but if we get some other kind
|
||||
# of integrity error, then it's a server error.
|
||||
if ex.orig.args[0] == 1062:
|
||||
return flask.Response(status = 400)
|
||||
else:
|
||||
raise
|
||||
dbsession.close()
|
||||
return ret
|
||||
return db_handler
|
||||
|
||||
# /Auth/ endpoints use basic authentication and create new tokens.
|
||||
@cb_decorator
|
||||
def auth_route(path, cb):
|
||||
@db_route(f"/Auth{path}")
|
||||
def auth_handler(*args, dbsession, **kwargs):
|
||||
try:
|
||||
kind, value = flask.request.headers["Authentication"].split(" ")
|
||||
assert kind == "Basic"
|
||||
email, password = base64.b64decode(value).decode().split(":")
|
||||
except (KeyError, ValueError, AssertionError):
|
||||
print("no basic auth")
|
||||
return flask.Response(status = 401)
|
||||
email = email.strip()
|
||||
return cb(*args, **kwargs, dbsession = dbsession, email = email, password = password)
|
||||
return auth_handler
|
||||
|
||||
# /API/ endpoints use token authentication and operate as a
|
||||
# Unite user.
|
||||
@cb_decorator
|
||||
def api_route(path, cb):
|
||||
@db_route(f"/API{path}")
|
||||
def api_handler(*args, dbsession, **kwargs):
|
||||
try:
|
||||
kind, token = flask.request.headers["Authentication"].split(" ")
|
||||
assert kind == "Token"
|
||||
auth = Auth(dbsession, flask.request.remote_addr)
|
||||
authsession = auth.session(token)
|
||||
except (KeyError, ValueError, AssertionError, InvalidTokenError):
|
||||
return flask.Response(status = 401)
|
||||
return cb(*args, **kwargs, dbsession = dbsession, authsession = authsession)
|
||||
return api_handler
|
||||
|
||||
@auth_route("/Register")
|
||||
def register(email, password, dbsession):
|
||||
|
||||
def error(message):
|
||||
return flask.jsonify({"ClassName":"System.Exception","Message":message,"Data":None,"InnerException":None,"HelpURL":None,"StackTraceString":None,"RemoteStackTraceString":None,"RemoteStackIndex":0,"ExceptionMethod":None,"HResult":-2146233088,"Source":None})
|
||||
|
||||
try:
|
||||
displayname = flask.request.args["displayname"]
|
||||
sysname = flask.request.args["sysname"]
|
||||
appname = flask.request.args["appname"][:255]
|
||||
appdesc = flask.request.args["appdesc"][:255]
|
||||
version = flask.request.args["version"][:255]
|
||||
except KeyError as ex:
|
||||
return error(str(ex))
|
||||
|
||||
# Additional constraints for password
|
||||
if len(password) < 7:
|
||||
return error("Password too short")
|
||||
requirements = [".*[A-Z].*", ".*[a-z].*", ".*[0-9].*"]
|
||||
for expr in list(requirements):
|
||||
if re.compile(expr).match(password) is None:
|
||||
return error("Password does not meet the requirements")
|
||||
|
||||
password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
try:
|
||||
user = User(ID = str(uuid.uuid4()), Email = email, Password = password, DisplayName = displayname, SysName = sysname)
|
||||
except ValidationError:
|
||||
return error(str(ex))
|
||||
dbsession.add(user)
|
||||
|
||||
try:
|
||||
dbsession.commit()
|
||||
except IntegrityError as ex:
|
||||
if ex.orig.args[0] == 1062:
|
||||
dbsession.rollback()
|
||||
return error(ex.orig.args[1])
|
||||
else:
|
||||
raise
|
||||
|
||||
auth = Auth(dbsession, flask.request.remote_addr)
|
||||
return auth.create_session(user, appname, appdesc, version).Token
|
||||
|
||||
@auth_route("/Login")
|
||||
def login(email, password, dbsession):
|
||||
if email == "" or password == "":
|
||||
return flask.Response(status = 400)
|
||||
|
||||
try:
|
||||
appname = flask.request.args["appname"][:255]
|
||||
appdesc = flask.request.args["appdesc"][:255]
|
||||
version = flask.request.args["version"][:255]
|
||||
except KeyError:
|
||||
return flask.Response(status = 400)
|
||||
|
||||
try:
|
||||
user = dbsession.query(User).filter_by(Email = email).one()
|
||||
except NoResultFound:
|
||||
print("acct does not exist")
|
||||
return flask.Response(status = 401)
|
||||
|
||||
if user.Password is None: # login disabled
|
||||
print("login disabled")
|
||||
return flask.Response(status = 401)
|
||||
|
||||
if not bcrypt.checkpw(password.encode(), user.Password.encode()):
|
||||
print("password incorrect")
|
||||
return flask.Response(status = 401)
|
||||
|
||||
auth = Auth(dbsession, flask.request.remote_addr)
|
||||
return auth.create_session(user, appname, appdesc, version).Token
|
||||
|
||||
|
||||
@api_route("/GetDisplayName/<uid>")
|
||||
def get_display_name(uid, dbsession, authsession):
|
||||
return dbsession.query(User).filter_by(ID = uid).one().DisplayName
|
||||
|
||||
@api_route("/GetPongHighscores")
|
||||
def get_pong_highscores(dbsession, authsession):
|
||||
return flask.jsonify({"Pages": 0, # unused
|
||||
"Highscores": [mapping.map_to(user, mappings["PongHighscore"])
|
||||
for user in dbsession.query(User).all()
|
||||
if user.PongLevel is not None and user.PongCP is not None]})
|
||||
|
||||
@api_route("/GetEmail")
|
||||
def get_email(dbsession, authsession):
|
||||
return authsession.User.Email
|
||||
|
||||
def gettersetters(name, convert = lambda v: v):
|
||||
@api_route(f"/Get{name}")
|
||||
def getter(dbsession, authsession):
|
||||
return str(getattr(authsession.User, name))
|
||||
@api_route(f"/Set{name}/<value>")
|
||||
def setter(value, dbsession, authsession):
|
||||
setattr(authsession.User, name, convert(value))
|
||||
return flask.Response(status = 200)
|
||||
|
||||
@api_route("/GetPongCP")
|
||||
def get_pong_cp(dbsession, authsession):
|
||||
return str(authsession.User.PongCP or 0)
|
||||
|
||||
@api_route("/SetPongCP/<value>")
|
||||
def set_pong_cp(value, dbsession, authsession):
|
||||
value = int(value)
|
||||
if authsession.User.PongCP is not None and value <= authsession.User.PongCP:
|
||||
return flask.Response(status = 400)
|
||||
authsession.User.PongCP = value
|
||||
return flask.Response(status = 200)
|
||||
|
||||
@api_route("/GetPongLevel")
|
||||
def get_pong_level(dbsession, authsession):
|
||||
return str(authsession.User.PongLevel or 0)
|
||||
|
||||
@api_route("/SetPongLevel/<value>")
|
||||
def set_pong_cp(value, dbsession, authsession):
|
||||
value = int(value)
|
||||
if authsession.User.PongLevel is not None and value <= authsession.User.PongLevel:
|
||||
return flask.Response(status = 400)
|
||||
authsession.User.PongLevel = value
|
||||
return flask.Response(status = 200)
|
||||
|
||||
gettersetters("SysName")
|
||||
gettersetters("DisplayName")
|
||||
gettersetters("FullName")
|
||||
gettersetters("Codepoints", convert = int)
|
||||
|
||||
|
4
unite.wsgi
Normal file
4
unite.wsgi
Normal file
|
@ -0,0 +1,4 @@
|
|||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
from unite import app as application
|
Loading…
Reference in a new issue