Add more detailed permission system, photo uploads by users.

This commit is contained in:
voussoir 2025-11-14 17:47:45 -08:00
parent 8656b02403
commit ebe5847afc
32 changed files with 1219 additions and 240 deletions

View file

@ -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,
}

View file

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

View file

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

View file

@ -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,6 +1014,11 @@ class Photo(ObjectBase):
@decorators.required_feature('photo.add_remove_tag')
@worms.atomic
def add_tag(self, tag, timestamp=None):
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)
@ -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:

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ session_manager = common.session_manager
@site.route('/album/<album_id>')
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/<album_id>.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/<album_id>.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/<album_id>/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/<album_id>/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/<album_id>/remove_thumbnail_photo', methods=['POST'])
def post_album_remove_thumbnail_photo(album_id):
common.permission_manager.basic()
with common.P.transaction:
album = common.P_album(album_id, response_type='json')
common.permission_manager.edit_thing(album)
with common.P.transaction:
album.set_thumbnail_photo(None)
return flasktools.json_response(album.jsonify())
@site.route('/album/<album_id>/refresh_directories', methods=['POST'])
def post_album_refresh_directories(album_id):
common.permission_manager.basic()
with common.P.transaction:
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:
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/<album_id>/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()
with common.P.transaction:
album = common.P_album(album_id, response_type='json')
common.permission_manager.edit_thing(album)
with common.P.transaction:
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/<album_id>/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/<album_id>/delete', methods=['POST'])
def post_album_delete(album_id):
common.permission_manager.basic()
with common.P.transaction:
album = common.P_album(album_id, response_type='json')
common.permission_manager.delete_thing(album)
with common.P.transaction:
album.delete()
return flasktools.json_response({})

View file

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

View file

@ -13,16 +13,16 @@ session_manager = common.session_manager
@site.route('/bookmark/<bookmark_id>.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/<bookmark_id>/edit', methods=['POST'])
def post_bookmark_edit(bookmark_id):
common.permission_manager.basic()
with common.P.transaction:
bookmark = common.P_bookmark(bookmark_id, response_type='json')
common.permission_manager.edit_thing(bookmark)
with common.P.transaction:
# 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/<bookmark_id>/delete', methods=['POST'])
def post_bookmark_delete(bookmark_id):
common.permission_manager.basic()
with common.P.transaction:
bookmark = common.P_bookmark(bookmark_id, response_type='json')
common.permission_manager.delete_thing(bookmark)
with common.P.transaction:
bookmark.delete()
return flasktools.json_response({})

View file

