class ModBase(CopyDiff): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), nullable=False) desc = db.Column(db.Text, nullable=False, default="") website = db.Column(db.String(120), nullable=False, default="") def vsns_by_game_vsn(self): """ Returns a dict mapping supported Minecraft versions to a list of versions of this mod that support that Minecraft version. """ vsns = OrderedDict() for v in self.mod_vsns: if len(v.game_vsns) > 0: vsns.setdefault(v.game_vsns[0].name, []).append(v) else: vsns.setdefault('Unknown', []).append(v) return vsns # Methods for CopyDiff def copydiff_fields(self): return ['name', 'desc', 'website', 'authors'] def get_children(self): return self.mod_vsns def add_child(self, ch): self.mod_vsns.append(ch) def rm_child(self, ch): self.mod_vsns.remove(ch)
class ModFileBase(CopyDiff): id = db.Column(db.Integer, primary_key=True) desc = db.Column(db.Text, nullable=False, default="") @declared_attr def stored_id(cls): return db.Column(db.Integer, db.ForeignKey('stored_file.id'), nullable=True) @declared_attr def stored(cls): return db.relation('StoredFile') # Link to official web page with download links. page_url = db.Column(db.String(500), nullable=False, default="") # Official download link through redirect such as adfly. redirect_url = db.Column(db.String(500), nullable=False, default="") # Official direct file download link. direct_url = db.Column(db.String(500), nullable=False, default="") def copydiff_fields(self): return ['stored', 'page_url', 'redirect_url', 'direct_url'] def get_children(self): return []
class StoredFile(db.Model): """Represents a file stored in some sort of storage medium.""" __tablename__ = 'stored_file' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), nullable=False) sha256 = db.Column(db.String(130), nullable=False) upload_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) upload_by = db.relationship('User') # Path to this file within the B2 bucket. Null if file is not on B2. b2_path = db.Column(db.String(300), nullable=True) def b2_download_url(self): """Gets the URL to download this file from the archive's B2 bucket.""" if self.b2_path: return urljoin(app.config['B2_PUBLIC_URL'], self.b2_path)
class Mod(ModBase, db.Model): __tablename__ = "mod" slug = db.Column(db.String(80), nullable=False, unique=True) authors = db.relationship( "ModAuthor", secondary=authored_by_table, backref="mods") mod_vsns = db.relationship("ModVersion", back_populates="mod") def game_versions(self): """Returns a list of game versions supported by all the versions of this mod.""" gvs = GameVersion.query \ .join(ModVersion, GameVersion.mod_vsns) \ .filter(ModVersion.mod_id == self.id).all() gvsns = set() for gv in gvs: gvsns.add(gv.name) return sorted(list(gvsns)) def game_versions_str(self): """Returns a comma separated string listing the supported game versions for this mod.""" return ", ".join(map(lambda v: v, self.game_versions())) def blank(self, **kwargs): return Mod(**kwargs) def blank_child(self, **kwargs): return ModVersion(**kwargs) def log_change(self, user): entry = LogMod(user=user, cur_id=self.id, index=len(self.logs)) entry.copy_from(self) db.session.add(entry) return entry @property def latest_vsn(self): # FIXME: This could probably be done faster with a DB query. return self.logs[len(self.logs)-1] def make_draft(self, user): """Creates a DraftMod based on the latest version of this mod.""" latest = self.latest_vsn draft = DraftMod(user=user) draft.copy_from(latest) return draft def revert_to(self, log): """ Takes a `ModLog` and reverts this mod to its state at the time of that log entry. Raises `ValueError` if the given log entry is not for this mod. """ if log.cur_id != self.id: raise ValueError('Log entry {} is not for mod {}'.format(log, self)) self.copy_from(log)
class ModBase(CopyDiff): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), nullable=False) desc = db.Column(db.Text, nullable=False, default="") website = db.Column(db.String(2048), nullable=False, default="") uuid = db.Column(GUID(), nullable=False, default=uuid.uuid4) def vsns_by_game_vsn(self): """ Returns a dict mapping supported Minecraft versions to a list of versions of this mod that support that Minecraft version. """ vsns = OrderedDict() for v in self.mod_vsns: if len(v.game_vsns) > 0: vsns.setdefault(v.game_vsns[0].name, []).append(v) else: vsns.setdefault('Unknown', []).append(v) # Sort the list of versions by Minecraft version lst = sorted(vsns.items(), key=lambda vsn: key_mc_version(vsn[0]), reverse=True) # Sort each list of mod versions per Minecraft version return OrderedDict([(gvsn, sorted(modvsns, key=lambda vsn: key_mod_version(vsn.name), reverse=True)) for gvsn, modvsns in lst]) # Methods for CopyDiff def copydiff_fields(self): return ['name', 'desc', 'website', 'authors'] def get_children(self): return self.mod_vsns def add_child(self, ch): self.mod_vsns.append(ch) def rm_child(self, ch): self.mod_vsns.remove(ch)
class ModVersionBase(CopyDiff): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(40), nullable=False) desc = db.Column(db.Text, nullable=False, default="") url = db.Column(db.String(120), nullable=False, default="") def game_versions_str(self): """Returns a comma separated string listing the supported game versions for this mod.""" return ", ".join(map(lambda v: v.name, self.game_vsns)) def copydiff_fields(self): return ['name', 'desc', 'url', 'game_vsns'] def get_children(self): return self.files def add_child(self, ch): self.files.append(ch) def rm_child(self, ch): self.files.remove(ch)
class UserSetting(db.Model): # type: ignore """ Table for storing user settings. Each setting the user has changed from the defaults is stored as a separate row containing a key, value pair in this table. This schema allows for new settings to be added without requiring a database migration. """ key = db.Column(db.String(40), nullable=False, primary_key=True) value = db.Column(db.JSON, nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) user = db.relationship('User')
class Session(db.Model): """Represents a user's login session.""" id = db.Column(db.Integer, primary_key=True) # Session ID stored in the user's browser cookies. sess_id = db.Column(GUID(), nullable=False, unique=True) login_ip = db.Column(db.String(128), nullable=False) login_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) last_seen = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) # User this session is logged in as. user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user = db.relationship('User', backref=db.backref('sessions', lazy=True, order_by=last_seen.desc())) active = db.Column(db.Boolean, nullable=False, default=True) def __init__(self, *args, sess_id=None, **kwargs): if not sess_id: sess_id = uuid.uuid4() super(Session, self).__init__(*args, sess_id=sess_id, **kwargs) def expired(self): return not self.active or \ self.last_seen < datetime.utcnow() - app.config['SERV_SESSION_EXPIRE_TIME'] def disable(self): self.active = False def touch(self): """Updates this session's last seen date.""" now = datetime.utcnow() self.last_seen = now self.user.last_seen = now
class GameVersion(db.Model): __tablename__ = "game_version" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(40), nullable=False, unique=True)
class ModAuthor(db.Model): __tablename__ = "author" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), nullable=False, unique=True) desc = db.Column(db.Text, nullable=False, default="") website = db.Column(db.String(120), nullable=False, default="")
class Mod(ModBase, db.Model): __tablename__ = "mod" slug = db.Column(db.String(80), nullable=False, unique=True) authors = db.relationship( "ModAuthor", secondary=authored_by_table, backref="mods") mod_vsns = db.relationship("ModVersion", back_populates="mod") # If this is set to false, the mod will be de-listed. redist = db.Column(db.Boolean, nullable=False, default=True) @staticmethod def search_query(game_vsn=None, author=None, keyword=None, include_delisted=False): """ Returns a pre-made standard search query for listing mods. Optional parameters may be specified for filtering. """ query = Mod.query if not include_delisted: query = query.filter_by(redist=True) if author and len(author) > 0: query = query.join(ModAuthor, Mod.authors).filter(ModAuthor.name == author) if game_vsn and len(game_vsn) > 0: query = query.join(ModVersion) \ .join(GameVersion, ModVersion.game_vsns) \ .filter(GameVersion.name == game_vsn) if keyword and len(keyword) > 0: query = query.filter(Mod.name.ilike("%"+keyword+"%")) return query def game_versions(self): """Returns a list of game versions supported by all the versions of this mod.""" gvs = GameVersion.query \ .join(ModVersion, GameVersion.mod_vsns) \ .filter(ModVersion.mod_id == self.id).all() gvsns = set() for gv in gvs: gvsns.add(gv.name) return sorted(list(gvsns)) def game_versions_str(self): """Returns a comma separated string listing the supported game versions for this mod.""" return ", ".join(map(lambda v: v, self.game_versions())) def blank(self, **kwargs): return Mod(**kwargs) def blank_child(self, **kwargs): return ModVersion(**kwargs) def log_change(self, user, approved_by=None): """ Logs a new change for this mod. Assigns the change to `user`, and if `approved_by` is not `None`, assigns them as the user who approved it. """ entry = LogMod(user=user, approved_by=approved_by, cur_id=self.id, index=len(self.logs)) entry.copy_from(self) db.session.add(entry) return entry @property def latest_vsn(self): # FIXME: This could probably be done faster with a DB query. return self.logs[len(self.logs)-1] def make_draft(self, user): """Creates a DraftMod based on the latest version of this mod.""" latest = self.latest_vsn draft = DraftMod(user=user) draft.copy_from(latest) return draft def revert_to(self, log): """ Takes a `ModLog` and reverts this mod to its state at the time of that log entry. Raises `ValueError` if the given log entry is not for this mod. """ if log.cur_id != self.id: raise ValueError('Log entry {} is not for mod {}'.format(log, self)) self.copy_from(log)
class User(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.Binary(60), nullable=True) email = db.Column(db.String(120), unique=True, nullable=False) totp_secret = db.Column(db.String(16)) totp_last_auth = db.Column(db.String(6)) # To avoid replay attacks role = db.Column(db.Enum(UserRole), nullable=False) last_seen = db.Column(db.DateTime) disabled = db.Column(db.Boolean) _settings = None def __init__(self, *args, password=None, passhash=None, **kwargs): """ Creates a new `User` object. The given password will be hashed automatically. The `passhash` parameter specifies an already hashed password, and can be used to speed up tests, as bcrypt is intentionally slow. """ pwd = None if passhash: pwd = passhash elif password: pwd = bcrypt.generate_password_hash(password) super(User, self).__init__(*args, password=pwd, **kwargs) @property def settings(self): """ Returns a `UserSettings` object which provides access to the user's settings. """ if not self._settings: self._settings = UserSettings(self) return self._settings def has_role(self, role): """Returns True if the user's role is equal or greater than the given role. If the given role is None, returns True.""" if role is None: return True return self.role >= role def avatar_url(self): """Returns the Gravatar URL for this user based on their email.""" # See https://en.gravatar.com/site/implement/hash/ # Trim whitespace, convert to lowercase, md5 hash email = self.email.encode("utf-8") gravatar_hash = hashlib.md5(email.strip().lower()).hexdigest() return "https://gravatar.com/avatar/{}?d=identicon".format( gravatar_hash) def set_password(self, password): """Sets a new password. The given password will be hashed.""" self.password = bcrypt.generate_password_hash(password) def clear_password(self): """Clears this user's password, preventing them from logging in anymore. Also clears all of the user's sessions, logging them out.""" self.clear_sessions() self.password = None def reset_2fa_secret(self): """Generates a new TOTP secret for this user.""" self.clear_sessions() self.totp_secret = pyotp.random_base32() def clear_sessions(self): """Logs this user out by clearing all of their login sessions.""" for sess in self.sessions: sess.disable() def gen_passwd_reset_token(self): """Creates a reset token for this user and returns the string token.""" self.reset_token = ResetToken(kind=reset_type.password) token = self.reset_token.token return str(token) def gen_2fa_reset_token(self): """Creates a reset token for this user's 2-factor authentication secret.""" self.reset_token = ResetToken(kind=reset_type.otp) token = self.reset_token.token return str(token) def totp_uri(self): return pyotp.TOTP(self.totp_secret).provisioning_uri( self.email, "MCArchive") def validate_otp(self, code): if self.totp_last_auth == code: # Reject repeated codes to prevent a replay attack. return False else: totp = pyotp.TOTP(self.totp_secret) if totp.verify(code, valid_window=1): self.totp_last_auth = code return True else: return False def disable(self): for sess in self.sessions: sess.disable() self.disabled = True def enable(self): self.disabled = False def __repr__(self): return '<User %r>' % self.name
class Session(db.Model): """Represents a user's login session. A session may not be considered fully authenticated if `authed_2fa` is false. """ id = db.Column(db.Integer, primary_key=True) # Session ID stored in the user's browser cookies. sess_id = db.Column(GUID(), nullable=False, unique=True) login_ip = db.Column(db.String(128), nullable=False) login_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) last_seen = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) # User this session is logged in as. user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user = db.relationship('User', backref=db.backref('sessions', lazy=True, order_by=last_seen.desc())) authed_2fa = db.Column(db.Boolean, nullable=False, default=False) active = db.Column(db.Boolean, nullable=False, default=True) def __init__(self, *args, sess_id=None, **kwargs): if not sess_id: sess_id = uuid.uuid4() super(Session, self).__init__(*args, sess_id=sess_id, **kwargs) def expired(self): if not self.active: return True if self.authed_2fa: return self.last_seen < datetime.utcnow( ) - app.config['SERV_SESSION_EXPIRE_TIME'] else: return self.last_seen < datetime.utcnow() - \ app.config['SERV_PARTIAL_SESSION_EXPIRE_TIME'] def auth_2fa(self, code): """Authenticates the user's second factor with the given code. Returns true on success, false on failure. If successful, this session will be marked as fully authenticated. If the user's 2 factor is disabled, this has no effect. """ if self.user.totp_secret and self.user.validate_otp(code): self.authed_2fa = True db.session.commit() return True else: return False def disable(self): self.active = False def touch(self): """Updates this session's last seen date.""" now = datetime.utcnow() self.last_seen = now self.user.last_seen = now
class User(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.Text, nullable=True) email = db.Column(db.String(120), unique=True, nullable=False) role = db.Column(db.Enum(UserRole), nullable=False) last_seen = db.Column(db.DateTime) disabled = db.Column(db.Boolean) def __init__(self, *args, password, passhash=None, **kwargs): """ Creates a new `User` object. The given password will be hashed automatically. The `passhash` parameter specifies an already hashed password, and can be used to speed up tests, as bcrypt is intentionally slow. """ pwd = None if passhash is not None: pwd = passhash else: pwd = bcrypt.generate_password_hash(password) super(User, self).__init__(*args, password=pwd, **kwargs) def has_role(self, role): """Returns True if the user's role is equal or greater than the given role. If the given role is None, returns True.""" if role is None: return True return self.role >= role def avatar_url(self): """Returns the Gravatar URL for this user based on their email.""" # See https://en.gravatar.com/site/implement/hash/ # Trim whitespace, convert to lowercase, md5 hash email = self.email.encode("utf-8") gravatar_hash = hashlib.md5(email.strip().lower()).hexdigest() return "https://gravatar.com/avatar/{}?d=identicon".format( gravatar_hash) def set_password(self, password): """Sets a new password. The given password will be hashed.""" self.password = bcrypt.generate_password_hash(password) def clear_password(self): """Clears this user's password, preventing them from logging in anymore. Also clears all of the user's sessions, logging them out.""" for sess in self.sessions: sess.disable() self.password = None def gen_passwd_reset_token(self): """Creates a reset token for this user and returns the string token.""" self.reset_token = ResetToken() token = self.reset_token.token return str(token) def disable(self): for sess in self.sessions: sess.disable() self.disabled = True def enable(self): self.disabled = False def __repr__(self): return '<User %r>' % self.name