Merge branch 'Develop'
# Conflicts: # cps/db.py # cps/templates/user_edit.htmlpull/1218/head
commit
146068c936
@ -0,0 +1,622 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
from time import gmtime, strftime
|
||||
try:
|
||||
from urllib import unquote
|
||||
except ImportError:
|
||||
from urllib.parse import unquote
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
request,
|
||||
make_response,
|
||||
jsonify,
|
||||
current_app,
|
||||
url_for,
|
||||
redirect,
|
||||
abort
|
||||
)
|
||||
from flask_login import login_required
|
||||
from werkzeug.datastructures import Headers
|
||||
from sqlalchemy import func
|
||||
import requests
|
||||
|
||||
from . import config, logger, kobo_auth, db, helper
|
||||
from .services import SyncToken as SyncToken
|
||||
from .web import download_required
|
||||
from .kobo_auth import requires_kobo_auth
|
||||
|
||||
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
|
||||
KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
|
||||
|
||||
kobo = Blueprint("kobo", __name__, url_prefix="/kobo/<auth_token>")
|
||||
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
|
||||
kobo_auth.register_url_value_preprocessor(kobo)
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def get_store_url_for_current_request():
|
||||
# Programmatically modify the current url to point to the official Kobo store
|
||||
base, sep, request_path_with_auth_token = request.full_path.rpartition("/kobo/")
|
||||
auth_token, sep, request_path = request_path_with_auth_token.rstrip("?").partition(
|
||||
"/"
|
||||
)
|
||||
return KOBO_STOREAPI_URL + "/" + request_path
|
||||
|
||||
|
||||
CONNECTION_SPECIFIC_HEADERS = [
|
||||
"connection",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
]
|
||||
|
||||
def get_kobo_activated():
|
||||
return config.config_kobo_sync
|
||||
|
||||
|
||||
def make_request_to_kobo_store(sync_token=None):
|
||||
outgoing_headers = Headers(request.headers)
|
||||
outgoing_headers.remove("Host")
|
||||
if sync_token:
|
||||
sync_token.set_kobo_store_header(outgoing_headers)
|
||||
|
||||
store_response = requests.request(
|
||||
method=request.method,
|
||||
url=get_store_url_for_current_request(),
|
||||
headers=outgoing_headers,
|
||||
data=request.get_data(),
|
||||
allow_redirects=False,
|
||||
timeout=(2, 10)
|
||||
)
|
||||
return store_response
|
||||
|
||||
|
||||
def redirect_or_proxy_request():
|
||||
if config.config_kobo_proxy:
|
||||
if request.method == "GET":
|
||||
return redirect(get_store_url_for_current_request(), 307)
|
||||
if request.method == "DELETE":
|
||||
log.info('Delete Book')
|
||||
return make_response(jsonify({}))
|
||||
else:
|
||||
# The Kobo device turns other request types into GET requests on redirects, so we instead proxy to the Kobo store ourselves.
|
||||
store_response = make_request_to_kobo_store()
|
||||
|
||||
response_headers = store_response.headers
|
||||
for header_key in CONNECTION_SPECIFIC_HEADERS:
|
||||
response_headers.pop(header_key, default=None)
|
||||
|
||||
return make_response(
|
||||
store_response.content, store_response.status_code, response_headers.items()
|
||||
)
|
||||
else:
|
||||
return make_response(jsonify({}))
|
||||
|
||||
|
||||
@kobo.route("/v1/library/sync")
|
||||
@requires_kobo_auth
|
||||
@download_required
|
||||
def HandleSyncRequest():
|
||||
sync_token = SyncToken.SyncToken.from_headers(request.headers)
|
||||
log.info("Kobo library sync request received.")
|
||||
if not current_app.wsgi_app.is_proxied:
|
||||
log.debug('Kobo: Received unproxied request, changed request port to server port')
|
||||
|
||||
# TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header
|
||||
# instead so that the device triggers another sync.
|
||||
|
||||
new_books_last_modified = sync_token.books_last_modified
|
||||
new_books_last_created = sync_token.books_last_created
|
||||
entitlements = []
|
||||
|
||||
# We reload the book database so that the user get's a fresh view of the library
|
||||
# in case of external changes (e.g: adding a book through Calibre).
|
||||
db.reconnect_db(config)
|
||||
|
||||
# sqlite gives unexpected results when performing the last_modified comparison without the datetime cast.
|
||||
# It looks like it's treating the db.Books.last_modified field as a string and may fail
|
||||
# the comparison because of the +00:00 suffix.
|
||||
changed_entries = (
|
||||
db.session.query(db.Books)
|
||||
.join(db.Data)
|
||||
.filter(func.datetime(db.Books.last_modified) > sync_token.books_last_modified)
|
||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||
.all()
|
||||
)
|
||||
|
||||
for book in changed_entries:
|
||||
entitlement = {
|
||||
"BookEntitlement": create_book_entitlement(book),
|
||||
"BookMetadata": get_metadata(book),
|
||||
"ReadingState": reading_state(book),
|
||||
}
|
||||
if book.timestamp > sync_token.books_last_created:
|
||||
entitlements.append({"NewEntitlement": entitlement})
|
||||
else:
|
||||
entitlements.append({"ChangedEntitlement": entitlement})
|
||||
|
||||
new_books_last_modified = max(
|
||||
book.last_modified, sync_token.books_last_modified
|
||||
)
|
||||
new_books_last_created = max(book.timestamp, sync_token.books_last_created)
|
||||
|
||||
sync_token.books_last_created = new_books_last_created
|
||||
sync_token.books_last_modified = new_books_last_modified
|
||||
|
||||
if config.config_kobo_proxy:
|
||||
return generate_sync_response(request, sync_token, entitlements)
|
||||
|
||||
return make_response(jsonify(entitlements))
|
||||
# Missing feature: Detect server-side book deletions.
|
||||
|
||||
|
||||
def generate_sync_response(request, sync_token, entitlements):
|
||||
extra_headers = {}
|
||||
if config.config_kobo_proxy:
|
||||
# Merge in sync results from the official Kobo store.
|
||||
try:
|
||||
store_response = make_request_to_kobo_store(sync_token)
|
||||
|
||||
store_entitlements = store_response.json()
|
||||
entitlements += store_entitlements
|
||||
sync_token.merge_from_store_response(store_response)
|
||||
extra_headers["x-kobo-sync"] = store_response.headers.get("x-kobo-sync")
|
||||
extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode")
|
||||
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
|
||||
|
||||
except Exception as e:
|
||||
log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e))
|
||||
sync_token.to_headers(extra_headers)
|
||||
|
||||
response = make_response(jsonify(entitlements), extra_headers)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@kobo.route("/v1/library/<book_uuid>/metadata")
|
||||
@requires_kobo_auth
|
||||
@download_required
|
||||
def HandleMetadataRequest(book_uuid):
|
||||
if not current_app.wsgi_app.is_proxied:
|
||||
log.debug('Kobo: Received unproxied request, changed request port to server port')
|
||||
log.info("Kobo library metadata request received for book %s" % book_uuid)
|
||||
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
|
||||
if not book or not book.data:
|
||||
log.info(u"Book %s not found in database", book_uuid)
|
||||
return redirect_or_proxy_request()
|
||||
|
||||
metadata = get_metadata(book)
|
||||
return jsonify([metadata])
|
||||
|
||||
|
||||
def get_download_url_for_book(book, book_format):
|
||||
if not current_app.wsgi_app.is_proxied:
|
||||
return "{url_scheme}://{url_base}:{url_port}/download/{book_id}/{book_format}".format(
|
||||
url_scheme=request.environ['wsgi.url_scheme'],
|
||||
url_base=request.environ['SERVER_NAME'],
|
||||
url_port=config.config_port,
|
||||
book_id=book.id,
|
||||
book_format=book_format.lower()
|
||||
)
|
||||
else:
|
||||
return url_for(
|
||||
"web.download_link",
|
||||
book_id=book.id,
|
||||
book_format=book_format.lower(),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
|
||||
def create_book_entitlement(book):
|
||||
book_uuid = book.uuid
|
||||
return {
|
||||
"Accessibility": "Full",
|
||||
"ActivePeriod": {"From": current_time(),},
|
||||
"Created": book.timestamp,
|
||||
"CrossRevisionId": book_uuid,
|
||||
"Id": book_uuid,
|
||||
"IsHiddenFromArchive": False,
|
||||
"IsLocked": False,
|
||||
# Setting this to true removes from the device.
|
||||
"IsRemoved": False,
|
||||
"LastModified": book.last_modified,
|
||||
"OriginCategory": "Imported",
|
||||
"RevisionId": book_uuid,
|
||||
"Status": "Active",
|
||||
}
|
||||
|
||||
|
||||
def current_time():
|
||||
return strftime("%Y-%m-%dT%H:%M:%SZ", gmtime())
|
||||
|
||||
|
||||
def get_description(book):
|
||||
if not book.comments:
|
||||
return None
|
||||
return book.comments[0].text
|
||||
|
||||
|
||||
# TODO handle multiple authors
|
||||
def get_author(book):
|
||||
if not book.authors:
|
||||
return None
|
||||
return book.authors[0].name
|
||||
|
||||
|
||||
def get_publisher(book):
|
||||
if not book.publishers:
|
||||
return None
|
||||
return book.publishers[0].name
|
||||
|
||||
|
||||
def get_series(book):
|
||||
if not book.series:
|
||||
return None
|
||||
return book.series[0].name
|
||||
|
||||
|
||||
def get_metadata(book):
|
||||
download_urls = []
|
||||
for book_data in book.data:
|
||||
if book_data.format not in KOBO_FORMATS:
|
||||
continue
|
||||
for kobo_format in KOBO_FORMATS[book_data.format]:
|
||||
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
|
||||
download_urls.append(
|
||||
{
|
||||
"Format": kobo_format,
|
||||
"Size": book_data.uncompressed_size,
|
||||
"Url": get_download_url_for_book(book, book_data.format),
|
||||
# The Kobo forma accepts platforms: (Generic, Android)
|
||||
"Platform": "Generic",
|
||||
# "DrmType": "None", # Not required
|
||||
}
|
||||
)
|
||||
|
||||
book_uuid = book.uuid
|
||||
metadata = {
|
||||
"Categories": ["00000000-0000-0000-0000-000000000001",],
|
||||
"Contributors": get_author(book),
|
||||
"CoverImageId": book_uuid,
|
||||
"CrossRevisionId": book_uuid,
|
||||
"CurrentDisplayPrice": {"CurrencyCode": "USD", "TotalAmount": 0},
|
||||
"CurrentLoveDisplayPrice": {"TotalAmount": 0},
|
||||
"Description": get_description(book),
|
||||
"DownloadUrls": download_urls,
|
||||
"EntitlementId": book_uuid,
|
||||
"ExternalIds": [],
|
||||
"Genre": "00000000-0000-0000-0000-000000000001",
|
||||
"IsEligibleForKoboLove": False,
|
||||
"IsInternetArchive": False,
|
||||
"IsPreOrder": False,
|
||||
"IsSocialEnabled": True,
|
||||
"Language": "en",
|
||||
"PhoneticPronunciations": {},
|
||||
"PublicationDate": book.pubdate,
|
||||
"Publisher": {"Imprint": "", "Name": get_publisher(book),},
|
||||
"RevisionId": book_uuid,
|
||||
"Title": book.title,
|
||||
"WorkId": book_uuid,
|
||||
}
|
||||
|
||||
if get_series(book):
|
||||
if sys.version_info < (3, 0):
|
||||
name = get_series(book).encode("utf-8")
|
||||
else:
|
||||
name = get_series(book)
|
||||
metadata["Series"] = {
|
||||
"Name": get_series(book),
|
||||
"Number": book.series_index,
|
||||
"NumberFloat": float(book.series_index),
|
||||
# Get a deterministic id based on the series name.
|
||||
"Id": uuid.uuid3(uuid.NAMESPACE_DNS, name),
|
||||
}
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def reading_state(book):
|
||||
# TODO: Implement
|
||||
reading_state = {
|
||||
# "StatusInfo": {
|
||||
# "LastModified": get_single_cc_value(book, "lastreadtimestamp"),
|
||||
# "Status": get_single_cc_value(book, "reading_status"),
|
||||
# }
|
||||
# TODO: CurrentBookmark, Location
|
||||
}
|
||||
return reading_state
|
||||
|
||||
|
||||
@kobo.route("/<book_uuid>/image.jpg")
|
||||
@requires_kobo_auth
|
||||
def HandleCoverImageRequest(book_uuid):
|
||||
log.debug("Cover request received for book %s" % book_uuid)
|
||||
book_cover = helper.get_book_cover_with_uuid(
|
||||
book_uuid, use_generic_cover_on_failure=False
|
||||
)
|
||||
if not book_cover:
|
||||
if config.config_kobo_proxy:
|
||||
return redirect(get_store_url_for_current_request(), 307)
|
||||
else:
|
||||
abort(404)
|
||||
return book_cover
|
||||
|
||||
|
||||
@kobo.route("")
|
||||
def TopLevelEndpoint():
|
||||
return make_response(jsonify({}))
|
||||
|
||||
|
||||
# TODO: Implement the following routes
|
||||
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
|
||||
@kobo.route("/v1/library/<book_uuid>/state", methods=["PUT"])
|
||||
@kobo.route("/v1/library/tags", methods=["POST"])
|
||||
@kobo.route("/v1/library/tags/<shelf_name>", methods=["POST"])
|
||||
@kobo.route("/v1/library/tags/<tag_id>", methods=["DELETE"])
|
||||
def HandleUnimplementedRequest(dummy=None, book_uuid=None, shelf_name=None, tag_id=None):
|
||||
log.debug("Alternative Request received:")
|
||||
return redirect_or_proxy_request()
|
||||
|
||||
|
||||
# TODO: Implement the following routes
|
||||
@kobo.route("/v1/user/loyalty/<dummy>", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/user/profile", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
|
||||
def HandleUserRequest(dummy=None):
|
||||
log.debug("Unimplemented User Request received: %s", request.base_url)
|
||||
return redirect_or_proxy_request()
|
||||
|
||||
|
||||
@kobo.route("/v1/products/<dummy>/prices", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/products/<dummy>/recommendations", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/products/<dummy>/nextread", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/products/<dummy>/reviews", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/products/books/<dummy>", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
|
||||
def HandleProductsRequest(dummy=None):
|
||||
log.debug("Unimplemented Products Request received: %s", request.base_url)
|
||||
return redirect_or_proxy_request()
|
||||
|
||||
|
||||
@kobo.app_errorhandler(404)
|
||||
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)
|
||||
return redirect_or_proxy_request()
|
||||
|
||||
|
||||
def make_calibre_web_auth_response():
|
||||
# As described in kobo_auth.py, CalibreWeb doesn't make use practical use of this auth/device API call for
|
||||
# authentation (nor for authorization). We return a dummy response just to keep the device happy.
|
||||
content = request.get_json()
|
||||
AccessToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
||||
RefreshToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"AccessToken": AccessToken,
|
||||
"RefreshToken": RefreshToken,
|
||||
"TokenType": "Bearer",
|
||||
"TrackingId": str(uuid.uuid4()),
|
||||
"UserKey": content['UserKey'],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@kobo.route("/v1/auth/device", methods=["POST"])
|
||||
@requires_kobo_auth
|
||||
def HandleAuthRequest():
|
||||
log.debug('Kobo Auth request')
|
||||
if config.config_kobo_proxy:
|
||||
try:
|
||||
return redirect_or_proxy_request()
|
||||
except:
|
||||
log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.")
|
||||
return make_calibre_web_auth_response()
|
||||
|
||||
|
||||
def make_calibre_web_init_response(calibre_web_url):
|
||||
resources = NATIVE_KOBO_RESOURCES(calibre_web_url)
|
||||
response = make_response(jsonify({"Resources": resources}))
|
||||
response.headers["x-kobo-apitoken"] = "e30="
|
||||
return response
|
||||
|
||||
|
||||
@kobo.route("/v1/initialization")
|
||||
@requires_kobo_auth
|
||||
def HandleInitRequest():
|
||||
log.info('Init')
|
||||
|
||||
if not current_app.wsgi_app.is_proxied:
|
||||
log.debug('Kobo: Received unproxied request, changed request port to server port')
|
||||
if request.environ['SERVER_NAME'] != '::':
|
||||
calibre_web_url = "{url_scheme}://{url_base}:{url_port}".format(
|
||||
url_scheme=request.environ['wsgi.url_scheme'],
|
||||
url_base=request.environ['SERVER_NAME'],
|
||||
url_port=config.config_port
|
||||
)
|
||||
else:
|
||||
log.debug('Kobo: Received unproxied request, on IPV6 host')
|
||||
calibre_web_url = url_for("web.index", _external=True).strip("/")
|
||||
else:
|
||||
calibre_web_url = url_for("web.index", _external=True).strip("/")
|
||||
|
||||
if config.config_kobo_proxy:
|
||||
try:
|
||||
store_response = make_request_to_kobo_store()
|
||||
|
||||
store_response_json = store_response.json()
|
||||
if "Resources" in store_response_json:
|
||||
kobo_resources = store_response_json["Resources"]
|
||||
# calibre_web_url = url_for("web.index", _external=True).strip("/")
|
||||
kobo_resources["image_host"] = calibre_web_url
|
||||
kobo_resources["image_url_quality_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
|
||||
auth_token = kobo_auth.get_auth_token(),
|
||||
book_uuid="{ImageId}"))
|
||||
kobo_resources["image_url_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
|
||||
auth_token = kobo_auth.get_auth_token(),
|
||||
book_uuid="{ImageId}"))
|
||||
|
||||
return make_response(store_response_json, store_response.status_code)
|
||||
except:
|
||||
log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.")
|
||||
|
||||
return make_calibre_web_init_response(calibre_web_url)
|
||||
|
||||
|
||||
def NATIVE_KOBO_RESOURCES(calibre_web_url):
|
||||
return {
|
||||
"account_page": "https://secure.kobobooks.com/profile",
|
||||
"account_page_rakuten": "https://my.rakuten.co.jp/",
|
||||
"add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}",
|
||||
"affiliaterequest": "https://storeapi.kobo.com/v1/affiliate",
|
||||
"audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion",
|
||||
"authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations",
|
||||
"autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete",
|
||||
"blackstone_header": {"key": "x-amz-request-payer", "value": "requester"},
|
||||
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
|
||||
"book_detail_page": "https://store.kobobooks.com/{culture}/ebook/{slug}",
|
||||
"book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}",
|
||||
"book_landing_page": "https://store.kobobooks.com/ebooks",
|
||||
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
|
||||
"categories": "https://storeapi.kobo.com/v1/categories",
|
||||
"categories_page": "https://store.kobobooks.com/ebooks/categories",
|
||||
"category": "https://storeapi.kobo.com/v1/categories/{CategoryId}",
|
||||
"category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured",
|
||||
"category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products",
|
||||
"checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow",
|
||||
"configuration_data": "https://storeapi.kobo.com/v1/configuration",
|
||||
"content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access",
|
||||
"customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO",
|
||||
"daily_deal": "https://storeapi.kobo.com/v1/products/dailydeal",
|
||||
"deals": "https://storeapi.kobo.com/v1/deals",
|
||||
"delete_entitlement": "https://storeapi.kobo.com/v1/library/{Ids}",
|
||||
"delete_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
|
||||
"delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete",
|
||||
"device_auth": "https://storeapi.kobo.com/v1/auth/device",
|
||||
"device_refresh": "https://storeapi.kobo.com/v1/auth/refresh",
|
||||
"dictionary_host": "https://kbdownload1-a.akamaihd.net",
|
||||
"discovery_host": "https://discovery.kobobooks.com",
|
||||
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
||||
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
||||
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
||||
"facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/",
|
||||
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
||||
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
||||
"free_books_page": {
|
||||
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
||||
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
||||
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
||||
"NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
||||
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis",
|
||||
},
|
||||
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
||||
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
|
||||
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
|
||||
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
|
||||
"help_page": "http://www.kobo.com/help",
|
||||
"image_host": calibre_web_url,
|
||||
"image_url_quality_template": unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
|
||||
auth_token = kobo_auth.get_auth_token(),
|
||||
book_uuid="{ImageId}")),
|
||||
"image_url_template": unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
|
||||
auth_token = kobo_auth.get_auth_token(),
|
||||
book_uuid="{ImageId}")),
|
||||
"kobo_audiobooks_enabled": "False",
|
||||
"kobo_audiobooks_orange_deal_enabled": "False",
|
||||
"kobo_audiobooks_subscriptions_enabled": "False",
|
||||
"kobo_nativeborrow_enabled": "True",
|
||||
"kobo_onestorelibrary_enabled": "False",
|
||||
"kobo_redeem_enabled": "True",
|
||||
"kobo_shelfie_enabled": "False",
|
||||
"kobo_subscriptions_enabled": "False",
|
||||
"kobo_superpoints_enabled": "False",
|
||||
"kobo_wishlist_enabled": "True",
|
||||
"library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}",
|
||||
"library_items": "https://storeapi.kobo.com/v1/user/library",
|
||||
"library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata",
|
||||
"library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices",
|
||||
"library_stack": "https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}",
|
||||
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
||||
"love_dashboard_page": "https://store.kobobooks.com/{culture}/kobosuperpoints",
|
||||
"love_points_redemption_page": "https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}",
|
||||
"magazine_landing_page": "https://store.kobobooks.com/emagazines",
|
||||
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
||||
"oauth_host": "https://oauth.kobo.com",
|
||||
"overdrive_account": "https://auth.overdrive.com/account",
|
||||
"overdrive_library": "https://{libraryKey}.auth.overdrive.com/library",
|
||||
"overdrive_library_finder_host": "https://libraryfinder.api.overdrive.com",
|
||||
"overdrive_thunder_host": "https://thunder.api.overdrive.com",
|
||||
"password_retrieval_page": "https://www.kobobooks.com/passwordretrieval.html",
|
||||
"post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event",
|
||||
"privacy_page": "https://www.kobo.com/privacypolicy?style=onestore",
|
||||
"product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread",
|
||||
"product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices",
|
||||
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
||||
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
||||
"products": "https://storeapi.kobo.com/v1/products",
|
||||
"provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://store.kobobooks.com/",
|
||||
"purchase_buy": "https://www.kobo.com/checkout/createpurchase/",
|
||||
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
||||
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
||||
"quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase",
|
||||
"rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}",
|
||||
"reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state",
|
||||
"redeem_interstitial_page": "https://store.kobobooks.com",
|
||||
"registration_page": "https://authorize.kobo.com/signup?returnUrl=http://store.kobobooks.com/",
|
||||
"related_items": "https://storeapi.kobo.com/v1/products/{Id}/related",
|
||||
"remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}",
|
||||
"rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
|
||||
"review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}",
|
||||
"review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}",
|
||||
"shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie",
|
||||
"sign_in_page": "https://authorize.kobo.com/signin?returnUrl=http://store.kobobooks.com/",
|
||||
"social_authorization_host": "https://social.kobobooks.com:8443",
|
||||
"social_host": "https://social.kobobooks.com",
|
||||
"stacks_host_productId": "https://store.kobobooks.com/collections/byproductid/",
|
||||
"store_home": "www.kobo.com/{region}/{language}",
|
||||
"store_host": "store.kobobooks.com",
|
||||
"store_newreleases": "https://store.kobobooks.com/{culture}/List/new-releases/961XUjtsU0qxkFItWOutGA",
|
||||
"store_search": "https://store.kobobooks.com/{culture}/Search?Query={query}",
|
||||
"store_top50": "https://store.kobobooks.com/{culture}/ebooks/Top",
|
||||
"tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items",
|
||||
"tags": "https://storeapi.kobo.com/v1/library/tags",
|
||||
"taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile",
|
||||
"update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview",
|
||||
"use_one_store": "False",
|
||||
"user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits",
|
||||
"user_platform": "https://storeapi.kobo.com/v1/user/platform",
|
||||
"user_profile": "https://storeapi.kobo.com/v1/user/profile",
|
||||
"user_ratings": "https://storeapi.kobo.com/v1/user/ratings",
|
||||
"user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations",
|
||||
"user_reviews": "https://storeapi.kobo.com/v1/user/reviews",
|
||||
"user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist",
|
||||
"userguide_host": "https://kbdownload1-a.akamaihd.net",
|
||||
"wishlist_page": "https://store.kobobooks.com/{region}/{language}/account/wishlist",
|
||||
}
|
@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""This module is used to control authentication/authorization of Kobo sync requests.
|
||||
This module also includes research notes into the auth protocol used by Kobo devices.
|
||||
|
||||
Log-in:
|
||||
When first booting a Kobo device the user must sign into a Kobo (or affiliate) account.
|
||||
Upon successful sign-in, the user is redirected to
|
||||
https://auth.kobobooks.com/CrossDomainSignIn?id=<some id>
|
||||
which serves the following response:
|
||||
<script type='text/javascript'>location.href='kobo://UserAuthenticated?userId=<redacted>&userKey<redacted>&email=<redacted>&returnUrl=https%3a%2f%2fwww.kobo.com';</script>.
|
||||
And triggers the insertion of a userKey into the device's User table.
|
||||
|
||||
Together, the device's DeviceId and UserKey act as an *irrevocable* authentication
|
||||
token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is
|
||||
required to authorize the API call.
|
||||
|
||||
Changing Kobo password *does not* invalidate user keys! This is apparently a known
|
||||
issue for a few years now https://www.mobileread.com/forums/showpost.php?p=3476851&postcount=13
|
||||
(although this poster hypothesised that Kobo could blacklist a DeviceId, many endpoints
|
||||
will still grant access given the userkey.)
|
||||
|
||||
Official Kobo Store Api authorization:
|
||||
* For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is
|
||||
passed in the x-kobo-userkey header, and is sufficient to authorize the API call.
|
||||
* Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through
|
||||
an authorization header. To get a BearerToken, the device makes a POST request to the
|
||||
v1/auth/device endpoint with the secret UserKey and the device's DeviceId.
|
||||
* The book download endpoint passes an auth token as a URL param instead of a header.
|
||||
|
||||
Our implementation:
|
||||
We pretty much ignore all of the above. To authenticate the user, we generate a random
|
||||
and unique token that they append to the CalibreWeb Url when setting up the api_store
|
||||
setting on the device.
|
||||
Thus, every request from the device to the api_store will hit CalibreWeb with the
|
||||
auth_token in the url (e.g: https://mylibrary.com/<auth_token>/v1/library/sync).
|
||||
In addition, once authenticated we also set the login cookie on the response that will
|
||||
be sent back for the duration of the session to authorize subsequent API calls (in
|
||||
particular calls to non-Kobo specific endpoints such as the CalibreWeb book download).
|
||||
"""
|
||||
|
||||
from binascii import hexlify
|
||||
from datetime import datetime
|
||||
from os import urandom
|
||||
import os
|
||||
|
||||
from flask import g, Blueprint, url_for, abort, request
|
||||
from flask_login import login_user, login_required
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from . import logger, ub, lm
|
||||
from .web import render_title_template
|
||||
|
||||
try:
|
||||
from functools import wraps
|
||||
except ImportError:
|
||||
pass # We're not using Python 3
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
def register_url_value_preprocessor(kobo):
|
||||
@kobo.url_value_preprocessor
|
||||
def pop_auth_token(endpoint, values):
|
||||
g.auth_token = values.pop("auth_token")
|
||||
|
||||
|
||||
def disable_failed_auth_redirect_for_blueprint(bp):
|
||||
lm.blueprint_login_views[bp.name] = None
|
||||
|
||||
|
||||
def get_auth_token():
|
||||
if "auth_token" in g:
|
||||
return g.get("auth_token")
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def requires_kobo_auth(f):
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
auth_token = get_auth_token()
|
||||
if auth_token is not None:
|
||||
user = (
|
||||
ub.session.query(ub.User)
|
||||
.join(ub.RemoteAuthToken)
|
||||
.filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1)
|
||||
.first()
|
||||
)
|
||||
if user is not None:
|
||||
login_user(user)
|
||||
return f(*args, **kwargs)
|
||||
log.debug("Received Kobo request without a recognizable auth token.")
|
||||
return abort(401)
|
||||
return inner
|
||||
|
||||
|
||||
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||
|
||||
|
||||
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||
@login_required
|
||||
def generate_auth_token(user_id):
|
||||
host = ':'.join(request.host.rsplit(':')[0:-1])
|
||||
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f'):
|
||||
warning = _('PLease access calibre-web from non localhost to get valid api_endpoint for kobo device')
|
||||
return render_title_template(
|
||||
"generate_kobo_auth_url.html",
|
||||
title=_(u"Kobo Set-up"),
|
||||
warning = warning
|
||||
)
|
||||
else:
|
||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
|
||||
ub.RemoteAuthToken.user_id == user_id
|
||||
).filter(ub.RemoteAuthToken.token_type==1).first()
|
||||
|
||||
if not auth_token:
|
||||
auth_token = ub.RemoteAuthToken()
|
||||
auth_token.user_id = user_id
|
||||
auth_token.expiration = datetime.max
|
||||
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
|
||||
auth_token.token_type = 1
|
||||
|
||||
ub.session.add(auth_token)
|
||||
ub.session.commit()
|
||||
return render_title_template(
|
||||
"generate_kobo_auth_url.html",
|
||||
title=_(u"Kobo Set-up"),
|
||||
kobo_auth_url=url_for(
|
||||
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
|
||||
),
|
||||
warning = False
|
||||
)
|
||||
|
||||
|
||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>")
|
||||
@login_required
|
||||
def delete_auth_token(user_id):
|
||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||
ub.session.commit()
|
||||
return ""
|
@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
from base64 import b64decode, b64encode
|
||||
from jsonschema import validate, exceptions, __version__
|
||||
from datetime import datetime
|
||||
try:
|
||||
from urllib import unquote
|
||||
except ImportError:
|
||||
from urllib.parse import unquote
|
||||
|
||||
from flask import json
|
||||
from .. import logger as log
|
||||
|
||||
|
||||
def b64encode_json(json_data):
|
||||
if sys.version_info < (3, 0):
|
||||
return b64encode(json.dumps(json_data))
|
||||
else:
|
||||
return b64encode(json.dumps(json_data).encode())
|
||||
|
||||
|
||||
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2.
|
||||
def to_epoch_timestamp(datetime_object):
|
||||
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
||||
|
||||
|
||||
class SyncToken():
|
||||
""" The SyncToken is used to persist state accross requests.
|
||||
When serialized over the response headers, the Kobo device will propagate the token onto following requests to the service.
|
||||
As an example use-case, the SyncToken is used to detect books that have been added to the library since the last time the device synced to the server.
|
||||
|
||||
Attributes:
|
||||
books_last_created: Datetime representing the newest book that the device knows about.
|
||||
books_last_modified: Datetime representing the last modified book that the device knows about.
|
||||
"""
|
||||
|
||||
SYNC_TOKEN_HEADER = "x-kobo-synctoken"
|
||||
VERSION = "1-0-0"
|
||||
MIN_VERSION = "1-0-0"
|
||||
|
||||
token_schema = {
|
||||
"type": "object",
|
||||
"properties": {"version": {"type": "string"}, "data": {"type": "object"},},
|
||||
}
|
||||
# This Schema doesn't contain enough information to detect and propagate book deletions from Calibre to the device.
|
||||
# A potential solution might be to keep a list of all known book uuids in the token, and look for any missing from the db.
|
||||
data_schema_v1 = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"raw_kobo_store_token": {"type": "string"},
|
||||
"books_last_modified": {"type": "string"},
|
||||
"books_last_created": {"type": "string"},
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
raw_kobo_store_token="",
|
||||
books_last_created=datetime.min,
|
||||
books_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
|
||||
|
||||
@staticmethod
|
||||
def from_headers(headers):
|
||||
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
|
||||
if sync_token_header == "":
|
||||
return SyncToken()
|
||||
|
||||
# On the first sync from a Kobo device, we may receive the SyncToken
|
||||
# from the official Kobo store. Without digging too deep into it, that
|
||||
# token is of the form [b64encoded blob].[b64encoded blob 2]
|
||||
if "." in sync_token_header:
|
||||
return SyncToken(raw_kobo_store_token=sync_token_header)
|
||||
|
||||
try:
|
||||
sync_token_json = json.loads(
|
||||
b64decode(sync_token_header + "=" * (-len(sync_token_header) % 4))
|
||||
)
|
||||
validate(sync_token_json, SyncToken.token_schema)
|
||||
if sync_token_json["version"] < SyncToken.MIN_VERSION:
|
||||
raise ValueError
|
||||
|
||||
data_json = sync_token_json["data"]
|
||||
validate(sync_token_json, SyncToken.data_schema_v1)
|
||||
except (exceptions.ValidationError, ValueError) as e:
|
||||
log.error("Sync token contents do not follow the expected json schema.")
|
||||
return SyncToken()
|
||||
|
||||
raw_kobo_store_token = data_json["raw_kobo_store_token"]
|
||||
try:
|
||||
books_last_modified = datetime.utcfromtimestamp(
|
||||
data_json["books_last_modified"]
|
||||
)
|
||||
books_last_created = datetime.utcfromtimestamp(
|
||||
data_json["books_last_created"]
|
||||
)
|
||||
except TypeError:
|
||||
log.error("SyncToken timestamps don't parse to a datetime.")
|
||||
return SyncToken(raw_kobo_store_token=raw_kobo_store_token)
|
||||
|
||||
return SyncToken(
|
||||
raw_kobo_store_token=raw_kobo_store_token,
|
||||
books_last_created=books_last_created,
|
||||
books_last_modified=books_last_modified,
|
||||
)
|
||||
|
||||
def set_kobo_store_header(self, store_headers):
|
||||
store_headers.set(SyncToken.SYNC_TOKEN_HEADER, self.raw_kobo_store_token)
|
||||
|
||||
def merge_from_store_response(self, store_response):
|
||||
self.raw_kobo_store_token = store_response.headers.get(
|
||||
SyncToken.SYNC_TOKEN_HEADER, ""
|
||||
)
|
||||
|
||||
def to_headers(self, headers):
|
||||
headers[SyncToken.SYNC_TOKEN_HEADER] = self.build_sync_token()
|
||||
|
||||
def build_sync_token(self):
|
||||
token = {
|
||||
"version": SyncToken.VERSION,
|
||||
"data": {
|
||||
"raw_kobo_store_token": self.raw_kobo_store_token,
|
||||
"books_last_modified": to_epoch_timestamp(self.books_last_modified),
|
||||
"books_last_created": to_epoch_timestamp(self.books_last_created),
|
||||
},
|
||||
}
|
||||
return b64encode_json(token)
|
@ -0,0 +1,14 @@
|
||||
{% extends "fragment.html" %}
|
||||
{% block body %}
|
||||
<div class="well">
|
||||
<p>
|
||||
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>
|
||||
</p>
|
||||
<p>
|
||||
{% if not warning %}{{_('api_endpoint=')}}{{kobo_auth_url}}{% else %}{{warning}}{% endif %}</a>
|
||||
</p>
|
||||
<p>
|
||||
{{_('Please note that every visit to this current page invalidates any previously generated Authentication url for this user.')}}</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,39 @@
|
||||
{% macro restrict_modal() %}
|
||||
<div class="modal fade" id="restrictModal" tabindex="-1" role="dialog" aria-labelledby="restrictModalLabel">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title hidden" id="h1">{{_('Select allowed/denied Tags')}}</h4>
|
||||
<h4 class="modal-title hidden" id="h2">{{_('Select allowed/denied Custom Column values')}}</h4>
|
||||
<h4 class="modal-title hidden" id="h3">{{_('Select allowed/denied Tags of user')}}</h4>
|
||||
<h4 class="modal-title hidden" id="h4">{{_('Select allowed/denied Custom Column values of user')}}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="table table-no-bordered" id="restrict-elements-table" data-id-field="id" data-show-header="false" data-editable-mode="inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-field="Element" id="Element" data-editable-type="text" data-editable="true" data-editable-title="{{_('Enter Tag')}}"></th>
|
||||
<th data-field="type" id="type" data-visible="true"></th>
|
||||
<th data-field="id" id="id" data-visible="false"></th>
|
||||
<th data-align="right" data-formatter="RestrictionActions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<form id="add_restriction" action="" method="POST">
|
||||
<div class="form-group required">
|
||||
<label for="add_element">{{_('Add View Restriction')}}</label>
|
||||
<input type="text" class="form-control" name="add_element" id="add_element" >
|
||||
</div>
|
||||
<div class="form-group required">
|
||||
<input type="button" class="btn btn-default" value="{{_('Allow')}}" name="submit_allow" id="submit_allow" data-dismiss="static">
|
||||
<input type="button" class="btn btn-default" value="{{_('Deny')}}" name="submit_deny" id="submit_restrict" data-dismiss="static">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="restrict_close" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue