Add early support for user accounts

This commit is contained in:
voussoir 2016-12-16 15:45:46 -08:00
parent 785cf9f687
commit 1c7b736b1a
6 changed files with 222 additions and 34 deletions

View file

@ -3,6 +3,16 @@ Etiquette
This is the readme file. 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 ### Changelog
- **[addition]** A new feature was added. - **[addition]** A new feature was added.

View file

@ -32,6 +32,11 @@ MIN_TAG_NAME_LENGTH = 1
MAX_TAG_NAME_LENGTH = 32 MAX_TAG_NAME_LENGTH = 32
VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_' 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_ID_LENGTH = 12
DEFAULT_DATADIR = '.\\_etiquette' DEFAULT_DATADIR = '.\\_etiquette'
DEFAULT_DIGEST_EXCLUDE_FILES = [ DEFAULT_DIGEST_EXCLUDE_FILES = [

View file

@ -13,6 +13,24 @@ def upgrade_1_to_2(sql):
cur = sql.cursor() cur = sql.cursor()
cur.execute('ALTER TABLE photos ADD COLUMN tagged_at INT') 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): 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 = 'upgrade_%d_to_%d' % (current_version, version_number)
upgrade_function = eval(upgrade_function) upgrade_function = eval(upgrade_function)
upgrade_function(sql) upgrade_function(sql)
sql.cursor().execute('PRAGMA user_version = 2') sql.cursor().execute('PRAGMA user_version = %d' % version_number)
sql.commit() sql.commit()
current_version = version_number current_version = version_number
print('Upgrades finished.') print('Upgrades finished.')

View file

@ -1,6 +1,4 @@
class CantSynonymSelf(Exception): # NO SUCH
pass
class NoSuchAlbum(Exception): class NoSuchAlbum(Exception):
pass pass
@ -16,6 +14,13 @@ class NoSuchSynonym(Exception):
class NoSuchTag(Exception): class NoSuchTag(Exception):
pass pass
class NoSuchUser(Exception):
pass
# EXISTS
class GroupExists(Exception):
pass
class PhotoExists(Exception): class PhotoExists(Exception):
pass pass
@ -23,16 +28,39 @@ class PhotoExists(Exception):
class TagExists(Exception): class TagExists(Exception):
pass pass
class GroupExists(Exception): class UserExists(Exception):
pass pass
# TAG ERRORS
class TagTooLong(Exception): class TagTooLong(Exception):
pass pass
class TagTooShort(Exception): class TagTooShort(Exception):
pass 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): class NotExclusive(Exception):
''' '''
For when two or more mutually exclusive actions have been requested. For when two or more mutually exclusive actions have been requested.

View file