@ -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/<photo_id>')
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/<photo_id>.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/<photo_id>/download')
@site.route('/photo/<photo_id>/download/<basename>')
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/<photo_id>/thumbnail')
@site.route('/photo/<photo_id>/thumbnail/<basename>')
@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/<photo_id>/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()
with common.P.transaction:
photo = common.P_photo(photo_id, response_type='json')
common.permission_manager.edit_thing(photo)
with common.P.transaction:
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()
with common.P.transaction:
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.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'))
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/<photo_id>/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)
with common.P.transaction:
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.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/<photo_id>/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/<photo_id>/set_searchhidden', methods=['POST'])
def post_photo_set_searchhidden(photo_id):
common.permission_manager.basic()
with common.P.transaction:
photo = common.P_photo(photo_id, response_type='json')
common.permission_manager.edit_thing(photo)
with common.P.transaction:
photo.set_searchhidden(True)
return flasktools.json_response({})
@site.route('/photo/<photo_id>/unset_searchhidden', methods=['POST'])
def post_photo_unset_searchhidden(photo_id):
common.permission_manager.basic()
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)
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.set_searchhidden(searchhidden)
photo.set_searchhidden(False)
return flasktools.json_response({})
@site.route('/photo/<photo_id>/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)

View file

@ -15,13 +15,13 @@ session_manager = common.session_manager
@site.route('/tags/<specific_tag>')
@site.route('/tags/<specific_tag>.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/<tag_id>')
@site.route('/tagid/<tag_id>.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/<specific_tag_name>.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/<tagname>/edit', methods=['POST'])
def post_tag_edit(tagname):
common.permission_manager.basic()
with common.P.transaction:
tag = common.P_tag(tagname, response_type='json')
common.permission_manager.edit_thing(tag)
with common.P.transaction:
name = request.form.get('name', '').strip()
if name:
tag.rename(name)
@ -63,10 +63,13 @@ def post_tag_edit(tagname):
@site.route('/tag/<tagname>/add_child', methods=['POST'])
@flasktools.required_fields(['child_name'], forbid_whitespace=True)
def post_tag_add_child(tagname):
common.permission_manager.basic()
with common.P.transaction:
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.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/<tagname>/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/<tagname>/remove_child', methods=['POST'])
@flasktools.required_fields(['child_name'], forbid_whitespace=True)
def post_tag_remove_child(tagname):
common.permission_manager.basic()
with common.P.transaction:
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.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/<tagname>/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/<specific_tag_name>')
@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/<tagname>/delete', methods=['POST'])
def post_tag_delete(tagname):
common.permission_manager.basic()
with common.P.transaction:
tag = common.P_tag(tagname, response_type='json')
common.permission_manager.delete_thing(tag)
with common.P.transaction:
tag.delete()
response = {'action': 'delete_tag', 'tagname': tag.name}
return flasktools.json_response(response)

View file

@ -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/<username>')
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/<username>.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/<user_id>')
@site.route('/userid/<user_id>.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/<username>/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/<username>/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/<username>/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({})

View file

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

View file

@ -105,6 +105,8 @@ textarea::placeholder
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
text-decoration: none;
}
#content_body

View file

@ -175,7 +175,7 @@ p:last-child
{
/*position: absolute;*/
vertical-align: middle;
font-size: 8pt;
font-size: 7pt;
min-width: 18px;
min-height: 14px;

View file

@ -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,
});
}

View file

@ -27,17 +27,15 @@ th, td
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<div class="panel">
<h1>Admin tools</h1>
<p><button id="reload_config_button" class="green_button" onclick="return reload_config_form();">Reload config file</button></p>
<p><button id="uncache_button" class="green_button" onclick="return uncache_form();">Uncache objects</button></p>
<p><button id="clear_sessions_button" class="green_button" onclick="return clear_sessions_form();">Clear login sessions</button></p>
<p><a href="/admin/dbdownload">Download database file</a></p>
</div>
<div class="panel">
<h2>Statistics</h2>
<p><button id="uncache_button" class="red_button" onclick="return uncache_form();">Uncache objects</button></p>
<table>
<tr><th></th><th>Stored</th><th>Cached</th></tr>
<tr><td>Albums</td><td>{{counts.albums}}</td><td>{{cached.albums}}</td></tr>
@ -47,6 +45,60 @@ th, td
<tr><td>Users</td><td>{{counts.users}}</td><td>{{cached.users}}</td></tr>
</table>
</div>
<div class="panel">
<h2>Users</h2>
<table id="users_table">
<thead>
<tr>
<td>ID</td>
<td>Name</td>
<td>Username</td>
</tr>
</thead>
{% for user in users %}
<tr>
<td><code>{{user.id}}</code></td>
<td><a href="/userid/{{user.id}}">{{user.display_name}}</a></td>
<td>{{user.username}}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="panel">
<h2>Config</h2>
<p><button id="reload_config_button" class="green_button" onclick="return reload_config_form();">Reload config file</button></p>
<pre style="white-space:break-spaces;">{{etq_config}}</pre>
<pre style="white-space:break-spaces;">{{server_config}}</pre>
</div>
<div class="panel">
<h2>Sessions</h2>
<p><button id="clear_sessions_button" class="red_button" onclick="return clear_sessions_form();">Clear login sessions</button></p>
<table id="sessions_table">
<thead>
<tr>
<td>Token</td>
<td>IP</td>
<td>User</td>
<td>Last activity</td>
<td>Remove</td>
</tr>
</thead>
<tbody>
{% for (token, session) in sessions %}
<tr>
<td class="session_token"><code>{{token}}</code></td>
<td class="session_ip"><code>{{session.ip_address}}</code></td>
<td>{% if session.user %}{{session.user.display_name}}{% else %}<i>anonymous</i>{%endif%}</td>
<td><time>{{session.last_activity|timestamp_to_8601}}</time></td>
<td><button class="red_button" onclick="return remove_session_form(event);">x</button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
@ -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");

View file

@ -98,7 +98,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body" class="sticky_side_right sticky_bottom_right">
<div id="left">
<div id="album_list" class="panel">
@ -160,7 +160,7 @@ const ALBUM_ID = undefined;
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body" class="sticky_side_right sticky_bottom_right">
<div id="right" class="panel">
{% if view != "list" %}

View file

@ -34,7 +34,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<div id="bookmark_panel" class="panel">
<h1><span class="dynamic_bookmark_count">{{bookmarks|length}}</span> Bookmarks</h1>

View file

@ -95,7 +95,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body" class="sticky_side_right sticky_bottom_right">
<div id="left">
@ -133,6 +133,13 @@
<div id="download_zip_area">
<button class="yellow_button" id="download_zip_button" onclick="return download_zip_form();">Download .zip</button>
</div>
{% if request.is_localhost %}
<div id="delete_photo_area">
<button class="red_button" id="soft_delete_photos_button" data-spinner-delay="500" onclick="return soft_delete_form();">Soft delete
<button class="red_button" id="hard_delete_photos_button" data-spinner-delay="500" onclick="return hard_delete_form();">Hard delete
</div>
{% endif %}
</div>
<div id="message_area">
@ -447,6 +454,54 @@ function unset_searchhidden_form()
api.photos.batch_unset_searchhidden(photo_ids, set_unset_searchhidden_callback);
}
////////////////////////////////////////////////////////////////////////////////
function delete_callback(response)
{
if (! response.meta.json_ok)
{
alert(JSON.stringify(response));
return;
}
if (response.meta.status !== 200)
{
alert(JSON.stringify(response));
return;
}
photo_clipboard.clear_clipboard();
common.refresh();
}
function soft_delete_form()
{
const photo_ids = Array.from(photo_clipboard.clipboard);
if (photo_ids.length === 0)
{
return;
}
const message = "You are about to REMOVE photos from the database, keeping the files on disk. Type 'REMOVE' to confirm.";
if (window.prompt(message) === "REMOVE")
{
api.photos.batch_hard_delete(photo_ids, delete_callback);
}
}
function hard_delete_form()
{
const photo_ids = Array.from(photo_clipboard.clipboard);
if (photo_ids.length === 0)
{
return;
}
const message = "You are about to DELETE photos from disk. This is irreversible. Type 'DELETE' to confirm.";
if (window.prompt(message) === "DELETE")
{
api.photos.batch_hard_delete(photo_ids, delete_callback);
}
}
////////////////////////////////////////////////////////////////////////////////
function on_pageload()
{
photo_clipboard.register_hotkeys();

View file

@ -1,11 +1,13 @@
{% macro make_header(session) %}
{% macro make_header(request) %}
<nav id="header">
<a class="header_element navigation_link" href="/">Etiquette</a>
<a class="header_element navigation_link" href="/search">Search</a>
<a class="header_element navigation_link" href="/tags">Tags</a>
{% if session.user %}
<a class="header_element navigation_link dynamic_user_display_name" href="/userid/{{session.user.id}}">{{session.user.display_name}}</a>
{% if request.session.user %}
<a class="header_element navigation_link dynamic_user_display_name" href="/userid/{{request.session.user.id}}">{{request.session.user.display_name}}{% if request.is_localhost %}<i> localhost</i>{% endif %}</a>
<button id="logout_button" class="header_element" onclick="return api.users.logout(common.go_to_root);" style="flex:0">Logout</button>
{% elif request.is_localhost %}
<a class="header_element navigation_link" href="/login"><i>localhost</i></a>
{% else %}
<a class="header_element navigation_link" href="/login">Log in</a>
{% endif %}

View file

@ -71,7 +71,7 @@ form h2
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<form id="login_form" class="panel" action="/login" method="post">
<h2>Log in</h2>
@ -79,18 +79,16 @@ form h2
<input type="password" id="login_input_password" name="password" placeholder="password">
<button type="submit" id="login_submit_button" class="green_button" onclick="return login_form(event);">Log in</button>
</form>
{% if registration_enabled %}
<form id="register_form" class="panel" action="/register" method="post">
<h2>Register</h2>
{% if registration_enabled %}
<input type="text" id="register_input_username" name="username" placeholder="username (at least {{min_username_length}})">
<input type="text" id="register_input_display_name" name="display_name" placeholder="display name (optional)">
<input type="password" id="register_input_password_1" name="password_1" placeholder="password (at least {{min_password_length}})">
<input type="password" id="register_input_password_2" name="password_2" placeholder="password again">
<button type="submit" id="register_input_button" class="green_button" onclick="return register_form(event);">Register</button>
{% else %}
<span>Registrations are disabled.</span>
{% endif %}
</form>
{% endif %}
<div id="message_area" class="panel">
</div>
</div>

View file

@ -158,7 +158,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<div id="left" class="panel">
<div id="editor_area">
@ -184,8 +184,8 @@
{% if photo.author is not none %}
<li>Author: <a href="/userid/{{photo.author.id}}">{{photo.author.display_name}}</a></li>
{% endif %}
{% if photo.width and photo.height %}
<li title="{{(photo.area / 1000000)|round(2)}} mpx">Dimensions: <a href="/search?width={{photo.width}}..{{photo.width}}&height={{photo.height}}..{{photo.height}}">{{photo.width}}&times;{{photo.height}}</a> px</li>
{% if photo.width and photo.height and photo.area %}
<li title="{{((photo.area) / 1000000)|round(2)}} mpx">Dimensions: <a href="/search?width={{photo.width}}..{{photo.width}}&height={{photo.height}}..{{photo.height}}">{{photo.width}}&times;{{photo.height}}</a> px</li>
{% set aspectratio = photo.aspectratio|round(2) %}
<li>Aspect ratio: <a href="/search?aspectratio={{aspectratio-0.01}}..{{aspectratio+0.01}}">{{aspectratio}}</a></li>
{% endif %}
@ -273,6 +273,7 @@
<button id="generate_thumbnail_button" class="green_button button_with_spinner" onclick="return generate_thumbnail_for_video_form();">Capture thumbnail</button>
{% endif %}
{% if request.is_localhost or (request.session.user and request.session.user.has_object_permission(photo, 'edit')) %}
<button
class="green_button button_with_confirm"
data-holder-id="copy_other_photo_tags_holder"
@ -283,24 +284,27 @@
>
Copy tags from other photo
</button>
{% endif %}
{% if request.is_localhost or (request.session.user and request.session.user.has_object_permission(photo, 'delete')) %}
<button
class="red_button button_with_confirm"
data-onclick="return delete_photo_form();"
data-onclick="return soft_delete_form();"
data-prompt="Delete photo, keep file?"
data-cancel-class="gray_button"
>
Remove
Soft delete
</button>
<button
class="red_button button_with_confirm"
data-onclick="return delete_photo_from_disk_form();"
data-onclick="return hard_delete_form();"
data-prompt="Delete file on disk?"
data-cancel-class="gray_button"
>
Delete
Hard delete
</button>
{% endif %}
</div>
</div>
</div>
@ -424,14 +428,32 @@ function add_remove_photo_tag_callback(response)
return abort;
}
function delete_photo_form()
function soft_delete_form()
{
api.photos.delete(PHOTO_ID, false, common.go_to_root);
function callback(response)
{
if (response.meta.status !== 200)
{
alert(JSON.stringify(response));
return;
}
common.go_to_root();
}
api.photos.delete(PHOTO_ID, false, callback);
}
function delete_photo_from_disk_form()
function hard_delete_form()
{
api.photos.delete(PHOTO_ID, true, common.go_to_root);
function callback(response)
{
if (response.meta.status !== 200)
{
alert(JSON.stringify(response));
return;
}
common.go_to_root();
}
api.photos.delete(PHOTO_ID, true, callback);
}
function generate_thumbnail_for_video_form()

