forked from mirrors/bookwyrm
commit
b4cf193a81
18 changed files with 65 additions and 44 deletions
|
@ -162,7 +162,7 @@ class AbstractConnector(ABC):
|
|||
def update_book(self, book, data=None):
|
||||
''' load new data '''
|
||||
if not book.sync and not book.sync_cover:
|
||||
return
|
||||
return book
|
||||
|
||||
if not data:
|
||||
key = getattr(book, self.key_name)
|
||||
|
@ -286,7 +286,7 @@ def get_data(url):
|
|||
return data
|
||||
|
||||
|
||||
class SearchResult(object):
|
||||
class SearchResult:
|
||||
''' standardized search result object '''
|
||||
def __init__(self, title, key, author, year):
|
||||
self.title = title
|
||||
|
@ -299,7 +299,7 @@ class SearchResult(object):
|
|||
self.key, self.title, self.author)
|
||||
|
||||
|
||||
class Mapping(object):
|
||||
class Mapping:
|
||||
''' associate a local database field with a field in an external dataset '''
|
||||
def __init__(
|
||||
self, local_field, remote_field=None, formatter=None, model=None):
|
||||
|
|
|
@ -123,15 +123,15 @@ class Connector(AbstractConnector):
|
|||
return data.get('docs')
|
||||
|
||||
|
||||
def format_search_result(self, doc):
|
||||
def format_search_result(self, search_result):
|
||||
# build the remote id from the openlibrary key
|
||||
key = self.books_url + doc['key']
|
||||
author = doc.get('author_name') or ['Unknown']
|
||||
key = self.books_url + search_result['key']
|
||||
author = search_result.get('author_name') or ['Unknown']
|
||||
return SearchResult(
|
||||
doc.get('title'),
|
||||
search_result.get('title'),
|
||||
key,
|
||||
', '.join(author),
|
||||
doc.get('first_publish_year'),
|
||||
search_result.get('first_publish_year'),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -7,10 +7,6 @@ from .abstract_connector import AbstractConnector, SearchResult
|
|||
|
||||
class Connector(AbstractConnector):
|
||||
''' instantiate a connector '''
|
||||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
||||
|
||||
def search(self, query):
|
||||
''' right now you can't search bookwyrm sorry, but when
|
||||
that gets implemented it will totally rule '''
|
||||
|
@ -44,18 +40,18 @@ class Connector(AbstractConnector):
|
|||
return search_results
|
||||
|
||||
|
||||
def format_search_result(self, book):
|
||||
def format_search_result(self, search_result):
|
||||
return SearchResult(
|
||||
book.title,
|
||||
book.local_id,
|
||||
book.author_text,
|
||||
book.published_date.year if book.published_date else None,
|
||||
search_result.title,
|
||||
search_result.local_id,
|
||||
search_result.author_text,
|
||||
search_result.published_date.year if \
|
||||
search_result.published_date else None,
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_book(self, remote_id):
|
||||
''' this COULD be semi-implemented but I think it shouldn't be used '''
|
||||
pass
|
||||
|
||||
|
||||
def is_work_data(self, data):
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
''' usin django model forms '''
|
||||
''' using django model forms '''
|
||||
import datetime
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import ModelForm, PasswordInput, widgets
|
||||
from django import forms
|
||||
|
||||
|
|
|
@ -38,10 +38,8 @@ def shared_inbox(request):
|
|||
|
||||
try:
|
||||
activity = json.loads(request.body)
|
||||
except json.decoder.JSONDecodeError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not activity.get('object'):
|
||||
activity_object = activity['object']
|
||||
except (json.decoder.JSONDecodeError, KeyError):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not has_valid_signature(request, activity):
|
||||
|
@ -74,7 +72,7 @@ def shared_inbox(request):
|
|||
|
||||
handler = handlers.get(activity_type, None)
|
||||
if isinstance(handler, dict):
|
||||
handler = handler.get(activity['object']['type'], None)
|
||||
handler = handler.get(activity_object['type'], None)
|
||||
|
||||
if not handler:
|
||||
return HttpResponseNotFound()
|
||||
|
|
|
@ -35,6 +35,7 @@ def construct_search_term(title, author):
|
|||
|
||||
|
||||
class ImportJob(models.Model):
|
||||
''' entry for a specific request for book data import '''
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_date = models.DateTimeField(default=timezone.now)
|
||||
task_id = models.CharField(max_length=100, null=True)
|
||||
|
@ -42,6 +43,7 @@ class ImportJob(models.Model):
|
|||
'Status', null=True, on_delete=models.PROTECT)
|
||||
|
||||
class ImportItem(models.Model):
|
||||
''' a single line of a csv being imported '''
|
||||
job = models.ForeignKey(
|
||||
ImportJob,
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -77,6 +79,7 @@ class ImportItem(models.Model):
|
|||
|
||||
@property
|
||||
def isbn(self):
|
||||
''' pulls out the isbn13 field from the csv line data '''
|
||||
return unquote_string(self.data['ISBN13'])
|
||||
|
||||
@property
|
||||
|
@ -87,24 +90,29 @@ class ImportItem(models.Model):
|
|||
|
||||
@property
|
||||
def review(self):
|
||||
''' a user-written review, to be imported with the book data '''
|
||||
return self.data['My Review']
|
||||
|
||||
@property
|
||||
def rating(self):
|
||||
''' x/5 star rating for a book '''
|
||||
return int(self.data['My Rating'])
|
||||
|
||||
@property
|
||||
def date_added(self):
|
||||
''' when the book was added to this dataset '''
|
||||
if self.data['Date Added']:
|
||||
return dateutil.parser.parse(self.data['Date Added'])
|
||||
|
||||
@property
|
||||
def date_read(self):
|
||||
''' the date a book was completed '''
|
||||
if self.data['Date Read']:
|
||||
return dateutil.parser.parse(self.data['Date Read'])
|
||||
|
||||
@property
|
||||
def reads(self):
|
||||
''' formats a read through dataset for the book in this line '''
|
||||
if (self.shelf == 'reading'
|
||||
and self.date_added and not self.date_read):
|
||||
return [ReadThrough(start_date=self.date_added)]
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
''' the particulars for this instance of BookWyrm '''
|
||||
import base64
|
||||
|
||||
from Crypto import Random
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .user import User
|
||||
|
||||
class SiteSettings(models.Model):
|
||||
''' customized settings for this instance '''
|
||||
name = models.CharField(default=DOMAIN, max_length=100)
|
||||
instance_description = models.TextField(
|
||||
default="This instance has no description.")
|
||||
|
@ -18,6 +19,7 @@ class SiteSettings(models.Model):
|
|||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
''' gets the site settings db entry or defaults '''
|
||||
try:
|
||||
return cls.objects.get(id=1)
|
||||
except cls.DoesNotExist:
|
||||
|
@ -26,9 +28,11 @@ class SiteSettings(models.Model):
|
|||
return default_settings
|
||||
|
||||
def new_invite_code():
|
||||
''' the identifier for a user invite '''
|
||||
return base64.b32encode(Random.get_random_bytes(5)).decode('ascii')
|
||||
|
||||
class SiteInvite(models.Model):
|
||||
''' gives someone access to create an account on the instance '''
|
||||
code = models.CharField(max_length=32, default=new_invite_code)
|
||||
expiry = models.DateTimeField(blank=True, null=True)
|
||||
use_limit = models.IntegerField(blank=True, null=True)
|
||||
|
@ -36,10 +40,12 @@ class SiteInvite(models.Model):
|
|||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def valid(self):
|
||||
''' make sure it hasn't expired or been used '''
|
||||
return (
|
||||
(self.expiry is None or self.expiry > timezone.now()) and
|
||||
(self.use_limit is None or self.times_used < self.use_limit))
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
''' formats the invite link '''
|
||||
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
from html.parser import HTMLParser
|
||||
|
||||
class InputHtmlParser(HTMLParser):
|
||||
''' Removes any html that isn't whitelisted from a block '''
|
||||
''' Removes any html that isn't allowed_tagsed from a block '''
|
||||
|
||||
def __init__(self):
|
||||
HTMLParser.__init__(self)
|
||||
self.whitelist = ['p', 'b', 'i', 'pre', 'a', 'span']
|
||||
self.allowed_tags = ['p', 'b', 'i', 'pre', 'a', 'span']
|
||||
self.tag_stack = []
|
||||
self.output = []
|
||||
# if the html appears invalid, we just won't allow any at all
|
||||
|
@ -15,7 +15,7 @@ class InputHtmlParser(HTMLParser):
|
|||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
''' check if the tag is valid '''
|
||||
if self.allow_html and tag in self.whitelist:
|
||||
if self.allow_html and tag in self.allowed_tags:
|
||||
self.output.append(('tag', self.get_starttag_text()))
|
||||
self.tag_stack.append(tag)
|
||||
else:
|
||||
|
@ -24,7 +24,7 @@ class InputHtmlParser(HTMLParser):
|
|||
|
||||
def handle_endtag(self, tag):
|
||||
''' keep the close tag '''
|
||||
if not self.allow_html or tag not in self.whitelist:
|
||||
if not self.allow_html or tag not in self.allowed_tags:
|
||||
self.output.append(('data', ''))
|
||||
return
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ OL_URL = env('OL_URL')
|
|||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
#'django.contrib.admin',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
''' signs activitypub activities '''
|
||||
import hashlib
|
||||
from urllib.parse import urlparse
|
||||
import datetime
|
||||
|
@ -11,6 +12,7 @@ from Crypto.Hash import SHA256
|
|||
MAX_SIGNATURE_AGE = 300
|
||||
|
||||
def create_key_pair():
|
||||
''' a new public/private key pair, used for creating new users '''
|
||||
random_generator = Random.new().read
|
||||
key = RSA.generate(1024, random_generator)
|
||||
private_key = key.export_key().decode('utf8')
|
||||
|
@ -20,6 +22,7 @@ def create_key_pair():
|
|||
|
||||
|
||||
def make_signature(sender, destination, date, digest):
|
||||
''' uses a private key to sign an outgoing message '''
|
||||
inbox_parts = urlparse(destination)
|
||||
signature_headers = [
|
||||
'(request-target): post %s' % inbox_parts.path,
|
||||
|
@ -38,10 +41,15 @@ def make_signature(sender, destination, date, digest):
|
|||
}
|
||||
return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items())
|
||||
|
||||
|
||||
def make_digest(data):
|
||||
return 'SHA-256=' + b64encode(hashlib.sha256(data.encode('utf-8')).digest()).decode('utf-8')
|
||||
''' creates a message digest for signing '''
|
||||
return 'SHA-256=' + b64encode(hashlib.sha256(data.encode('utf-8'))\
|
||||
.digest()).decode('utf-8')
|
||||
|
||||
|
||||
def verify_digest(request):
|
||||
''' checks if a digest is syntactically valid and matches the message '''
|
||||
algorithm, digest = request.headers['digest'].split('=', 1)
|
||||
if algorithm == 'SHA-256':
|
||||
hash_function = hashlib.sha256
|
||||
|
@ -55,6 +63,7 @@ def verify_digest(request):
|
|||
raise ValueError("Invalid HTTP Digest header")
|
||||
|
||||
class Signature:
|
||||
''' read and validate incoming signatures '''
|
||||
def __init__(self, key_id, headers, signature):
|
||||
self.key_id = key_id
|
||||
self.headers = headers
|
||||
|
@ -62,6 +71,7 @@ class Signature:
|
|||
|
||||
@classmethod
|
||||
def parse(cls, request):
|
||||
''' extract and parse a signature from an http request '''
|
||||
signature_dict = {}
|
||||
for pair in request.headers['Signature'].split(','):
|
||||
k, v = pair.split('=', 1)
|
||||
|
@ -105,7 +115,9 @@ class Signature:
|
|||
# raises a ValueError if it fails
|
||||
signer.verify(digest, self.signature)
|
||||
|
||||
|
||||
def http_date_age(datestr):
|
||||
''' age of a signature in seconds '''
|
||||
parsed = datetime.datetime.strptime(datestr, '%a, %d %b %Y %H:%M:%S GMT')
|
||||
delta = datetime.datetime.utcnow() - parsed
|
||||
return delta.total_seconds()
|
||||
|
|
|
@ -122,7 +122,7 @@ def get_boosted(boost):
|
|||
def get_edition_info(book):
|
||||
''' paperback, French language, 1982 '''
|
||||
if not book:
|
||||
return
|
||||
return ''
|
||||
items = [
|
||||
book.physical_format if isinstance(book, models.Edition) else None,
|
||||
book.languages[0] + ' language' if book.languages and \
|
||||
|
@ -184,6 +184,7 @@ def current_shelf(context, book):
|
|||
|
||||
@register.simple_tag(takes_context=False)
|
||||
def latest_read_through(book, user):
|
||||
''' the most recent read activity '''
|
||||
return models.ReadThrough.objects.filter(
|
||||
user=user,
|
||||
book=book).order_by('-created_date').first()
|
||||
|
|
|
@ -7,7 +7,7 @@ from bookwyrm.connectors.abstract_connector import Mapping,\
|
|||
from bookwyrm.connectors.bookwyrm_connector import Connector
|
||||
|
||||
|
||||
class BookWyrmConnector(TestCase):
|
||||
class AbstractConnector(TestCase):
|
||||
def setUp(self):
|
||||
self.book = models.Edition.objects.create(title='Example Edition')
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from bookwyrm import models, broadcast
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
class Book(TestCase):
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
''' url routing for the app and api '''
|
||||
from django.conf.urls.static import static
|
||||
#from django.contrib import admin
|
||||
from django.contrib import admin
|
||||
from django.urls import path, re_path
|
||||
|
||||
from bookwyrm import incoming, outgoing, views, settings, wellknown
|
||||
|
@ -20,7 +20,7 @@ book_path = r'^book/(?P<book_id>\d+)'
|
|||
handler404 = 'bookwyrm.views.not_found_page'
|
||||
handler500 = 'bookwyrm.views.server_error_page'
|
||||
urlpatterns = [
|
||||
# path('admin/', admin.site.urls),
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
# federation endpoints
|
||||
re_path(r'^inbox/?$', incoming.shared_inbox),
|
||||
|
@ -66,15 +66,18 @@ urlpatterns = [
|
|||
|
||||
# books
|
||||
re_path(r'%s(.json)?/?$' % book_path, views.book_page),
|
||||
re_path(r'%s/(?P<tab>friends|local|federated)?$' % book_path, views.book_page),
|
||||
re_path(r'%s/(?P<tab>friends|local|federated)?$' % \
|
||||
book_path, views.book_page),
|
||||
re_path(r'%s/edit/?$' % book_path, views.edit_book_page),
|
||||
re_path(r'^editions/(?P<work_id>\d+)/?$', views.editions_page),
|
||||
|
||||
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
|
||||
# TODO: tag needs a .json path
|
||||
re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page),
|
||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % user_path, views.shelf_page),
|
||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page),
|
||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
|
||||
user_path, views.shelf_page),
|
||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
|
||||
local_user_path, views.shelf_page),
|
||||
|
||||
re_path(r'^search/?$', views.search),
|
||||
|
||||
|
|
|
@ -435,6 +435,7 @@ def import_data(request):
|
|||
|
||||
@login_required
|
||||
def create_invite(request):
|
||||
''' creates a user invite database entry '''
|
||||
form = forms.CreateInviteForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return HttpResponseBadRequest("ERRORS : %s" % (form.errors,))
|
||||
|
|
|
@ -6,5 +6,3 @@ from __future__ import absolute_import, unicode_literals
|
|||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
''' configures celery for task management '''
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
from . import settings
|
||||
|
||||
|
@ -22,4 +23,3 @@ app.autodiscover_tasks(['bookwyrm'], related_name='incoming')
|
|||
app.autodiscover_tasks(['bookwyrm'], related_name='broadcast')
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='books_manager')
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import')
|
||||
|
||||
|
|
2
fr-dev
2
fr-dev
|
@ -45,6 +45,6 @@ case "$1" in
|
|||
docker-compose exec web coverage report
|
||||
;;
|
||||
*)
|
||||
echo "Unrecognised command. Try: up, initdb, resetdb,makemigrations, migrate, shell, dbshell, restart_celery, test, test_report"
|
||||
echo "Unrecognised command. Try: up, initdb, resetdb, makemigrations, migrate, shell, dbshell, restart_celery, test, test_report"
|
||||
;;
|
||||
esac
|
||||
|
|
Loading…
Reference in a new issue