Add early support for user accounts
This commit is contained in:
		
							parent
							
								
									785cf9f687
								
							
						
					
					
						commit
						1c7b736b1a
					
				
					 6 changed files with 222 additions and 34 deletions
				
			
		
							
								
								
									
										10
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								README.md
									
									
									
									
									
								
							|  | @ -3,6 +3,16 @@ Etiquette | |||
| 
 | ||||
| This is the readme file. | ||||
| 
 | ||||
| ### To do list | ||||
| - User account system, permission levels, private pages. | ||||
| - Bookmark system. Maybe the ability to submit URLs as photo objects. | ||||
| - Generalize the filename expression filter so it can work with any strings. | ||||
| - Improve the "tags on this page" list. Maybe add separate buttons for must/may/forbid on each. | ||||
| - Some way for the database to re-identify a file that was moved / renamed (lost & found). Maybe file hash of the first few mb is good enough. | ||||
| - Move out more helpers | ||||
| - Create objects.py | ||||
| - Debate whether the `UserMixin.login` method should accept usernames or I should standardize the usage of IDs only internally. | ||||
| 
 | ||||
| ### Changelog | ||||
| 
 | ||||
| - **[addition]** A new feature was added. | ||||
|  |  | |||
|  | @ -32,6 +32,11 @@ MIN_TAG_NAME_LENGTH = 1 | |||
| MAX_TAG_NAME_LENGTH = 32 | ||||
| VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_' | ||||
| 
 | ||||
| MIN_USERNAME_LENGTH = 2 | ||||
| MAX_USERNAME_LENGTH = 24 | ||||
| MIN_PASSWORD_LENGTH = 6 | ||||
| VALID_USERNAME_CHARS = string.ascii_letters + string.digits + '~!@#$%^&*()[]{}:;,.<>/\\-_+=' | ||||
| 
 | ||||
