Commit 146068c9 authored by Ozzieisaacs's avatar Ozzieisaacs

Merge branch 'Develop'

# Conflicts:
#	cps/db.py
#	cps/templates/user_edit.html
parents 3b8c5ef2 134a10f5
...@@ -41,6 +41,14 @@ from cps.shelf import shelf ...@@ -41,6 +41,14 @@ from cps.shelf import shelf
from cps.admin import admi from cps.admin import admi
from cps.gdrive import gdrive from cps.gdrive import gdrive
from cps.editbooks import editbook from cps.editbooks import editbook
try:
from cps.kobo import kobo, get_kobo_activated
from cps.kobo_auth import kobo_auth
kobo_available = get_kobo_activated()
except ImportError:
kobo_available = False
try: try:
from cps.oauth_bb import oauth from cps.oauth_bb import oauth
oauth_available = True oauth_available = True
...@@ -58,6 +66,9 @@ def main(): ...@@ -58,6 +66,9 @@ def main():
app.register_blueprint(admi) app.register_blueprint(admi)
app.register_blueprint(gdrive) app.register_blueprint(gdrive)
app.register_blueprint(editbook) app.register_blueprint(editbook)
if kobo_available:
app.register_blueprint(kobo)
app.register_blueprint(kobo_auth)
if oauth_available: if oauth_available:
app.register_blueprint(oauth) app.register_blueprint(oauth)
success = web_server.start() success = web_server.start()
......
...@@ -70,7 +70,7 @@ _VERSIONS = OrderedDict( ...@@ -70,7 +70,7 @@ _VERSIONS = OrderedDict(
Unidecode = unidecode_version, Unidecode = unidecode_version,
Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed', Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed',
Goodreads = u'installed' if bool(services.goodreads_support) else u'not installed', Goodreads = u'installed' if bool(services.goodreads_support) else u'not installed',
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else u'not installed',
) )
_VERSIONS.update(uploader.get_versions()) _VERSIONS.update(uploader.get_versions())
......
This diff is collapsed.
...@@ -25,7 +25,7 @@ import sys ...@@ -25,7 +25,7 @@ import sys
from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from . import constants, cli, logger from . import constants, cli, logger, ub
log = logger.create() log = logger.create()
...@@ -68,12 +68,18 @@ class _Settings(_Base): ...@@ -68,12 +68,18 @@ class _Settings(_Base):
config_anonbrowse = Column(SmallInteger, default=0) config_anonbrowse = Column(SmallInteger, default=0)
config_public_reg = Column(SmallInteger, default=0) config_public_reg = Column(SmallInteger, default=0)
config_remote_login = Column(Boolean, default=False) config_remote_login = Column(Boolean, default=False)
config_kobo_sync = Column(Boolean, default=False)
config_default_role = Column(SmallInteger, default=0) config_default_role = Column(SmallInteger, default=0)
config_default_show = Column(SmallInteger, default=6143) config_default_show = Column(SmallInteger, default=6143)
config_columns_to_ignore = Column(String) config_columns_to_ignore = Column(String)
config_denied_tags = Column(String, default="")
config_allowed_tags = Column(String, default="")
config_restricted_column = Column(SmallInteger, default=0)
config_denied_column_value = Column(String, default="")
config_allowed_column_value = Column(String, default="")
config_use_google_drive = Column(Boolean, default=False) config_use_google_drive = Column(Boolean, default=False)
config_google_drive_folder = Column(String) config_google_drive_folder = Column(String)
config_google_drive_watch_changes_response = Column(String) config_google_drive_watch_changes_response = Column(String)
...@@ -84,7 +90,8 @@ class _Settings(_Base): ...@@ -84,7 +90,8 @@ class _Settings(_Base):
config_login_type = Column(Integer, default=0) config_login_type = Column(Integer, default=0)
# config_oauth_provider = Column(Integer) config_kobo_proxy = Column(Boolean, default=False)
config_ldap_provider_url = Column(String, default='localhost') config_ldap_provider_url = Column(String, default='localhost')
config_ldap_port = Column(SmallInteger, default=389) config_ldap_port = Column(SmallInteger, default=389)
...@@ -179,11 +186,20 @@ class _ConfigSQL(object): ...@@ -179,11 +186,20 @@ class _ConfigSQL(object):
def show_detail_random(self): def show_detail_random(self):
return self.show_element_new_user(constants.DETAIL_RANDOM) return self.show_element_new_user(constants.DETAIL_RANDOM)
def show_mature_content(self): def list_denied_tags(self):
return self.show_element_new_user(constants.MATURE_CONTENT) mct = self.config_denied_tags.split(",")
return [t.strip() for t in mct]
def list_allowed_tags(self):
mct = self.config_allowed_tags.split(",")
return [t.strip() for t in mct]
def list_denied_column_values(self):
mct = self.config_denied_column_value.split(",")
return [t.strip() for t in mct]
def mature_content_tags(self): def list_allowed_column_values(self):
mct = self.config_mature_content_tags.split(",") mct = self.config_allowed_column_value.split(",")
return [t.strip() for t in mct] return [t.strip() for t in mct]
def get_log_level(self): def get_log_level(self):
...@@ -323,5 +339,12 @@ def load_configuration(session): ...@@ -323,5 +339,12 @@ def load_configuration(session):
if not session.query(_Settings).count(): if not session.query(_Settings).count():
session.add(_Settings()) session.add(_Settings())
session.commit() session.commit()
conf = _ConfigSQL(session)
return _ConfigSQL(session) # Migrate from global restrictions to user based restrictions
if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
conf.config_denied_tags = conf.config_mature_content_tags
conf.save()
session.query(ub.User).filter(ub.User.mature_content != True). \
update({"restricted_tags": conf.config_mature_content_tags}, synchronize_session=False)
session.commit()
return conf
...@@ -25,7 +25,7 @@ import ast ...@@ -25,7 +25,7 @@ import ast
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy import Table, Column, ForeignKey from sqlalchemy import Table, Column, ForeignKey
from sqlalchemy import String, Integer, Boolean, Float from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
from sqlalchemy.orm import relationship, sessionmaker, scoped_session from sqlalchemy.orm import relationship, sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
...@@ -251,10 +251,10 @@ class Books(Base): ...@@ -251,10 +251,10 @@ class Books(Base):
title = Column(String) title = Column(String)
sort = Column(String) sort = Column(String)
author_sort = Column(String) author_sort = Column(String)
timestamp = Column(String) timestamp = Column(TIMESTAMP)
pubdate = Column(String) pubdate = Column(String)
series_index = Column(String) series_index = Column(String)
last_modified = Column(String) last_modified = Column(TIMESTAMP)
path = Column(String) path = Column(String)
has_cover = Column(Integer) has_cover = Column(Integer)
uuid = Column(String) uuid = Column(String)
......
...@@ -448,32 +448,46 @@ def delete_book(book, calibrepath, book_format): ...@@ -448,32 +448,46 @@ def delete_book(book, calibrepath, book_format):
return delete_book_file(book, calibrepath, book_format) return delete_book_file(book, calibrepath, book_format)
def get_cover_on_failure(use_generic_cover):
if use_generic_cover:
return send_from_directory(_STATIC_DIR, "generic_cover.jpg")
else:
return None
def get_book_cover(book_id): def get_book_cover(book_id):
book = db.session.query(db.Books).filter(db.Books.id == book_id).first() book = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first()
if book.has_cover: return get_book_cover_internal(book, use_generic_cover_on_failure=True)
def get_book_cover_with_uuid(book_uuid,
use_generic_cover_on_failure=True):
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
return get_book_cover_internal(book, use_generic_cover_on_failure)
def get_book_cover_internal(book,
use_generic_cover_on_failure):
if book and book.has_cover:
if config.config_use_google_drive: if config.config_use_google_drive:
try: try:
if not gd.is_gdrive_ready(): if not gd.is_gdrive_ready():
return send_from_directory(_STATIC_DIR, "generic_cover.jpg") return get_cover_on_failure(use_generic_cover_on_failure)
path=gd.get_cover_via_gdrive(book.path) path=gd.get_cover_via_gdrive(book.path)
if path: if path:
return redirect(path) return redirect(path)
else: else:
log.error('%s/cover.jpg not found on Google Drive', book.path) log.error('%s/cover.jpg not found on Google Drive', book.path)
return send_from_directory(_STATIC_DIR, "generic_cover.jpg") return get_cover_on_failure(use_generic_cover_on_failure)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
# traceback.print_exc() # traceback.print_exc()
return send_from_directory(_STATIC_DIR,"generic_cover.jpg") return get_cover_on_failure(use_generic_cover_on_failure)
else: else:
cover_file_path = os.path.join(config.config_calibre_dir, book.path) cover_file_path = os.path.join(config.config_calibre_dir, book.path)
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")): if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
return send_from_directory(cover_file_path, "cover.jpg") return send_from_directory(cover_file_path, "cover.jpg")
else: else:
return send_from_directory(_STATIC_DIR,"generic_cover.jpg") return get_cover_on_failure(use_generic_cover_on_failure)
else: else:
return send_from_directory(_STATIC_DIR,"generic_cover.jpg") return get_cover_on_failure(use_generic_cover_on_failure)
# saves book cover from url # saves book cover from url
...@@ -674,20 +688,40 @@ def common_filters(): ...@@ -674,20 +688,40 @@ def common_filters():
lang_filter = db.Books.languages.any(db.Languages.lang_code == current_user.filter_language()) lang_filter = db.Books.languages.any(db.Languages.lang_code == current_user.filter_language())
else: else:
lang_filter = true() lang_filter = true()
content_rating_filter = false() if current_user.mature_content else \ negtags_list = current_user.list_denied_tags()
db.Books.tags.any(db.Tags.name.in_(config.mature_content_tags())) postags_list = current_user.list_allowed_tags()
return and_(lang_filter, ~content_rating_filter) neg_content_tags_filter = false() if negtags_list == [''] else db.Books.tags.any(db.Tags.name.in_(negtags_list))
pos_content_tags_filter = true() if postags_list == [''] else db.Books.tags.any(db.Tags.name.in_(postags_list))
if config.config_restricted_column:
pos_cc_list = current_user.allowed_column_value.split(',')
pos_content_cc_filter = true() if pos_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
any(db.cc_classes[config.config_restricted_column].value.in_(pos_cc_list))
neg_cc_list = current_user.denied_column_value.split(',')
neg_content_cc_filter = false() if neg_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
any(db.cc_classes[config.config_restricted_column].value.in_(neg_cc_list))
else:
pos_content_cc_filter = true()
neg_content_cc_filter = false()
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
pos_content_cc_filter, ~neg_content_cc_filter)
def tags_filters(): def tags_filters():
return ~(false() if current_user.mature_content else \ negtags_list = current_user.list_denied_tags()
db.Tags.name.in_(config.mature_content_tags())) postags_list = current_user.list_allowed_tags()
# return db.session.query(db.Tags).filter(~content_rating_filter).order_by(db.Tags.name).all() neg_content_tags_filter = false() if negtags_list == [''] else db.Tags.name.in_(negtags_list)
pos_content_tags_filter = true() if postags_list == [''] else db.Tags.name.in_(postags_list)
return and_(pos_content_tags_filter, ~neg_content_tags_filter)
# return ~(false()) if postags_list == [''] else db.Tags.in_(postags_list)
# Creates for all stored languages a translated speaking name in the array for the UI # Creates for all stored languages a translated speaking name in the array for the UI
def speaking_language(languages=None): def speaking_language(languages=None):
if not languages: if not languages:
languages = db.session.query(db.Languages).all() languages = db.session.query(db.Languages).join(db.books_languages_link).join(db.Books).filter(common_filters())\
.group_by(text('books_languages_link.lang_code')).all()
for lang in languages: for lang in languages:
try: try:
cur_l = LC.parse(lang.lang_code) cur_l = LC.parse(lang.lang_code)
...@@ -774,7 +808,7 @@ def get_cc_columns(): ...@@ -774,7 +808,7 @@ def get_cc_columns():
cc = [] cc = []
for col in tmpcc: for col in tmpcc:
r = re.compile(config.config_columns_to_ignore) r = re.compile(config.config_columns_to_ignore)
if r.match(col.label): if not r.match(col.name):
cc.append(col) cc.append(col)
else: else:
cc = tmpcc cc = tmpcc
......
This diff is collapsed.
#!/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 ""
...@@ -59,10 +59,13 @@ class ReverseProxied(object): ...@@ -59,10 +59,13 @@ class ReverseProxied(object):
def __init__(self, application): def __init__(self, application):
self.app = application self.app = application
self.proxied = False
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
self.proxied = False
script_name = environ.get('HTTP_X_SCRIPT_NAME', '') script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
if script_name: if script_name:
self.proxied = True
environ['SCRIPT_NAME'] = script_name environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '') path_info = environ.get('PATH_INFO', '')
if path_info and path_info.startswith(script_name): if path_info and path_info.startswith(script_name):
...@@ -75,3 +78,7 @@ class ReverseProxied(object): ...@@ -75,3 +78,7 @@ class ReverseProxied(object):
if servr: if servr:
environ['HTTP_HOST'] = servr environ['HTTP_HOST'] = servr
return self.app(environ, start_response) return self.app(environ, start_response)
@property
def is_proxied(self):
return self.proxied
...@@ -55,6 +55,7 @@ class WebServer(object): ...@@ -55,6 +55,7 @@ class WebServer(object):
def __init__(self): def __init__(self):
signal.signal(signal.SIGINT, self._killServer) signal.signal(signal.SIGINT, self._killServer)
signal.signal(signal.SIGTERM, self._killServer) signal.signal(signal.SIGTERM, self._killServer)
signal.signal(signal.SIGQUIT, self._killServer)
self.wsgiserver = None self.wsgiserver = None
self.access_logger = None self.access_logger = None
......
#!/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)
...@@ -35,4 +35,10 @@ except ImportError as err: ...@@ -35,4 +35,10 @@ except ImportError as err:
log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err) log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err)
ldap = None ldap = None
try:
from . import SyncToken as SyncToken
kobo = True
except ImportError as err:
log.debug("cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
kobo = None
SyncToken = None
...@@ -293,9 +293,11 @@ def show_shelf(shelf_type, shelf_id): ...@@ -293,9 +293,11 @@ def show_shelf(shelf_type, shelf_id):
if cur_book: if cur_book:
result.append(cur_book) result.append(cur_book)
else: else:
log.info('Not existing book %s in %s deleted', book.book_id, shelf) cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete() if not cur_book:
ub.session.commit() log.info('Not existing book %s in %s deleted', book.book_id, shelf)
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete()
ub.session.commit()
return render_title_template(page, entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name), return render_title_template(page, entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelf") shelf=shelf, page="shelf")
else: else:
...@@ -329,9 +331,18 @@ def order_shelf(shelf_id): ...@@ -329,9 +331,18 @@ def order_shelf(shelf_id):
.order_by(ub.BookShelf.order.asc()).all() .order_by(ub.BookShelf.order.asc()).all()
for book in books_in_shelf2: for book in books_in_shelf2:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first() cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
result.append(cur_book) if cur_book:
#books_list = [ b.book_id for b in books_in_shelf2] result.append({'title':cur_book.title,
#result = db.session.query(db.Books).filter(db.Books.id.in_(books_list)).filter(common_filters()).all() 'id':cur_book.id,
'author':cur_book.authors,
'series':cur_book.series,
'series_index':cur_book.series_index})
else:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
result.append({'title':_('Hidden Book'),
'id':cur_book.id,
'author':[],
'series':[]})
return render_title_template('shelf_order.html', entries=result, return render_title_template('shelf_order.html', entries=result,
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name), title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelforder") shelf=shelf, page="shelforder")
...@@ -26,7 +26,7 @@ html.http-error { ...@@ -26,7 +26,7 @@ html.http-error {
body{background:#f2f2f2}body h2{font-weight:normal;color:#444} body{background:#f2f2f2}body h2{font-weight:normal;color:#444}
body { margin-bottom: 40px;} body { margin-bottom: 40px;}
a{color: #45b29d}a:hover{color: #444;} a{color: #45b29d} /*a:hover{color: #444;}*/
.navigation .nav-head{text-transform:uppercase;color:#999;margin:20px 0}.navigation .nav-head:nth-child(1n+2){border-top:1px solid #ccc;padding-top:20px} .navigation .nav-head{text-transform:uppercase;color:#999;margin:20px 0}.navigation .nav-head:nth-child(1n+2){border-top:1px solid #ccc;padding-top:20px}
.navigation li a{color:#444;text-decoration:none;display:block;padding:10px}.navigation li a:hover{background:rgba(153,153,153,0.4);border-radius:5px} .navigation li a{color:#444;text-decoration:none;display:block;padding:10px}.navigation li a:hover{background:rgba(153,153,153,0.4);border-radius:5px}
.navigation li a span{margin-right:10px} .navigation li a span{margin-right:10px}
...@@ -82,6 +82,12 @@ span.glyphicon.glyphicon-tags {padding-right: 5px;color: #999;vertical-align: te ...@@ -82,6 +82,12 @@ span.glyphicon.glyphicon-tags {padding-right: 5px;color: #999;vertical-align: te
.spinner {margin:0 41%;} .spinner {margin:0 41%;}
.spinner2 {margin:0 41%;} .spinner2 {margin:0 41%;}
table .bg-dark-danger {background-color: #d9534f; color: #fff;}
table .bg-dark-danger a {color: #fff;}
table .bg-dark-danger:hover {background-color: #c9302c;}
table .bg-primary:hover {background-color: #1C5484;}
table .bg-primary a {color: #fff;}
.block-label {display: block;} .block-label {display: block;}
.fake-input {position: absolute; pointer-events: none; top: 0;} .fake-input {position: absolute; pointer-events: none; top: 0;}
......
...@@ -228,6 +228,41 @@ $(function() { ...@@ -228,6 +228,41 @@ $(function() {
$(this).find(".modal-body").html("..."); $(this).find(".modal-body").html("...");
}); });
$("#modal_kobo_token")
.on("show.bs.modal", function(e) {
var $modalBody = $(this).find(".modal-body");
// Prevent static assets from loading multiple times
var useCache = function(options) {
options.async = true;
options.cache = true;
};
preFilters.add(useCache);
$.get(e.relatedTarget.href).done(function(content) {
$modalBody.html(content);
preFilters.remove(useCache);
});
})
.on("hidden.bs.modal", function() {
$(this).find(".modal-body").html("...");
$("#config_delete_kobo_token").show();
});
$("#btndeletetoken").click(function() {
//get data-id attribute of the clicked element
var pathname = document.getElementsByTagName("script"), src = pathname[pathname.length-1].src;
var path = src.substring(0,src.lastIndexOf("/"));
// var domainId = $(this).value("domainId");
$.ajax({
method:"get",
url: path + "/../../kobo_auth/deleteauthtoken/" + this.value,
});
$("#modalDeleteToken").modal("hide");
$("#config_delete_kobo_token").hide();
});
$(window).resize(function() { $(window).resize(function() {
$(".discover .row").isotope("layout"); $(".discover .row").isotope("layout");
}); });
......
...@@ -93,6 +93,116 @@ $(function() { ...@@ -93,6 +93,116 @@ $(function() {
var domainId = $(e.relatedTarget).data("domain-id"); var domainId = $(e.relatedTarget).data("domain-id");
$(e.currentTarget).find("#btndeletedomain").data("domainId", domainId); $(e.currentTarget).find("#btndeletedomain").data("domainId", domainId);
}); });
$('#restrictModal').on('hidden.bs.modal', function () {
// Destroy table and remove hooks for buttons
$("#restrict-elements-table").unbind();
$('#restrict-elements-table').bootstrapTable('destroy');
$("[id^=submit_]").unbind();
$('#h1').addClass('hidden');
$('#h2').addClass('hidden');
$('#h3').addClass('hidden');
$('#h4').addClass('hidden');
});
function startTable(type){
var pathname = document.getElementsByTagName("script"), src = pathname[pathname.length-1].src;
var path = src.substring(0,src.lastIndexOf("/"));
$("#restrict-elements-table").bootstrapTable({
formatNoMatches: function () {
return "";
},
url: path + "/../../ajax/listrestriction/" + type,
rowStyle: function(row, index) {
console.log('Reihe :' + row + ' Index :'+ index);
if (row.id.charAt(0) == 'a') {
return {classes: 'bg-primary'}
}
else {
return {classes: 'bg-dark-danger'}
}
},
onClickCell: function (field, value, row, $element) {
if(field == 3){
console.log("element")
$.ajax ({
type: 'Post',
data: 'id=' + row.id + '&type=' + row.type + "&Element=" + row.Element,
url: path + "/../../ajax/deleterestriction/" + type,
async: true,
timeout: 900,
success:function(data) {
$.ajax({
method:"get",
url: path + "/../../ajax/listrestriction/"+type,
async: true,
timeout: 900,
success:function(data) {
$("#restrict-elements-table").bootstrapTable("load", data);
}
});
}
});
}
},
striped: false
});
$("#restrict-elements-table").removeClass('table-hover');
$("#restrict-elements-table").on('editable-save.bs.table', function (e, field, row, old, $el) {
console.log("Hallo");
$.ajax({
url: path + "/../../ajax/editrestriction/"+type,
type: 'Post',
data: row //$(this).closest("form").serialize() + "&" + $(this)[0].name + "=",
});
});
$("[id^=submit_]").click(function(event) {
// event.stopPropagation();
// event.preventDefault();
$(this)[0].blur();
console.log($(this)[0].name);
$.ajax({
url: path + "/../../ajax/addrestriction/"+type,
type: 'Post',
data: $(this).closest("form").serialize() + "&" + $(this)[0].name + "=",
success: function () {
$.ajax ({
method:"get",
url: path + "/../../ajax/listrestriction/"+type,
async: true,
timeout: 900,
success:function(data) {
$("#restrict-elements-table").bootstrapTable("load", data);
}
});
}
});
return;
});
}
$('#get_column_values').on('click',function()
{
startTable(1);
$('#h2').removeClass('hidden');
});
$('#get_tags').on('click',function()
{
startTable(0);
$('#h1').removeClass('hidden');
});
$('#get_user_column_values').on('click',function()
{
startTable(3);
$('#h4').removeClass('hidden');
});
$('#get_user_tags').on('click',function()
{
startTable(2);
$(this)[0].blur();
$('#h3').removeClass('hidden');
});
}); });
/* Function for deleting domain restrictions */ /* Function for deleting domain restrictions */
...@@ -104,3 +214,12 @@ function TableActions (value, row, index) { ...@@ -104,3 +214,12 @@ function TableActions (value, row, index) {
"</a>" "</a>"
].join(""); ].join("");
} }
/* Function for deleting domain restrictions */
function RestrictionActions (value, row, index) {
return [
"<div class=\"danger remove\" data-restriction-id=\"" + row.id + "\" title=\"Remove\">",
"<i class=\"glyphicon glyphicon-trash\"></i>",
"</div>"
].join("");
}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover"> <div class="discover" xmlns:text-indent="http://www.w3.org/1999/xhtml">
<h2>{{title}}</h2> <h2>{{title}}</h2>
<form role="form" method="POST" autocomplete="off"> <form role="form" method="POST" autocomplete="off">
<div class="panel-group"> <div class="panel-group">
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
</div> </div>
</div> </div>
</div> </div>
{% if show_back_button %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h4 class="panel-title"> <h4 class="panel-title">
...@@ -169,6 +169,18 @@ ...@@ -169,6 +169,18 @@
<input type="checkbox" id="config_remote_login" name="config_remote_login" {% if config.config_remote_login %}checked{% endif %}> <input type="checkbox" id="config_remote_login" name="config_remote_login" {% if config.config_remote_login %}checked{% endif %}>
<label for="config_remote_login">{{_('Enable remote login ("magic link")')}}</label> <label for="config_remote_login">{{_('Enable remote login ("magic link")')}}</label>
</div> </div>
{% if feature_support['kobo'] %}
<div class="form-group">
<input type="checkbox" id="config_kobo_sync" name="config_kobo_sync" data-control="kobo-settings" {% if config.config_kobo_sync %}checked{% endif %}>
<label for="config_kobo_sync">{{_('Enable Kobo sync')}}</label>
</div>
<div data-related="kobo-settings">
<div class="form-group" style="text-indent:10px;">
<input type="checkbox" id="config_kobo_proxy" name="config_kobo_proxy" {% if config.config_kobo_proxy %}checked{% endif %}>
<label for="config_kobo_proxy">{{_('Proxy unknown requests to Kobo Store')}}</label>
</div>
</div>
{% endif %}
{% if feature_support['goodreads'] %} {% if feature_support['goodreads'] %}
<div class="form-group"> <div class="form-group">
<input type="checkbox" id="config_use_goodreads" name="config_use_goodreads" data-control="goodreads-settings" {% if config.config_use_goodreads %}checked{% endif %}> <input type="checkbox" id="config_use_goodreads" name="config_use_goodreads" data-control="goodreads-settings" {% if config.config_use_goodreads %}checked{% endif %}>
...@@ -322,11 +334,14 @@ ...@@ -322,11 +334,14 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="col-sm-12"> <div class="col-sm-12">
{% if not show_login_button %}
<button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button> <button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button>
{% endif %}
{% if show_back_button %} {% if show_back_button %}
<a href="{{ url_for('admin.admin') }}" class="btn btn-default">{{_('Back')}}</a> <a href="{{ url_for('admin.admin') }}" class="btn btn-default">{{_('Back')}}</a>
{% endif %} {% endif %}
......
{% extends "layout.html" %} {% extends "layout.html" %}
{% block header %}
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
{% endblock %}
{% block body %} {% block body %}
<div class="discover"> <div class="discover">
<h2>{{title}}</h2> <h2>{{title}}</h2>
...@@ -51,16 +55,19 @@ ...@@ -51,16 +55,19 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="form-group">
<label for="config_restricted_column">{{_('View restriction based on Calibre column')}}</label>
<select name="config_restricted_column" id="config_restricted_column" class="form-control">
<option value="0" {% if conf.config_restricted_column == 0 %}selected{% endif %}>{{ _('None') }}</option>
{% for restrictColumn in restrictColumns %}
<option value="{{ restrictColumn.id }}" {% if restrictColumn.id == conf.config_restricted_column %}selected{% endif %}>{{ restrictColumn.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="config_title_regex">{{_('Regular expression for title sorting')}}</label> <label for="config_title_regex">{{_('Regular expression for title sorting')}}</label>
<input type="text" class="form-control" name="config_title_regex" id="config_title_regex" value="{% if conf.config_title_regex != None %}{{ conf.config_title_regex }}{% endif %}" autocomplete="off"> <input type="text" class="form-control" name="config_title_regex" id="config_title_regex" value="{% if conf.config_title_regex != None %}{{ conf.config_title_regex }}{% endif %}" autocomplete="off">
</div> </div>
<div class="form-group">
<label for="config_mature_content_tags">{{_('Tags for Mature Content')}}</label>
<input type="text" class="form-control" name="config_mature_content_tags" id="config_mature_content_tags"
value="{% if conf.config_mature_content_tags != None%}{{ conf.config_mature_content_tags }}{% endif %}"
autocomplete="off"
>
</div> </div>
</div> </div>
</div> </div>
...@@ -134,14 +141,11 @@ ...@@ -134,14 +141,11 @@
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if conf.show_detail_random() %}checked{% endif %}> <input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if conf.show_detail_random() %}checked{% endif %}>
<label for="Show_detail_random">{{_('Show random books in detail view')}}</label> <label for="Show_detail_random">{{_('Show random books in detail view')}}</label>
</div> </div>
<div class="form-group"> <a href="#" id="get_tags" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied Tags')}}</a>
<input type="checkbox" name="Show_mature_content" id="Show_mature_content" {% if conf.show_mature_content() %}checked{% endif %}> <a href="#" id="get_column_values" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied custom column values')}}</a>
<label for="Show_mature_content">{{_('Show mature content')}}</label>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="col-sm-12"> <div class="col-sm-12">
<button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button> <button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button>
<a href="{{ url_for('admin.admin') }}" class="btn btn-default">{{_('Back')}}</a> <a href="{{ url_for('admin.admin') }}" class="btn btn-default">{{_('Back')}}</a>
...@@ -149,6 +153,9 @@ ...@@ -149,6 +153,9 @@
</form> </form>
</div> </div>
{% endblock %} {% endblock %}
{% block modal %}
{{ restrict_modal() }}
{% endblock %}
{% block js %} {% block js %}
<script type="text/javascript"> <script type="text/javascript">
$('.collapse').on('shown.bs.collapse', function(){ $('.collapse').on('shown.bs.collapse', function(){
...@@ -157,4 +164,8 @@ ...@@ -157,4 +164,8 @@
$(this).parent().find(".glyphicon-minus").removeClass("glyphicon-minus").addClass("glyphicon-plus"); $(this).parent().find(".glyphicon-minus").removeClass("glyphicon-minus").addClass("glyphicon-plus");
}); });
</script> </script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/table.js') }}"></script>
{% endblock %} {% endblock %}
{% 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 %}
{% from 'modal_restriction.html' import restrict_modal %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ g.user.locale }}"> <html lang="{{ g.user.locale }}">
<head> <head>
...@@ -11,6 +12,7 @@ ...@@ -11,6 +12,7 @@
<link rel="apple-touch-icon" sizes="140x140" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="apple-touch-icon" sizes="140x140" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link href="{{ url_for('static', filename='css/libs/bootstrap.min.css') }}" rel="stylesheet" media="screen"> <link href="{{ url_for('static', filename='css/libs/bootstrap.min.css') }}" rel="stylesheet" media="screen">
{% block header %}{% endblock %}
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet" media="screen"> <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet" media="screen">
<link href="{{ url_for('static', filename='css/upload.css') }}" rel="stylesheet" media="screen"> <link href="{{ url_for('static', filename='css/upload.css') }}" rel="stylesheet" media="screen">
{% if g.current_theme == 1 %} {% if g.current_theme == 1 %}
...@@ -22,8 +24,6 @@ ...@@ -22,8 +24,6 @@
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> <script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> <script src="https://oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script>
<![endif]--> <![endif]-->
{% block header %}{% endblock %}
</head> </head>
<body class="{{ page }}" data-text="{{_('Home')}}" data-textback="{{_('Back')}}"> <body class="{{ page }}" data-text="{{_('Home')}}" data-textback="{{_('Back')}}">
<!-- Static navbar --> <!-- Static navbar -->
......
{% 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 %}
...@@ -57,7 +57,13 @@ ...@@ -57,7 +57,13 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
{% if kobo_support and not new_user %}
<label>{{ _('Kobo Sync Token')}}</label>
<div class="form-group col">
<a class="btn btn-default" id="config_create_kobo_token" data-toggle="modal" data-target="#modal_kobo_token" data-remote="false" href="{{ url_for('kobo_auth.generate_auth_token', user_id=content.id) }}">{{_('Create/View')}}</a>
<div class="btn btn-danger" id="config_delete_kobo_token" data-toggle="modal" data-target="#modalDeleteToken" data-remote="false" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Delete')}}</div>
</div>
{% endif %} {% endif %}
<div class="col-sm-6"> <div class="col-sm-6">
{% for element in sidebar %} {% for element in sidebar %}
...@@ -73,6 +79,10 @@ ...@@ -73,6 +79,10 @@
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if content.show_detail_random() %}checked{% endif %}> <input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if content.show_detail_random() %}checked{% endif %}>
<label for="Show_detail_random">{{_('Show random books in detail view')}}</label> <label for="Show_detail_random">{{_('Show random books in detail view')}}</label>
</div> </div>
{% if ( g.user and g.user.role_admin() and not new_user ) %}
<a href="#" id="get_user_tags" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied Tags')}}</a>
<a href="#" id="get_user_column_values" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied custom column values')}}</a>
{% endif %}
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
{% if g.user and g.user.role_admin() and not profile %} {% if g.user and g.user.role_admin() and not profile %}
...@@ -82,10 +92,6 @@ ...@@ -82,10 +92,6 @@
<label for="admin_role">{{_('Admin user')}}</label> <label for="admin_role">{{_('Admin user')}}</label>
</div> </div>
{% endif %} {% endif %}
<div class="form-group">
<input type="checkbox" name="Show_mature_content" id="Show_mature_content" {% if content.mature_content %}checked{% endif %}>
<label for="Show_mature_content">{{_('Show mature content')}}</label>
</div>
<div class="form-group"> <div class="form-group">
<input type="checkbox" name="download_role" id="download_role" {% if content.role_download() %}checked{% endif %}> <input type="checkbox" name="download_role" id="download_role" {% if content.role_download() %}checked{% endif %}>
<label for="download_role">{{_('Allow Downloads')}}</label> <label for="download_role">{{_('Allow Downloads')}}</label>
...@@ -146,4 +152,42 @@ ...@@ -146,4 +152,42 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="modal fade" id="modal_kobo_token" tabindex="-1" role="dialog" aria-labelledby="kobo_tokenModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="kobo_tokenModalLabel">{{_('Generate Kobo Auth URL')}}</h4>
</div>
<div class="modal-body">...</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
</div>
</div>
</div>
</div>
<div id="modalDeleteToken" class="modal fade" role="dialog">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header bg-danger">
</div>
<div class="modal-body text-center">
<p>{{_('Do you really want to delete the Kobo Token?')}}</p>
<button type="button" class="btn btn-danger" id="btndeletetoken" value="{{content.id}}">{{_('Delete')}}</button>
<button type="button" class="btn btn-default" id="btncancel" data-dismiss="modal">{{_('Back')}}</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block modal %}
{{ restrict_modal() }}
{% endblock %}
{% block js %}
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/table.js') }}"></script>
{% endblock %} {% endblock %}
...@@ -156,6 +156,22 @@ class UserBase: ...@@ -156,6 +156,22 @@ class UserBase:
def show_detail_random(self): def show_detail_random(self):
return self.check_visibility(constants.DETAIL_RANDOM) return self.check_visibility(constants.DETAIL_RANDOM)
def list_denied_tags(self):
mct = self.denied_tags.split(",")
return [t.strip() for t in mct]
def list_allowed_tags(self):
mct = self.allowed_tags.split(",")
return [t.strip() for t in mct]
def list_denied_column_values(self):
mct = self.denied_column_value.split(",")
return [t.strip() for t in mct]
def list_allowed_column_values(self):
mct = self.allowed_column_value.split(",")
return [t.strip() for t in mct]
def __repr__(self): def __repr__(self):
return '<User %r>' % self.nickname return '<User %r>' % self.nickname
...@@ -178,6 +194,11 @@ class User(UserBase, Base): ...@@ -178,6 +194,11 @@ class User(UserBase, Base):
sidebar_view = Column(Integer, default=1) sidebar_view = Column(Integer, default=1)
default_language = Column(String(3), default="all") default_language = Column(String(3), default="all")
mature_content = Column(Boolean, default=True) mature_content = Column(Boolean, default=True)
denied_tags = Column(String, default="")
allowed_tags = Column(String, default="")
denied_column_value = Column(String, default="")
allowed_column_value = Column(String, default="")
remote_auth_token = relationship('RemoteAuthToken', backref='user', lazy='dynamic')
if oauth_support: if oauth_support:
...@@ -213,9 +234,10 @@ class Anonymous(AnonymousUserMixin, UserBase): ...@@ -213,9 +234,10 @@ class Anonymous(AnonymousUserMixin, UserBase):
self.locale = data.locale self.locale = data.locale
self.mature_content = data.mature_content self.mature_content = data.mature_content
self.kindle_mail = data.kindle_mail self.kindle_mail = data.kindle_mail
self.denied_tags = data.denied_tags
# settings = session.query(config).first() self.allowed_tags = data.allowed_tags
# self.anon_browse = settings.config_anonbrowse self.denied_column_value = data.denied_column_value
self.allowed_column_value = data.allowed_column_value
def role_admin(self): def role_admin(self):
return False return False
...@@ -311,6 +333,7 @@ class RemoteAuthToken(Base): ...@@ -311,6 +333,7 @@ class RemoteAuthToken(Base):
user_id = Column(Integer, ForeignKey('user.id')) user_id = Column(Integer, ForeignKey('user.id'))
verified = Column(Boolean, default=False) verified = Column(Boolean, default=False)
expiration = Column(DateTime) expiration = Column(DateTime)
token_type = Column(Integer, default=0)
def __init__(self): def __init__(self):
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8') self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
...@@ -342,6 +365,15 @@ def migrate_Database(session): ...@@ -342,6 +365,15 @@ def migrate_Database(session):
conn.execute("ALTER TABLE registration ADD column 'allow' INTEGER") conn.execute("ALTER TABLE registration ADD column 'allow' INTEGER")
conn.execute("update registration set 'allow' = 1") conn.execute("update registration set 'allow' = 1")
session.commit() session.commit()
try:
session.query(exists().where(RemoteAuthToken.token_type)).scalar()
session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
conn = engine.connect()
conn.execute("ALTER TABLE remote_auth_token ADD column 'token_type' INTEGER DEFAULT 0")
conn.execute("update remote_auth_token set 'token_type' = 0")
session.commit()
# Handle table exists, but no content # Handle table exists, but no content
cnt = session.query(Registration).count() cnt = session.query(Registration).count()
if not cnt: if not cnt:
...@@ -378,12 +410,19 @@ def migrate_Database(session): ...@@ -378,12 +410,19 @@ def migrate_Database(session):
'side_autor': constants.SIDEBAR_AUTHOR, 'side_autor': constants.SIDEBAR_AUTHOR,
'detail_random': constants.DETAIL_RANDOM}) 'detail_random': constants.DETAIL_RANDOM})
session.commit() session.commit()
try: '''try:
session.query(exists().where(User.mature_content)).scalar() session.query(exists().where(User.mature_content)).scalar()
except exc.OperationalError: except exc.OperationalError:
conn = engine.connect() conn = engine.connect()
conn.execute("ALTER TABLE user ADD column `mature_content` INTEGER DEFAULT 1") conn.execute("ALTER TABLE user ADD column `mature_content` INTEGER DEFAULT 1")'''
try:
session.query(exists().where(User.denied_tags)).scalar()
except exc.OperationalError: # Database is not compatible, some columns are missing
conn = engine.connect()
conn.execute("ALTER TABLE user ADD column `denied_tags` String DEFAULT ''")
conn.execute("ALTER TABLE user ADD column `allowed_tags` String DEFAULT ''")
conn.execute("ALTER TABLE user ADD column `denied_column_value` DEFAULT ''")
conn.execute("ALTER TABLE user ADD column `allowed_column_value` DEFAULT ''")
if session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS).first() is None: if session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS).first() is None:
create_anonymous_user(session) create_anonymous_user(session)
try: try:
...@@ -424,7 +463,8 @@ def migrate_Database(session): ...@@ -424,7 +463,8 @@ def migrate_Database(session):
def clean_database(session): def clean_database(session):
# Remove expired remote login tokens # Remove expired remote login tokens
now = datetime.datetime.now() now = datetime.datetime.now()
session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).delete() session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).\
filter(RemoteAuthToken.token_type !=1 ).delete()
session.commit() session.commit()
......
...@@ -27,7 +27,7 @@ import datetime ...@@ -27,7 +27,7 @@ import datetime
import json import json
import mimetypes import mimetypes
import traceback import traceback
import sys import binascii
from babel import Locale as LC from babel import Locale as LC
from babel.dates import format_date from babel.dates import format_date
...@@ -42,7 +42,7 @@ from werkzeug.exceptions import default_exceptions ...@@ -42,7 +42,7 @@ from werkzeug.exceptions import default_exceptions
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from . import constants, config, logger, isoLanguages, services, worker from . import constants, logger, isoLanguages, services, worker
from . import searched_ids, lm, babel, db, ub, config, get_locale, app from . import searched_ids, lm, babel, db, ub, config, get_locale, app
from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download
from .helper import common_filters, get_search_results, fill_indexpage, speaking_language, check_valid_domain, \ from .helper import common_filters, get_search_results, fill_indexpage, speaking_language, check_valid_domain, \
...@@ -54,7 +54,8 @@ from .redirect import redirect_back ...@@ -54,7 +54,8 @@ from .redirect import redirect_back
feature_support = { feature_support = {
'ldap': False, # bool(services.ldap), 'ldap': False, # bool(services.ldap),
'goodreads': bool(services.goodreads_support) 'goodreads': bool(services.goodreads_support),
'kobo': bool(services.kobo)
} }
try: try:
...@@ -149,7 +150,7 @@ def load_user_from_auth_header(header_val): ...@@ -149,7 +150,7 @@ def load_user_from_auth_header(header_val):
header_val = base64.b64decode(header_val).decode('utf-8') header_val = base64.b64decode(header_val).decode('utf-8')
basic_username = header_val.split(':')[0] basic_username = header_val.split(':')[0]
basic_password = header_val.split(':')[1] basic_password = header_val.split(':')[1]
except (TypeError, UnicodeDecodeError): except (TypeError, UnicodeDecodeError, binascii.Error):
pass pass
user = _fetch_user_by_name(basic_username) user = _fetch_user_by_name(basic_username)
if user and check_password_hash(str(user.password), basic_password): if user and check_password_hash(str(user.password), basic_password):
...@@ -459,12 +460,6 @@ def get_matching_tags(): ...@@ -459,12 +460,6 @@ def get_matching_tags():
if len(exclude_tag_inputs) > 0: if len(exclude_tag_inputs) > 0:
for tag in exclude_tag_inputs: for tag in exclude_tag_inputs:
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag))) q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))
'''if len(include_extension_inputs) > 0:
for tag in exclude_tag_inputs:
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))
if len(exclude_extension_inputs) > 0:
for tag in exclude_tag_inputs:
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))'''
for book in q: for book in q:
for tag in book.tags: for tag in book.tags:
if tag.id not in tag_dict['tags']: if tag.id not in tag_dict['tags']:
...@@ -966,11 +961,13 @@ def advanced_search(): ...@@ -966,11 +961,13 @@ def advanced_search():
return render_title_template('search.html', searchterm=searchterm, return render_title_template('search.html', searchterm=searchterm,
entries=q, title=_(u"search"), page="search") entries=q, title=_(u"search"), page="search")
# prepare data for search-form # prepare data for search-form
# tags = db.session.query(db.Tags).order_by(db.Tags.name).all() tags = db.session.query(db.Tags).join(db.books_tags_link).join(db.Books).filter(common_filters())\
tags = db.session.query(db.Tags).filter(tags_filters()).order_by(db.Tags.name).all() .group_by(text('books_tags_link.tag')).order_by(db.Tags.name).all()
series = db.session.query(db.Series).order_by(db.Series.name).all() series = db.session.query(db.Series).join(db.books_series_link).join(db.Books).filter(common_filters())\
extensions = db.session.query(db.Data) \ .group_by(text('books_series_link.series')).order_by(db.Series.name).filter(common_filters()).all()
extensions = db.session.query(db.Data).join(db.Books).filter(common_filters())\
.group_by(db.Data.format).order_by(db.Data.format).all() .group_by(db.Data.format).order_by(db.Data.format).all()
if current_user.filter_language() == u"all": if current_user.filter_language() == u"all":
languages = speaking_language() languages = speaking_language()
else: else:
...@@ -1091,10 +1088,10 @@ def register(): ...@@ -1091,10 +1088,10 @@ def register():
if not to_save["nickname"] or not to_save["email"]: if not to_save["nickname"] or not to_save["email"]:
flash(_(u"Please fill out all fields!"), category="error") flash(_(u"Please fill out all fields!"), category="error")
return render_title_template('register.html', title=_(u"register"), page="register") return render_title_template('register.html', title=_(u"register"), page="register")
existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"] existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"]
.lower()).first() .lower()).first()
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()).first() existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()).first()
if not existing_user and not existing_email: if not existing_user and not existing_email:
content = ub.User() content = ub.User()
# content.password = generate_password_hash(to_save["password"]) # content.password = generate_password_hash(to_save["password"])
...@@ -1105,7 +1102,7 @@ def register(): ...@@ -1105,7 +1102,7 @@ def register():
content.password = generate_password_hash(password) content.password = generate_password_hash(password)
content.role = config.config_default_role content.role = config.config_default_role
content.sidebar_view = config.config_default_show content.sidebar_view = config.config_default_show
content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT) #content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT)
try: try:
ub.session.add(content) ub.session.add(content)
ub.session.commit() ub.session.commit()
...@@ -1294,10 +1291,12 @@ def profile(): ...@@ -1294,10 +1291,12 @@ def profile():
downloads = list() downloads = list()
languages = speaking_language() languages = speaking_language()
translations = babel.list_translations() + [LC('en')] translations = babel.list_translations() + [LC('en')]
kobo_support = feature_support['kobo'] and config.config_kobo_sync
if feature_support['oauth']: if feature_support['oauth']:
oauth_status = get_oauth_status() oauth_status = get_oauth_status()
else: else:
oauth_status = None oauth_status = None
for book in current_user.downloads: for book in current_user.downloads:
downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
if downloadBook: if downloadBook:
...@@ -1312,11 +1311,14 @@ def profile(): ...@@ -1312,11 +1311,14 @@ def profile():
current_user.password = generate_password_hash(to_save["password"]) current_user.password = generate_password_hash(to_save["password"])
if "kindle_mail" in to_save and to_save["kindle_mail"] != current_user.kindle_mail: if "kindle_mail" in to_save and to_save["kindle_mail"] != current_user.kindle_mail:
current_user.kindle_mail = to_save["kindle_mail"] current_user.kindle_mail = to_save["kindle_mail"]
if "allowed_tags" in to_save and to_save["allowed_tags"] != current_user.allowed_tags:
current_user.allowed_tags = to_save["allowed_tags"].strip()
if to_save["email"] and to_save["email"] != current_user.email: if to_save["email"] and to_save["email"] != current_user.email:
if config.config_public_reg and not check_valid_domain(to_save["email"]): if config.config_public_reg and not check_valid_domain(to_save["email"]):
flash(_(u"E-mail is not from valid domain"), category="error") flash(_(u"E-mail is not from valid domain"), category="error")
return render_title_template("user_edit.html", content=current_user, downloads=downloads, return render_title_template("user_edit.html", content=current_user, downloads=downloads,
title=_(u"%(name)s's profile", name=current_user.nickname), page="me", title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
kobo_support=kobo_support,
registered_oauth=oauth_check, oauth_status=oauth_status) registered_oauth=oauth_check, oauth_status=oauth_status)
if "nickname" in to_save and to_save["nickname"] != current_user.nickname: if "nickname" in to_save and to_save["nickname"] != current_user.nickname:
# Query User nickname, if not existing, change # Query User nickname, if not existing, change
...@@ -1327,6 +1329,7 @@ def profile(): ...@@ -1327,6 +1329,7 @@ def profile():
return render_title_template("user_edit.html", return render_title_template("user_edit.html",
translations=translations, translations=translations,
languages=languages, languages=languages,
kobo_support=kobo_support,
new_user=0, content=current_user, new_user=0, content=current_user,
downloads=downloads, downloads=downloads,
registered_oauth=oauth_check, registered_oauth=oauth_check,
...@@ -1349,7 +1352,7 @@ def profile(): ...@@ -1349,7 +1352,7 @@ def profile():
if "Show_detail_random" in to_save: if "Show_detail_random" in to_save:
current_user.sidebar_view += constants.DETAIL_RANDOM current_user.sidebar_view += constants.DETAIL_RANDOM
current_user.mature_content = "Show_mature_content" in to_save #current_user.mature_content = "Show_mature_content" in to_save
try: try:
ub.session.commit() ub.session.commit()
...@@ -1358,13 +1361,13 @@ def profile(): ...@@ -1358,13 +1361,13 @@ def profile():
flash(_(u"Found an existing account for this e-mail address."), category="error") flash(_(u"Found an existing account for this e-mail address."), category="error")
log.debug(u"Found an existing account for this e-mail address.") log.debug(u"Found an existing account for this e-mail address.")
return render_title_template("user_edit.html", content=current_user, downloads=downloads, return render_title_template("user_edit.html", content=current_user, downloads=downloads,
translations=translations, translations=translations, kobo_support=kobo_support,
title=_(u"%(name)s's profile", name=current_user.nickname), page="me", title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
registered_oauth=oauth_check, oauth_status=oauth_status) registered_oauth=oauth_check, oauth_status=oauth_status)
flash(_(u"Profile updated"), category="success") flash(_(u"Profile updated"), category="success")
log.debug(u"Profile updated") log.debug(u"Profile updated")
return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages, return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages,
content=current_user, downloads=downloads, content=current_user, downloads=downloads, kobo_support=kobo_support,
title= _(u"%(name)s's profile", name=current_user.nickname), title= _(u"%(name)s's profile", name=current_user.nickname),
page="me", registered_oauth=oauth_check, oauth_status=oauth_status) page="me", registered_oauth=oauth_check, oauth_status=oauth_status)
......
...@@ -32,3 +32,7 @@ rarfile>=2.7 ...@@ -32,3 +32,7 @@ rarfile>=2.7
# other # other
natsort>=2.2.0,<7.1.0 natsort>=2.2.0,<7.1.0
git+https://github.com/OzzieIsaacs/comicapi.git@ad8bfe5a1c31db882480433f86db2c5c57634a3f#egg=comicapi git+https://github.com/OzzieIsaacs/comicapi.git@ad8bfe5a1c31db882480433f86db2c5c57634a3f#egg=comicapi
#Kobo integration
jsonschema>=3.2.0,<3.3.0
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment