Add early support for user accounts

master
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.
### 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.

View File

@ -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 = [

View File

@ -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.')

View File

@ -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.

View File

@ -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)

View File

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