| DEFAULT_ID_LENGTH = 12 | ||||
| DEFAULT_DATADIR = '.\\_etiquette' | ||||
| DEFAULT_DIGEST_EXCLUDE_FILES = [ | ||||
|  |  | |||
|  | @ -13,6 +13,24 @@ def upgrade_1_to_2(sql): | |||
|     cur = sql.cursor() | ||||
|     cur.execute('ALTER TABLE photos ADD COLUMN tagged_at INT') | ||||
| 
 | ||||
| def upgrade_2_to_3(sql): | ||||
|     ''' | ||||
|     Preliminary support for user account management was added. This includes a `user` table | ||||
|     with id, username, password hash, and a timestamp. | ||||
|     Plus some indices. | ||||
|     ''' | ||||
|     cur = sql.cursor() | ||||
|     cur.execute(''' | ||||
|     CREATE TABLE IF NOT EXISTS users( | ||||
|         id TEXT, | ||||
|         username TEXT COLLATE NOCASE, | ||||
|         password BLOB, | ||||
|         created INT | ||||
|     ) | ||||
|     ''') | ||||
|     cur.execute('CREATE INDEX IF NOT EXISTS index_user_id ON users(id)') | ||||
|     cur.execute('CREATE INDEX IF NOT EXISTS index_user_username ON users(username COLLATE NOCASE)') | ||||
| 
 | ||||
| 
 | ||||
| def upgrade_all(database_filename): | ||||
|     ''' | ||||
|  | @ -38,7 +56,7 @@ def upgrade_all(database_filename): | |||
|         upgrade_function = 'upgrade_%d_to_%d' % (current_version, version_number) | ||||
|         upgrade_function = eval(upgrade_function) | ||||
|         upgrade_function(sql) | ||||
|         sql.cursor().execute('PRAGMA user_version = 2') | ||||
|         sql.cursor().execute('PRAGMA user_version = %d' % version_number) | ||||
|         sql.commit() | ||||
|         current_version = version_number | ||||
|     print('Upgrades finished.') | ||||
|  |  | |||
|  | @ -1,6 +1,4 @@ | |||
| class CantSynonymSelf(Exception): | ||||
|     pass | ||||
| 
 | ||||
| # NO SUCH | ||||
| class NoSuchAlbum(Exception): | ||||
|     pass | ||||
| 
 | ||||
|  | @ -16,6 +14,13 @@ class NoSuchSynonym(Exception): | |||
| class NoSuchTag(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchUser(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| # EXISTS | ||||
| class GroupExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class PhotoExists(Exception): | ||||
|     pass | ||||
|  | @ -23,16 +28,39 @@ class PhotoExists(Exception): | |||
| class TagExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class GroupExists(Exception): | ||||
| class UserExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| # TAG ERRORS | ||||
| class TagTooLong(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class TagTooShort(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class CantSynonymSelf(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| # USER ERRORS | ||||
| class InvalidUsernameChars(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class PasswordTooShort(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class UsernameTooLong(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class UsernameTooShort(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class WrongLogin(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| # GENERAL ERRORS | ||||
| class NotExclusive(Exception): | ||||
|     ''' | ||||
|     For when two or more mutually exclusive actions have been requested. | ||||
|  |  | |||
							
								
								
									
										184
									
								
								phototagger.py
									
									
									
									
									
								
							
							
						
						
									
										184
									
								
								phototagger.py
									
									
									
									
									
								
							|  | @ -1,3 +1,4 @@ | |||
| import bcrypt | ||||
| import collections | ||||
| import converter | ||||
| import datetime | ||||
|  | @ -85,20 +86,28 @@ SQL_TAGGROUP_COLUMNS = [ | |||
|     'parentid', | ||||
|     'memberid', | ||||
| ] | ||||
| SQL_USER_COLUMNS = [ | ||||
|     'id', | ||||
|     'username', | ||||
|     'password', | ||||
|     'created', | ||||
| ] | ||||
| 
 | ||||
| SQL_ALBUM = {key:index for (index, key) in enumerate(SQL_ALBUM_COLUMNS)} | ||||
| SQL_ALBUMPHOTO = {key:index for (index, key) in enumerate(SQL_ALBUMPHOTO_COLUMNS)} | ||||
| SQL_LASTID = {key:index for (index, key) in enumerate(SQL_LASTID_COLUMNS)} | ||||
| SQL_PHOTO = {key:index for (index, key) in enumerate(SQL_PHOTO_COLUMNS)} | ||||
| SQL_PHOTOTAG = {key:index for (index, key) in enumerate(SQL_PHOTOTAG_COLUMNS)} | ||||
| SQL_SYN = {key:index for (index, key) in enumerate(SQL_SYN_COLUMNS)} | ||||
| SQL_TAG = {key:index for (index, key) in enumerate(SQL_TAG_COLUMNS)} | ||||
| SQL_TAGGROUP = {key:index for (index, key) in enumerate(SQL_TAGGROUP_COLUMNS)} | ||||
| _sql_dictify = lambda columns: {key:index for (index, key) in enumerate(columns)} | ||||
| SQL_ALBUM = _sql_dictify(SQL_ALBUM_COLUMNS) | ||||
| SQL_ALBUMPHOTO = _sql_dictify(SQL_ALBUMPHOTO_COLUMNS) | ||||
| SQL_LASTID = _sql_dictify(SQL_LASTID_COLUMNS) | ||||
| SQL_PHOTO = _sql_dictify(SQL_PHOTO_COLUMNS) | ||||
| SQL_PHOTOTAG = _sql_dictify(SQL_PHOTOTAG_COLUMNS) | ||||
| SQL_SYN = _sql_dictify(SQL_SYN_COLUMNS) | ||||
| SQL_TAG = _sql_dictify(SQL_TAG_COLUMNS) | ||||
| SQL_TAGGROUP = _sql_dictify(SQL_TAGGROUP_COLUMNS) | ||||
| SQL_USER = _sql_dictify(SQL_USER_COLUMNS) | ||||
| 
 | ||||
| # Note: Setting user_version pragma in init sequence is safe because it only | ||||
| # happens after the out-of-date check occurs, so no chance of accidentally | ||||
| # overwriting it. | ||||
| DATABASE_VERSION = 2 | ||||
| DATABASE_VERSION = 3 | ||||
| DB_INIT = ''' | ||||
| PRAGMA count_changes = OFF; | ||||
| PRAGMA cache_size = 10000; | ||||
|  | @ -144,11 +153,16 @@ CREATE TABLE IF NOT EXISTS tag_synonyms( | |||
|     name TEXT, | ||||
|     mastername TEXT | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE IF NOT EXISTS id_numbers( | ||||
|     tab TEXT, | ||||
|     last_id TEXT | ||||
| ); | ||||
| CREATE TABLE IF NOT EXISTS users( | ||||
|     id TEXT, | ||||
|     username TEXT COLLATE NOCASE, | ||||
|     password BLOB, | ||||
|     created INT | ||||
| ); | ||||
| 
 | ||||
| -- Album | ||||
| CREATE INDEX IF NOT EXISTS index_album_id on albums(id); | ||||
|  | @ -176,6 +190,10 @@ CREATE INDEX IF NOT EXISTS index_tagsyn_name on tag_synonyms(name); | |||
| -- Tag-group relation | ||||
| CREATE INDEX IF NOT EXISTS index_grouprel_parentid on tag_group_rel(parentid); | ||||
| CREATE INDEX IF NOT EXISTS index_grouprel_memberid on tag_group_rel(memberid); | ||||
| 
 | ||||
| -- User | ||||
| CREATE INDEX IF NOT EXISTS index_user_id on users(id); | ||||
| CREATE INDEX IF NOT EXISTS index_user_username on users(username COLLATE NOCASE); | ||||
| '''.format(user_version=DATABASE_VERSION) | ||||
| 
 | ||||
| 
 | ||||
|  | @ -852,7 +870,7 @@ class PDBTagMixin: | |||
|         Redirect to get_tag_by_id or get_tag_by_name after xor-checking the parameters. | ||||
|         ''' | ||||
|         if not helpers.is_xor(id, name): | ||||
|             raise exceptions.NotExclusive('One and only one of `id`, `name` can be passed.') | ||||
|             raise exceptions.NotExclusive('One and only one of `id`, `name` must be passed.') | ||||
| 
 | ||||
|         if id is not None: | ||||
|             return self.get_tag_by_id(id) | ||||
|  | @ -910,7 +928,101 @@ class PDBTagMixin: | |||
|         return tag | ||||
| 
 | ||||
| 
 | ||||
| class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | ||||
| class PDBUserMixin: | ||||
|     def generate_user_id(self): | ||||
|         ''' | ||||
|         User IDs are randomized instead of integers like the other objects, | ||||
|         so they get their own method. | ||||
|         ''' | ||||
|         possible = string.digits + string.ascii_uppercase | ||||
|         for retry in range(20): | ||||
|             user_id = [random.choice(possible) for x in range(self.id_length)] | ||||
|             user_id = ''.join(user_id) | ||||
| 
 | ||||
|             self.cur.execute('SELECT * FROM users WHERE id == ?', [user_id]) | ||||
|             if self.cur.fetchone() is None: | ||||
|                 break | ||||
|         else: | ||||
|             raise Exception('Failed to create user id after 20 tries.') | ||||
| 
 | ||||
|         return user_id | ||||
| 
 | ||||
|     def get_user(self, username=None, id=None): | ||||
|         if not helpers.is_xor(id, username): | ||||
|             raise exceptions.NotExclusive('One and only one of `id`, `username` must be passed.') | ||||
| 
 | ||||
|         if username is not None: | ||||
|             self.cur.execute('SELECT * FROM users WHERE username == ?', [username]) | ||||
|         else: | ||||
|             self.cur.execute('SELECT * FROM users WHERE id == ?', [id]) | ||||
| 
 | ||||
|         fetch = self.cur.fetchone() | ||||
|         if fetch is not None: | ||||
|             return User(self, fetch) | ||||
|         else: | ||||
|             raise exceptions.NoSuchUser(username) | ||||
| 
 | ||||
|     def login(self, user_id, password): | ||||
|         self.cur.execute('SELECT * FROM users WHERE id == ?', [user_id]) | ||||
|         fetch = self.cur.fetchone() | ||||
| 
 | ||||
|         if fetch is None: | ||||
|             raise exceptions.WrongLogin() | ||||
| 
 | ||||
|         stored_password = fetch[SQL_USER['password']] | ||||
| 
 | ||||
|         if not isinstance(password, bytes): | ||||
|             password = password.encode('utf-8') | ||||
| 
 | ||||
|         success = bcrypt.checkpw(password, stored_password) | ||||
|         if not success: | ||||
|             raise exceptions.WrongLogin() | ||||
| 
 | ||||
|         return User(self, fetch) | ||||
| 
 | ||||
|     def register_user(self, username, password, commit=True): | ||||
|         if len(username) < constants.MIN_USERNAME_LENGTH: | ||||
|             raise exceptions.UsernameTooShort(username) | ||||
| 
 | ||||
|         if len(username) > constants.MAX_USERNAME_LENGTH: | ||||
|             raise exceptions.UsernameTooLong(username) | ||||
| 
 | ||||
|         badchars = [c for c in username if c not in constants.VALID_USERNAME_CHARS] | ||||
|         if badchars: | ||||
|             raise exceptions.InvalidUsernameChars(badchars) | ||||
| 
 | ||||
|         if not isinstance(password, bytes): | ||||
|             password = password.encode('utf-8') | ||||
| 
 | ||||
|         if len(password) < constants.MIN_PASSWORD_LENGTH: | ||||
|             raise exceptions.PasswordTooShort | ||||
| 
 | ||||
|         self.cur.execute('SELECT * FROM users WHERE username == ?', [username]) | ||||
|         if self.cur.fetchone() is not None: | ||||
|             raise exceptions.UserExists(username) | ||||
| 
 | ||||
|         user_id = self.generate_user_id() | ||||
|         hashed_password = bcrypt.hashpw(password, bcrypt.gensalt()) | ||||
|         created = int(getnow()) | ||||
| 
 | ||||
|         data = {} | ||||
|         data['id'] = user_id | ||||
|         data['username'] = username | ||||
|         data['password'] = hashed_password | ||||
|         data['created'] = created | ||||
| 
 | ||||
|         (qmarks, bindings) = binding_filler(SQL_USER_COLUMNS, data) | ||||
|         query = 'INSERT INTO users VALUES(%s)' % qmarks | ||||
|         self.cur.execute(query, bindings) | ||||
| 
 | ||||
|         if commit: | ||||
|             log.debug('Committing - register user') | ||||
|             self.commit() | ||||
| 
 | ||||
|         return User(self, data) | ||||
| 
 | ||||
| 
 | ||||
| class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin, PDBUserMixin): | ||||
|     ''' | ||||
|     This class represents an SQLite3 database containing the following tables: | ||||
| 
 | ||||
|  | @ -973,8 +1085,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|             if existing_version != DATABASE_VERSION: | ||||
|                 message = constants.ERROR_DATABASE_OUTOFDATE | ||||
|                 message = message.format(current=existing_version, new=DATABASE_VERSION) | ||||
|                 log.critical(message) | ||||
|                 raise SystemExit | ||||
|                 raise SystemExit(message) | ||||
| 
 | ||||
|         statements = DB_INIT.split(';') | ||||
|         for statement in statements: | ||||
|  | @ -1192,15 +1303,14 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|     def generate_id(self, table): | ||||
|         ''' | ||||
|         Create a new ID number that is unique to the given table. | ||||
|         Note that this method does not commit the database. We'll wait for that | ||||
|         to happen in whoever is calling us, so we know the ID is actually used. | ||||
|         Note that while this method may INSERT / UPDATE, it does not commit. | ||||
|         We'll wait for that to happen in whoever is calling us, so we know the | ||||
|         ID is actually used. | ||||
|         ''' | ||||
|         table = table.lower() | ||||
|         if table not in ['photos', 'tags', 'groups']: | ||||
|             raise ValueError('Invalid table requested: %s.', table) | ||||
| 
 | ||||
|         do_insert = False | ||||
| 
 | ||||
|         self.cur.execute('SELECT * FROM id_numbers WHERE tab == ?', [table]) | ||||
|         fetch = self.cur.fetchone() | ||||
|         if fetch is None: | ||||
|  | @ -1210,6 +1320,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|         else: | ||||
|             # Use database value | ||||
|             new_id_int = int(fetch[SQL_LASTID['last_id']]) + 1 | ||||
|             do_insert = False | ||||
| 
 | ||||
|         new_id = str(new_id_int).rjust(self.id_length, '0') | ||||
|         if do_insert: | ||||
|  | @ -1273,9 +1384,11 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
| 
 | ||||
| class ObjectBase: | ||||
|     def __eq__(self, other): | ||||
|         if not isinstance(other, type(self)): | ||||
|             return False | ||||
|         return self.id == other.id | ||||
|         return ( | ||||
|             isinstance(other, type(self)) and | ||||
|             self.photodb == other.photodb and | ||||
|             self.id == other.id | ||||
|         ) | ||||
| 
 | ||||
|     def __format__(self, formcode): | ||||
|         if formcode == 'r': | ||||
|  | @ -1878,14 +1991,6 @@ class Tag(ObjectBase, GroupableMixin): | |||
|         self.group_getter = self.photodb.get_tag | ||||
|         self._cached_qualified_name = None | ||||
| 
 | ||||
|     def __eq__(self, other): | ||||
|         if isinstance(other, str): | ||||
|             return self.name == other | ||||
|         elif isinstance(other, Tag): | ||||
|             return self.id == other.id and self.name == other.name | ||||
|         else: | ||||
|             return False | ||||
| 
 | ||||
|     def __hash__(self): | ||||
|         return hash(self.name) | ||||
| 
 | ||||
|  | @ -2032,6 +2137,27 @@ class Tag(ObjectBase, GroupableMixin): | |||
|         return fetch | ||||
| 
 | ||||
| 
 | ||||
| class User(ObjectBase): | ||||
|     ''' | ||||
|     A dear friend of ours. | ||||
|     ''' | ||||
|     def __init__(self, photodb, row_tuple): | ||||
|         self.photodb = photodb | ||||
|         if isinstance(row_tuple, (list, tuple)): | ||||
|             row_tuple = {SQL_USER_COLUMNS[index]: value for (index, value) in enumerate(row_tuple)} | ||||
|         self.id = row_tuple['id'] | ||||
|         self.username = row_tuple['username'] | ||||
|         self.created = row_tuple['created'] | ||||
| 
 | ||||
|     def __repr__(self): | ||||
|         rep = 'User:{id}:{username}'.format(id=self.id, username=self.username) | ||||
|         return rep | ||||
| 
 | ||||
|     def __str__(self): | ||||
|         rep = 'User:{username}'.format(username=self.username) | ||||
|         return rep | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == '__main__': | ||||
|     p = PhotoDB() | ||||
|     print(p) | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| bcrypt | ||||
| flask | ||||
| gevent | ||||
| pillow | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue