From ebe5847afcd145b0ae8e8b8dd1dd7e5e120da88d Mon Sep 17 00:00:00 2001 From: Ethan Dalool Date: Fri, 14 Nov 2025 17:47:45 -0800 Subject: [PATCH] Add more detailed permission system, photo uploads by users. --- etiquette/constants.py | 88 ++++++- etiquette/exceptions.py | 8 +- etiquette/helpers.py | 8 +- etiquette/objects.py | 107 +++++++- etiquette/photodb.py | 26 +- frontends/etiquette_flask/backend/common.py | 14 +- .../backend/endpoints/admin_endpoints.py | 32 ++- .../backend/endpoints/album_endpoints.py | 57 +++-- .../backend/endpoints/basic_endpoints.py | 7 +- .../backend/endpoints/bookmark_endpoints.py | 18 +- .../backend/endpoints/photo_endpoints.py | 237 ++++++++++++++---- .../backend/endpoints/tag_endpoints.py | 51 ++-- .../backend/endpoints/user_endpoints.py | 80 ++++-- .../etiquette_flask/backend/permissions.py | 109 +++++++- .../etiquette_flask/static/css/common.css | 2 + .../etiquette_flask/static/css/etiquette.css | 2 +- frontends/etiquette_flask/static/js/api.js | 56 +++++ .../etiquette_flask/templates/admin.html | 77 +++++- .../etiquette_flask/templates/album.html | 4 +- .../etiquette_flask/templates/bookmarks.html | 2 +- .../etiquette_flask/templates/clipboard.html | 57 ++++- .../etiquette_flask/templates/header.html | 8 +- .../etiquette_flask/templates/login.html | 8 +- .../etiquette_flask/templates/photo.html | 44 +++- frontends/etiquette_flask/templates/root.html | 5 +- .../etiquette_flask/templates/search.html | 2 +- .../etiquette_flask/templates/swipe.html | 2 +- frontends/etiquette_flask/templates/tags.html | 4 +- .../etiquette_flask/templates/template.html | 2 +- .../etiquette_flask/templates/upload.html | 217 ++++++++++++++++ frontends/etiquette_flask/templates/user.html | 108 +++++--- utilities/database_upgrader.py | 17 ++ 32 files changed, 1219 insertions(+), 240 deletions(-) create mode 100644 frontends/etiquette_flask/templates/upload.html diff --git a/etiquette/constants.py b/etiquette/constants.py index a362034..471d1f0 100644 --- a/etiquette/constants.py +++ b/etiquette/constants.py @@ -41,7 +41,7 @@ ffmpeg = _load_ffmpeg() # Database ######################################################################################### -DATABASE_VERSION = 25 +DATABASE_VERSION = 26 DB_INIT = ''' CREATE TABLE IF NOT EXISTS albums( @@ -204,6 +204,15 @@ CREATE TABLE IF NOT EXISTS tag_synonyms( ); CREATE INDEX IF NOT EXISTS index_tag_synonyms_name on tag_synonyms(name); CREATE INDEX IF NOT EXISTS index_tag_synonyms_mastername on tag_synonyms(mastername); +---------------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS user_permissions( + userid TEXT NOT NULL, + permission TEXT NOT NULL, + created INT, + PRIMARY KEY(userid, permission), + FOREIGN KEY(userid) REFERENCES users(id) +); +CREATE INDEX IF NOT EXISTS index_user_permissions_userid on user_permissions(userid); ''' SQL_COLUMNS = sqlhelpers.extract_table_column_map(DB_INIT) @@ -309,6 +318,7 @@ DEFAULT_DATADIR = '_etiquette' DEFAULT_DBNAME = 'phototagger.db' DEFAULT_CONFIGNAME = 'config.json' DEFAULT_THUMBDIR = 'thumbnails' +DEFAULT_UPLOADS_DIR = 'uploads' DEFAULT_CONFIGURATION = { 'cache_size': { @@ -322,25 +332,30 @@ DEFAULT_CONFIGURATION = { 'enable_feature': { 'album': { 'edit': True, + 'delete': True, 'new': True, }, 'bookmark': { 'edit': True, + 'delete': True, 'new': True, }, 'photo': { 'add_remove_tag': True, 'new': True, 'edit': True, + 'delete': True, 'generate_thumbnail': True, 'reload_metadata': True, }, 'tag': { 'edit': True, + 'delete': True, 'new': True, }, 'user': { 'edit': True, + 'delete': True, 'login': True, 'new': True, }, @@ -383,3 +398,74 @@ DEFAULT_CONFIGURATION = { 'Good morning, Paul. What will your first sequence of the day be?', ], } + +# Permissions ###################################################################################### + +ANONYMOUS_USER_ID = 0 + +PERMISSION_ADMIN = 'admin' +PERMISSION_DEPUTY = 'deputy' + +PERMISSION_ALBUM_CREATE = 'album_create' +PERMISSION_ALBUM_DELETE_ALL = 'album_delete_all' +PERMISSION_ALBUM_DELETE_OWN = 'album_delete_own' +PERMISSION_ALBUM_EDIT_ALL = 'album_edit_all' +PERMISSION_ALBUM_EDIT_OWN = 'album_edit_own' + +PERMISSION_BOOKMARK_CREATE = 'bookmark_create' +PERMISSION_BOOKMARK_DELETE_ALL = 'bookmark_delete_all' +PERMISSION_BOOKMARK_DELETE_OWN = 'bookmark_delete_own' +PERMISSION_BOOKMARK_EDIT_ALL = 'bookmark_edit_all' +PERMISSION_BOOKMARK_EDIT_OWN = 'bookmark_edit_own' + +PERMISSION_PHOTO_CREATE = 'photo_create' +PERMISSION_PHOTO_DELETE_ALL = 'photo_delete_all' +PERMISSION_PHOTO_DELETE_OWN = 'photo_delete_own' +PERMISSION_PHOTO_EDIT_ALL = 'photo_edit_all' +PERMISSION_PHOTO_EDIT_OWN = 'photo_edit_own' + +PERMISSION_TAG_CREATE = 'tag_create' +PERMISSION_TAG_DELETE_ALL = 'tag_delete_all' +PERMISSION_TAG_DELETE_OWN = 'tag_delete_own' +PERMISSION_TAG_EDIT_ALL = 'tag_edit_all' +PERMISSION_TAG_EDIT_OWN = 'tag_edit_own' + +ALL_PERMISSIONS = { + PERMISSION_ADMIN, + # deputy is omitted intentionally + PERMISSION_ALBUM_CREATE, + PERMISSION_ALBUM_DELETE_ALL, + PERMISSION_ALBUM_DELETE_OWN, + PERMISSION_ALBUM_EDIT_ALL, + PERMISSION_ALBUM_EDIT_OWN, + PERMISSION_BOOKMARK_CREATE, + PERMISSION_BOOKMARK_DELETE_ALL, + PERMISSION_BOOKMARK_DELETE_OWN, + PERMISSION_BOOKMARK_EDIT_ALL, + PERMISSION_BOOKMARK_EDIT_OWN, + PERMISSION_PHOTO_CREATE, + PERMISSION_PHOTO_DELETE_ALL, + PERMISSION_PHOTO_DELETE_OWN, + PERMISSION_PHOTO_EDIT_ALL, + PERMISSION_PHOTO_EDIT_OWN, + PERMISSION_TAG_CREATE, + PERMISSION_TAG_DELETE_ALL, + PERMISSION_TAG_DELETE_OWN, + PERMISSION_TAG_EDIT_ALL, + PERMISSION_TAG_EDIT_OWN, +} + +NEW_USER_PERMISSIONS = { + PERMISSION_ALBUM_CREATE, + PERMISSION_ALBUM_DELETE_OWN, + PERMISSION_ALBUM_EDIT_OWN, + PERMISSION_BOOKMARK_CREATE, + PERMISSION_BOOKMARK_DELETE_OWN, + PERMISSION_BOOKMARK_EDIT_OWN, + PERMISSION_PHOTO_CREATE, + PERMISSION_PHOTO_DELETE_OWN, + PERMISSION_PHOTO_EDIT_OWN, + PERMISSION_TAG_CREATE, + PERMISSION_TAG_DELETE_OWN, + PERMISSION_TAG_EDIT_OWN, +} diff --git a/etiquette/exceptions.py b/etiquette/exceptions.py index 97a2ba5..2e55649 100644 --- a/etiquette/exceptions.py +++ b/etiquette/exceptions.py @@ -157,12 +157,14 @@ class UsernameTooShort(InvalidUsername): class DisplayNameTooLong(EtiquetteException): error_message = 'Display name "{display_name}" is longer than maximum of {max_length}.' -class Unauthorized(EtiquetteException): - error_message = 'You\'re not allowed to do that.' - class WrongLogin(EtiquetteException): error_message = 'Wrong username-password combination.' +# PERMISSION ERRORS ################################################################################ + +class Unauthorized(EtiquetteException): + error_message = 'You\'re not allowed to do that.' + # GENERAL ERRORS ################################################################################### class BadDataDirectory(EtiquetteException): diff --git a/etiquette/helpers.py b/etiquette/helpers.py index d77d9d0..de125ea 100644 --- a/etiquette/helpers.py +++ b/etiquette/helpers.py @@ -270,7 +270,7 @@ def image_is_mostly_black(image): return (black_count / len(pixels)) > 0.5 -def generate_video_thumbnail(filepath, width, height, **special) -> PIL.Image: +def generate_video_thumbnail(filepath, width, height, special={}) -> PIL.Image: file = pathclass.Path(filepath) file.assert_is_file() probe = constants.ffmpeg.probe(filepath) @@ -284,16 +284,16 @@ def generate_video_thumbnail(filepath, width, height, **special) -> PIL.Image: frame_width=width, frame_height=height, ) - duration = probe.video.duration + duration = probe.video.duration or probe.format.duration if 'timestamp' in special: - timestamp_choices = [special['timestamp']] + timestamp_choices = [float(special['timestamp'])] else: timestamp_choices = list(range(0, int(duration), 3)) image = None for this_time in timestamp_choices: - log.debug('Attempting video thumbnail at t=%d', this_time) + log.debug('Attempting video thumbnail at t=%s', this_time) command = kkroening_ffmpeg.input(file.absolute_path, ss=this_time) command = command.filter('scale', size[0], size[1]) command = command.output('pipe:', vcodec='bmp', format='image2pipe', vframes=1) diff --git a/etiquette/objects.py b/etiquette/objects.py index f12426a..52b048b 100644 --- a/etiquette/objects.py +++ b/etiquette/objects.py @@ -273,6 +273,10 @@ class Album(ObjectBase, GroupableMixin): table = 'albums' group_table = 'album_group_rel' no_such_exception = exceptions.NoSuchAlbum + permission_edit_all = constants.PERMISSION_ALBUM_EDIT_ALL + permission_edit_own = constants.PERMISSION_ALBUM_EDIT_OWN + permission_delete_all = constants.PERMISSION_ALBUM_DELETE_ALL + permission_delete_own = constants.PERMISSION_ALBUM_DELETE_OWN def __init__(self, photodb, db_row): super().__init__(photodb) @@ -468,7 +472,7 @@ class Album(ObjectBase, GroupableMixin): return soup - @decorators.required_feature('album.edit') + @decorators.required_feature('album.delete') @worms.atomic def delete(self, *, delete_children=False) -> None: log.info('Deleting %s.', self) @@ -775,6 +779,10 @@ class Album(ObjectBase, GroupableMixin): class Bookmark(ObjectBase): table = 'bookmarks' no_such_exception = exceptions.NoSuchBookmark + permission_edit_all = constants.PERMISSION_BOOKMARK_EDIT_ALL + permission_edit_own = constants.PERMISSION_BOOKMARK_EDIT_OWN + permission_delete_all = constants.PERMISSION_BOOKMARK_DELETE_ALL + permission_delete_own = constants.PERMISSION_BOOKMARK_DELETE_OWN def __init__(self, photodb, db_row): super().__init__(photodb) @@ -854,7 +862,7 @@ class Bookmark(ObjectBase): return soup - @decorators.required_feature('bookmark.edit') + @decorators.required_feature('bookmark.delete') @worms.atomic def delete(self) -> None: self.photodb.delete(table=Bookmark, pairs={'id': self.id}) @@ -915,6 +923,10 @@ class Photo(ObjectBase): ''' table = 'photos' no_such_exception = exceptions.NoSuchPhoto + permission_edit_all = constants.PERMISSION_PHOTO_EDIT_ALL + permission_edit_own = constants.PERMISSION_PHOTO_EDIT_OWN + permission_delete_all = constants.PERMISSION_PHOTO_DELETE_ALL + permission_delete_own = constants.PERMISSION_PHOTO_DELETE_OWN def __init__(self, photodb, db_row): super().__init__(photodb) @@ -1002,7 +1014,12 @@ class Photo(ObjectBase): @decorators.required_feature('photo.add_remove_tag') @worms.atomic def add_tag(self, tag, timestamp=None): - tag = self.photodb.get_tag(name=tag) + if isinstance(tag, PhotoTagRel): + tag = tag.tag + elif isinstance(tag, Tag): + pass + else: + tag = self.photodb.get_tag(name=tag) existing = self.has_tag(tag, check_children=False, match_timestamp=timestamp) if existing: @@ -1075,9 +1092,9 @@ class Photo(ObjectBase): Take all of the tags owned by other_photo and apply them to this photo. ''' for tag in other_photo.get_tags(): - self.add_tag(tag) + self.add_tag(tag, timestamp=tag.timestamp) - @decorators.required_feature('photo.edit') + @decorators.required_feature('photo.delete') @worms.atomic def delete(self, *, delete_file=False) -> None: ''' @@ -1113,11 +1130,11 @@ class Photo(ObjectBase): @decorators.required_feature('photo.generate_thumbnail') @worms.atomic - def generate_thumbnail(self, trusted_file=False, **special): + def generate_thumbnail(self, trusted_file=False, special={}): ''' special: - For images, you can provide `max_width` and/or `max_height` to - override the config file. + You can provide `max_width` and/or `max_height` to override the + config file. For videos, you can provide a `timestamp` to take the thumbnail at. ''' image = None @@ -1140,9 +1157,9 @@ class Photo(ObjectBase): try: image = helpers.generate_video_thumbnail( self.real_path.absolute_path, - width=self.photodb.config['thumbnail_width'], - height=self.photodb.config['thumbnail_height'], - **special + width=special.get('max_width', self.photodb.config['thumbnail_width']), + height=special.get('max_height', self.photodb.config['thumbnail_height']), + special=special, ) except Exception: log.warning(traceback.format_exc()) @@ -1375,6 +1392,7 @@ class Photo(ObjectBase): 'bytes': self.bytes, } self.photodb.update(table=Photo, pairs=data, where_key='id') + self.__reinit__() @decorators.required_feature('photo.edit') @worms.atomic @@ -2098,6 +2116,10 @@ class Tag(ObjectBase, GroupableMixin): table = 'tags' group_table = 'tag_group_rel' no_such_exception = exceptions.NoSuchTag + permission_edit_all = constants.PERMISSION_TAG_EDIT_ALL + permission_edit_own = constants.PERMISSION_TAG_EDIT_OWN + permission_delete_all = constants.PERMISSION_TAG_DELETE_ALL + permission_delete_own = constants.PERMISSION_TAG_DELETE_OWN def __init__(self, photodb, db_row): super().__init__(photodb) @@ -2291,7 +2313,7 @@ class Tag(ObjectBase, GroupableMixin): # Enjoy your new life as a monk. mastertag.add_synonym(self.name) - @decorators.required_feature('tag.edit') + @decorators.required_feature('tag.delete') @worms.atomic def delete(self, *, delete_children=False) -> None: log.info('Deleting %s.', self) @@ -2500,6 +2522,15 @@ class User(ObjectBase): def _uncache(self): self.photodb.caches[User].remove(self.id) + @decorators.required_feature('user.edit') + def add_permission(self, permission): + pairs = { + 'userid': self.id, + 'permission': permission, + 'created': timetools.now().timestamp(), + } + self.photodb.insert(table='user_permissions', pairs=pairs, ignore_duplicate=True) + @decorators.required_feature('user.login') def check_password(self, password): if not isinstance(password, bytes): @@ -2510,7 +2541,7 @@ class User(ObjectBase): raise exceptions.WrongLogin() return success - @decorators.required_feature('user.edit') + @decorators.required_feature('user.delete') @worms.atomic def delete(self, *, disown_authored_things) -> None: ''' @@ -2574,6 +2605,13 @@ class User(ObjectBase): [self.id] ) + @decorators.cache_until_commit + def get_permissions(self): + return set(self.photodb.select_column( + 'SELECT permission FROM user_permissions WHERE userid == ?', + [self.id], + )) + def get_photos(self, *, direction='asc') -> typing.Iterable[Photo]: ''' Raises ValueError if direction is not asc or desc. @@ -2618,6 +2656,41 @@ class User(ObjectBase): exists = self.photodb.select_one_value(query, [self.id]) return exists is not None + def has_object_permission(self, thing, edit_or_delete) -> bool: + my_permissions = self.get_permissions() + if constants.PERMISSION_ADMIN in my_permissions: + return True + + if edit_or_delete == 'edit' and thing.permission_edit_all in my_permissions: + return True + + if edit_or_delete == 'delete' and thing.permission_delete_all in my_permissions: + return True + + # If this user does not have ADMIN or ALL permission, then they must be + # the owner or deputy of the owner. So if the thing has no owner then + # there's no way. + thing_author = thing.author + if not thing_author: + return False + + if edit_or_delete == 'edit': + own_permission = thing.permission_edit_own + elif edit_or_delete == 'delete': + own_permission = thing.permission_delete_own + + if thing_author == self and own_permission in my_permissions: + return True + + deputy_permission = constants.PERMISSION_DEPUTY + f':{thing_author.id}' + if deputy_permission in my_permissions and own_permission in thing_author.get_permissions(): + return True + + return False + + def has_permission(self, permission_string): + return permission_string in self.get_permissions() + def jsonify(self) -> dict: j = { 'type': 'user', @@ -2631,6 +2704,14 @@ class User(ObjectBase): return j + @decorators.required_feature('user.edit') + def remove_permission(self, permission): + pairs = { + 'userid': self.id, + 'permission': permission, + } + self.photodb.delete(table='user_permissions', pairs=pairs) + @decorators.required_feature('user.edit') @worms.atomic def set_display_name(self, display_name) -> None: diff --git a/etiquette/photodb.py b/etiquette/photodb.py index 949c070..39500fa 100644 --- a/etiquette/photodb.py +++ b/etiquette/photodb.py @@ -315,6 +315,7 @@ class PDBPhotoMixin: do_thumbnail=True, hash_kwargs=None, known_hash=None, + override_filename=None, searchhidden=False, tags=None, trusted_file=False, @@ -356,7 +357,7 @@ class PDBPhotoMixin: data = { 'id': photo_id, 'filepath': filepath.absolute_path, - 'override_filename': None, + 'override_filename': override_filename, 'created': timetools.now().timestamp(), 'tagged_at': None, 'author_id': author_id, @@ -682,7 +683,14 @@ class PDBUserMixin: @decorators.required_feature('user.new') @worms.atomic - def new_user(self, username, password, *, display_name=None) -> objects.User: + def new_user( + self, + username, + password, + *, + display_name=None, + permissions=constants.NEW_USER_PERMISSIONS, + ) -> objects.User: # These might raise exceptions. self.assert_valid_username(username) self.assert_no_such_user(username=username) @@ -703,15 +711,24 @@ class PDBUserMixin: hashed_password = bcrypt.hashpw(password, bcrypt.gensalt()) + now = timetools.now().timestamp() data = { 'id': user_id, 'username': username, 'password': hashed_password, 'display_name': display_name, - 'created': timetools.now().timestamp(), + 'created': now, } self.insert(table=objects.User, pairs=data) + for permission in set(permissions): + permission_row = { + 'userid': user_id, + 'permission': permission, + 'created': now, + } + self.insert(table='user_permissions', pairs=permission_row) + return self.get_cached_instance(objects.User, data) #################################################################################################### @@ -1138,6 +1155,9 @@ class PhotoDB( self.thumbnail_directory = self.data_directory.with_child(constants.DEFAULT_THUMBDIR) self.thumbnail_directory.makedirs(exist_ok=True) + self.uploads_directory = self.data_directory.with_child(constants.DEFAULT_UPLOADS_DIR) + self.uploads_directory.makedirs(exist_ok=True) + # CONFIG self.config_filepath = self.data_directory.with_child(constants.DEFAULT_CONFIGNAME) self.load_config() diff --git a/frontends/etiquette_flask/backend/common.py b/frontends/etiquette_flask/backend/common.py index b481668..6090e65 100644 --- a/frontends/etiquette_flask/backend/common.py +++ b/frontends/etiquette_flask/backend/common.py @@ -27,8 +27,8 @@ log = vlogging.getLogger(__name__) # Constants ######################################################################################## DEFAULT_SERVER_CONFIG = { - 'anonymous_read': True, - 'anonymous_write': True, + 'anonymous_read': False, + 'registration_enabled': False, } BROWSER_CACHE_DURATION = 180 @@ -84,7 +84,9 @@ def catch_etiquette_exception(endpoint): try: return endpoint(*args, **kwargs) except etiquette.exceptions.EtiquetteException as exc: - if isinstance(exc, etiquette.exceptions.NoSuch): + if isinstance(exc, etiquette.exceptions.Unauthorized): + status = 403 + elif isinstance(exc, etiquette.exceptions.NoSuch): status = 404 else: status = 400 @@ -98,7 +100,9 @@ def before_request(): # visitors, make sure your reverse proxy is properly setting X-Forwarded-For # so that werkzeug's proxyfix can set that as the remote_addr. # In NGINX: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + request.client_address = request.remote_addr request.is_localhost = (request.remote_addr == '127.0.0.1') + if site.localhost_only and not request.is_localhost: return flask.abort(403) @@ -145,7 +149,9 @@ def P_wrapper(function): return function(thingid) except etiquette.exceptions.EtiquetteException as exc: - if isinstance(exc, etiquette.exceptions.NoSuch): + if isinstance(exc, etiquette.exceptions.Unauthorized): + status = 403 + elif isinstance(exc, etiquette.exceptions.NoSuch): status = 404 else: status = 400 diff --git a/frontends/etiquette_flask/backend/endpoints/admin_endpoints.py b/frontends/etiquette_flask/backend/endpoints/admin_endpoints.py index d8a523c..a14ceac 100644 --- a/frontends/etiquette_flask/backend/endpoints/admin_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/admin_endpoints.py @@ -1,4 +1,5 @@ import flask; from flask import request +import json from voussoirkit import dotdict from voussoirkit import flasktools @@ -15,7 +16,7 @@ session_manager = common.session_manager @site.route('/admin') def get_admin(): - common.permission_manager.admin() + common.permission_manager.admin_only() counts = dotdict.DotDict({ 'albums': common.P.get_album_count(), @@ -31,11 +32,20 @@ def get_admin(): 'tags': len(common.P.caches[etiquette.objects.Tag]), 'users': len(common.P.caches[etiquette.objects.User]), }) - return common.render_template(request, 'admin.html', cached=cached, counts=counts) + return common.render_template( + request, + 'admin.html', + etq_config=json.dumps(common.P.config, indent=4, sort_keys=True), + server_config=json.dumps(common.site.server_config, indent=4, sort_keys=True), + cached=cached, + counts=counts, + users=list(common.P.get_users()), + sessions=list(session_manager.sessions.items()), + ) @site.route('/admin/dbdownload') def get_dbdump(): - common.permission_manager.admin() + common.permission_manager.admin_only() with common.P.transaction: binary = common.P.database_filepath.read('rb') @@ -50,14 +60,24 @@ def get_dbdump(): @site.route('/admin/clear_sessions', methods=['POST']) def post_clear_sessions(): - common.permission_manager.admin() + common.permission_manager.admin_only() session_manager.clear() + session_manager.save_state() + return flasktools.json_response({}) + +@site.route('/admin/remove_session', methods=['POST']) +@flasktools.required_fields(['token'], forbid_whitespace=True) +def post_remove_session(): + common.permission_manager.admin_only() + + token = request.form['token'] + session_manager.remove(token) return flasktools.json_response({}) @site.route('/admin/reload_config', methods=['POST']) def post_reload_config(): - common.permission_manager.admin() + common.permission_manager.admin_only() common.P.load_config() common.load_config() @@ -66,7 +86,7 @@ def post_reload_config(): @site.route('/admin/uncache', methods=['POST']) def post_uncache(): - common.permission_manager.admin() + common.permission_manager.admin_only() with common.P.transaction: for cache in common.P.caches.values(): diff --git a/frontends/etiquette_flask/backend/endpoints/album_endpoints.py b/frontends/etiquette_flask/backend/endpoints/album_endpoints.py index 4b65825..0aef70d 100644 --- a/frontends/etiquette_flask/backend/endpoints/album_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/album_endpoints.py @@ -17,7 +17,7 @@ session_manager = common.session_manager @site.route('/album/') def get_album_html(album_id): - common.permission_manager.basic() + common.permission_manager.read() album = common.P_album(album_id, response_type='html') response = common.render_template( request, @@ -29,14 +29,14 @@ def get_album_html(album_id): @site.route('/album/.json') def get_album_json(album_id): - common.permission_manager.basic() + common.permission_manager.read() album = common.P_album(album_id, response_type='json') album = album.jsonify() return flasktools.json_response(album) @site.route('/album/.zip') def get_album_zip(album_id): - common.permission_manager.basic() + common.permission_manager.read() album = common.P_album(album_id, response_type='html') recursive = request.args.get('recursive', True) @@ -61,10 +61,10 @@ def get_album_zip(album_id): @site.route('/album//add_child', methods=['POST']) @flasktools.required_fields(['child_id'], forbid_whitespace=True) def post_album_add_child(album_id): - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) child_ids = stringtools.comma_space_split(request.form['child_id']) with common.P.transaction: - album = common.P_album(album_id, response_type='json') children = list(common.P_albums(child_ids, response_type='json')) print(children) album.add_children(children) @@ -75,10 +75,10 @@ def post_album_add_child(album_id): @site.route('/album//remove_child', methods=['POST']) @flasktools.required_fields(['child_id'], forbid_whitespace=True) def post_album_remove_child(album_id): - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) child_ids = stringtools.comma_space_split(request.form['child_id']) with common.P.transaction: - album = common.P_album(album_id, response_type='json') children = list(common.P_albums(child_ids, response_type='json')) album.remove_children(children) response = album.jsonify() @@ -86,17 +86,18 @@ def post_album_remove_child(album_id): @site.route('/album//remove_thumbnail_photo', methods=['POST']) def post_album_remove_thumbnail_photo(album_id): - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) with common.P.transaction: - album = common.P_album(album_id, response_type='json') album.set_thumbnail_photo(None) return flasktools.json_response(album.jsonify()) @site.route('/album//refresh_directories', methods=['POST']) def post_album_refresh_directories(album_id): - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) + common.permission_manager.permission_string(etiquette.constants.PERMISSION_PHOTO_CREATE) with common.P.transaction: - album = common.P_album(album_id, response_type='json') for directory in album.get_associated_directories(): if not directory.is_dir: continue @@ -107,9 +108,9 @@ def post_album_refresh_directories(album_id): @site.route('/album//set_thumbnail_photo', methods=['POST']) @flasktools.required_fields(['photo_id'], forbid_whitespace=True) def post_album_set_thumbnail_photo(album_id): - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) with common.P.transaction: - album = common.P_album(album_id, response_type='json') photo = common.P_photo(request.form['photo_id'], response_type='json') album.set_thumbnail_photo(photo) return flasktools.json_response(album.jsonify()) @@ -122,10 +123,10 @@ def post_album_add_photo(album_id): ''' Add a photo or photos to this album. ''' - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) photo_ids = stringtools.comma_space_split(request.form['photo_id']) with common.P.transaction: - album = common.P_album(album_id, response_type='json') photos = list(common.P_photos(photo_ids, response_type='json')) album.add_photos(photos) response = album.jsonify() @@ -137,10 +138,10 @@ def post_album_remove_photo(album_id): ''' Remove a photo or photos from this album. ''' - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) photo_ids = stringtools.comma_space_split(request.form['photo_id']) with common.P.transaction: - album = common.P_album(album_id, response_type='json') photos = list(common.P_photos(photo_ids, response_type='json')) album.remove_photos(photos) response = album.jsonify() @@ -153,7 +154,7 @@ def post_album_add_tag(album_id): ''' Apply a tag to every photo in the album. ''' - common.permission_manager.basic() + common.permission_manager.admin_only() response = {} with common.P.transaction: album = common.P_album(album_id, response_type='json') @@ -178,12 +179,12 @@ def post_album_edit(album_id): ''' Edit the title / description. ''' - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.edit_thing(album) title = request.form.get('title', None) description = request.form.get('description', None) with common.P.transaction: - album = common.P_album(album_id, response_type='json') album.edit(title=title, description=description) response = album.jsonify( @@ -195,9 +196,7 @@ def post_album_edit(album_id): @site.route('/album//show_in_folder', methods=['POST']) def post_album_show_in_folder(album_id): - common.permission_manager.basic() - if not request.is_localhost: - flask.abort(403) + common.permission_manager.localhost_only() album = common.P_album(album_id, response_type='json') directories = album.get_associated_directories() @@ -215,7 +214,7 @@ def post_album_show_in_folder(album_id): # Album listings ################################################################################### @site.route('/all_albums.json') -@common.permission_manager.basic_decorator +@common.permission_manager.read_decorator @flasktools.cached_endpoint(max_age=15) def get_all_album_names(): all_albums = {album.id: album.display_name for album in common.P.get_albums()} @@ -224,7 +223,7 @@ def get_all_album_names(): @site.route('/albums') def get_albums_html(): - common.permission_manager.basic() + common.permission_manager.read() albums = list(common.P.get_root_albums()) albums.sort(key=lambda x: x.display_name.lower()) response = common.render_template( @@ -237,7 +236,7 @@ def get_albums_html(): @site.route('/albums.json') def get_albums_json(): - common.permission_manager.basic() + common.permission_manager.read() albums = list(common.P.get_albums()) albums.sort(key=lambda x: x.display_name.lower()) albums = [album.jsonify(include_photos=False) for album in albums] @@ -247,7 +246,7 @@ def get_albums_json(): @site.route('/albums/create_album', methods=['POST']) def post_albums_create(): - common.permission_manager.basic() + common.permission_manager.permission_string(etiquette.constants.PERMISSION_ALBUM_CREATE) title = request.form.get('title', None) description = request.form.get('description', None) parent_id = request.form.get('parent_id', None) @@ -270,8 +269,8 @@ def post_albums_create(): @site.route('/album//delete', methods=['POST']) def post_album_delete(album_id): - common.permission_manager.basic() + album = common.P_album(album_id, response_type='json') + common.permission_manager.delete_thing(album) with common.P.transaction: - album = common.P_album(album_id, response_type='json') album.delete() return flasktools.json_response({}) diff --git a/frontends/etiquette_flask/backend/endpoints/basic_endpoints.py b/frontends/etiquette_flask/backend/endpoints/basic_endpoints.py index b06cb65..6c05359 100644 --- a/frontends/etiquette_flask/backend/endpoints/basic_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/basic_endpoints.py @@ -12,7 +12,12 @@ session_manager = common.session_manager def root(): common.permission_manager.global_public() motd = random.choice(common.P.config['motd_strings']) - return common.render_template(request, 'root.html', motd=motd) + return common.render_template( + request, + 'root.html', + motd=motd, + anonymous_read=common.site.server_config['anonymous_read'], + ) @site.route('/favicon.ico') @site.route('/favicon.png') diff --git a/frontends/etiquette_flask/backend/endpoints/bookmark_endpoints.py b/frontends/etiquette_flask/backend/endpoints/bookmark_endpoints.py index 4ef791c..4d0e408 100644 --- a/frontends/etiquette_flask/backend/endpoints/bookmark_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/bookmark_endpoints.py @@ -13,16 +13,16 @@ session_manager = common.session_manager @site.route('/bookmark/.json') def get_bookmark_json(bookmark_id): - common.permission_manager.basic() + common.permission_manager.read() bookmark = common.P_bookmark(bookmark_id, response_type='json') response = bookmark.jsonify() return flasktools.json_response(response) @site.route('/bookmark//edit', methods=['POST']) def post_bookmark_edit(bookmark_id): - common.permission_manager.basic() + bookmark = common.P_bookmark(bookmark_id, response_type='json') + common.permission_manager.edit_thing(bookmark) with common.P.transaction: - bookmark = common.P_bookmark(bookmark_id, response_type='json') # Emptystring is okay for titles, but not for URL. title = request.form.get('title', None) url = request.form.get('url', None) or None @@ -36,7 +36,7 @@ def post_bookmark_edit(bookmark_id): @site.route('/bookmarks.atom') def get_bookmarks_atom(): - common.permission_manager.basic() + common.permission_manager.read() bookmarks = common.P.get_bookmarks() response = etiquette.helpers.make_atom_feed( bookmarks, @@ -48,13 +48,13 @@ def get_bookmarks_atom(): @site.route('/bookmarks') def get_bookmarks_html(): - common.permission_manager.basic() + common.permission_manager.read() bookmarks = list(common.P.get_bookmarks()) return common.render_template(request, 'bookmarks.html', bookmarks=bookmarks) @site.route('/bookmarks.json') def get_bookmarks_json(): - common.permission_manager.basic() + common.permission_manager.read() bookmarks = [b.jsonify() for b in common.P.get_bookmarks()] return flasktools.json_response(bookmarks) @@ -63,7 +63,7 @@ def get_bookmarks_json(): @site.route('/bookmarks/create_bookmark', methods=['POST']) @flasktools.required_fields(['url'], forbid_whitespace=True) def post_bookmark_create(): - common.permission_manager.basic() + common.permission_manager.permission_string(etiquette.constants.PERMISSION_BOOKMARK_CREATE) url = request.form['url'] title = request.form.get('title', None) user = session_manager.get(request).user @@ -75,8 +75,8 @@ def post_bookmark_create(): @site.route('/bookmark//delete', methods=['POST']) def post_bookmark_delete(bookmark_id): - common.permission_manager.basic() + bookmark = common.P_bookmark(bookmark_id, response_type='json') + common.permission_manager.delete_thing(bookmark) with common.P.transaction: - bookmark = common.P_bookmark(bookmark_id, response_type='json') bookmark.delete() return flasktools.json_response({}) diff --git a/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py b/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py index 738c305..79fe609 100644 --- a/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py @@ -1,5 +1,8 @@ +import gevent import flask; from flask import request import os +import random +import re import subprocess import traceback import urllib.parse @@ -8,6 +11,7 @@ from voussoirkit import cacheclass from voussoirkit import flasktools from voussoirkit import pathclass from voussoirkit import stringtools +from voussoirkit import timetools from voussoirkit import vlogging log = vlogging.get_logger(__name__) @@ -17,6 +21,8 @@ import etiquette from .. import common from .. import helpers +RNG = random.SystemRandom() + site = common.site session_manager = common.session_manager photo_download_zip_tokens = cacheclass.Cache(maxlen=100) @@ -25,13 +31,13 @@ photo_download_zip_tokens = cacheclass.Cache(maxlen=100) @site.route('/photo/') def get_photo_html(photo_id): - common.permission_manager.basic() + common.permission_manager.read() photo = common.P_photo(photo_id, response_type='html') return common.render_template(request, 'photo.html', photo=photo) @site.route('/photo/.json') def get_photo_json(photo_id): - common.permission_manager.basic() + common.permission_manager.read() photo = common.P_photo(photo_id, response_type='json') photo = photo.jsonify() photo = flasktools.json_response(photo) @@ -40,7 +46,7 @@ def get_photo_json(photo_id): @site.route('/photo//download') @site.route('/photo//download/') def get_file(photo_id, basename=None): - common.permission_manager.basic() + common.permission_manager.read() photo_id = photo_id.split('.')[0] photo = common.P.get_photo(photo_id) @@ -66,7 +72,7 @@ def get_file(photo_id, basename=None): @site.route('/photo//thumbnail') @site.route('/photo//thumbnail/') -@common.permission_manager.basic_decorator +@common.permission_manager.read_decorator @flasktools.cached_endpoint(max_age=common.BROWSER_CACHE_DURATION, etag_function=lambda: common.P.last_commit_id) def get_thumbnail(photo_id, basename=None): photo_id = photo_id.split('.')[0] @@ -89,11 +95,11 @@ def get_thumbnail(photo_id, basename=None): @site.route('/photo//delete', methods=['POST']) def post_photo_delete(photo_id): - common.permission_manager.basic() + photo = common.P_photo(photo_id, response_type='json') + common.permission_manager.delete_thing(photo) delete_file = request.form.get('delete_file', False) delete_file = stringtools.truthystring(delete_file) with common.P.transaction: - photo = common.P_photo(photo_id, response_type='json') photo.delete(delete_file=delete_file) return flasktools.json_response({}) @@ -106,6 +112,9 @@ def post_photo_add_remove_tag_core(photo_ids, tagname, add_or_remove, timestamp= photos = list(common.P_photos(photo_ids, response_type='json')) tag = common.P_tag(tagname, response_type='json') + for photo in photos: + common.permission_manager.edit_thing(photo) + response = {'action': add_or_remove, 'tagname': tag.name} with common.P.transaction: for photo in photos: @@ -123,8 +132,8 @@ def post_photo_add_tag(photo_id): ''' Add a tag to this photo. ''' - common.permission_manager.basic() photo = common.P_photo(photo_id, response_type='json') + common.permission_manager.edit_thing(photo) tag = common.P_tag(request.form['tagname'], response_type='json') with common.P.transaction: @@ -138,9 +147,9 @@ def post_photo_copy_tags(photo_id): ''' Copy the tags from another photo. ''' - common.permission_manager.basic() + photo = common.P_photo(photo_id, response_type='json') + common.permission_manager.edit_thing(photo) with common.P.transaction: - photo = common.P_photo(photo_id, response_type='json') other = common.P_photo(request.form['other_photo'], response_type='json') photo.copy_tags(other) return flasktools.json_response([tag.jsonify() for tag in photo.get_tags()]) @@ -151,7 +160,7 @@ def post_photo_remove_tag(photo_id): ''' Remove a tag from this photo. ''' - common.permission_manager.basic() + common.permission_manager.edit_thing(photo) response = post_photo_add_remove_tag_core( photo_ids=photo_id, tagname=request.form['tagname'], @@ -164,16 +173,15 @@ def post_photo_tag_rel_delete(photo_tag_rel_id): ''' Remove a tag from a photo. ''' - common.permission_manager.basic() + photo_tag = common.P.get_object_by_id(etiquette.objects.PhotoTagRel, photo_tag_rel_id) + common.permission_manager.edit_thing(photo_tag.photo) with common.P.transaction: - photo_tag = common.P.get_object_by_id(etiquette.objects.PhotoTagRel, photo_tag_rel_id) photo_tag.delete() return flasktools.json_response(photo_tag.jsonify()) @site.route('/batch/photos/add_tag', methods=['POST']) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) def post_batch_photos_add_tag(): - common.permission_manager.basic() response = post_photo_add_remove_tag_core( photo_ids=request.form['photo_ids'], tagname=request.form['tagname'], @@ -184,7 +192,6 @@ def post_batch_photos_add_tag(): @site.route('/batch/photos/remove_tag', methods=['POST']) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) def post_batch_photos_remove_tag(): - common.permission_manager.basic() response = post_photo_add_remove_tag_core( photo_ids=request.form['photo_ids'], tagname=request.form['tagname'], @@ -198,14 +205,15 @@ def post_photo_generate_thumbnail_core(photo_ids, special={}): if isinstance(photo_ids, str): photo_ids = stringtools.comma_space_split(photo_ids) - with common.P.transaction: - photos = list(common.P_photos(photo_ids, response_type='json')) + photos = list(common.P_photos(photo_ids, response_type='json')) + for photo in photos: + common.permission_manager.edit_thing(photo) + with common.P.transaction: for photo in photos: photo._uncache() - photo = common.P_photo(photo.id, response_type='json') try: - photo.generate_thumbnail() + photo.generate_thumbnail(special=special) except Exception: log.warning(traceback.format_exc()) @@ -213,14 +221,14 @@ def post_photo_generate_thumbnail_core(photo_ids, special={}): @site.route('/photo//generate_thumbnail', methods=['POST']) def post_photo_generate_thumbnail(photo_id): - common.permission_manager.basic() + common.permission_manager.early_read() special = request.form.to_dict() response = post_photo_generate_thumbnail_core(photo_ids=photo_id, special=special) return response @site.route('/batch/photos/generate_thumbnail', methods=['POST']) def post_batch_photos_generate_thumbnail(): - common.permission_manager.basic() + common.permission_manager.early_read() special = request.form.to_dict() response = post_photo_generate_thumbnail_core(photo_ids=request.form['photo_ids'], special=special) return response @@ -229,14 +237,18 @@ def post_photo_refresh_metadata_core(photo_ids): if isinstance(photo_ids, str): photo_ids = stringtools.comma_space_split(photo_ids) + photos = list(common.P_photos(photo_ids, response_type='json')) + for photo in photos: + common.permission_manager.edit_thing(photo) + with common.P.transaction: - photos = list(common.P_photos(photo_ids, response_type='json')) for photo in photos: photo._uncache() photo = common.P_photo(photo.id, response_type='json') try: photo.reload_metadata() + gevent.sleep(0.01) except pathclass.NotFile: flask.abort(404) @@ -250,50 +262,36 @@ def post_photo_refresh_metadata_core(photo_ids): @site.route('/photo//refresh_metadata', methods=['POST']) def post_photo_refresh_metadata(photo_id): - common.permission_manager.basic() + common.permission_manager.early_read() response = post_photo_refresh_metadata_core(photo_ids=photo_id) return response @site.route('/batch/photos/refresh_metadata', methods=['POST']) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True) def post_batch_photos_refresh_metadata(): - common.permission_manager.basic() + common.permission_manager.early_read() response = post_photo_refresh_metadata_core(photo_ids=request.form['photo_ids']) return response @site.route('/photo//set_searchhidden', methods=['POST']) def post_photo_set_searchhidden(photo_id): - common.permission_manager.basic() + photo = common.P_photo(photo_id, response_type='json') + common.permission_manager.edit_thing(photo) with common.P.transaction: - photo = common.P_photo(photo_id, response_type='json') photo.set_searchhidden(True) return flasktools.json_response({}) @site.route('/photo//unset_searchhidden', methods=['POST']) def post_photo_unset_searchhidden(photo_id): - common.permission_manager.basic() + photo = common.P_photo(photo_id, response_type='json') + common.permission_manager.edit_thing(photo) with common.P.transaction: - photo = common.P_photo(photo_id, response_type='json') photo.set_searchhidden(False) return flasktools.json_response({}) -def post_batch_photos_searchhidden_core(photo_ids, searchhidden): - if isinstance(photo_ids, str): - photo_ids = stringtools.comma_space_split(photo_ids) - - with common.P.transaction: - photos = list(common.P_photos(photo_ids, response_type='json')) - - for photo in photos: - photo.set_searchhidden(searchhidden) - - return flasktools.json_response({}) - @site.route('/photo//show_in_folder', methods=['POST']) def post_photo_show_in_folder(photo_id): - common.permission_manager.basic() - if not request.is_localhost: - flask.abort(403) + common.permission_manager.localhost_only() photo = common.P_photo(photo_id, response_type='json') if os.name == 'nt': @@ -307,10 +305,25 @@ def post_photo_show_in_folder(photo_id): flask.abort(501) +def post_batch_photos_searchhidden_core(photo_ids, searchhidden): + if isinstance(photo_ids, str): + photo_ids = stringtools.comma_space_split(photo_ids) + + photos = list(common.P_photos(photo_ids, response_type='json')) + for photo in photos: + common.permission_manager.edit_thing(photo) + + with common.P.transaction: + + for photo in photos: + photo.set_searchhidden(searchhidden) + + return flasktools.json_response({}) + @site.route('/batch/photos/set_searchhidden', methods=['POST']) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True) def post_batch_photos_set_searchhidden(): - common.permission_manager.basic() + common.permission_manager.early_read() photo_ids = request.form['photo_ids'] response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=True) return response @@ -318,16 +331,46 @@ def post_batch_photos_set_searchhidden(): @site.route('/batch/photos/unset_searchhidden', methods=['POST']) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True) def post_batch_photos_unset_searchhidden(): - common.permission_manager.basic() + common.permission_manager.early_read() photo_ids = request.form['photo_ids'] response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=False) return response +def post_batch_photos_delete_core(photo_ids, delete_file): + if isinstance(photo_ids, str): + photo_ids = stringtools.comma_space_split(photo_ids) + + photos = list(common.P_photos(photo_ids, response_type='json')) + for photo in photos: + common.permission_manager.delete_thing(photo) + + with common.P.transaction: + for photo in photos: + photo.delete(delete_file=delete_file) + + return flasktools.json_response({}) + +@site.route('/batch/photos/soft_delete', methods=['POST']) +@flasktools.required_fields(['photo_ids'], forbid_whitespace=True) +def post_batch_photos_soft_delete(): + common.permission_manager.early_read() + photo_ids = request.form['photo_ids'] + response = post_batch_photos_delete_core(photo_ids=photo_ids, delete_file=False) + return response + +@site.route('/batch/photos/hard_delete', methods=['POST']) +@flasktools.required_fields(['photo_ids'], forbid_whitespace=True) +def post_batch_photos_hard_delete(): + common.permission_manager.early_read() + photo_ids = request.form['photo_ids'] + response = post_batch_photos_delete_core(photo_ids=photo_ids, delete_file=True) + return response + # Clipboard ######################################################################################## @site.route('/clipboard') def get_clipboard_page(): - common.permission_manager.basic() + common.permission_manager.read() return common.render_template(request, 'clipboard.html') @site.route('/batch/photos', methods=['POST']) @@ -336,7 +379,7 @@ def post_batch_photos(): ''' Return a list of photo.jsonify() for each requested photo id. ''' - common.permission_manager.basic() + common.permission_manager.read() photo_ids = request.form['photo_ids'] photo_ids = stringtools.comma_space_split(photo_ids) @@ -349,7 +392,7 @@ def post_batch_photos(): @site.route('/batch/photos/photo_card', methods=['POST']) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True) def post_batch_photos_photo_cards(): - common.permission_manager.basic() + common.permission_manager.read() photo_ids = request.form['photo_ids'] photo_ids = stringtools.comma_space_split(photo_ids) @@ -381,7 +424,7 @@ def get_batch_photos_download_zip(zip_token): After the user has generated their zip token, they can retrieve that zip file. ''' - common.permission_manager.basic() + common.permission_manager.read() zip_token = zip_token.split('.')[0] try: photo_ids = photo_download_zip_tokens[zip_token] @@ -411,7 +454,7 @@ def post_batch_photos_download_zip(): so the way this works is we generate a token representing the photoset that they want, and then they can retrieve the zip itself via GET. ''' - common.permission_manager.basic() + common.permission_manager.read() photo_ids = request.form['photo_ids'] photo_ids = stringtools.comma_space_split(photo_ids) @@ -486,7 +529,7 @@ def get_search_core(): @site.route('/search_embed') def get_search_embed(): - common.permission_manager.basic() + common.permission_manager.read() search = get_search_core() response = common.render_template( request, @@ -498,7 +541,7 @@ def get_search_embed(): @site.route('/search') def get_search_html(): - common.permission_manager.basic() + common.permission_manager.read() search = get_search_core() search.kwargs.view = request.args.get('view', 'grid') @@ -549,7 +592,7 @@ def get_search_html(): @site.route('/search.atom') def get_search_atom(): - common.permission_manager.basic() + common.permission_manager.read() search = get_search_core() soup = etiquette.helpers.make_atom_feed( search.results, @@ -562,7 +605,7 @@ def get_search_atom(): @site.route('/search.json') def get_search_json(): - common.permission_manager.basic() + common.permission_manager.read() search = get_search_core() response = search.jsonify() return flasktools.json_response(response) @@ -571,6 +614,92 @@ def get_search_json(): @site.route('/swipe') def get_swipe(): - common.permission_manager.basic() + common.permission_manager.read() response = common.render_template(request, 'swipe.html') return response + +# Upload ########################################################################################### + +@site.route('/upload') +def get_upload_page(): + common.permission_manager.permission_string(etiquette.constants.PERMISSION_PHOTO_CREATE) + response = common.render_template(request, 'upload.html') + return response + +@site.route('/photo/upload', methods=['POST']) +def post_photo_upload(): + common.permission_manager.permission_string(etiquette.constants.PERMISSION_PHOTO_CREATE) + + files = request.files.getlist('file') + print(files) + if len(files) == 0: + return flask.abort(400) + + job_id = f'{int(timetools.now().timestamp() * 1000)}_{RNG.getrandbits(32)}' + folder = common.P.uploads_directory.with_child(job_id) + folder.makedirs() + + # All uploading/saving must be done before the transaction so the database + # is not locked up by a slow uploader. + diskpaths = [] + for (index, file) in enumerate(files): + log.debug('Receiving uploaded file %s.' % file.filename) + unsafepath = file.filename + unsafepath = unsafepath.replace('\\', '/') + unsafepath = re.sub(r'//+', '/', unsafepath) + unsafepath = unsafepath.strip('/') + + extension = pathclass.Path(unsafepath).extension + if len(extension) > 30: + extension = '' + + diskpath = folder.with_child(f'{job_id}_{index}').add_extension(extension) + file.save(diskpath.absolute_path) + + diskpaths.append(diskpath) + diskpath._unsafepath = unsafepath + + diskpaths.sort(key=lambda x: x._unsafepath) + + with common.P.transaction: + albums_by_path = {} + for diskpath in diskpaths: + print(diskpath._unsafepath) + photo = common.P.new_photo( + diskpath, + author=request.session.user, + override_filename=diskpath._unsafepath.split('/')[-1], + ) + + if '/' not in diskpath._unsafepath: + continue + + (parentpath, basename) = diskpath._unsafepath.rsplit('/', 1) + if parentpath in albums_by_path: + parentalbum = albums_by_path[parentpath] + else: + parentname = parentpath.rsplit('/', 1)[-1] + parentalbum = common.P.new_album(parentname) + albums_by_path[parentpath] = parentalbum + + parentalbum.add_photo(photo) + + # Build the Album tree + to_check = set(albums_by_path.keys()) + while len(to_check) > 0: + key = to_check.pop() + album = albums_by_path[key] + print(key, album) + + if '/' not in key: + continue + + (parentkey, parentname) = key.rsplit('/', 1) + if parentkey in albums_by_path: + parentalbum = albums_by_path[parentkey] + else: + parentalbum = common.P.new_album(parentname) + albums_by_path[parentkey] = parentalbum + to_check.add(parentkey) + + parentalbum.add_child(album) diff --git a/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py b/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py index d5ef4ec..bed9ec8 100644 --- a/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py @@ -15,13 +15,13 @@ session_manager = common.session_manager @site.route('/tags/') @site.route('/tags/.json') def get_tags_specific_redirect(specific_tag): - common.permission_manager.basic() + common.permission_manager.read() return flask.redirect(request.url.replace('/tags/', '/tag/')) @site.route('/tagid/') @site.route('/tagid/.json') def get_tag_id_redirect(tag_id): - common.permission_manager.basic() + common.permission_manager.read() if request.path.endswith('.json'): tag = common.P_tag_id(tag_id, response_type='json') else: @@ -33,7 +33,7 @@ def get_tag_id_redirect(tag_id): @site.route('/tag/.json') def get_tag_json(specific_tag_name): - common.permission_manager.basic() + common.permission_manager.read() specific_tag = common.P_tag(specific_tag_name, response_type='json') if specific_tag.name != specific_tag_name: new_url = f'/tag/{specific_tag.name}.json' + request.query_string.decode('utf-8') @@ -47,9 +47,9 @@ def get_tag_json(specific_tag_name): @site.route('/tag//edit', methods=['POST']) def post_tag_edit(tagname): - common.permission_manager.basic() + tag = common.P_tag(tagname, response_type='json') + common.permission_manager.edit_thing(tag) with common.P.transaction: - tag = common.P_tag(tagname, response_type='json') name = request.form.get('name', '').strip() if name: tag.rename(name) @@ -63,10 +63,13 @@ def post_tag_edit(tagname): @site.route('/tag//add_child', methods=['POST']) @flasktools.required_fields(['child_name'], forbid_whitespace=True) def post_tag_add_child(tagname): - common.permission_manager.basic() + parent = common.P_tag(tagname, response_type='json') + common.permission_manager.edit_thing(parent) + + child = common.P_tag(request.form['child_name'], response_type='json') + common.permission_manager.edit_thing(child) + with common.P.transaction: - parent = common.P_tag(tagname, response_type='json') - child = common.P_tag(request.form['child_name'], response_type='json') parent.add_child(child) response = {'action': 'add_child', 'tagname': f'{parent.name}.{child.name}'} return flasktools.json_response(response) @@ -74,11 +77,11 @@ def post_tag_add_child(tagname): @site.route('/tag//add_synonym', methods=['POST']) @flasktools.required_fields(['syn_name'], forbid_whitespace=True) def post_tag_add_synonym(tagname): - common.permission_manager.basic() + master_tag = common.P_tag(tagname, response_type='json') + common.permission_manager.edit_thing(master_tag) syn_name = request.form['syn_name'] with common.P.transaction: - master_tag = common.P_tag(tagname, response_type='json') syn_name = master_tag.add_synonym(syn_name) response = {'action': 'add_synonym', 'synonym': syn_name} @@ -87,10 +90,13 @@ def post_tag_add_synonym(tagname): @site.route('/tag//remove_child', methods=['POST']) @flasktools.required_fields(['child_name'], forbid_whitespace=True) def post_tag_remove_child(tagname): - common.permission_manager.basic() + parent = common.P_tag(tagname, response_type='json') + common.permission_manager.edit_thing(parent) + + child = common.P_tag(request.form['child_name'], response_type='json') + common.permission_manager.edit_thing(child) + with common.P.transaction: - parent = common.P_tag(tagname, response_type='json') - child = common.P_tag(request.form['child_name'], response_type='json') parent.remove_child(child) response = {'action': 'remove_child', 'tagname': f'{parent.name}.{child.name}'} return flasktools.json_response(response) @@ -98,11 +104,11 @@ def post_tag_remove_child(tagname): @site.route('/tag//remove_synonym', methods=['POST']) @flasktools.required_fields(['syn_name'], forbid_whitespace=True) def post_tag_remove_synonym(tagname): - common.permission_manager.basic() + master_tag = common.P_tag(tagname, response_type='json') + common.permission_manager.edit_thing(master_tag) syn_name = request.form['syn_name'] with common.P.transaction: - master_tag = common.P_tag(tagname, response_type='json') syn_name = master_tag.remove_synonym(syn_name) response = {'action': 'delete_synonym', 'synonym': syn_name} @@ -111,7 +117,7 @@ def post_tag_remove_synonym(tagname): # Tag listings ##################################################################################### @site.route('/all_tags.json') -@common.permission_manager.basic_decorator +@common.permission_manager.read_decorator @flasktools.cached_endpoint(max_age=15) def get_all_tag_names(): all_tags = list(sorted(common.P.get_all_tag_names())) @@ -122,7 +128,7 @@ def get_all_tag_names(): @site.route('/tag/') @site.route('/tags') def get_tags_html(specific_tag_name=None): - common.permission_manager.basic() + common.permission_manager.read() if specific_tag_name is None: specific_tag = None else: @@ -161,7 +167,7 @@ def get_tags_html(specific_tag_name=None): @site.route('/tags.json') def get_tags_json(): - common.permission_manager.basic() + common.permission_manager.read() include_synonyms = request.args.get('synonyms') include_synonyms = include_synonyms is None or stringtools.truthystring(include_synonyms) @@ -175,7 +181,8 @@ def get_tags_json(): @site.route('/tags/create_tag', methods=['POST']) @flasktools.required_fields(['name'], forbid_whitespace=True) def post_tag_create(): - common.permission_manager.basic() + common.permission_manager.permission_string(etiquette.constants.PERMISSION_TAG_CREATE) + name = request.form['name'] description = request.form.get('description', None) @@ -187,7 +194,7 @@ def post_tag_create(): @site.route('/tags/easybake', methods=['POST']) @flasktools.required_fields(['easybake_string'], forbid_whitespace=True) def post_tag_easybake(): - common.permission_manager.basic() + common.permission_manager.admin_only() easybake_string = request.form['easybake_string'] with common.P.transaction: @@ -197,9 +204,9 @@ def post_tag_easybake(): @site.route('/tag//delete', methods=['POST']) def post_tag_delete(tagname): - common.permission_manager.basic() + tag = common.P_tag(tagname, response_type='json') + common.permission_manager.delete_thing(tag) with common.P.transaction: - tag = common.P_tag(tagname, response_type='json') tag.delete() response = {'action': 'delete_tag', 'tagname': tag.name} return flasktools.json_response(response) diff --git a/frontends/etiquette_flask/backend/endpoints/user_endpoints.py b/frontends/etiquette_flask/backend/endpoints/user_endpoints.py index ce6d0f3..3158466 100644 --- a/frontends/etiquette_flask/backend/endpoints/user_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/user_endpoints.py @@ -1,6 +1,7 @@ import flask; from flask import request from voussoirkit import flasktools +from voussoirkit import stringtools import etiquette @@ -14,13 +15,19 @@ session_manager = common.session_manager @site.route('/user/') def get_user_html(username): - common.permission_manager.basic() + common.permission_manager.read() user = common.P_user(username, response_type='html') - return common.render_template(request, 'user.html', user=user) + return common.render_template( + request, + 'user.html', + user=user, + user_permissions=user.get_permissions(), + constants_all_permissions=etiquette.constants.ALL_PERMISSIONS, + ) @site.route('/user/.json') def get_user_json(username): - common.permission_manager.basic() + common.permission_manager.read() user = common.P_user(username, response_type='json') user = user.jsonify() return flasktools.json_response(user) @@ -28,7 +35,7 @@ def get_user_json(username): @site.route('/userid/') @site.route('/userid/.json') def get_user_id_redirect(user_id): - common.permission_manager.basic() + common.permission_manager.read() if request.path.endswith('.json'): user = common.P_user_id(user_id, response_type='json') else: @@ -40,12 +47,8 @@ def get_user_id_redirect(user_id): @site.route('/user//edit', methods=['POST']) def post_user_edit(username): - common.permission_manager.basic() - if not request.session: - return flasktools.json_response(etiquette.exceptions.Unauthorized().jsonify(), status=403) user = common.P_user(username, response_type='json') - if request.session.user != user: - return flasktools.json_response(etiquette.exceptions.Unauthorized().jsonify(), status=403) + common.permission_manager.logged_in(user) display_name = request.form.get('display_name') if display_name is not None: @@ -54,6 +57,54 @@ def post_user_edit(username): return flasktools.json_response(user.jsonify()) +@site.route('/user//set_password', methods=['POST']) +@flasktools.required_fields(['current_password', 'password_1', 'password_2']) +def post_user_set_password(username): + user = common.P_user(username, response_type='json') + common.permission_manager.logged_in(user) + + current_password = request.form.get('current_password') + try: + user.check_password(current_password) + except (etiquette.exceptions.WrongLogin): + exc = etiquette.exceptions.WrongLogin() + response = exc.jsonify() + return flasktools.json_response(response, status=422) + except etiquette.exceptions.FeatureDisabled as exc: + response = exc.jsonify() + return flasktools.json_response(response, status=400) + + password_1 = request.form.get('password_1') + password_2 = request.form.get('password_2') + if password_1 != password_2: + response = { + 'error_type': 'PASSWORDS_DONT_MATCH', + 'error_message': 'Passwords do not match.', + } + return flasktools.json_response(response, status=422) + + with common.P.transaction: + user.set_password(password_1) + + sessions = list(session_manager.sessions.items()) + for (token, session) in sessions: + if session.user == user and token != request.session.token: + session_manager.remove(token) + +@site.route('/user//set_permission', methods=['POST']) +@flasktools.required_fields(['permission', 'value']) +def post_user_set_permission(username): + common.permission_manager.admin_only() + permission_string = request.form['permission'] + permission_value = stringtools.truthystring(request.form['value']) + user = common.P_user(username, response_type='json') + with common.P.transaction: + if permission_value: + user.add_permission(permission_string) + else: + user.remove_permission(permission_string) + return flasktools.json_response(user.jsonify()) + # Login and logout ################################################################################# @site.route('/login', methods=['GET']) @@ -64,7 +115,7 @@ def get_login(): 'login.html', min_username_length=common.P.config['user']['min_username_length'], min_password_length=common.P.config['user']['min_password_length'], - registration_enabled=common.P.config['enable_feature']['user']['new'], + registration_enabled=common.site.server_config['registration_enabled'], ) return response @@ -96,13 +147,13 @@ def post_login(): response = exc.jsonify() return flasktools.json_response(response, status=400) - request.session = sessions.Session(request, user) - session_manager.add(request.session) + request.session = sessions.Session.from_request(session_manager=session_manager, request=request, user=user) + session_manager.save_state() return flasktools.json_response({}) @site.route('/logout', methods=['POST']) def post_logout(): - common.permission_manager.basic() + common.permission_manager.logged_in() session_manager.remove(request) response = flasktools.json_response({}) return response @@ -138,6 +189,5 @@ def post_register(): with common.P.transaction: user = common.P.new_user(username, password_1, display_name=display_name) - request.session = sessions.Session(request, user) - session_manager.add(request.session) + request.session = sessions.Session.from_request(session_manager=session_manager, request=request, user=user) return flasktools.json_response({}) diff --git a/frontends/etiquette_flask/backend/permissions.py b/frontends/etiquette_flask/backend/permissions.py index c774f2c..5558b27 100644 --- a/frontends/etiquette_flask/backend/permissions.py +++ b/frontends/etiquette_flask/backend/permissions.py @@ -3,41 +3,124 @@ import functools from voussoirkit import vlogging +import etiquette + log = vlogging.getLogger(__name__) class PermissionManager: def __init__(self, site): self.site = site - def admin(self): + def admin_only(self): + if request.is_localhost: + request.checked_permissions = True + return True + elif request.session.user and request.session.user.has_permission(etiquette.constants.PERMISSION_ADMIN): + request.checked_permissions = True + return True + else: + raise etiquette.exceptions.Unauthorized() + + def localhost_only(self): if request.is_localhost: request.checked_permissions = True return True else: - return flask.abort(403) + raise etiquette.exceptions.Unauthorized() - def basic(self): - if request.method not in {'GET', 'POST'}: - return flask.abort(405) - elif request.is_localhost: + def logged_in(self, user=None): + ''' + Require that the visitor be logged in as any user, or as one + specific user. + ''' + if request.session and request.session.user and user is None: request.checked_permissions = True return True - elif request.method == 'GET' and self.site.server_config['anonymous_read'] or request.session.user: - request.checked_permissions = True - return True - elif request.method == 'POST' and self.site.server_config['anonymous_write'] or request.session.user: + if request.session and request.session.user and request.session.user == user: request.checked_permissions = True return True else: - return flask.abort(403) + raise etiquette.exceptions.Unauthorized() - def basic_decorator(self, endpoint): + def early_read(self): + ''' + This method does not set request.checked_permissions and must be used + along with one of the other checks. However, this check can act as a + cheap blocker against logged-out users before the caller wastes any time + loading items from the database to check more specific permissions. + For example, it is used by several of the bulk endpoints before checking + any of the individual items. + ''' + if request.is_localhost: + return True + elif self.site.server_config['anonymous_read'] or request.session.user: + return True + else: + raise etiquette.exceptions.Unauthorized() + + def edit_thing(self, thing): + if request.is_localhost: + request.checked_permissions = True + return True + elif request.session.user and request.session.user.has_object_permission(thing, 'edit'): + request.checked_permissions = True + return True + else: + raise etiquette.exceptions.Unauthorized() + + def delete_thing(self, thing): + if request.is_localhost: + request.checked_permissions = True + return True + elif request.session.user and request.session.user.has_object_permission(thing, 'delete'): + request.checked_permissions = True + return True + else: + raise etiquette.exceptions.Unauthorized() + + def permission_string(self, permission_string): + ''' + Require that the user has this specific permission string (mostly for + the CREATE permissions rather than edit/delete permissions). + ''' + if request.is_localhost: + request.checked_permissions = True + return True + elif request.session.user and permission_string in request.session.user.get_permissions(): + request.checked_permissions = True + return True + else: + raise etiquette.exceptions.Unauthorized() + + def read(self): + ''' + BE CAREFUL WITH CACHED ENDPOINTS. Use read_decorator instead. + ''' + if request.is_localhost: + request.checked_permissions = True + return True + elif self.site.server_config['anonymous_read'] or request.session.user: + request.checked_permissions = True + return True + else: + raise etiquette.exceptions.Unauthorized() + + def read_decorator(self, endpoint): + ''' + Make sure to place read_decorator ABOVE the cached_endpoint decorator so + Python runs this one first. + ''' log.debug('Decorating %s with basic_decorator.', endpoint) @functools.wraps(endpoint) def wrapped(*args, **kwargs): - self.basic() + self.read() return endpoint(*args, **kwargs) return wrapped def global_public(self): + ''' + This check always passes. Use this for the root and login page so people + can log in. + ''' request.checked_permissions = True + return True diff --git a/frontends/etiquette_flask/static/css/common.css b/frontends/etiquette_flask/static/css/common.css index a1376a0..ab39d31 100644 --- a/frontends/etiquette_flask/static/css/common.css +++ b/frontends/etiquette_flask/static/css/common.css @@ -105,6 +105,8 @@ textarea::placeholder display: flex; justify-content: center; align-items: center; + gap: 8px; + text-decoration: none; } #content_body diff --git a/frontends/etiquette_flask/static/css/etiquette.css b/frontends/etiquette_flask/static/css/etiquette.css index e0f3b7b..38a5119 100644 --- a/frontends/etiquette_flask/static/css/etiquette.css +++ b/frontends/etiquette_flask/static/css/etiquette.css @@ -175,7 +175,7 @@ p:last-child { /*position: absolute;*/ vertical-align: middle; - font-size: 8pt; + font-size: 7pt; min-width: 18px; min-height: 14px; diff --git a/frontends/etiquette_flask/static/js/api.js b/frontends/etiquette_flask/static/js/api.js index ff6703c..bef622b 100644 --- a/frontends/etiquette_flask/static/js/api.js +++ b/frontends/etiquette_flask/static/js/api.js @@ -21,6 +21,16 @@ function reload_config(callback) }); } +api.admin.remove_session = +function remove_session(token, callback) +{ + return http.post({ + url: "/admin/remove_session", + data: {"token": token}, + callback: callback, + }); +} + api.admin.uncache = function uncache(callback) { @@ -240,6 +250,26 @@ function batch_add_tag(photo_ids, tagname, callback) }); } +api.photos.batch_soft_delete = +function batch_soft_delete(photo_ids, callback) +{ + return http.post({ + url: "/batch/photos/soft_delete", + data: {"photo_ids": photo_ids.join(",")}, + callback: callback, + }); +} + +api.photos.batch_hard_delete = +function batch_hard_delete(photo_ids, callback) +{ + return http.post({ + url: "/batch/photos/hard_delete", + data: {"photo_ids": photo_ids.join(",")}, + callback: callback, + }); +} + api.photos.batch_generate_thumbnail = function batch_generate_thumbnail(photo_ids, callback) { @@ -577,3 +607,29 @@ function register(username, display_name, password_1, password_2, callback) callback: callback, }); } + +api.users.set_password = +function set_password(username, current_password, password_1, password_2, callback) +{ + const data = { + "username": username, + "current_password": current_password, + "password_1": password_1, + "password_2": password_2, + }; + return http.post({ + url: `/user/${username}/set_password`, + data: data, + callback: callback, + }) +} + +api.users.set_permission = +function set_permission(username, permission_string, value, callback) +{ + return http.post({ + url: `/user/${username}/set_permission`, + data: {"permission": permission_string, "value": value}, + callback: callback, + }); +} diff --git a/frontends/etiquette_flask/templates/admin.html b/frontends/etiquette_flask/templates/admin.html index 33e4edc..e35b630 100644 --- a/frontends/etiquette_flask/templates/admin.html +++ b/frontends/etiquette_flask/templates/admin.html @@ -27,17 +27,15 @@ th, td -{{header.make_header(session=request.session)}} +{{header.make_header(request=request)}}

Admin tools

-

-

-

Download database file

Statistics

+

@@ -47,6 +45,60 @@ th, td
StoredCached
Albums{{counts.albums}}{{cached.albums}}
Users{{counts.users}}{{cached.users}}
+ +
+

Users

+ + + + + + + + + {% for user in users %} + + + + + + {% endfor %} +
IDNameUsername
{{user.id}}{{user.display_name}}{{user.username}}
+
+ +
+

Config

+

+
{{etq_config}}
+
{{server_config}}
+
+ +
+

Sessions

+

+ + + + + + + + + + + + {% for (token, session) in sessions %} + + + + + + + + {% endfor %} + +
TokenIPUserLast activityRemove
{{token}}{{session.ip_address}}{% if session.user %}{{session.user.display_name}}{% else %}anonymous{%endif%}
+
@@ -63,6 +115,7 @@ function clear_sessions_form() { alert(JSON.stringify(response)); } + common.delete_all_children(document.querySelector("#sessions_table tbody")); } return api.admin.clear_sessions(callback); } @@ -82,6 +135,22 @@ function reload_config_form() return api.admin.reload_config(callback); } +function remove_session_form(event) +{ + const button = event.target; + const row = button.closest("tr"); + const token = row.querySelector(".session_token").innerText; + function callback(response) + { + if (response.meta.status !== 200) + { + alert(JSON.stringify(response)); + } + row.parentElement.removeChild(row); + } + return api.admin.remove_session(token, callback); +} + function uncache_form() { const uncache_button = document.getElementById("uncache_button"); diff --git a/frontends/etiquette_flask/templates/album.html b/frontends/etiquette_flask/templates/album.html index f74e317..5f7c08e 100644 --- a/frontends/etiquette_flask/templates/album.html +++ b/frontends/etiquette_flask/templates/album.html @@ -98,7 +98,7 @@ -{{header.make_header(session=request.session)}} +{{header.make_header(request=request)}}
@@ -160,7 +160,7 @@ const ALBUM_ID = undefined; -{{header.make_header(session=request.session)}} +{{header.make_header(request=request)}}