View file

@ -64,16 +64,19 @@ body > .nice_link
{{motd[1]}}
{%- endif -%}
</p>
{% if anonymous_read or request.session.user or request.is_localhost %}
<a class="nice_link navigation_link" href="/search">Search</a>
<a class="nice_link navigation_link" href="/tags">Tags</a>
<a class="nice_link navigation_link" href="/albums">Albums</a>
<a class="nice_link navigation_link" href="/bookmarks">Bookmarks</a>
<a class="nice_link navigation_link" href="/upload">Upload</a>
{% endif %}
{% if request.session.user %}
<a class="nice_link navigation_link" href="/userid/{{request.session.user.id}}">{{request.session.user.display_name}}</a>
{% else %}
<a class="nice_link navigation_link" href="/login">Log in</a>
{% endif %}
{% if request.is_localhost %}
{% if request.is_localhost or (request.session.user and request.session.user.has_permission('admin')) %}
<a class="nice_link navigation_link" href="/admin">Admin</a>
{% endif %}
<div class="link_group">

View file

@ -190,7 +190,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<div id="left" class="panel">
{% for tagtype in ["musts", "mays", "forbids"] %}

View file

@ -91,7 +91,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<div id="right" class="panel">
<a id="name_tag" target="_blank">Swipe!</a>