@ -1,3 +1,4 @@
import bcrypt
import collections import collections
import converter import converter
import datetime import datetime
@ -85,20 +86,28 @@ SQL_TAGGROUP_COLUMNS = [
'parentid', 'parentid',
'memberid', 'memberid',
] ]
SQL_USER_COLUMNS = [
'id',
'username',
'password',
'created',
]
SQL_ALBUM = {key:index for (index, key) in enumerate(SQL_ALBUM_COLUMNS)} _sql_dictify = lambda columns: {key:index for (index, key) in enumerate(columns)}
SQL_ALBUMPHOTO = {key:index for (index, key) in enumerate(SQL_ALBUMPHOTO_COLUMNS)} SQL_ALBUM = _sql_dictify(SQL_ALBUM_COLUMNS)
SQL_LASTID = {key:index for (index, key) in enumerate(SQL_LASTID_COLUMNS)} SQL_ALBUMPHOTO = _sql_dictify(SQL_ALBUMPHOTO_COLUMNS)
SQL_PHOTO = {key:index for (index, key) in enumerate(SQL_PHOTO_COLUMNS)} SQL_LASTID = _sql_dictify(SQL_LASTID_COLUMNS)
SQL_PHOTOTAG = {key:index for (index, key) in enumerate(SQL_PHOTOTAG_COLUMNS)} SQL_PHOTO = _sql_dictify(SQL_PHOTO_COLUMNS)
SQL_SYN = {key:index for (index, key) in enumerate(SQL_SYN_COLUMNS)} SQL_PHOTOTAG = _sql_dictify(SQL_PHOTOTAG_COLUMNS)
SQL_TAG = {key:index for (index, key) in enumerate(SQL_TAG_COLUMNS)} SQL_SYN = _sql_dictify(SQL_SYN_COLUMNS)
SQL_TAGGROUP = {key:index for (index, key) in enumerate(SQL_TAGGROUP_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 # 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 # happens after the out-of-date check occurs, so no chance of accidentally
# overwriting it. # overwriting it.
DATABASE_VERSION = 2 DATABASE_VERSION = 3
DB_INIT = ''' DB_INIT = '''
PRAGMA count_changes = OFF; PRAGMA count_changes = OFF;
PRAGMA cache_size = 10000; PRAGMA cache_size = 10000;
@ -144,11 +153,16 @@ CREATE TABLE IF NOT EXISTS tag_synonyms(
name TEXT, name TEXT,
mastername TEXT mastername TEXT
); );
CREATE TABLE IF NOT EXISTS id_numbers( CREATE TABLE IF NOT EXISTS id_numbers(
tab TEXT, tab TEXT,
last_id TEXT last_id TEXT
); );
CREATE TABLE IF NOT EXISTS users(
id TEXT,
username TEXT COLLATE NOCASE,
password BLOB,
created INT
);
-- Album -- Album
CREATE INDEX IF NOT EXISTS index_album_id on albums(id); 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 -- Tag-group relation
CREATE INDEX IF NOT EXISTS index_grouprel_parentid on tag_group_rel(parentid); 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); 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) '''.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. Redirect to get_tag_by_id or get_tag_by_name after xor-checking the parameters.
''' '''
if not helpers.is_xor(id, name): 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: if id is not None:
return self.get_tag_by_id(id) return self.get_tag_by_id(id)
@ -910,7 +928,101 @@ class PDBTagMixin:
return tag 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: This class represents an SQLite3 database containing the following tables:
@ -973,8 +1085,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
if existing_version != DATABASE_VERSION: if existing_version != DATABASE_VERSION:
message = constants.ERROR_DATABASE_OUTOFDATE message = constants.ERROR_DATABASE_OUTOFDATE
message = message.format(current=existing_version, new=DATABASE_VERSION) message = message.format(current=existing_version, new=DATABASE_VERSION)
log.critical(message) raise SystemExit(message)
raise SystemExit
statements = DB_INIT.split(';') statements = DB_INIT.split(';')
for statement in statements: for statement in statements:
@ -1192,15 +1303,14 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
def generate_id(self, table): def generate_id(self, table):
''' '''
Create a new ID number that is unique to the given 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 Note that while this method may INSERT / UPDATE, it does not commit.
to happen in whoever is calling us, so we know the ID is actually used. We'll wait for that to happen in whoever is calling us, so we know the
ID is actually used.
''' '''
table = table.lower() table = table.lower()
if table not in ['photos', 'tags', 'groups']: if table not in ['photos', 'tags', 'groups']:
raise ValueError('Invalid table requested: %s.', table) raise ValueError('Invalid table requested: %s.', table)
do_insert = False
self.cur.execute('SELECT * FROM id_numbers WHERE tab == ?', [table]) self.cur.execute('SELECT * FROM id_numbers WHERE tab == ?', [table])
fetch = self.cur.fetchone() fetch = self.cur.fetchone()
if fetch is None: if fetch is None:
@ -1210,6 +1320,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
else: else:
# Use database value # Use database value
new_id_int = int(fetch[SQL_LASTID['last_id']]) + 1 new_id_int = int(fetch[SQL_LASTID['last_id']]) + 1
do_insert = False
new_id = str(new_id_int).rjust(self.id_length, '0') new_id = str(new_id_int).rjust(self.id_length, '0')
if do_insert: if do_insert:
@ -1273,9 +1384,11 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
class ObjectBase: class ObjectBase:
def __eq__(self, other): def __eq__(self, other):
if not isinstance(other, type(self)): return (
return False isinstance(other, type(self)) and
return self.id == other.id self.photodb == other.photodb and
self.id == other.id
)
def __format__(self, formcode): def __format__(self, formcode):
if formcode == 'r': if formcode == 'r':
@ -1878,14 +1991,6 @@ class Tag(ObjectBase, GroupableMixin):
self.group_getter = self.photodb.get_tag self.group_getter = self.photodb.get_tag
self._cached_qualified_name = None 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): def __hash__(self):
return hash(self.name) return hash(self.name)
@ -2032,6 +2137,27 @@ class Tag(ObjectBase, GroupableMixin):
return fetch 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__': if __name__ == '__main__':
p = PhotoDB() p = PhotoDB()
print(p) print(p)

View file

@ -1,3 +1,4 @@
bcrypt
flask flask
gevent gevent
pillow pillow