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 #########################################################################################
DATABASE_VERSION = 25 DATABASE_VERSION = 26
DB_INIT = ''' DB_INIT = '''
CREATE TABLE IF NOT EXISTS albums( 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_name on tag_synonyms(name);
CREATE INDEX IF NOT EXISTS index_tag_synonyms_mastername on tag_synonyms(mastername); 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) SQL_COLUMNS = sqlhelpers.extract_table_column_map(DB_INIT)
@ -309,6 +318,7 @@ DEFAULT_DATADIR = '_etiquette'
DEFAULT_DBNAME = 'phototagger.db' DEFAULT_DBNAME = 'phototagger.db'
DEFAULT_CONFIGNAME = 'config.json' DEFAULT_CONFIGNAME = 'config.json'
DEFAULT_THUMBDIR = 'thumbnails' DEFAULT_THUMBDIR = 'thumbnails'
DEFAULT_UPLOADS_DIR = 'uploads'
DEFAULT_CONFIGURATION = { DEFAULT_CONFIGURATION = {
'cache_size': { 'cache_size': {
@ -322,25 +332,30 @@ DEFAULT_CONFIGURATION = {
'enable_feature': { 'enable_feature': {
'album': { 'album': {
'edit': True, 'edit': True,
'delete': True,
'new': True, 'new': True,
}, },
'bookmark': { 'bookmark': {
'edit': True, 'edit': True,
'delete': True,
'new': True, 'new': True,
}, },
'photo': { 'photo': {
'add_remove_tag': True, 'add_remove_tag': True,
'new': True, 'new': True,
'edit': True, 'edit': True,
'delete': True,
'generate_thumbnail': True, 'generate_thumbnail': True,
'reload_metadata': True, 'reload_metadata': True,
}, },
'tag': { 'tag': {
'edit': True, 'edit': True,
'delete': True,
'new': True, 'new': True,
}, },
'user': { 'user': {
'edit': True, 'edit': True,
'delete': True,
'login': True, 'login': True,
'new': True, 'new': True,
}, },
@ -383,3 +398,74 @@ DEFAULT_CONFIGURATION = {
'Good morning, Paul. What will your first sequence of the day be?', '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): class DisplayNameTooLong(EtiquetteException):
error_message = 'Display name "{display_name}" is longer than maximum of {max_length}.' 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): class WrongLogin(EtiquetteException):
error_message = 'Wrong username-password combination.' error_message = 'Wrong username-password combination.'
# PERMISSION ERRORS ################################################################################
class Unauthorized(EtiquetteException):
error_message = 'You\'re not allowed to do that.'
# GENERAL ERRORS ################################################################################### # GENERAL ERRORS ###################################################################################
class BadDataDirectory(EtiquetteException): class BadDataDirectory(EtiquetteException):

View file

@ -270,7 +270,7 @@ def image_is_mostly_black(image):
return (black_count / len(pixels)) > 0.5 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 = pathclass.Path(filepath)
file.assert_is_file() file.assert_is_file()
probe = constants.ffmpeg.probe(filepath) probe = constants.ffmpeg.probe(filepath)
@ -284,16 +284,16 @@ def generate_video_thumbnail(filepath, width, height, **special) -> PIL.Image:
frame_width=width, frame_width=width,
frame_height=height, frame_height=height,
) )
duration = probe.video.duration duration = probe.video.duration or probe.format.duration
if 'timestamp' in special: if 'timestamp' in special:
timestamp_choices = [special['timestamp']] timestamp_choices = [float(special['timestamp'])]
else: else:
timestamp_choices = list(range(0, int(duration), 3)) timestamp_choices = list(range(0, int(duration), 3))
image = None image = None
for this_time in timestamp_choices: 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 = kkroening_ffmpeg.input(file.absolute_path, ss=this_time)
command = command.filter('scale', size[0], size[1]) command = command.filter('scale', size[0], size[1])
command = command.output('pipe:', vcodec='bmp', format='image2pipe', vframes=1) command = command.output('pipe:', vcodec='bmp', format='image2pipe', vframes=1)

View file

@ -273,6 +273,10 @@ class Album(ObjectBase, GroupableMixin):
table = 'albums' table = 'albums'
group_table = 'album_group_rel' group_table = 'album_group_rel'
no_such_exception = exceptions.NoSuchAlbum 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): def __init__(self, photodb, db_row):
super().__init__(photodb) super().__init__(photodb)
@ -468,7 +472,7 @@ class Album(ObjectBase, GroupableMixin):
return soup return soup
@decorators.required_feature('album.edit') @decorators.required_feature('album.delete')
@worms.atomic @worms.atomic
def delete(self, *, delete_children=False) -> None: def delete(self, *, delete_children=False) -> None:
log.info('Deleting %s.', self) log.info('Deleting %s.', self)
@ -775,6 +779,10 @@ class Album(ObjectBase, GroupableMixin):
class Bookmark(ObjectBase): class Bookmark(ObjectBase):
table = 'bookmarks' table = 'bookmarks'
no_such_exception = exceptions.NoSuchBookmark 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): def __init__(self, photodb, db_row):
super().__init__(photodb) super().__init__(photodb)
@ -854,7 +862,7 @@ class Bookmark(ObjectBase):
return soup return soup
@decorators.required_feature('bookmark.edit') @decorators.required_feature('bookmark.delete')
@worms.atomic @worms.atomic
def delete(self) -> None: def delete(self) -> None:
self.photodb.delete(table=Bookmark, pairs={'id': self.id}) self.photodb.delete(table=Bookmark, pairs={'id': self.id})
@ -915,6 +923,10 @@ class Photo(ObjectBase):
''' '''
table = 'photos' table = 'photos'
no_such_exception = exceptions.NoSuchPhoto 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): def __init__(self, photodb, db_row):
super().__init__(photodb) super().__init__(photodb)
@ -1002,6 +1014,11 @@ class Photo(ObjectBase):
@decorators.required_feature('photo.add_remove_tag') @decorators.required_feature('photo.add_remove_tag')
@worms.atomic @worms.atomic
def add_tag(self, tag, timestamp=None): 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) tag = self.photodb.get_tag(name=tag)
existing = self.has_tag(tag, check_children=False, match_timestamp=timestamp) 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. Take all of the tags owned by other_photo and apply them to this photo.
''' '''
for tag in other_photo.get_tags(): 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 @worms.atomic
def delete(self, *, delete_file=False) -> None: def delete(self, *, delete_file=False) -> None:
''' '''
@ -1113,11 +1130,11 @@ class Photo(ObjectBase):
@decorators.required_feature('photo.generate_thumbnail') @decorators.required_feature('photo.generate_thumbnail')
@worms.atomic @worms.atomic
def generate_thumbnail(self, trusted_file=False, **special): def generate_thumbnail(self, trusted_file=False, special={}):
''' '''
special: special:
For images, you can provide `max_width` and/or `max_height` to You can provide `max_width` and/or `max_height` to override the
override the config file. config file.
For videos, you can provide a `timestamp` to take the thumbnail at. For videos, you can provide a `timestamp` to take the thumbnail at.
''' '''
image = None image = None
@ -1140,9 +1157,9 @@ class Photo(ObjectBase):
try: try:
image = helpers.generate_video_thumbnail( image = helpers.generate_video_thumbnail(
self.real_path.absolute_path, self.real_path.absolute_path,
width=self.photodb.config['thumbnail_width'], width=special.get('max_width', self.photodb.config['thumbnail_width']),
height=self.photodb.config['thumbnail_height'], height=special.get('max_height', self.photodb.config['thumbnail_height']),
**special special=special,
) )
except Exception: except Exception:
log.warning(traceback.format_exc()) log.warning(traceback.format_exc())
@ -1375,6 +1392,7 @@ class Photo(ObjectBase):
'bytes': self.bytes, 'bytes': self.bytes,
} }
self.photodb.update(table=Photo, pairs=data, where_key='id') self.photodb.update(table=Photo, pairs=data, where_key='id')
self.__reinit__()
@decorators.required_feature('photo.edit') @decorators.required_feature('photo.edit')
@worms.atomic @worms.atomic
@ -2098,6 +2116,10 @@ class Tag(ObjectBase, GroupableMixin):
table = 'tags' table = 'tags'
group_table = 'tag_group_rel' group_table = 'tag_group_rel'
no_such_exception = exceptions.NoSuchTag 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): def __init__(self, photodb, db_row):
super().__init__(photodb) super().__init__(photodb)
@ -2291,7 +2313,7 @@ class Tag(ObjectBase, GroupableMixin):
# Enjoy your new life as a monk. # Enjoy your new life as a monk.
mastertag.add_synonym(self.name) mastertag.add_synonym(self.name)
@decorators.required_feature('tag.edit') @decorators.required_feature('tag.delete')
@worms.atomic @worms.atomic
def delete(self, *, delete_children=False) -> None: def delete(self, *, delete_children=False) -> None:
log.info('Deleting %s.', self) log.info('Deleting %s.', self)
@ -2500,6 +2522,15 @@ class User(ObjectBase):
def _uncache(self): def _uncache(self):
self.photodb.caches[User].remove(self.id) 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') @decorators.required_feature('user.login')
def check_password(self, password): def check_password(self, password):
if not isinstance(password, bytes): if not isinstance(password, bytes):
@ -2510,7 +2541,7 @@ class User(ObjectBase):
raise exceptions.WrongLogin() raise exceptions.WrongLogin()
return success return success
@decorators.required_feature('user.edit') @decorators.required_feature('user.delete')
@worms.atomic @worms.atomic
def delete(self, *, disown_authored_things) -> None: def delete(self, *, disown_authored_things) -> None:
''' '''
@ -2574,6 +2605,13 @@ class User(ObjectBase):
[self.id] [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]: def get_photos(self, *, direction='asc') -> typing.Iterable[Photo]:
''' '''
Raises ValueError if direction is not asc or desc. 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]) exists = self.photodb.select_one_value(query, [self.id])
return exists is not None 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: def jsonify(self) -> dict:
j = { j = {
'type': 'user', 'type': 'user',
@ -2631,6 +2704,14 @@ class User(ObjectBase):
return j 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') @decorators.required_feature('user.edit')
@worms.atomic @worms.atomic
def set_display_name(self, display_name) -> None: def set_display_name(self, display_name) -> None:

View file

@ -315,6 +315,7 @@ class PDBPhotoMixin:
do_thumbnail=True, do_thumbnail=True,
hash_kwargs=None, hash_kwargs=None,
known_hash=None, known_hash=None,
override_filename=None,
searchhidden=False, searchhidden=False,
tags=None, tags=None,
trusted_file=False, trusted_file=False,
@ -356,7 +357,7 @@ class PDBPhotoMixin:
data = { data = {
'id': photo_id, 'id': photo_id,
'filepath': filepath.absolute_path, 'filepath': filepath.absolute_path,
'override_filename': None, 'override_filename': override_filename,
'created': timetools.now().timestamp(), 'created': timetools.now().timestamp(),
'tagged_at': None, 'tagged_at': None,
'author_id': author_id, 'author_id': author_id,
@ -682,7 +683,14 @@ class PDBUserMixin:
@decorators.required_feature('user.new') @decorators.required_feature('user.new')
@worms.atomic @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. # These might raise exceptions.
self.assert_valid_username(username) self.assert_valid_username(username)
self.assert_no_such_user(username=username) self.assert_no_such_user(username=username)
@ -703,15 +711,24 @@ class PDBUserMixin:
hashed_password = bcrypt.hashpw(password, bcrypt.gensalt()) hashed_password = bcrypt.hashpw(password, bcrypt.gensalt())
now = timetools.now().timestamp()
data = { data = {
'id': user_id, 'id': user_id,
'username': username, 'username': username,
'password': hashed_password, 'password': hashed_password,
'display_name': display_name, 'display_name': display_name,
'created': timetools.now().timestamp(), 'created': now,
} }
self.insert(table=objects.User, pairs=data) 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) 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 = self.data_directory.with_child(constants.DEFAULT_THUMBDIR)
self.thumbnail_directory.makedirs(exist_ok=True) 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 # CONFIG
self.config_filepath = self.data_directory.with_child(constants.DEFAULT_CONFIGNAME) self.config_filepath = self.data_directory.with_child(constants.DEFAULT_CONFIGNAME)
self.load_config() self.load_config()

View file

@ -27,8 +27,8 @@ log = vlogging.getLogger(__name__)
# Constants ######################################################################################## # Constants ########################################################################################
DEFAULT_SERVER_CONFIG = { DEFAULT_SERVER_CONFIG = {
'anonymous_read': True, 'anonymous_read': False,
'anonymous_write': True, 'registration_enabled': False,
} }
BROWSER_CACHE_DURATION = 180 BROWSER_CACHE_DURATION = 180
@ -84,7 +84,9 @@ def catch_etiquette_exception(endpoint):
try: try:
return endpoint(*args, **kwargs) return endpoint(*args, **kwargs)
except etiquette.exceptions.EtiquetteException as exc: 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 status = 404
else: else:
status = 400 status = 400
@ -98,7 +100,9 @@ def before_request():
# visitors, make sure your reverse proxy is properly setting X-Forwarded-For # visitors, make sure your reverse proxy is properly setting X-Forwarded-For
# so that werkzeug's proxyfix can set that as the remote_addr. # 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; # 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') request.is_localhost = (request.remote_addr == '127.0.0.1')
if site.localhost_only and not request.is_localhost: if site.localhost_only and not request.is_localhost:
return flask.abort(403) return flask.abort(403)
@ -145,7 +149,9 @@ def P_wrapper(function):
return function(thingid) return function(thingid)
except etiquette.exceptions.EtiquetteException as exc: 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 status = 404
else: else:
status = 400 status = 400

View file

@ -1,4 +1,5 @@
import flask; from flask import request import flask; from flask import request
import json
from voussoirkit import dotdict from voussoirkit import dotdict
from voussoirkit import flasktools from voussoirkit import flasktools
@ -15,7 +16,7 @@ session_manager = common.session_manager
@site.route('/admin') @site.route('/admin')
def get_admin(): def get_admin():
common.permission_manager.admin() common.permission_manager.admin_only()
counts = dotdict.DotDict({ counts = dotdict.DotDict({
'albums': common.P.get_album_count(), 'albums': common.P.get_album_count(),
@ -31,11 +32,20 @@ def get_admin():
'tags': len(common.P.caches[etiquette.objects.Tag]), 'tags': len(common.P.caches[etiquette.objects.Tag]),
'users': len(common.P.caches[etiquette.objects.User]), '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') @site.route('/admin/dbdownload')
def get_dbdump(): def get_dbdump():
common.permission_manager.admin() common.permission_manager.admin_only()
with common.P.transaction: with common.P.transaction:
binary = common.P.database_filepath.read('rb') binary = common.P.database_filepath.read('rb')
@ -50,14 +60,24 @@ def get_dbdump():
@site.route('/admin/clear_sessions', methods=['POST']) @site.route('/admin/clear_sessions', methods=['POST'])
def post_clear_sessions(): def post_clear_sessions():
common.permission_manager.admin() common.permission_manager.admin_only()
session_manager.clear() 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({}) return flasktools.json_response({})
@site.route('/admin/reload_config', methods=['POST']) @site.route('/admin/reload_config', methods=['POST'])
def post_reload_config(): def post_reload_config():
common.permission_manager.admin() common.permission_manager.admin_only()
common.P.load_config() common.P.load_config()
common.load_config() common.load_config()
@ -66,7 +86,7 @@ def post_reload_config():
@site.route('/admin/uncache', methods=['POST']) @site.route('/admin/uncache', methods=['POST'])
def post_uncache(): def post_uncache():
common.permission_manager.admin() common.permission_manager.admin_only()
with common.P.transaction: with common.P.transaction:
for cache in common.P.caches.values(): for cache in common.P.caches.values():

View file

@ -17,7 +17,7 @@ session_manager = common.session_manager
@site.route('/album/<album_id>') @site.route('/album/<album_id>')
def get_album_html(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') album = common.P_album(album_id, response_type='html')
response = common.render_template( response = common.render_template(
request, request,
@ -29,14 +29,14 @@ def get_album_html(album_id):
@site.route('/album/<album_id>.json') @site.route('/album/<album_id>.json')
def get_album_json(album_id): def get_album_json(album_id):
common.permission_manager.basic() common.permission_manager.read()
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
album = album.jsonify() album = album.jsonify()
return flasktools.json_response(album) return flasktools.json_response(album)
@site.route('/album/<album_id>.zip') @site.route('/album/<album_id>.zip')
def get_album_zip(album_id): def get_album_zip(album_id):
common.permission_manager.basic() common.permission_manager.read()
album = common.P_album(album_id, response_type='html') album = common.P_album(album_id, response_type='html')
recursive = request.args.get('recursive', True) 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']) @site.route('/album/<album_id>/add_child', methods=['POST'])
@flasktools.required_fields(['child_id'], forbid_whitespace=True) @flasktools.required_fields(['child_id'], forbid_whitespace=True)
def post_album_add_child(album_id): 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']) child_ids = stringtools.comma_space_split(request.form['child_id'])
with common.P.transaction: with common.P.transaction:
album = common.P_album(album_id, response_type='json')
children = list(common.P_albums(child_ids, response_type='json')) children = list(common.P_albums(child_ids, response_type='json'))
print(children) print(children)
album.add_children(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']) @site.route('/album/<album_id>/remove_child', methods=['POST'])
@flasktools.required_fields(['child_id'], forbid_whitespace=True) @flasktools.required_fields(['child_id'], forbid_whitespace=True)
def post_album_remove_child(album_id): 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']) child_ids = stringtools.comma_space_split(request.form['child_id'])
with common.P.transaction: with common.P.transaction:
album = common.P_album(album_id, response_type='json')
children = list(common.P_albums(child_ids, response_type='json')) children = list(common.P_albums(child_ids, response_type='json'))
album.remove_children(children) album.remove_children(children)
response = album.jsonify() response = album.jsonify()
@ -86,17 +86,18 @@ def post_album_remove_child(album_id):
@site.route('/album/<album_id>/remove_thumbnail_photo', methods=['POST']) @site.route('/album/<album_id>/remove_thumbnail_photo', methods=['POST'])
def post_album_remove_thumbnail_photo(album_id): 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') album = common.P_album(album_id, response_type='json')
common.permission_manager.edit_thing(album)
with common.P.transaction:
album.set_thumbnail_photo(None) album.set_thumbnail_photo(None)
return flasktools.json_response(album.jsonify()) return flasktools.json_response(album.jsonify())
@site.route('/album/<album_id>/refresh_directories', methods=['POST']) @site.route('/album/<album_id>/refresh_directories', methods=['POST'])
def post_album_refresh_directories(album_id): def post_album_refresh_directories(album_id):
common.permission_manager.basic()
with common.P.transaction:
album = common.P_album(album_id, response_type='json') 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(): for directory in album.get_associated_directories():
if not directory.is_dir: if not directory.is_dir:
continue continue
@ -107,9 +108,9 @@ def post_album_refresh_directories(album_id):
@site.route('/album/<album_id>/set_thumbnail_photo', methods=['POST']) @site.route('/album/<album_id>/set_thumbnail_photo', methods=['POST'])
@flasktools.required_fields(['photo_id'], forbid_whitespace=True) @flasktools.required_fields(['photo_id'], forbid_whitespace=True)
def post_album_set_thumbnail_photo(album_id): 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') 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') photo = common.P_photo(request.form['photo_id'], response_type='json')
album.set_thumbnail_photo(photo) album.set_thumbnail_photo(photo)
return flasktools.json_response(album.jsonify()) 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. 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']) photo_ids = stringtools.comma_space_split(request.form['photo_id'])
with common.P.transaction: with common.P.transaction:
album = common.P_album(album_id, response_type='json')
photos = list(common.P_photos(photo_ids, response_type='json')) photos = list(common.P_photos(photo_ids, response_type='json'))
album.add_photos(photos) album.add_photos(photos)
response = album.jsonify() response = album.jsonify()
@ -137,10 +138,10 @@ def post_album_remove_photo(album_id):
''' '''
Remove a photo or photos from this album. 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']) photo_ids = stringtools.comma_space_split(request.form['photo_id'])
with common.P.transaction: with common.P.transaction:
album = common.P_album(album_id, response_type='json')
photos = list(common.P_photos(photo_ids, response_type='json')) photos = list(common.P_photos(photo_ids, response_type='json'))
album.remove_photos(photos) album.remove_photos(photos)
response = album.jsonify() response = album.jsonify()
@ -153,7 +154,7 @@ def post_album_add_tag(album_id):
''' '''
Apply a tag to every photo in the album. Apply a tag to every photo in the album.
''' '''
common.permission_manager.basic() common.permission_manager.admin_only()
response = {} response = {}
with common.P.transaction: with common.P.transaction:
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
@ -178,12 +179,12 @@ def post_album_edit(album_id):
''' '''
Edit the title / description. 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) title = request.form.get('title', None)
description = request.form.get('description', None) description = request.form.get('description', None)
with common.P.transaction: with common.P.transaction:
album = common.P_album(album_id, response_type='json')
album.edit(title=title, description=description) album.edit(title=title, description=description)
response = album.jsonify( response = album.jsonify(
@ -195,9 +196,7 @@ def post_album_edit(album_id):
@site.route('/album/<album_id>/show_in_folder', methods=['POST']) @site.route('/album/<album_id>/show_in_folder', methods=['POST'])
def post_album_show_in_folder(album_id): def post_album_show_in_folder(album_id):
common.permission_manager.basic() common.permission_manager.localhost_only()
if not request.is_localhost:
flask.abort(403)
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
directories = album.get_associated_directories() directories = album.get_associated_directories()
@ -215,7 +214,7 @@ def post_album_show_in_folder(album_id):
# Album listings ################################################################################### # Album listings ###################################################################################
@site.route('/all_albums.json') @site.route('/all_albums.json')
@common.permission_manager.basic_decorator @common.permission_manager.read_decorator
@flasktools.cached_endpoint(max_age=15) @flasktools.cached_endpoint(max_age=15)
def get_all_album_names(): def get_all_album_names():
all_albums = {album.id: album.display_name for album in common.P.get_albums()} 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') @site.route('/albums')
def get_albums_html(): def get_albums_html():
common.permission_manager.basic() common.permission_manager.read()
albums = list(common.P.get_root_albums()) albums = list(common.P.get_root_albums())
albums.sort(key=lambda x: x.display_name.lower()) albums.sort(key=lambda x: x.display_name.lower())
response = common.render_template( response = common.render_template(
@ -237,7 +236,7 @@ def get_albums_html():
@site.route('/albums.json') @site.route('/albums.json')
def get_albums_json(): def get_albums_json():
common.permission_manager.basic() common.permission_manager.read()
albums = list(common.P.get_albums()) albums = list(common.P.get_albums())
albums.sort(key=lambda x: x.display_name.lower()) albums.sort(key=lambda x: x.display_name.lower())
albums = [album.jsonify(include_photos=False) for album in albums] 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']) @site.route('/albums/create_album', methods=['POST'])
def post_albums_create(): def post_albums_create():
common.permission_manager.basic() common.permission_manager.permission_string(etiquette.constants.PERMISSION_ALBUM_CREATE)
title = request.form.get('title', None) title = request.form.get('title', None)
description = request.form.get('description', None) description = request.form.get('description', None)
parent_id = request.form.get('parent_id', 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']) @site.route('/album/<album_id>/delete', methods=['POST'])
def post_album_delete(album_id): def post_album_delete(album_id):
common.permission_manager.basic()
with common.P.transaction:
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
common.permission_manager.delete_thing(album)
with common.P.transaction:
album.delete() album.delete()
return flasktools.json_response({}) return flasktools.json_response({})

View file

@ -12,7 +12,12 @@ session_manager = common.session_manager
def root(): def root():
common.permission_manager.global_public() common.permission_manager.global_public()
motd = random.choice(common.P.config['motd_strings']) 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.ico')
@site.route('/favicon.png') @site.route('/favicon.png')

View file

@ -13,16 +13,16 @@ session_manager = common.session_manager
@site.route('/bookmark/<bookmark_id>.json') @site.route('/bookmark/<bookmark_id>.json')
def get_bookmark_json(bookmark_id): def get_bookmark_json(bookmark_id):
common.permission_manager.basic() common.permission_manager.read()
bookmark = common.P_bookmark(bookmark_id, response_type='json') bookmark = common.P_bookmark(bookmark_id, response_type='json')
response = bookmark.jsonify() response = bookmark.jsonify()
return flasktools.json_response(response) return flasktools.json_response(response)
@site.route('/bookmark/<bookmark_id>/edit', methods=['POST']) @site.route('/bookmark/<bookmark_id>/edit', methods=['POST'])
def post_bookmark_edit(bookmark_id): def post_bookmark_edit(bookmark_id):
common.permission_manager.basic()
with common.P.transaction:
bookmark = common.P_bookmark(bookmark_id, response_type='json') 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. # Emptystring is okay for titles, but not for URL.
title = request.form.get('title', None) title = request.form.get('title', None)
url = request.form.get('url', None) or None url = request.form.get('url', None) or None
@ -36,7 +36,7 @@ def post_bookmark_edit(bookmark_id):
@site.route('/bookmarks.atom') @site.route('/bookmarks.atom')
def get_bookmarks_atom(): def get_bookmarks_atom():
common.permission_manager.basic() common.permission_manager.read()
bookmarks = common.P.get_bookmarks() bookmarks = common.P.get_bookmarks()
response = etiquette.helpers.make_atom_feed( response = etiquette.helpers.make_atom_feed(
bookmarks, bookmarks,
@ -48,13 +48,13 @@ def get_bookmarks_atom():
@site.route('/bookmarks') @site.route('/bookmarks')
def get_bookmarks_html(): def get_bookmarks_html():
common.permission_manager.basic() common.permission_manager.read()
bookmarks = list(common.P.get_bookmarks()) bookmarks = list(common.P.get_bookmarks())
return common.render_template(request, 'bookmarks.html', bookmarks=bookmarks) return common.render_template(request, 'bookmarks.html', bookmarks=bookmarks)
@site.route('/bookmarks.json') @site.route('/bookmarks.json')
def get_bookmarks_json(): def get_bookmarks_json():
common.permission_manager.basic() common.permission_manager.read()
bookmarks = [b.jsonify() for b in common.P.get_bookmarks()] bookmarks = [b.jsonify() for b in common.P.get_bookmarks()]
return flasktools.json_response(bookmarks) return flasktools.json_response(bookmarks)
@ -63,7 +63,7 @@ def get_bookmarks_json():
@site.route('/bookmarks/create_bookmark', methods=['POST']) @site.route('/bookmarks/create_bookmark', methods=['POST'])
@flasktools.required_fields(['url'], forbid_whitespace=True) @flasktools.required_fields(['url'], forbid_whitespace=True)
def post_bookmark_create(): def post_bookmark_create():
common.permission_manager.basic() common.permission_manager.permission_string(etiquette.constants.PERMISSION_BOOKMARK_CREATE)
url = request.form['url'] url = request.form['url']
title = request.form.get('title', None) title = request.form.get('title', None)
user = session_manager.get(request).user user = session_manager.get(request).user
@ -75,8 +75,8 @@ def post_bookmark_create():
@site.route('/bookmark/<bookmark_id>/delete', methods=['POST']) @site.route('/bookmark/<bookmark_id>/delete', methods=['POST'])
def post_bookmark_delete(bookmark_id): def post_bookmark_delete(bookmark_id):
common.permission_manager.basic()
with common.P.transaction:
bookmark = common.P_bookmark(bookmark_id, response_type='json') bookmark = common.P_bookmark(bookmark_id, response_type='json')
common.permission_manager.delete_thing(bookmark)
with common.P.transaction:
bookmark.delete() bookmark.delete()
return flasktools.json_response({}) return flasktools.json_response({})

View file

@ -1,5 +1,8 @@
import gevent
import flask; from flask import request import flask; from flask import request
import os import os
import random
import re
import subprocess import subprocess
import traceback import traceback
import urllib.parse import urllib.parse
@ -8,6 +11,7 @@ from voussoirkit import cacheclass
from voussoirkit import flasktools from voussoirkit import flasktools
from voussoirkit import pathclass from voussoirkit import pathclass
from voussoirkit import stringtools from voussoirkit import stringtools
from voussoirkit import timetools
from voussoirkit import vlogging from voussoirkit import vlogging
log = vlogging.get_logger(__name__) log = vlogging.get_logger(__name__)
@ -17,6 +21,8 @@ import etiquette
from .. import common from .. import common
from .. import helpers from .. import helpers
RNG = random.SystemRandom()
site = common.site site = common.site
session_manager = common.session_manager session_manager = common.session_manager
photo_download_zip_tokens = cacheclass.Cache(maxlen=100) 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>') @site.route('/photo/<photo_id>')
def get_photo_html(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') photo = common.P_photo(photo_id, response_type='html')
return common.render_template(request, 'photo.html', photo=photo) return common.render_template(request, 'photo.html', photo=photo)
@site.route('/photo/<photo_id>.json') @site.route('/photo/<photo_id>.json')
def get_photo_json(photo_id): def get_photo_json(photo_id):
common.permission_manager.basic() common.permission_manager.read()
photo = common.P_photo(photo_id, response_type='json') photo = common.P_photo(photo_id, response_type='json')
photo = photo.jsonify() photo = photo.jsonify()
photo = flasktools.json_response(photo) 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')
@site.route('/photo/<photo_id>/download/<basename>') @site.route('/photo/<photo_id>/download/<basename>')
def get_file(photo_id, basename=None): def get_file(photo_id, basename=None):
common.permission_manager.basic() common.permission_manager.read()
photo_id = photo_id.split('.')[0] photo_id = photo_id.split('.')[0]
photo = common.P.get_photo(photo_id) 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')
@site.route('/photo/<photo_id>/thumbnail/<basename>') @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) @flasktools.cached_endpoint(max_age=common.BROWSER_CACHE_DURATION, etag_function=lambda: common.P.last_commit_id)
def get_thumbnail(photo_id, basename=None): def get_thumbnail(photo_id, basename=None):
photo_id = photo_id.split('.')[0] 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']) @site.route('/photo/<photo_id>/delete', methods=['POST'])
def post_photo_delete(photo_id): 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 = request.form.get('delete_file', False)
delete_file = stringtools.truthystring(delete_file) delete_file = stringtools.truthystring(delete_file)
with common.P.transaction: with common.P.transaction:
photo = common.P_photo(photo_id, response_type='json')
photo.delete(delete_file=delete_file) photo.delete(delete_file=delete_file)
return flasktools.json_response({}) 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')) photos = list(common.P_photos(photo_ids, response_type='json'))
tag = common.P_tag(tagname, 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} response = {'action': add_or_remove, 'tagname': tag.name}
with common.P.transaction: with common.P.transaction:
for photo in photos: for photo in photos:
@ -123,8 +132,8 @@ def post_photo_add_tag(photo_id):
''' '''
Add a tag to this photo. Add a tag to this photo.
''' '''
common.permission_manager.basic()
photo = common.P_photo(photo_id, response_type='json') 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') tag = common.P_tag(request.form['tagname'], response_type='json')
with common.P.transaction: with common.P.transaction:
@ -138,9 +147,9 @@ def post_photo_copy_tags(photo_id):
''' '''
Copy the tags from another photo. Copy the tags from another photo.
''' '''
common.permission_manager.basic()
with common.P.transaction:
photo = common.P_photo(photo_id, response_type='json') 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') other = common.P_photo(request.form['other_photo'], response_type='json')
photo.copy_tags(other) photo.copy_tags(other)
return flasktools.json_response([tag.jsonify() for tag in photo.get_tags()]) 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. Remove a tag from this photo.
''' '''
common.permission_manager.basic() common.permission_manager.edit_thing(photo)
response = post_photo_add_remove_tag_core( response = post_photo_add_remove_tag_core(
photo_ids=photo_id, photo_ids=photo_id,
tagname=request.form['tagname'], tagname=request.form['tagname'],
@ -164,16 +173,15 @@ def post_photo_tag_rel_delete(photo_tag_rel_id):
''' '''
Remove a tag from a photo. 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) 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() photo_tag.delete()
return flasktools.json_response(photo_tag.jsonify()) return flasktools.json_response(photo_tag.jsonify())
@site.route('/batch/photos/add_tag', methods=['POST']) @site.route('/batch/photos/add_tag', methods=['POST'])
@flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True)
def post_batch_photos_add_tag(): def post_batch_photos_add_tag():
common.permission_manager.basic()
response = post_photo_add_remove_tag_core( response = post_photo_add_remove_tag_core(
photo_ids=request.form['photo_ids'], photo_ids=request.form['photo_ids'],
tagname=request.form['tagname'], tagname=request.form['tagname'],
@ -184,7 +192,6 @@ def post_batch_photos_add_tag():
@site.route('/batch/photos/remove_tag', methods=['POST']) @site.route('/batch/photos/remove_tag', methods=['POST'])
@flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True)
def post_batch_photos_remove_tag(): def post_batch_photos_remove_tag():
common.permission_manager.basic()
response = post_photo_add_remove_tag_core( response = post_photo_add_remove_tag_core(
photo_ids=request.form['photo_ids'], photo_ids=request.form['photo_ids'],
tagname=request.form['tagname'], tagname=request.form['tagname'],
@ -198,14 +205,15 @@ def post_photo_generate_thumbnail_core(photo_ids, special={}):
if isinstance(photo_ids, str): if isinstance(photo_ids, str):
photo_ids = stringtools.comma_space_split(photo_ids) 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: for photo in photos:
photo._uncache() photo._uncache()
photo = common.P_photo(photo.id, response_type='json')
try: try:
photo.generate_thumbnail() photo.generate_thumbnail(special=special)
except Exception: except Exception:
log.warning(traceback.format_exc()) 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']) @site.route('/photo/<photo_id>/generate_thumbnail', methods=['POST'])
def post_photo_generate_thumbnail(photo_id): def post_photo_generate_thumbnail(photo_id):
common.permission_manager.basic() common.permission_manager.early_read()
special = request.form.to_dict() special = request.form.to_dict()
response = post_photo_generate_thumbnail_core(photo_ids=photo_id, special=special) response = post_photo_generate_thumbnail_core(photo_ids=photo_id, special=special)
return response return response
@site.route('/batch/photos/generate_thumbnail', methods=['POST']) @site.route('/batch/photos/generate_thumbnail', methods=['POST'])
def post_batch_photos_generate_thumbnail(): def post_batch_photos_generate_thumbnail():
common.permission_manager.basic() common.permission_manager.early_read()
special = request.form.to_dict() special = request.form.to_dict()
response = post_photo_generate_thumbnail_core(photo_ids=request.form['photo_ids'], special=special) response = post_photo_generate_thumbnail_core(photo_ids=request.form['photo_ids'], special=special)
return response return response
@ -229,14 +237,18 @@ def post_photo_refresh_metadata_core(photo_ids):
if isinstance(photo_ids, str): if isinstance(photo_ids, str):
photo_ids = stringtools.comma_space_split(photo_ids) 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: for photo in photos:
photo._uncache() photo._uncache()
photo = common.P_photo(photo.id, response_type='json') photo = common.P_photo(photo.id, response_type='json')
try: try:
photo.reload_metadata() photo.reload_metadata()
gevent.sleep(0.01)
except pathclass.NotFile: except pathclass.NotFile:
flask.abort(404) flask.abort(404)
@ -250,50 +262,36 @@ def post_photo_refresh_metadata_core(photo_ids):
@site.route('/photo/<photo_id>/refresh_metadata', methods=['POST']) @site.route('/photo/<photo_id>/refresh_metadata', methods=['POST'])
def post_photo_refresh_metadata(photo_id): 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) response = post_photo_refresh_metadata_core(photo_ids=photo_id)
return response return response
@site.route('/batch/photos/refresh_metadata', methods=['POST']) @site.route('/batch/photos/refresh_metadata', methods=['POST'])
@flasktools.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_refresh_metadata(): 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']) response = post_photo_refresh_metadata_core(photo_ids=request.form['photo_ids'])
return response return response
@site.route('/photo/<photo_id>/set_searchhidden', methods=['POST']) @site.route('/photo/<photo_id>/set_searchhidden', methods=['POST'])
def post_photo_set_searchhidden(photo_id): def post_photo_set_searchhidden(photo_id):
common.permission_manager.basic()
with common.P.transaction:
photo = common.P_photo(photo_id, response_type='json') photo = common.P_photo(photo_id, response_type='json')
common.permission_manager.edit_thing(photo)
with common.P.transaction:
photo.set_searchhidden(True) photo.set_searchhidden(True)
return flasktools.json_response({}) return flasktools.json_response({})
@site.route('/photo/<photo_id>/unset_searchhidden', methods=['POST']) @site.route('/photo/<photo_id>/unset_searchhidden', methods=['POST'])
def post_photo_unset_searchhidden(photo_id): 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 = common.P_photo(photo_id, response_type='json')
photo.set_searchhidden(False) common.permission_manager.edit_thing(photo)
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: with common.P.transaction:
photos = list(common.P_photos(photo_ids, response_type='json')) photo.set_searchhidden(False)
for photo in photos:
photo.set_searchhidden(searchhidden)
return flasktools.json_response({}) return flasktools.json_response({})
@site.route('/photo/<photo_id>/show_in_folder', methods=['POST']) @site.route('/photo/<photo_id>/show_in_folder', methods=['POST'])
def post_photo_show_in_folder(photo_id): def post_photo_show_in_folder(photo_id):
common.permission_manager.basic() common.permission_manager.localhost_only()
if not request.is_localhost:
flask.abort(403)
photo = common.P_photo(photo_id, response_type='json') photo = common.P_photo(photo_id, response_type='json')
if os.name == 'nt': if os.name == 'nt':
@ -307,10 +305,25 @@ def post_photo_show_in_folder(photo_id):
flask.abort(501) 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']) @site.route('/batch/photos/set_searchhidden', methods=['POST'])
@flasktools.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_set_searchhidden(): def post_batch_photos_set_searchhidden():
common.permission_manager.basic() common.permission_manager.early_read()
photo_ids = request.form['photo_ids'] photo_ids = request.form['photo_ids']
response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=True) response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=True)
return response return response
@ -318,16 +331,46 @@ def post_batch_photos_set_searchhidden():
@site.route('/batch/photos/unset_searchhidden', methods=['POST']) @site.route('/batch/photos/unset_searchhidden', methods=['POST'])
@flasktools.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_unset_searchhidden(): def post_batch_photos_unset_searchhidden():
common.permission_manager.basic() common.permission_manager.early_read()
photo_ids = request.form['photo_ids'] photo_ids = request.form['photo_ids']
response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=False) response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=False)
return response 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 ######################################################################################## # Clipboard ########################################################################################
@site.route('/clipboard') @site.route('/clipboard')
def get_clipboard_page(): def get_clipboard_page():
common.permission_manager.basic() common.permission_manager.read()
return common.render_template(request, 'clipboard.html') return common.render_template(request, 'clipboard.html')
@site.route('/batch/photos', methods=['POST']) @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. 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 = request.form['photo_ids']
photo_ids = stringtools.comma_space_split(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']) @site.route('/batch/photos/photo_card', methods=['POST'])
@flasktools.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_photo_cards(): def post_batch_photos_photo_cards():
common.permission_manager.basic() common.permission_manager.read()
photo_ids = request.form['photo_ids'] photo_ids = request.form['photo_ids']
photo_ids = stringtools.comma_space_split(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 After the user has generated their zip token, they can retrieve
that zip file. that zip file.
''' '''
common.permission_manager.basic() common.permission_manager.read()
zip_token = zip_token.split('.')[0] zip_token = zip_token.split('.')[0]
try: try:
photo_ids = photo_download_zip_tokens[zip_token] 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 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. 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 = request.form['photo_ids']
photo_ids = stringtools.comma_space_split(photo_ids) photo_ids = stringtools.comma_space_split(photo_ids)
@ -486,7 +529,7 @@ def get_search_core():
@site.route('/search_embed') @site.route('/search_embed')
def get_search_embed(): def get_search_embed():
common.permission_manager.basic() common.permission_manager.read()
search = get_search_core() search = get_search_core()
response = common.render_template( response = common.render_template(
request, request,
@ -498,7 +541,7 @@ def get_search_embed():
@site.route('/search') @site.route('/search')
def get_search_html(): def get_search_html():
common.permission_manager.basic() common.permission_manager.read()
search = get_search_core() search = get_search_core()
search.kwargs.view = request.args.get('view', 'grid') search.kwargs.view = request.args.get('view', 'grid')
@ -549,7 +592,7 @@ def get_search_html():
@site.route('/search.atom') @site.route('/search.atom')
def get_search_atom(): def get_search_atom():
common.permission_manager.basic() common.permission_manager.read()
search = get_search_core() search = get_search_core()
soup = etiquette.helpers.make_atom_feed( soup = etiquette.helpers.make_atom_feed(
search.results, search.results,
@ -562,7 +605,7 @@ def get_search_atom():
@site.route('/search.json') @site.route('/search.json')
def get_search_json(): def get_search_json():
common.permission_manager.basic() common.permission_manager.read()
search = get_search_core() search = get_search_core()
response = search.jsonify() response = search.jsonify()
return flasktools.json_response(response) return flasktools.json_response(response)
@ -571,6 +614,92 @@ def get_search_json():
@site.route('/swipe') @site.route('/swipe')
def get_swipe(): def get_swipe():
common.permission_manager.basic() common.permission_manager.read()
response = common.render_template(request, 'swipe.html') response = common.render_template(request, 'swipe.html')
return response 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>')
@site.route('/tags/<specific_tag>.json') @site.route('/tags/<specific_tag>.json')
def get_tags_specific_redirect(specific_tag): def get_tags_specific_redirect(specific_tag):
common.permission_manager.basic() common.permission_manager.read()
return flask.redirect(request.url.replace('/tags/', '/tag/')) return flask.redirect(request.url.replace('/tags/', '/tag/'))
@site.route('/tagid/<tag_id>') @site.route('/tagid/<tag_id>')
@site.route('/tagid/<tag_id>.json') @site.route('/tagid/<tag_id>.json')
def get_tag_id_redirect(tag_id): def get_tag_id_redirect(tag_id):
common.permission_manager.basic() common.permission_manager.read()
if request.path.endswith('.json'): if request.path.endswith('.json'):
tag = common.P_tag_id(tag_id, response_type='json') tag = common.P_tag_id(tag_id, response_type='json')
else: else:
@ -33,7 +33,7 @@ def get_tag_id_redirect(tag_id):
@site.route('/tag/<specific_tag_name>.json') @site.route('/tag/<specific_tag_name>.json')
def get_tag_json(specific_tag_name): 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') specific_tag = common.P_tag(specific_tag_name, response_type='json')
if specific_tag.name != specific_tag_name: if specific_tag.name != specific_tag_name:
new_url = f'/tag/{specific_tag.name}.json' + request.query_string.decode('utf-8') 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']) @site.route('/tag/<tagname>/edit', methods=['POST'])
def post_tag_edit(tagname): def post_tag_edit(tagname):
common.permission_manager.basic()
with common.P.transaction:
tag = common.P_tag(tagname, response_type='json') tag = common.P_tag(tagname, response_type='json')
common.permission_manager.edit_thing(tag)
with common.P.transaction:
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
if name: if name:
tag.rename(name) tag.rename(name)
@ -63,10 +63,13 @@ def post_tag_edit(tagname):
@site.route('/tag/<tagname>/add_child', methods=['POST']) @site.route('/tag/<tagname>/add_child', methods=['POST'])
@flasktools.required_fields(['child_name'], forbid_whitespace=True) @flasktools.required_fields(['child_name'], forbid_whitespace=True)
def post_tag_add_child(tagname): def post_tag_add_child(tagname):
common.permission_manager.basic()
with common.P.transaction:
parent = common.P_tag(tagname, response_type='json') 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') 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) parent.add_child(child)
response = {'action': 'add_child', 'tagname': f'{parent.name}.{child.name}'} response = {'action': 'add_child', 'tagname': f'{parent.name}.{child.name}'}
return flasktools.json_response(response) return flasktools.json_response(response)
@ -74,11 +77,11 @@ def post_tag_add_child(tagname):
@site.route('/tag/<tagname>/add_synonym', methods=['POST']) @site.route('/tag/<tagname>/add_synonym', methods=['POST'])
@flasktools.required_fields(['syn_name'], forbid_whitespace=True) @flasktools.required_fields(['syn_name'], forbid_whitespace=True)
def post_tag_add_synonym(tagname): 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'] syn_name = request.form['syn_name']
with common.P.transaction: with common.P.transaction:
master_tag = common.P_tag(tagname, response_type='json')
syn_name = master_tag.add_synonym(syn_name) syn_name = master_tag.add_synonym(syn_name)
response = {'action': 'add_synonym', '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']) @site.route('/tag/<tagname>/remove_child', methods=['POST'])
@flasktools.required_fields(['child_name'], forbid_whitespace=True) @flasktools.required_fields(['child_name'], forbid_whitespace=True)
def post_tag_remove_child(tagname): def post_tag_remove_child(tagname):
common.permission_manager.basic()
with common.P.transaction:
parent = common.P_tag(tagname, response_type='json') 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') 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) parent.remove_child(child)
response = {'action': 'remove_child', 'tagname': f'{parent.name}.{child.name}'} response = {'action': 'remove_child', 'tagname': f'{parent.name}.{child.name}'}
return flasktools.json_response(response) return flasktools.json_response(response)
@ -98,11 +104,11 @@ def post_tag_remove_child(tagname):
@site.route('/tag/<tagname>/remove_synonym', methods=['POST']) @site.route('/tag/<tagname>/remove_synonym', methods=['POST'])
@flasktools.required_fields(['syn_name'], forbid_whitespace=True) @flasktools.required_fields(['syn_name'], forbid_whitespace=True)
def post_tag_remove_synonym(tagname): 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'] syn_name = request.form['syn_name']
with common.P.transaction: with common.P.transaction:
master_tag = common.P_tag(tagname, response_type='json')
syn_name = master_tag.remove_synonym(syn_name) syn_name = master_tag.remove_synonym(syn_name)
response = {'action': 'delete_synonym', 'synonym': syn_name} response = {'action': 'delete_synonym', 'synonym': syn_name}
@ -111,7 +117,7 @@ def post_tag_remove_synonym(tagname):
# Tag listings ##################################################################################### # Tag listings #####################################################################################
@site.route('/all_tags.json') @site.route('/all_tags.json')
@common.permission_manager.basic_decorator @common.permission_manager.read_decorator
@flasktools.cached_endpoint(max_age=15) @flasktools.cached_endpoint(max_age=15)
def get_all_tag_names(): def get_all_tag_names():
all_tags = list(sorted(common.P.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('/tag/<specific_tag_name>')
@site.route('/tags') @site.route('/tags')
def get_tags_html(specific_tag_name=None): def get_tags_html(specific_tag_name=None):
common.permission_manager.basic() common.permission_manager.read()
if specific_tag_name is None: if specific_tag_name is None:
specific_tag = None specific_tag = None
else: else:
@ -161,7 +167,7 @@ def get_tags_html(specific_tag_name=None):
@site.route('/tags.json') @site.route('/tags.json')
def get_tags_json(): def get_tags_json():
common.permission_manager.basic() common.permission_manager.read()
include_synonyms = request.args.get('synonyms') include_synonyms = request.args.get('synonyms')
include_synonyms = include_synonyms is None or stringtools.truthystring(include_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']) @site.route('/tags/create_tag', methods=['POST'])
@flasktools.required_fields(['name'], forbid_whitespace=True) @flasktools.required_fields(['name'], forbid_whitespace=True)
def post_tag_create(): def post_tag_create():
common.permission_manager.basic() common.permission_manager.permission_string(etiquette.constants.PERMISSION_TAG_CREATE)
name = request.form['name'] name = request.form['name']
description = request.form.get('description', None) description = request.form.get('description', None)
@ -187,7 +194,7 @@ def post_tag_create():
@site.route('/tags/easybake', methods=['POST']) @site.route('/tags/easybake', methods=['POST'])
@flasktools.required_fields(['easybake_string'], forbid_whitespace=True) @flasktools.required_fields(['easybake_string'], forbid_whitespace=True)
def post_tag_easybake(): def post_tag_easybake():
common.permission_manager.basic() common.permission_manager.admin_only()
easybake_string = request.form['easybake_string'] easybake_string = request.form['easybake_string']
with common.P.transaction: with common.P.transaction:
@ -197,9 +204,9 @@ def post_tag_easybake():
@site.route('/tag/<tagname>/delete', methods=['POST']) @site.route('/tag/<tagname>/delete', methods=['POST'])
def post_tag_delete(tagname): def post_tag_delete(tagname):
common.permission_manager.basic()
with common.P.transaction:
tag = common.P_tag(tagname, response_type='json') tag = common.P_tag(tagname, response_type='json')
common.permission_manager.delete_thing(tag)
with common.P.transaction:
tag.delete() tag.delete()
response = {'action': 'delete_tag', 'tagname': tag.name} response = {'action': 'delete_tag', 'tagname': tag.name}
return flasktools.json_response(response) return flasktools.json_response(response)

View file

@ -1,6 +1,7 @@
import flask; from flask import request import flask; from flask import request
from voussoirkit import flasktools from voussoirkit import flasktools
from voussoirkit import stringtools
import etiquette import etiquette
@ -14,13 +15,19 @@ session_manager = common.session_manager
@site.route('/user/<username>') @site.route('/user/<username>')
def get_user_html(username): def get_user_html(username):
common.permission_manager.basic() common.permission_manager.read()
user = common.P_user(username, response_type='html') 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') @site.route('/user/<username>.json')
def get_user_json(username): def get_user_json(username):
common.permission_manager.basic() common.permission_manager.read()
user = common.P_user(username, response_type='json') user = common.P_user(username, response_type='json')
user = user.jsonify() user = user.jsonify()
return flasktools.json_response(user) return flasktools.json_response(user)
@ -28,7 +35,7 @@ def get_user_json(username):
@site.route('/userid/<user_id>') @site.route('/userid/<user_id>')
@site.route('/userid/<user_id>.json') @site.route('/userid/<user_id>.json')
def get_user_id_redirect(user_id): def get_user_id_redirect(user_id):
common.permission_manager.basic() common.permission_manager.read()
if request.path.endswith('.json'): if request.path.endswith('.json'):
user = common.P_user_id(user_id, response_type='json') user = common.P_user_id(user_id, response_type='json')
else: else:
@ -40,12 +47,8 @@ def get_user_id_redirect(user_id):
@site.route('/user/<username>/edit', methods=['POST']) @site.route('/user/<username>/edit', methods=['POST'])
def post_user_edit(username): 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') user = common.P_user(username, response_type='json')
if request.session.user != user: common.permission_manager.logged_in(user)
return flasktools.json_response(etiquette.exceptions.Unauthorized().jsonify(), status=403)
display_name = request.form.get('display_name') display_name = request.form.get('display_name')
if display_name is not None: if display_name is not None:
@ -54,6 +57,54 @@ def post_user_edit(username):
return flasktools.json_response(user.jsonify()) 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 ################################################################################# # Login and logout #################################################################################
@site.route('/login', methods=['GET']) @site.route('/login', methods=['GET'])
@ -64,7 +115,7 @@ def get_login():
'login.html', 'login.html',
min_username_length=common.P.config['user']['min_username_length'], min_username_length=common.P.config['user']['min_username_length'],
min_password_length=common.P.config['user']['min_password_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 return response
@ -96,13 +147,13 @@ def post_login():
response = exc.jsonify() response = exc.jsonify()
return flasktools.json_response(response, status=400) return flasktools.json_response(response, status=400)
request.session = sessions.Session(request, user) request.session = sessions.Session.from_request(session_manager=session_manager, request=request, user=user)
session_manager.add(request.session) session_manager.save_state()
return flasktools.json_response({}) return flasktools.json_response({})
@site.route('/logout', methods=['POST']) @site.route('/logout', methods=['POST'])
def post_logout(): def post_logout():
common.permission_manager.basic() common.permission_manager.logged_in()
session_manager.remove(request) session_manager.remove(request)
response = flasktools.json_response({}) response = flasktools.json_response({})
return response return response
@ -138,6 +189,5 @@ def post_register():
with common.P.transaction: with common.P.transaction:
user = common.P.new_user(username, password_1, display_name=display_name) user = common.P.new_user(username, password_1, display_name=display_name)
request.session = sessions.Session(request, user) request.session = sessions.Session.from_request(session_manager=session_manager, request=request, user=user)
session_manager.add(request.session)
return flasktools.json_response({}) return flasktools.json_response({})

View file

@ -3,41 +3,124 @@ import functools
from voussoirkit import vlogging from voussoirkit import vlogging
import etiquette
log = vlogging.getLogger(__name__) log = vlogging.getLogger(__name__)
class PermissionManager: class PermissionManager:
def __init__(self, site): def __init__(self, site):
self.site = 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: if request.is_localhost:
request.checked_permissions = True request.checked_permissions = True
return True return True
else: else:
return flask.abort(403) raise etiquette.exceptions.Unauthorized()
def basic(self): def logged_in(self, user=None):
if request.method not in {'GET', 'POST'}: '''
return flask.abort(405) Require that the visitor be logged in as any user, or as one
elif request.is_localhost: specific user.
'''
if request.session and request.session.user and user is None:
request.checked_permissions = True request.checked_permissions = True
return True return True
elif request.method == 'GET' and self.site.server_config['anonymous_read'] or request.session.user: if request.session and request.session.user and request.session.user == user:
request.checked_permissions = True
return True
elif request.method == 'POST' and self.site.server_config['anonymous_write'] or request.session.user:
request.checked_permissions = True request.checked_permissions = True
return True return True
else: 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) log.debug('Decorating %s with basic_decorator.', endpoint)
@functools.wraps(endpoint) @functools.wraps(endpoint)
def wrapped(*args, **kwargs): def wrapped(*args, **kwargs):
self.basic() self.read()
return endpoint(*args, **kwargs) return endpoint(*args, **kwargs)
return wrapped return wrapped
def global_public(self): 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 request.checked_permissions = True
return True

View file

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

View file

@ -175,7 +175,7 @@ p:last-child
{ {
/*position: absolute;*/ /*position: absolute;*/
vertical-align: middle; vertical-align: middle;
font-size: 8pt; font-size: 7pt;
min-width: 18px; min-width: 18px;
min-height: 14px; 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 = api.admin.uncache =
function uncache(callback) 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 = api.photos.batch_generate_thumbnail =
function batch_generate_thumbnail(photo_ids, callback) function batch_generate_thumbnail(photo_ids, callback)
{ {
@ -577,3 +607,29 @@ function register(username, display_name, password_1, password_2, callback)
callback: 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> <body>
{{header.make_header(session=request.session)}} {{header.make_header(request=request)}}
<div id="content_body"> <div id="content_body">
<div class="panel"> <div class="panel">
<h1>Admin tools</h1> <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> <p><a href="/admin/dbdownload">Download database file</a></p>
</div> </div>
<div class="panel"> <div class="panel">
<h2>Statistics</h2> <h2>Statistics</h2>
<p><button id="uncache_button" class="red_button" onclick="return uncache_form();">Uncache objects</button></p>
<table> <table>
<tr><th></th><th>Stored</th><th>Cached</th></tr> <tr><th></th><th>Stored</th><th>Cached</th></tr>
<tr><td>Albums</td><td>{{counts.albums}}</td><td>{{cached.albums}}</td></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> <tr><td>Users</td><td>{{counts.users}}</td><td>{{cached.users}}</td></tr>
</table> </table>
</div> </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> </div>
</body> </body>
@ -63,6 +115,7 @@ function clear_sessions_form()
{ {
alert(JSON.stringify(response)); alert(JSON.stringify(response));
} }
common.delete_all_children(document.querySelector("#sessions_table tbody"));
} }
return api.admin.clear_sessions(callback); return api.admin.clear_sessions(callback);
} }
@ -82,6 +135,22 @@ function reload_config_form()
return api.admin.reload_config(callback); 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() function uncache_form()
{ {
const uncache_button = document.getElementById("uncache_button"); const uncache_button = document.getElementById("uncache_button");

View file

@ -98,7 +98,7 @@
</head> </head>
<body> <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="content_body" class="sticky_side_right sticky_bottom_right">
<div id="left"> <div id="left">
<div id="album_list" class="panel"> <div id="album_list" class="panel">
@ -160,7 +160,7 @@ const ALBUM_ID = undefined;
</head> </head>
<body> <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="content_body" class="sticky_side_right sticky_bottom_right">
<div id="right" class="panel"> <div id="right" class="panel">
{% if view != "list" %} {% if view != "list" %}

View file

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

View file

@ -95,7 +95,7 @@
</head> </head>
<body> <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="content_body" class="sticky_side_right sticky_bottom_right">
<div id="left"> <div id="left">
@ -133,6 +133,13 @@
<div id="download_zip_area"> <div id="download_zip_area">
<button class="yellow_button" id="download_zip_button" onclick="return download_zip_form();">Download .zip</button> <button class="yellow_button" id="download_zip_button" onclick="return download_zip_form();">Download .zip</button>
</div> </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>
<div id="message_area"> <div id="message_area">
@ -447,6 +454,54 @@ function unset_searchhidden_form()
api.photos.batch_unset_searchhidden(photo_ids, set_unset_searchhidden_callback); 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() function on_pageload()
{ {
photo_clipboard.register_hotkeys(); photo_clipboard.register_hotkeys();

View file

@ -1,11 +1,13 @@
{% macro make_header(session) %} {% macro make_header(request) %}
<nav id="header"> <nav id="header">
<a class="header_element navigation_link" href="/">Etiquette</a> <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="/search">Search</a>
<a class="header_element navigation_link" href="/tags">Tags</a> <a class="header_element navigation_link" href="/tags">Tags</a>
{% if session.user %} {% if request.session.user %}
<a class="header_element navigation_link dynamic_user_display_name" href="/userid/{{session.user.id}}">{{session.user.display_name}}</a> <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> <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 %} {% else %}
<a class="header_element navigation_link" href="/login">Log in</a> <a class="header_element navigation_link" href="/login">Log in</a>
{% endif %} {% endif %}

View file

@ -71,7 +71,7 @@ form h2
</head> </head>
<body> <body>
{{header.make_header(session=request.session)}} {{header.make_header(request=request)}}
<div id="content_body"> <div id="content_body">
<form id="login_form" class="panel" action="/login" method="post"> <form id="login_form" class="panel" action="/login" method="post">
<h2>Log in</h2> <h2>Log in</h2>
@ -79,18 +79,16 @@ form h2
<input type="password" id="login_input_password" name="password" placeholder="password"> <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> <button type="submit" id="login_submit_button" class="green_button" onclick="return login_form(event);">Log in</button>
</form> </form>
{% if registration_enabled %}
<form id="register_form" class="panel" action="/register" method="post"> <form id="register_form" class="panel" action="/register" method="post">
<h2>Register</h2> <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_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="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_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"> <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> <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> </form>
{% endif %}
<div id="message_area" class="panel"> <div id="message_area" class="panel">
</div> </div>
</div> </div>

View file

@ -158,7 +158,7 @@
</head> </head>
<body> <body>
{{header.make_header(session=request.session)}} {{header.make_header(request=request)}}
<div id="content_body"> <div id="content_body">
<div id="left" class="panel"> <div id="left" class="panel">
<div id="editor_area"> <div id="editor_area">
@ -184,8 +184,8 @@
{% if photo.author is not none %} {% if photo.author is not none %}
<li>Author: <a href="/userid/{{photo.author.id}}">{{photo.author.display_name}}</a></li> <li>Author: <a href="/userid/{{photo.author.id}}">{{photo.author.display_name}}</a></li>
{% endif %} {% endif %}
{% if photo.width and photo.height %} {% 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> <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) %} {% set aspectratio = photo.aspectratio|round(2) %}
<li>Aspect ratio: <a href="/search?aspectratio={{aspectratio-0.01}}..{{aspectratio+0.01}}">{{aspectratio}}</a></li> <li>Aspect ratio: <a href="/search?aspectratio={{aspectratio-0.01}}..{{aspectratio+0.01}}">{{aspectratio}}</a></li>
{% endif %} {% 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> <button id="generate_thumbnail_button" class="green_button button_with_spinner" onclick="return generate_thumbnail_for_video_form();">Capture thumbnail</button>
{% endif %} {% endif %}
{% if request.is_localhost or (request.session.user and request.session.user.has_object_permission(photo, 'edit')) %}
<button <button
class="green_button button_with_confirm" class="green_button button_with_confirm"
data-holder-id="copy_other_photo_tags_holder" data-holder-id="copy_other_photo_tags_holder"
@ -283,24 +284,27 @@
> >
Copy tags from other photo Copy tags from other photo
</button> </button>
{% endif %}
{% if request.is_localhost or (request.session.user and request.session.user.has_object_permission(photo, 'delete')) %}
<button <button
class="red_button button_with_confirm" 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-prompt="Delete photo, keep file?"
data-cancel-class="gray_button" data-cancel-class="gray_button"
> >
Remove Soft delete
</button> </button>
<button <button
class="red_button button_with_confirm" 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-prompt="Delete file on disk?"
data-cancel-class="gray_button" data-cancel-class="gray_button"
> >
Delete Hard delete
</button> </button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -424,14 +428,32 @@ function add_remove_photo_tag_callback(response)
return abort; 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() function generate_thumbnail_for_video_form()

View file

@ -64,16 +64,19 @@ body > .nice_link
{{motd[1]}} {{motd[1]}}
{%- endif -%} {%- endif -%}
</p> </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="/search">Search</a>
<a class="nice_link navigation_link" href="/tags">Tags</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="/albums">Albums</a>
<a class="nice_link navigation_link" href="/bookmarks">Bookmarks</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 %} {% if request.session.user %}
<a class="nice_link navigation_link" href="/userid/{{request.session.user.id}}">{{request.session.user.display_name}}</a> <a class="nice_link navigation_link" href="/userid/{{request.session.user.id}}">{{request.session.user.display_name}}</a>
{% else %} {% else %}
<a class="nice_link navigation_link" href="/login">Log in</a> <a class="nice_link navigation_link" href="/login">Log in</a>
{% endif %} {% 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> <a class="nice_link navigation_link" href="/admin">Admin</a>
{% endif %} {% endif %}
<div class="link_group"> <div class="link_group">

View file

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

View file

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

View file

@ -87,7 +87,7 @@
</head> </head>
<body> <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="content_body" class="sticky_side_right sticky_bottom_right">
<div id="right" class="panel"> <div id="right" class="panel">
<div id="editor_area"> <div id="editor_area">

View file

@ -17,7 +17,7 @@
</head> </head>
<body> <body>
{{header.make_header(session=request.session)}} {{header.make_header(request=request)}}
<div id="content_body"> <div id="content_body">
<p>test</p> <p>test</p>
</div> </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> </head>
<body> <body>
{{header.make_header(session=request.session)}} {{header.make_header(request=request)}}
<div id="content_body"> <div id="content_body">
<div id="hierarchy_self" class="panel"> <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 %} {% if user.display_name != user.username %}
<p>Username: {{user.username}}</p> <p>Username: {{user.username}}</p>
{% endif %} {% endif %}
@ -73,10 +73,43 @@
</div> </div>
</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> </div>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
const USER_ID = "{{user.id}}";
const USERNAME = "{{user.username}}";
const PHOTOS = [ const PHOTOS = [
{% for photo in user.get_photos(direction='desc')|islice(0, 15) %} {% for photo in user.get_photos(direction='desc')|islice(0, 15) %}
{{photo.jsonify(include_albums=False)|tojson|safe}}, {{photo.jsonify(include_albums=False)|tojson|safe}},
@ -101,6 +134,14 @@ const BOOKMARKS = [
{% endfor %} {% endfor %}
]; ];
function permission_checkbox_onchange(event)
{
function callback(response)
{
}
api.users.set_permission(USERNAME, event.target.name, event.target.checked, callback);
}
function on_pageload() function on_pageload()
{ {
for (const photo of PHOTOS) for (const photo of PHOTOS)
@ -132,15 +173,12 @@ function on_pageload()
document.addEventListener("DOMContentLoaded", on_pageload); document.addEventListener("DOMContentLoaded", on_pageload);
{% if user.id == request.session.user.id %} {% if user.id == request.session.user.id %}
const USERNAME = "{{user.username}}"; function set_display_name_form(event)
profile_ed_on_open = undefined;
function profile_ed_on_save(ed)
{ {
const input = document.getElementById("set_display_name_input");
function callback(response) function callback(response)
{ {
ed.hide_spinner(); console.log("HI");
if (! response.meta.json_ok) if (! response.meta.json_ok)
{ {
alert(JSON.stringify(response)); alert(JSON.stringify(response));
@ -148,7 +186,7 @@ function profile_ed_on_save(ed)
} }
if ("error_type" in response.data) 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; return;
} }
@ -156,32 +194,44 @@ function profile_ed_on_save(ed)
const new_display_name = response.data.display_name; const new_display_name = response.data.display_name;
common.update_dynamic_elements("dynamic_user_display_name", new_display_name); common.update_dynamic_elements("dynamic_user_display_name", new_display_name);
ed.elements["display_name"].edit.value = new_display_name; input.value = new_display_name;
ed.save();
} }
ed.show_spinner(); api.users.edit(USERNAME, input.value, callback);
api.users.edit(USERNAME, ed.elements["display_name"].edit.value, callback);
} }
const profile_ed_on_cancel = undefined; function set_password_form(event)
const profile_ed_elements = [
{ {
"id": "display_name", const current_password = document.getElementById("current_password_input");
"element": document.getElementById("display_name"), const password_1 = document.getElementById("password_1_input");
"placeholder": "Display name", const password_2 = document.getElementById("password_2_input");
"empty_text": USERNAME, const message_area = document.getElementById("message_area");
"autofocus": true,
}, function callback(response)
]; {
const profile_ed = new editor.Editor( if (! response.meta.json_ok)
profile_ed_elements, {
profile_ed_on_open, alert(JSON.stringify(response));
profile_ed_on_save, return;
profile_ed_on_cancel, }
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 %} {% endif %}
</script> </script>
</html> </html>

View file

@ -1059,6 +1059,23 @@ def upgrade_24_to_25(photodb):
''' '''
m.go() 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): def upgrade_all(data_directory):
''' '''
Given the directory containing a phototagger database, apply all of the Given the directory containing a phototagger database, apply all of the