View file

@ -87,7 +87,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body" class="sticky_side_right sticky_bottom_right">
<div id="right" class="panel">
<div id="editor_area">

View file

@ -17,7 +17,7 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<p>test</p>
</div>

View file

@ -0,0 +1,217 @@
<!DOCTYPE html>
<html class="theme_{{theme}}">
<head>
{% import "header.html" as header %}
<title>Upload</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="/favicon.png" type="image/png"/>
<link rel="stylesheet" href="/static/css/common.css">
<link rel="stylesheet" href="/static/css/etiquette.css">
<script src="/static/js/common.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/http.js"></script>
<script src="/static/js/spinners.js"></script>
<style>
button
{
width: 80px;
}
#content_body
{
margin: auto;
}
form
{
display: grid;
grid-auto-rows: max-content;
grid-row-gap: 8px;
margin: 0;
}
form h2
{
margin-bottom: 0;
}
progress
{
width: 100%;
}
#login_form { grid-area: login_form; }
#register_form { grid-area: register_form; }
#message_area
{
grid-area: message_area;
}
#upload_submit_button
{
width: 100%;
}
@media screen and (min-width: 800px)
{
#content_body
{
grid-template:
"upload_files_form" auto
}
}
@media screen and (max-width: 800px)
{
#content_body
{
grid-template:
"login_form" auto
"register_form" auto
"message_area" 150px
/ 1fr;
}
}
</style>
</head>
<body>
{{header.make_header(request=request)}}
<div id="content_body">
<form id="upload_form" class="panel" action="/photo/upload" method="post" enctype="multipart/form-data">
<div id="div_single_files">
<h2>Upload single files</h2>
<input id="files_input" type="file" name="file[]" multiple onchange="return file_input_onchange(event);"/>
</div>
<div id="div_folder">
<h2>Upload a folder</h2>
<input id="folder_input" type="file" name="folder[]" directory webkitdirectory onchange="return folder_input_onchange(event);"/>
</div>
<!-- <h2>Options</h2> -->
<!-- <label><input id="make_album_checkbox" type="checkbox" onchange="return make_album_checkbox_onchange(event);"> Make an album</label> -->
<!-- <input id="make_album_name_input" type="text" class="hidden" placeholder="Album name"/> -->
<button type="submit" id="upload_submit_button" class="green_button" onclick="return upload_form(event);">Upload</button>
</form>
<div class="panel">
<p id="progresstext">Progress</p>
<progress id="progressbar" max="100" value="0"></progress>
</div>
</div>
</body>
<script type="text/javascript">
const message_area = document.getElementById("message_area");
function make_album_checkbox_onchange(event)
{
const make_album_name = document.getElementById("make_album_name_input");
if (document.getElementById("make_album_checkbox").checked)
{
make_album_name.classList.remove("hidden");
}
else
{
make_album_name.classList.add("hidden");
}
}
function file_input_onchange(event)
{
const files_input = document.getElementById("files_input");
if (files_input.files.length > 0)
{
const folder_input = document.getElementById("div_folder");
folder_input.classList.add("hidden");
}
}
function folder_input_onchange(event)
{
const folder_input = document.getElementById("folder_input");
if (folder_input.files.length > 0)
{
const files_input = document.getElementById("div_single_files");
files_input.classList.add("hidden");
// document.getElementById("make_album_checkbox").checked = true;
// const make_album_name = document.getElementById("make_album_name_input");
// make_album_name.classList.remove("hidden");
// const albumname = folder_input.files[0].webkitRelativePath.split("/")[0];
// make_album_name.value = albumname;
}
const progress = document.getElementById("progressbar");
progress.max = Array.from(folder_input.files).reduce((accumulator, file) => accumulator + file.size, 0);
}
function upload_form(event)
{
event.preventDefault();
console.log(event);
const xhr = new XMLHttpRequest();
function xhr_event(e)
{
if (e.loaded === e.total)
{
progresstext.innerText = 'Processing...';
}
else
{
progresstext.textContent = `${e.loaded} / ${e.total}`;
}
progressbar.value = e.total;
progressbar.value = e.loaded;
}
function xhr_loadend(event)
{
window.location.href = "/search"
}
xhr.onreadystatechange = function()
{
// window.alert("readystate: " + xhr.readyState + " " + xhr.status);
}
xhr.addEventListener("loadstart", xhr_event);
xhr.addEventListener("load", xhr_event);
xhr.addEventListener("loadend", xhr_loadend);
xhr.addEventListener("progress", xhr_event);
xhr.addEventListener("error", xhr_event);
xhr.addEventListener("abort", xhr_event);
xhr.upload.addEventListener("progress", xhr_event);
xhr.open("POST", "/photo/upload");
const files_input = document.getElementById("files_input");
const folder_input = document.getElementById("folder_input");
let files;
if (files_input.files.length > 0)
{
files = files_input.files;
}
else if (folder_input.files.length > 0)
{
files = folder_input.files;
}
else
{
return false;
}
console.log(files);
const formdata = new FormData();
for (const file of files)
{
formdata.append("file", file);
}
// if (document.getElementById("make_album_checkbox").checked)
// {
// formdata.append("album_name", document.getElementById("make_album_name_input").value);
// }
const progresstext = document.getElementById("progresstext");
const progressbar = document.getElementById("progressbar");
xhr.send(formdata);
}
</script>
</html>

