diff --git a/cps/kobo.py b/cps/kobo.py index 6b70bdf5..ee0aa810 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -17,9 +17,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import datetime -import sys import base64 +import datetime +import itertools +import json +import sys import os import uuid from time import gmtime, strftime @@ -45,7 +47,7 @@ from sqlalchemy import func from sqlalchemy.sql.expression import and_, or_ import requests -from . import config, logger, kobo_auth, db, helper, ub +from . import config, logger, kobo_auth, db, helper, shelf as shelf_lib, ub from .services import SyncToken as SyncToken from .web import download_required from .kobo_auth import requires_kobo_auth @@ -120,6 +122,7 @@ def redirect_or_proxy_request(): def convert_to_kobo_timestamp_string(timestamp): return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ") + @kobo.route("/v1/library/sync") @requires_kobo_auth @download_required @@ -203,24 +206,23 @@ def HandleSyncRequest(): ub.KoboReadingState.user_id == current_user.id, ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements)))) for kobo_reading_state in changed_reading_states.all(): - book = db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one() - sync_results.append({ - "ChangedReadingState": { - "ReadingState": get_kobo_reading_state_response(book, kobo_reading_state) - } - }) - new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified) + book = db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one_or_none() + if book: + sync_results.append({ + "ChangedReadingState": { + "ReadingState": get_kobo_reading_state_response(book, kobo_reading_state) + } + }) + new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified) + + sync_shelves(sync_token, sync_results) sync_token.books_last_created = new_books_last_created sync_token.books_last_modified = new_books_last_modified sync_token.archive_last_modified = new_archived_last_modified sync_token.reading_state_last_modified = new_reading_state_last_modified - if config.config_kobo_proxy: - return generate_sync_response(request, sync_token, sync_results) - - return make_response(jsonify(sync_results)) - # Missing feature: Detect server-side book deletions. + return generate_sync_response(request, sync_token, sync_results) def generate_sync_response(request, sync_token, sync_results): @@ -392,6 +394,222 @@ def get_metadata(book): return metadata +@kobo.route("/v1/library/tags", methods=["POST"]) +@login_required +# Creates a Shelf with the given items, and returns the shelf's uuid. +def HandleTagCreate(): + shelf_request = request.json + name, items = None, None + try: + name = shelf_request["Name"] + items = shelf_request["Items"] + except KeyError: + log.debug("Received malformed v1/library/tags request.") + abort(400, description="Malformed tags POST request. Data is missing 'Name' or 'Items' field") + + shelf = ub.session.query(ub.Shelf).filter(and_(ub.Shelf.name) == name, ub.Shelf.user_id == + current_user.id).one_or_none() + if shelf and not shelf_lib.check_shelf_edit_permissions(shelf): + abort(401, description="User is unauthaurized to edit shelf.") + + if not shelf: + shelf = ub.Shelf(user_id=current_user.id, name=name, uuid=uuid.uuid4()) + ub.session.add(shelf) + + items_unknown_to_calibre = add_items_to_shelf(items, shelf) + if items_unknown_to_calibre: + log.debug("Received request to add unknown books to a collection. Silently ignoring items.") + ub.session.commit() + + return make_response(jsonify(str(shelf.uuid)), 201) + + +@kobo.route("/v1/library/tags/", methods=["DELETE", "PUT"]) +def HandleTagUpdate(tag_id): + shelf = ub.session.query(ub.Shelf).filter(and_(ub.Shelf.uuid) == tag_id, + ub.Shelf.user_id == current_user.id).one_or_none() + if not shelf: + log.debug("Received Kobo tag update request on a collection unknown to CalibreWeb") + if config.config_kobo_proxy: + return redirect_or_proxy_request() + else: + abort(404, description="Collection isn't known to CalibreWeb") + + if not shelf_lib.check_shelf_edit_permissions(shelf): + abort(401, description="User is unauthaurized to edit shelf.") + + if request.method == "DELETE": + shelf_lib.delete_shelf_helper(shelf) + else: + shelf_request = request.json + name = None + try: + name = shelf_request["Name"] + except KeyError: + log.debug("Received malformed v1/library/tags rename request.") + abort(400, description="Malformed tags POST request. Data is missing 'Name' field") + + shelf.name = name + ub.session.merge(shelf) + ub.session.commit() + + return make_response(' ', 200) + + +# Adds items to the given shelf. +def add_items_to_shelf(items, shelf): + book_ids_already_in_shelf = set([book_shelf.book_id for book_shelf in shelf.books]) + items_unknown_to_calibre = [] + for item in items: + if item["Type"] != "ProductRevisionTagItem": + items_unknown_to_calibre.append(item) + continue + + book = db.session.query(db.Books).filter(db.Books.uuid == item["RevisionId"]).one_or_none() + if not book: + items_unknown_to_calibre.append(item) + continue + + book_id = book.id + if book_id not in book_ids_already_in_shelf: + shelf.books.append(ub.BookShelf(book_id=book_id)) + return items_unknown_to_calibre + + +@kobo.route("/v1/library/tags//items", methods=["POST"]) +@login_required +def HandleTagAddItem(tag_id): + tag_request = request.json + items = None + try: + items = tag_request["Items"] + except KeyError: + log.debug("Received malformed v1/library/tags//items/delete request.") + abort(400, description="Malformed tags POST request. Data is missing 'Items' field") + + shelf = ub.session.query(ub.Shelf).filter(and_(ub.Shelf.uuid) == tag_id, + ub.Shelf.user_id == current_user.id).one_or_none() + if not shelf: + log.debug("Received Kobo request on a collection unknown to CalibreWeb") + abort(404, description="Collection isn't known to CalibreWeb") + + if not shelf_lib.check_shelf_edit_permissions(shelf): + abort(401, description="User is unauthaurized to edit shelf.") + + items_unknown_to_calibre = add_items_to_shelf(items, shelf) + if items_unknown_to_calibre: + log.debug("Received request to add an unknown book to a collecition. Silently ignoring item.") + + ub.session.merge(shelf) + ub.session.commit() + + return make_response('', 201) + + +@kobo.route("/v1/library/tags//items/delete", methods=["POST"]) +@login_required +def HandleTagRemoveItem(tag_id): + tag_request = request.json + items = None + try: + items = tag_request["Items"] + except KeyError: + log.debug("Received malformed v1/library/tags//items/delete request.") + abort(400, description="Malformed tags POST request. Data is missing 'Items' field") + + shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.uuid == tag_id, + ub.Shelf.user_id == current_user.id).one_or_none() + if not shelf: + log.debug( + "Received a request to remove an item from a Collection unknown to CalibreWeb.") + abort(404, description="Collection isn't known to CalibreWeb") + + if not shelf_lib.check_shelf_edit_permissions(shelf): + abort(401, description="User is unauthaurized to edit shelf.") + + items_unknown_to_calibre = [] + for item in items: + if item["Type"] != "ProductRevisionTagItem": + items_unknown_to_calibre.append(item) + continue + + book = db.session.query(db.Books).filter(db.Books.uuid == item["RevisionId"]).one_or_none() + if not book: + items_unknown_to_calibre.append(item) + continue + + shelf.books.filter(ub.BookShelf.book_id == book.id).delete() + ub.session.commit() + + if items_unknown_to_calibre: + log.debug("Received request to remove an unknown book to a collecition. Silently ignoring item.") + + return make_response('', 200) + + +# Add new, changed, or deleted shelves to the sync_results. +# Note: Public shelves that aren't owned by the user aren't supported. +def sync_shelves(sync_token, sync_results): + new_tags_last_modified = sync_token.tags_last_modified + + for shelf in ub.session.query(ub.ShelfArchive).filter(func.datetime(ub.ShelfArchive.last_modified) > sync_token.tags_last_modified, ub.ShelfArchive.user_id == current_user.id): + new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified) + + sync_results.append({ + "DeletedTag": { + "Tag": { + "Id": shelf.uuid, + "LastModified": convert_to_kobo_timestamp_string(shelf.last_modified) + } + } + }) + + for shelf in ub.session.query(ub.Shelf).filter(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified, ub.Shelf.user_id == current_user.id): + if not shelf_lib.check_shelf_view_permissions(shelf): + continue + + new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified) + + tag = create_kobo_tag(shelf) + if not tag: + continue + + if shelf.created > sync_token.tags_last_modified: + sync_results.append({ + "NewTag": tag + }) + else: + sync_results.append({ + "ChangedTag": tag + }) + sync_token.tags_last_modified = new_tags_last_modified + ub.session.commit() + + +# Creates a Kobo "Tag" object from a ub.Shelf object +def create_kobo_tag(shelf): + tag = { + "Created": convert_to_kobo_timestamp_string(shelf.created), + "Id": shelf.uuid, + "Items": [], + "LastModified": convert_to_kobo_timestamp_string(shelf.last_modified), + "Name": shelf.name, + "Type": "UserTag" + } + for book_shelf in shelf.books: + book = db.session.query(db.Books).filter(db.Books.id == book_shelf.book_id).one_or_none() + if not book: + log.info(u"Book (id: %s) in BookShelf (id: %s) not found in book database", book_shelf.book_id, shelf.id) + return None + tag["Items"].append( + { + "RevisionId": book.uuid, + "Type": "ProductRevisionTagItem" + } + ) + return {"Tag": tag} + + @kobo.route("/v1/library//state", methods=["GET", "PUT"]) @login_required def HandleStateRequest(book_uuid): @@ -589,10 +807,7 @@ def HandleBookDeletionRequest(book_uuid): # TODO: Implement the following routes @kobo.route("/v1/library/", methods=["DELETE", "GET"]) -@kobo.route("/v1/library/tags", methods=["POST"]) -@kobo.route("/v1/library/tags/", methods=["POST"]) -@kobo.route("/v1/library/tags/", methods=["DELETE"]) -def HandleUnimplementedRequest(dummy=None, book_uuid=None, shelf_name=None, tag_id=None): +def HandleUnimplementedRequest(dummy=None): log.debug("Unimplemented Library Request received: %s", request.base_url) return redirect_or_proxy_request() @@ -612,6 +827,7 @@ def HandleUserRequest(dummy=None): @kobo.route("/v1/products//recommendations", methods=["GET", "POST"]) @kobo.route("/v1/products//nextread", methods=["GET", "POST"]) @kobo.route("/v1/products//reviews", methods=["GET", "POST"]) +@kobo.route("/v1/products/books/series/", methods=["GET", "POST"]) @kobo.route("/v1/products/books/", methods=["GET", "POST"]) @kobo.route("/v1/products/dailydeal", methods=["GET", "POST"]) @kobo.route("/v1/products", methods=["GET", "POST"]) @@ -624,7 +840,7 @@ def HandleProductsRequest(dummy=None): def handle_404(err): # This handler acts as a catch-all for endpoints that we don't have an interest in # implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc) - log.debug("Unknown Request received: %s", request.base_url) + log.debug("Unknown Request received: %s, method: %s, data: %s", request.base_url, request.method, request.data) return redirect_or_proxy_request() diff --git a/cps/opds.py b/cps/opds.py index 5cfb5348..8877cfb2 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -326,12 +326,12 @@ def feed_shelfindex(): def feed_shelf(book_id): off = request.args.get("offset") or 0 if current_user.is_anonymous: - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == book_id).first() + shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == book_id, not ub.Shelf.deleted).first() else: shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id), ub.Shelf.id == book_id), and_(ub.Shelf.is_public == 1, - ub.Shelf.id == book_id))).first() + ub.Shelf.id == book_id)), not ub.Shelf.deleted).first() result = list() # user is allowed to access shelf if shelf: diff --git a/cps/services/SyncToken.py b/cps/services/SyncToken.py index 9804fdb3..abc639f1 100644 --- a/cps/services/SyncToken.py +++ b/cps/services/SyncToken.py @@ -78,6 +78,7 @@ class SyncToken(): "books_last_created": {"type": "string"}, "archive_last_modified": {"type": "string"}, "reading_state_last_modified": {"type": "string"}, + "tags_last_modified": {"type": "string"}, }, } @@ -88,13 +89,14 @@ class SyncToken(): books_last_modified=datetime.min, archive_last_modified=datetime.min, reading_state_last_modified=datetime.min, + tags_last_modified=datetime.min, ): self.raw_kobo_store_token = raw_kobo_store_token self.books_last_created = books_last_created self.books_last_modified = books_last_modified self.archive_last_modified = archive_last_modified self.reading_state_last_modified = reading_state_last_modified - + self.tags_last_modified = tags_last_modified @staticmethod def from_headers(headers): @@ -128,6 +130,7 @@ class SyncToken(): books_last_created = get_datetime_from_json(data_json, "books_last_created") archive_last_modified = get_datetime_from_json(data_json, "archive_last_modified") reading_state_last_modified = get_datetime_from_json(data_json, "reading_state_last_modified") + tags_last_modified = get_datetime_from_json(data_json, "tags_last_modified") except TypeError: log.error("SyncToken timestamps don't parse to a datetime.") return SyncToken(raw_kobo_store_token=raw_kobo_store_token) @@ -137,7 +140,8 @@ class SyncToken(): books_last_created=books_last_created, books_last_modified=books_last_modified, archive_last_modified=archive_last_modified, - reading_state_last_modified=reading_state_last_modified + reading_state_last_modified=reading_state_last_modified, + tags_last_modified=tags_last_modified ) def set_kobo_store_header(self, store_headers): @@ -159,7 +163,8 @@ class SyncToken(): "books_last_modified": to_epoch_timestamp(self.books_last_modified), "books_last_created": to_epoch_timestamp(self.books_last_created), "archive_last_modified": to_epoch_timestamp(self.archive_last_modified), - "reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified) + "reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified), + "tags_last_modified": to_epoch_timestamp(self.tags_last_modified) }, } return b64encode_json(token) diff --git a/cps/shelf.py b/cps/shelf.py index c78059ca..4924a186 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -35,6 +35,24 @@ from .helper import common_filters shelf = Blueprint('shelf', __name__) log = logger.create() +def check_shelf_edit_permissions(cur_shelf): + if not cur_shelf.is_public and not cur_shelf.user_id == int(current_user.id): + log.error("User %s not allowed to edit shelf %s", current_user, cur_shelf) + return False + if cur_shelf.is_public and not current_user.role_edit_shelfs(): + log.info("User %s not allowed to edit public shelves", current_user) + return False + return True + + +def check_shelf_view_permissions(cur_shelf): + if cur_shelf.is_public: + return True + if current_user.is_anonymous or cur_shelf.user_id != current_user.id: + log.error("User is unauthorized to view non-public shelf: %s", cur_shelf) + return False + return True + @shelf.route("/shelf/add//") @login_required @@ -48,21 +66,13 @@ def add_to_shelf(shelf_id, book_id): return redirect(url_for('web.index')) return "Invalid shelf specified", 400 - if not shelf.is_public and not shelf.user_id == int(current_user.id): - log.error("User %s not allowed to add a book to %s", current_user, shelf) + if not check_shelf_edit_permissions(shelf): if not xhr: flash(_(u"Sorry you are not allowed to add a book to the the shelf: %(shelfname)s", shelfname=shelf.name), category="error") return redirect(url_for('web.index')) return "Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name, 403 - if shelf.is_public and not current_user.role_edit_shelfs(): - log.info("User %s not allowed to edit public shelves", current_user) - if not xhr: - flash(_(u"You are not allowed to edit public shelves"), category="error") - return redirect(url_for('web.index')) - return "User is not allowed to edit public shelves", 403 - book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id, ub.BookShelf.book_id == book_id).first() if book_in_shelf: @@ -78,8 +88,8 @@ def add_to_shelf(shelf_id, book_id): else: maxOrder = maxOrder[0] - ins = ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1) - ub.session.add(ins) + shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1)) + ub.session.merge(shelf) ub.session.commit() if not xhr: flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success") @@ -99,16 +109,10 @@ def search_to_shelf(shelf_id): flash(_(u"Invalid shelf specified"), category="error") return redirect(url_for('web.index')) - if not shelf.is_public and not shelf.user_id == int(current_user.id): - log.error("User %s not allowed to add a book to %s", current_user, shelf) + if not check_shelf_edit_permissions(shelf): flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error") return redirect(url_for('web.index')) - if shelf.is_public and not current_user.role_edit_shelfs(): - log.error("User %s not allowed to edit public shelves", current_user) - flash(_(u"User is not allowed to edit public shelves"), category="error") - return redirect(url_for('web.index')) - if current_user.id in searched_ids and searched_ids[current_user.id]: books_for_shelf = list() books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all() @@ -135,8 +139,8 @@ def search_to_shelf(shelf_id): for book in books_for_shelf: maxOrder = maxOrder + 1 - ins = ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder) - ub.session.add(ins) + shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder)) + ub.session.merge(shelf) ub.session.commit() flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success") else: @@ -163,8 +167,7 @@ def remove_from_shelf(shelf_id, book_id): # true 0 x 1 # false 0 x 0 - if (not shelf.is_public and shelf.user_id == int(current_user.id)) \ - or (shelf.is_public and current_user.role_edit_shelfs()): + if check_shelf_edit_permissions(shelf): book_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id, ub.BookShelf.book_id == book_id).first() @@ -185,7 +188,6 @@ def remove_from_shelf(shelf_id, book_id): return redirect(url_for('web.index')) return "", 204 else: - log.error("User %s not allowed to remove a book from %s", current_user, shelf) if not xhr: flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name), category="error") @@ -238,7 +240,7 @@ def create_shelf(): @login_required def edit_shelf(shelf_id): shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() - if request.method == "POST": + if request.method == "POST": to_save = request.form.to_dict() is_shelf_name_unique = False @@ -275,41 +277,33 @@ def edit_shelf(shelf_id): return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit") +def delete_shelf_helper(cur_shelf): + if not cur_shelf or not check_shelf_edit_permissions(cur_shelf): + return + shelf_id = cur_shelf.id + ub.session.delete(cur_shelf) + ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete() + ub.session.add(ub.ShelfArchive(uuid = cur_shelf.uuid, user_id = cur_shelf.uuid)) + ub.session.commit() + log.info("successfully deleted %s", cur_shelf) + + @shelf.route("/shelf/delete/") @login_required def delete_shelf(shelf_id): cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() - deleted = None - if current_user.role_admin(): - deleted = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).delete() - else: - if (not cur_shelf.is_public and cur_shelf.user_id == int(current_user.id)) \ - or (cur_shelf.is_public and current_user.role_edit_shelfs()): - deleted = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == shelf_id), - and_(ub.Shelf.is_public == 1, - ub.Shelf.id == shelf_id))).delete() - - if deleted: - ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete() - ub.session.commit() - log.info("successfully deleted %s", cur_shelf) + delete_shelf_helper(cur_shelf) return redirect(url_for('web.index')) # @shelf.route("/shelfdown/") @shelf.route("/shelf/", defaults={'shelf_type': 1}) @shelf.route("/shelf//") def show_shelf(shelf_type, shelf_id): - if current_user.is_anonymous: - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first() - else: - shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == shelf_id), - and_(ub.Shelf.is_public == 1, - ub.Shelf.id == shelf_id))).first() + shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() + result = list() # user is allowed to access shelf - if shelf: + if shelf and check_shelf_view_permissions(shelf): page = "shelf.html" if shelf_type == 1 else 'shelfdown.html' books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id)\ @@ -344,15 +338,10 @@ def order_shelf(shelf_id): setattr(book, 'order', to_save[str(book.book_id)]) counter += 1 ub.session.commit() - if current_user.is_anonymous: - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first() - else: - shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == shelf_id), - and_(ub.Shelf.is_public == 1, - ub.Shelf.id == shelf_id))).first() + + shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() result = list() - if shelf: + if shelf and check_shelf_view_permissions(shelf): books_in_shelf2 = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \ .order_by(ub.BookShelf.order.asc()).all() for book in books_in_shelf2: diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 8522a6a3..d2a15d7b 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -233,18 +233,6 @@