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.
|
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.
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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.')
|
||||||
|
|
|
@ -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.
|
||||||
|
|
184
phototagger.py
184
phototagger.py
|
@ -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)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
bcrypt
|
||||||
flask
|
flask
|
||||||
gevent
|
gevent
|
||||||
pillow
|
pillow
|
||||||
|
|
Loading…
Reference in a new issue