View file

@ -38,10 +38,10 @@
</head>
<body>
{{header.make_header(session=request.session)}}
{{header.make_header(request=request)}}
<div id="content_body">
<div id="hierarchy_self" class="panel">
<h1 id="display_name">{{user.display_name}}</h1>
<h1 id="display_name" class="dynamic_user_display_name">{{user.display_name}}</h1>
{% if user.display_name != user.username %}
<p>Username: {{user.username}}</p>
{% endif %}
@ -73,10 +73,43 @@
</div>
</div>
{% if user.id == request.session.user.id %}
<div id="hierarchy_settings" class="panel">
<h2>Settings</h2>
<div>
<input type="text" id="set_display_name_input" value="{{user.display_name}}" placeholder="display name"/>
<button id="set_display_name_button" onclick="return set_display_name_form(event);">Set display name</button>
</div>
<div>
<input type="password" id="current_password_input" placeholder="current password"/>
<input type="password" id="password_1_input" placeholder="new password"/>
<input type="password" id="password_2_input" placeholder="new password again"/>
<button id="set_password_button" onclick="return set_password_form(event);">Set password</button>
</div>
<div id="message_area">
</div>
</div>
{% endif %}
{% if request.is_localhost %}
<div id="hierarchy_permissions" class="panel">
<h2>Permissions</h2>
<ul>
{% for permission in constants_all_permissions|sort %}
<li><label><input type="checkbox" name="{{permission}}" onchange="return permission_checkbox_onchange(event);" {% if permission in user_permissions %}checked{%endif%}/>{{permission}}</label></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</body>
<script type="text/javascript">
const USER_ID = "{{user.id}}";
const USERNAME = "{{user.username}}";
const PHOTOS = [
{% for photo in user.get_photos(direction='desc')|islice(0, 15) %}
{{photo.jsonify(include_albums=False)|tojson|safe}},
@ -101,6 +134,14 @@ const BOOKMARKS = [
{% endfor %}
];
function permission_checkbox_onchange(event)
{
function callback(response)
{
}
api.users.set_permission(USERNAME, event.target.name, event.target.checked, callback);
}
function on_pageload()
{
for (const photo of PHOTOS)
@ -132,15 +173,12 @@ function on_pageload()
document.addEventListener("DOMContentLoaded", on_pageload);
{% if user.id == request.session.user.id %}
const USERNAME = "{{user.username}}";
profile_ed_on_open = undefined;
function profile_ed_on_save(ed)
function set_display_name_form(event)
{
const input = document.getElementById("set_display_name_input");
function callback(response)
{
ed.hide_spinner();
console.log("HI");
if (! response.meta.json_ok)
{
alert(JSON.stringify(response));
@ -148,7 +186,7 @@ function profile_ed_on_save(ed)
}
if ("error_type" in response.data)
{
ed.show_error(`${response.data.error_type} ${response.data.error_message}`);
common.create_message_button(message_area, "message_negative", response.data.error_message);
return;
}
@ -156,32 +194,44 @@ function profile_ed_on_save(ed)
const new_display_name = response.data.display_name;
common.update_dynamic_elements("dynamic_user_display_name", new_display_name);
ed.elements["display_name"].edit.value = new_display_name;
ed.save();
input.value = new_display_name;
}
ed.show_spinner();
api.users.edit(USERNAME, ed.elements["display_name"].edit.value, callback);
api.users.edit(USERNAME, input.value, callback);
}
const profile_ed_on_cancel = undefined;
const profile_ed_elements = [
function set_password_form(event)
{
"id": "display_name",
"element": document.getElementById("display_name"),
"placeholder": "Display name",
"empty_text": USERNAME,
"autofocus": true,
},
];
const profile_ed = new editor.Editor(
profile_ed_elements,
profile_ed_on_open,
profile_ed_on_save,
profile_ed_on_cancel,
const current_password = document.getElementById("current_password_input");
const password_1 = document.getElementById("password_1_input");
const password_2 = document.getElementById("password_2_input");
const message_area = document.getElementById("message_area");
function callback(response)
{
if (! response.meta.json_ok)
{
alert(JSON.stringify(response));
return;
}
if ("error_type" in response.data)
{
common.create_message_bubble(message_area, "message_negative", response.data.error_message);
return;
}
common.create_message_bubble(message_area, "message_positive", "Password has been changed.");
current_password.value = "";
password_1.value = "";
password_2.value = "";
}
api.users.set_password(
USERNAME,
current_password.value,
password_1.value,
password_2.value,
callback,
);
}
{% endif %}
</script>
</html>

View file

@ -1059,6 +1059,23 @@ def upgrade_24_to_25(photodb):
'''
m.go()
def upgrade_25_to_26(photodb):
'''
In this version, the `user_permissions` table was added.
'''
photodb.execute('''
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)
);
''')
photodb.execute('''
CREATE INDEX IF NOT EXISTS index_user_permissions_userid on user_permissions(userid);
''')
def upgrade_all(data_directory):
'''
Given the directory containing a phototagger database, apply all of the